@plannotator/byoa-connector-core
v0.1.2
Published
The host-free core of the BYOA connector: pairing, credentials, the session doors, the turn scheduler and the proxied tools. No Node, no host: every effect goes through a port.
Readme
@plannotator/byoa-connector-core
The host-free core of the BYOA connector. One connector runs in each agent host the person uses (a Claude Code mod, a Pi extension, an OpenCode 2 plugin). It serves every app the person paired. This package holds everything that does not depend on the host:
- the pairing client (RFC 8628 device grant against an app's
pair_startandpair_poll); - the credential files;
- the session doors (register, poll, events) for each app and each session;
- the turn scheduler (one app turn at a time per session, in arrival order across apps);
- the provenance line;
- the proxied MCP tool, one per app;
- the
/byoacommands.
A host adapter is thin. It supplies the ports, reports its host's turn events, and does what the core asks. The wire is in docs/SPEC.md; its types and parsers are in @plannotator/byoa-protocol.
Copying into a host
A Claude Code mod can import only its own folder and has no Node. So:
src/imports nothing outside itself except@plannotator/byoa-protocol, and onlysrc/protocol.tsdoes that, with one line:export * from "@plannotator/byoa-protocol";. A sync script copiesconnector-core/src/*(all buttesting.ts) andprotocol/src/*into the mod. It then rewrites that one line to the copied protocol, for exampleexport * from "../protocol/index.ts";(seeconnectors/claude-code/scripts/sync-core.mjs).- No file uses a Node module or a global beyond the language,
URLandJSON. That means nofetch, timers,TextEncoderorcrypto. All I/O goes through the ports.test/purity.test.tsenforces both rules.
The adapter interface
These are in src/host.ts.
interface HostAdapter { // one per host process
host: HostId; hostVersion?: string; machineName?: string;
isInteractive(): boolean; // false for -p / RPC / SDK runs: nothing registers
display(d: { band: string | null; toast?: { text; level } }): void;
}
interface HostSession { // one per live conversation (OpenCode: one per session)
hostSessionId(): string | null; // re-read every tick; a change (/clear) retires and re-registers
workspace(): Workspace | Promise<Workspace>; // all it knows; sharing filters per app
busy(): boolean; // a main-loop turn runs or a prompt waits; never subagents
blocked(): boolean; // a permission prompt waits
submit(text: string): Promise<void>; // resolves when the host took it
abort?(): Promise<void>; // abort the running turn (only ever ours)
settings?(): SessionSetting[]; // { id, name, value | null, choices }
applySetting?(id: string, value: string): Promise<void>;
}
interface SessionHandle { // returned by connector.attach(session)
turnStarted(prompt: string): boolean; // true when the prompt is (or wraps) our submitted text
delta(text: string): void;
tool(name: string, title?: string): void; // at tool start; title is a fixed label, never input
turnEnded(outcome: "done" | "error" | "interrupted", reason?: string): void;
foreignPrompt(prompt?: string): boolean; // a prompt was announced (Enter): silences our turn
takenOver(): void; // that prompt joined our turn: interrupted, taken_over
isOurs(prompt: string): boolean;
tools(): Promise<ProxiedTool[]>; // one per app with MCP; built once, fixed for the handle
detach(): void; // the conversation ended for good
}The adapter reports every main-loop turn. The core keeps only the turns it started, matched by the text it submitted ("contains", because Claude Code wraps a plugin prompt in its own frame). Deltas and tool steps of any other turn are dropped.
Ports
These are in src/ports.ts.
HttpPort(request) → { status, text, headers, url? }: whole bodies. It may abort at 30 s, as Claude Code's$.http.fetchdoes. It MUST NOT follow redirects. Several requests run at once: one held poll per app per session, plus events and tool calls. The core never chooses a hold. It relies on the app'spoll_hold_seconds(at most 25), or polls everypoll_interval_secondswhen the hold is 0.FilePort:read(path) → { text, mode } | null,writePrivate(path, text)(0700 folder, 0600 file, temporary name, then rename),remove(path),list(dir).Clock: milliseconds since the epoch.- The tick: the core holds no timer. The adapter calls
connector.tick()about every second. A Claude Code mod does all background work from one$.clock.every.
Files under ~/.byoa
| Path | What | Rules |
| --- | --- | --- |
| credentials/<host>/<origin-key>.json | One per agent host and paired app origin: Claude Code and Pi on one machine are two connections (SPEC 4.1). It holds the token, the connection id, the account name, the app's id and name from pairing, and its discovery document. <origin-key> is the origin with :// and other non-name characters replaced by _, for example https_workspaces.example or http_127.0.0.1_4100. | Written 0600 in a 0700 folder, atomically. A file with any group or other bits is refused and named in status. The origin inside must match the file name. |
| tools/<host>/<origin-key>.json | The last tools/list each app answered, as read, with the origin and when. If tools/list fails at session start (after one quick retry), for example during an app deploy, the session builds that app's tool from this list, and status says "tools from cache". Calls still go to the live app. The tool built from it is byte for byte the one built from an equal fresh list, so the prompt cache holds. | Written 0600 in a 0700 folder, atomically, on each successful list. A file with any group or other bits is not used. Removed when the app is disconnected, and when its token is revoked (the credential itself stays until a reconnect replaces it). |
| policy.json | The one setting: which workspace fields each app is sent. { "share": { "<app id>": ["folder_name", "git_remote", "branch", "path"], "*": [...] } }. The default is ["folder_name", "git_remote"]. | A file other users can write, or one that does not parse, shares nothing, and status says so. It is read again every 30 s and on status. |
Commands
connector.command(args) returns the text to show. It is the same as connect(address), disconnect(app) and status().
connect <address>:workspaces.example,https://…, or127.0.0.1:4100(plain http only to 127.0.0.1, localhost or [::1]). It fetches/.well-known/byoa, starts the device grant, and polls once at once. If the app approved immediately (a local app), it answers just "connected to as ". Otherwise it shows the link and code, and names the origin.disconnect <app id | name | origin>: stops the app's links, revokes the token, and removes the file. If the app cannot be reached, the token stays, and the text says so.status: one line per app, with this session's state, pairings in progress, and file problems.
Wiring an adapter (sketch)
import { Connector, COMMAND } from "./byoa/core/index.ts";
const connector = new Connector({ host: adapter, http, files, now: () => Date.now(), home });
await connector.start();
const handle = connector.attach({
hostSessionId: () => $.session.id ?? null,
workspace: async () => ({ folder_name: basename(cwd), path: cwd, git_remote: await gitRemote(cwd), branch: await gitBranch(cwd) }),
busy: () => mainTurnRunning || $.prompt.queued() > 0,
blocked: () => permissionPromptOpen,
submit: (text) => $.prompt.submit({ text }), // never asUser
abort: () => $.turn.abort(),
});
for (const tool of await handle.tools()) $.tool.register(tool.name, tool.description, tool.inputSchema, (input) => tool.call(input));
$.command.register(COMMAND.slice(1), (args) => connector.command(args));
$.clock.every(1_000, () => connector.tick());
$.on("prompt.submit", (e) => { if (handle.foreignPrompt(e.text)) pendingForeign = true; });
$.on("turn.start", (e) => { if (!e.agentId) mainTurnRunning = true, handle.turnStarted(e.prompt); });
$.on("turn.step", (e) => {
if (e.agentId) return;
if (pendingForeign && e.promptJoined) { pendingForeign = false; handle.takenOver(); }
for (const part of e.text ?? []) handle.delta(part);
for (const t of e.toolStarts ?? []) handle.tool(t.name, t.label);
});
$.on("turn.complete", (e) => { if (!e.agentId) mainTurnRunning = false, handle.turnEnded(e.aborted ? "interrupted" : e.error ? "error" : "done"); });
$.on("session.end", () => handle.detach());The event names above are illustrative. Each host has its own; see docs/design.md for the per-host calls.
Not in 0.1
These follow SPEC 11: steer and upgrade, the loopback grant, state events, the on-disk lease and claims, per-app rate limits, policy beyond share, the DELETE session call, and tool phases.
Two processes on one session rely on the app handing each message to one poll. Polls that keep being superseded back off from 1 to 30 s, and status says why.
Testing an app against the real connector
@plannotator/byoa-connector-core/testing drives the real connector against a real app with no agent. It keeps the core's rules (no Node, no timers), so it runs in Node, Bun, Deno and workerd. It exports:
FakeHostandFakeSessionHost: a host process and one conversation. SetisBusyandisBlocked;submittedcollects the prompts the connector starts. Aftersession.attachTo(connector), play the host's side of a turn withstartTurn(),text(s),tool(name, title?),end(outcome?)andtakeOver(prompt?).abortscounts the connector's Stops.MemoryFiles: an in-memoryFilePortthat keeps permission bits.HOMEis a home folder to pass.fetchHttp(fetchLike): anHttpPortover any fetch-like function, with no redirects followed. In workerd passSELF.fetch; in-process wrap the app's handler:(url, init) => byoa.handle(new Request(url, init)).then((r) => r ?? new Response(null, { status: 404 })).ScriptedClock(clock.nowfor the connector,clock.advance(ms)) andrealClock.pairByPage({ connector, origin, request, headers, step }): pairs through the real approval page as the signed-in personheadersnames (a cookie), then callsstep()until the connector says it is connected.stepadvances the clock, callsconnector.tick(), and lets requests in flight finish.
const clock = new ScriptedClock();
const connector = new Connector({ host: new FakeHost(), http: fetchHttp(app), files: new MemoryFiles(), now: clock.now, home: HOME });
await connector.start();
const step = async () => { clock.advance(1000); await connector.tick(); await new Promise((r) => setTimeout(r, 10)); };
await pairByPage({ connector, origin, request: app, headers: { cookie: "session=..." }, step });
const session = new FakeSessionHost();
session.attachTo(connector);
// ...the app sends a chat; step until session.submitted has it, then:
session.startTurn(); session.text("Hello."); session.end("done");Tests
node --test packages/connector-core/test/*.test.ts and npx tsc -p packages/connector-core, from the repo root. They use a fake host, fake apps on an in-memory network, an in-memory FilePort and a scripted clock. There are no sleeps and no sockets.
