/** * The connector's local control plane: a unix-socket server the lifecycle hooks * talk to. Hooks are dumb relays — they forward the raw runtime event JSON (which * carries `hook_event_name`) and print whatever we reply. All the logic lives here, * in-process, because this is where the live mesh endpoint is. * * The socket plumbing is platform-agnostic; each connector passes a {@link HookHandle} * that maps its runtime's events to presence changes + (for inject-capable events) * queued peer messages, in that runtime's own hook-output shape. */ import { type Server } from "node:net"; import type { MeshAgent, InboxItem } from "./agent.js"; /** One lifecycle event, as the agent runtime delivers it on stdin. */ export interface HookEvent { hook_event_name?: string; [k: string]: unknown; } /** Maps one hook event to the JSON reply the runtime applies. */ export type HookHandle = (agent: MeshAgent, ev: HookEvent) => Promise>; /** One line a handoff-aware client writes back once the reply has cleared ITS output to the runtime. * Content is irrelevant — arrival is the signal — but a stable token keeps a transcript readable. */ export declare const HANDOFF_RECEIPT = "{\"handoff\":\"ok\"}\n"; export interface ControlServerOpts { /** Fail loud on a bind we can't hold. A managed listener (the in-agent MCP server, the Hermes * sidecar) MUST own its endpoint: if `listen` errors (e.g. a squatter already holds the win32 * pipe → `EADDRINUSE`; libuv binds with `FILE_FLAG_FIRST_PIPE_INSTANCE`), the process exits * rather than running on with a hijacked or no-op control plane. Default off (an ad-hoc/test * server logs and stays up). */ fatalBind?: boolean; /** Cooperative shutdown: invoked on an AUTHENTICATED `{op:"shutdown"}` frame. The connector runs * its own clean teardown (close the server, `agent.stop()` to leave the mesh, then exit) — the * server never owns `process.exit`. Absent → shutdown frames are accepted + acked but inert. */ onShutdown?: () => void; /** Read-only host-session identity. A manager uses this after Pi joins to bind supervised crash * recovery to the exact Pi JSONL session, never to a newest-session heuristic. Undefined means * the connector has not reached session_start yet. */ onSession?: () => string | undefined; /** Called once per handled hook frame, after the reply has been written, with whether it reached a * LIVE client. A hook reply is the only vehicle for the peer messages a handler injects, and it is * not guaranteed to arrive: the relay abandons the exchange after its own timeout, and the runtime * can kill the hook process outright. A handler that surfaced messages therefore commits them on * `delivered === true` and leaves them un-acked otherwise, so the durable redelivery brings them * back instead of the batch being silently consumed. * * **Frames overlap, so correlate on `ev`.** This is the identical object passed to `handle`, and * it is the only thing tying a verdict to the reply that carried a batch: each frame is its own * connection, and a `PreToolUse` can land while a `UserPromptSubmit` reply is still being written. * A single mutable "pending" slot will therefore commit one frame's messages on another frame's * verdict. Key per event (a WeakMap, so a frame whose verdict never arrives is collected). * * How strong `delivered` is depends on the client. A client that sets `handoff: true` (the hook * relay does) confirms only after its own write to the runtime-facing pipe completed cleanly — * strictly more than a socket write, and still short of proof the host read or applied the reply: * a small payload can sit in a kernel buffer that nobody ever drains. For any other client it * means only "written to a socket that was still open" — exact for the case that matters, a client * that had already gone away when we answered, but blind to one that dies with the reply * unflushed downstream. Both are why this errs toward at-least-once. */ onReply?: (ev: HookEvent, delivered: boolean) => void; } /** The context block injected into a turn when peer messages are waiting (else undefined). * * A LINE THAT BEGINS AT COLUMN ZERO IS WRITTEN BY THIS CONNECTOR, NEVER BY A PEER, and that is * what separates one injected item from the next. This block is auto-injected rather than asked * for, so the agent never had the chance to distrust it, and every peer-controlled field in it * (the body, the sender name and role, the service and channel labels) is rendered through * {@link fmtItem} in `framing.ts` — the same neutralization the `cotal_inbox` reply uses, not a * second convention. Measured against the raw interpolation this replaced: a body carrying a * newline produced a second item line, reading as a delivered message from a peer that never sent * one, and a sender naming itself `Ada] hi [DM from Boss` closed the real attribution and opened a * forged one. * * The tail names the ORDER OF OPERATIONS, not just the reply verbs. A peer message is frequently a * work order, and it arrives at the moment the model is choosing its next action: a tail that lists * only reply tools reads as "this is a chat turn, answer it", and an answer that sounds finished is * cheaper for a weak model than the work itself. Dogfooding a live seat produced exactly that — a * confirmation DM claiming a file had been written, sent seconds after the order, with no file tool * called and no file on disk, twice in a row. Naming the sequence (do it, verify it, then report * what the tools actually returned) is the part the connector owns; the model is still free to * disregard it, so this narrows the failure mode rather than closing it. */ export declare function formatInjection(items: InboxItem[]): string | undefined; /** Start the authenticated control server. One newline-delimited JSON {@link ControlFrame} → one * reply per connection. The first thing every connection does is validate its `token` against the * endpoint's (constant-time) — a mismatch is dropped before `handle` (or `onShutdown`) ever runs, * so an unauthenticated local process that finds/guesses the path still can't drive presence, * inject peer messages, or shut the agent down. */ export declare function startControlServer(agent: MeshAgent, endpoint: { path: string; token: string; }, handle: HookHandle, opts?: ControlServerOpts): Server; //# sourceMappingURL=control.d.ts.map