shard PROTOCOL.md
67 kB · 1422 lines
Markdown
at main

Shard — Wire Protocol #

The contract between every client (web, orchestrator, SDK) and the daemon/agent. Defined once here, carried over three transports. Every message is plain, serializable JSON data — no functions, no class instances.

This is the linchpin reference. If a behavior isn't defined here, it isn't part of the protocol. Cross-references to ARCHITECTURE.md use §N notation.

Status: v0.1 — protocol version 0.1.0. Expect churn until the first end-to-end agent runs (build-order step 7). After that, changes are versioned per §7.


1. Scope & Design #

The protocol has two layers, each a closed message set:

Layer Between Purpose
Per-agent one client ↔ one agent process drive a single agent: prompt, stream, permissions, UI, session ops
Orchestrator one client ↔ daemon registry lifecycle: list, create, kill, attach, status

The daemon is a proxy + multiplexer: on a WebSocket connection it routes per-agent messages by agent_id and handles orchestrator messages itself. Direct connections (stdio, dedicated unix socket to one agent) carry only per-agent messages and omit agent_id (the connection is bound to one agent).

Design rules #

  1. Wire-safe. Every message is JSON-serializable data. Errors carry {code, message}, never Error objects.
  2. One envelope. All messages share the shape in §5. No special-cased frames.
  3. Tagged multiplexing. On a daemon connection, per-agent messages carry agent_id; orchestrator messages do not. On a direct connection, agent_id is omitted.
  4. Symmetric directions. Commands and events use the same envelope; the difference is direction and correlation (§5), not structure.
  5. Events are idempotent snapshots where it matters. Streaming deltas are for live display; message_complete / tool_result reconcile state so a reconnecting client can resync without replaying every delta.

2. Transports & Framing #

Transport Framing Auth Used by
stdio JSONL, LF-delimited implicit (local trust) headless agent, subagent↔parent, SDK subprocess
unix socket JSONL, LF-delimited implicit (filesystem perms on ~/.shard/daemon.sock) short-lived job clients ↔ daemon
WebSocket one JSON message per WS text frame token in upgrade handshake (§4) web client, orchestrator

2.1 JSONL rule (stdio, unix socket) #

Split on \n only. Do not use Node readline — it splits on U+2028/U+2029, which are valid inside JSON strings. Strip a single trailing \r if present (tolerate CRLF input). One JSON object per line.

2.2 WebSocket rule #

One JSON message per WS text frame — the frame boundary is the record boundary, so no newline delimiting is needed. Binary frames are unused in v0.1. Keepalive via WS ping/pong (server pings every 30s; client must respond pong or be considered dead). Clients reconnect with exponential backoff (1s, 2s, 4s, …, capped 30s) and re-attach to agents (§6.4).

2.3 Encoding #

UTF-8 throughout. Messages may be large (tool results, file reads); there is no hard size limit, but agents should avoid streaming multi-MB blobs inline — prefer truncation with a recall/expand affordance, mirroring the recall tool's pagination model (ARCHITECTURE §12).


3. Connection Lifecycle #

open transport with token WS onlyhello event protocol version and capabilitiesagent.attach one or many daemon path onlystreamed events tagged with agent_idcommands tagged with agent_idevents continue until detach or agent exitsclient may detach or dropClientServer daemon or agentClientServer daemon or agent
  1. Open. WS upgrade carries the token (§4); stdio/socket connections open with implicit local trust.
  2. hello. The server emits hello immediately on connect (§9.1). The client checks the protocol version; on mismatch it may disconnect or attempt negotiation (§7).
  3. Attach (daemon path only). The client sends agent.attach for each agent it wants to watch. Direct connections are already bound to one agent — no attach needed.
  4. Stream. Events flow, tagged with agent_id on the daemon path. Commands flow back, also tagged.
  5. Detach / drop. agent.detach stops a stream; closing the connection detaches from all. Detach does not immediately stop active work. Inactive Runtime generations are automatically reaped after the daemon's idle-runtime timeout and restored on demand.
  6. Terminate. A client that owns the agent lifecycle sends agent.terminate on clean exit. An inactive agent stops immediately; an active agent finishes its current work and stops when it next becomes inactive.

4. Authentication & Authorization #

4.1 WebSocket handshake (daemon) #

  • Browser authentication establishes a principal and session using the owner-only, versioned durable security store under SHARD_HOME (default ~/.shard). It persists only credential hashes and metadata; raw credentials and tokens are never persisted or logged.
  • The session is represented by a __Host- Secure, HttpOnly, SameSite=Strict cookie.
  • The client obtains a short-lived, single-use WS ticket. The ticket is bound to the principal, session, and exact serialized Origin; it is consumed during the upgrade and cannot be replayed or used from another origin.
  • The daemon rejects an upgrade with 401 for a missing, expired, mismatched, or replayed ticket, and with 400 if the Origin header is absent or not in allowed_origins.
  • Host validation is exact (including the port) and accepts only the configured loopback Host; DNS-rebinding aliases, suffix matches, and alternate loopback spellings are rejected.

4.1.1 Loopback mode (default, unchanged) #

With no remote flags set the daemon binds 127.0.0.1:<port>, expectedHost is 127.0.0.1:<port>, the implicit http://127.0.0.1:<port> origin is the only allowlist entry, no forwarded header is honored, the session cookie is HttpOnly; SameSite=Strict without Secure, and the bullet list above describes the full behavior. This mode is byte-identical to the original hug1mc web client and is regression-guarded by the existing web/security tests.

4.1.2 Remote mode (saga vr7kgx.9) #

Remote mode lets a browser on a different machine reach the web client. It is opt-in, selected by shard daemon flags, and validated loudly at startup — the daemon never silently degrades into an unsafe remote configuration. There are three configurations:

Config Bind Transport Trusted proxy Required flags
Loopback (default) 127.0.0.1 plaintext, loopback-only none (none)
Direct non-loopback <ip> (non-loopback) plaintext, network-facing none --web-host <ip>, --public-host <host:port>, --allowed-origin <origin>, --allow-insecure-remote
Trusted reverse proxy 127.0.0.1 (or a configured <ip>) TLS at the proxy; plaintext to the daemon --trust-proxy <upstream> --trust-proxy <upstream>, --public-host <host:port>, --allowed-origin <origin> (plus --allow-insecure-remote iff the proxy fronts plaintext)

Trusted-proxy semantics (D2). --trust-proxy is opt-in and disabled by default. When set, forwarded headers are honored only when the request's direct peer matches the configured upstream (loopback = 127.0.0.1/::1, or an exact IP literal). From any other peer the forwarded headers are ignored and the request is treated as direct.

  • X-Forwarded-Proto is honored only from the configured upstream; the effective scheme is https iff that header is https (case-insensitive), otherwise http.
  • X-Forwarded-Host is cross-checked against the pinned --public-host and the request is rejected (400) on mismatch — it is never used to derive expectedHost. The pinned publicHost is always the effective host. This closes the spoofing hole even when the peer is trusted: a compromised proxy that forwards the wrong host is rejected rather than silently re-trusted.
  • X-Forwarded-For is never trusted for any security decision; the peer address is the trust anchor. (The consumed header names default to X-Forwarded-Proto/X-Forwarded-Host and are configurable for deployments that rename them.)

publicHost is always required in remote mode (D3). Whenever --web-host is non-loopback or --trust-proxy is set, both --public-host <host:port> and --allowed-origin <origin> are mandatory and the startup check fails loudly if either is missing. expectedHost is the pinned publicHost; there is no X-Forwarded-Host derivation path. The implicit http://127.0.0.1:<port> origin is added to the allowlist only in pure loopback mode, so a remote request that arrives without honored forwarded headers is rejected rather than silently falling back to loopback trust.

--allow-insecure-remote opt-in and cookie-exposure risk (decisions 4–5). Any non-https remote operation requires an explicit --allow-insecure-remote flag. Setting --web-host to a non-loopback address alone does not imply consent — the daemon refuses to start until the flag is also set, making the cookie-exposure risk a deliberate, acknowledged choice. When the flag is set the daemon logs a one-line plaintext-cookie-exposure warning at startup. The operator is responsible for confining non-TLS remote to a trusted network, VPN, or SSH-forwarded path; on an untrusted network the session cookie is knowingly interceptable.

Secure / __Host- cookie enforcement on the effective scheme (D4). The session cookie is issued in its Secure, __Host- form iff the issuing request's effective scheme is https; on an http effective scheme (loopback, or remote under --allow-insecure-remote) it is non-Secure. A Secure-form cookie arriving over a request whose effective scheme is not https is rejected at /bootstrap (no ticket issued), and enrollment refuses to set a Secure cookie over plaintext. On the --allow-insecure-remote http path the cookie is non-Secure by design, so a misconfigured proxy cannot mint Secure cookies for a hijacked plaintext session.

hug1mc HTTPS-required invariant — amendment. The hug1mc clause "any future remote mode must require HTTPS and Secure cookies" is relaxed to permit an explicit, two-step opt-in non-TLS remote mode (--web-host <non-loopback> --allow-insecure-remote) for use on trusted networks, VPNs, or SSH-forwarded setups. All other hug1mc invariants stand unchanged (per-browser enrollment, the Sec-WebSocket-Protocol ticket, exact Origin matching, frame-ancestors 'none', and selected-agent-only interactive attachment).

For concrete deployment recipes (Caddy, nginx, direct non-loopback) see the Web deployment cookbook at WEB-DEPLOYMENT.md. See ARCHITECTURE §14.3 for the architecture-level threat model and trust-anchor summary.

4.2 stdio / unix socket #

No in-band auth — local trust. The unix socket's filesystem permissions (0600, owned by the user) are the access control. stdio inherits the spawning process's privileges.

4.3 Authorization (v0.1: single trust level) #

Any authenticated client may create/kill agents, send prompts, and answer permission/UI requests for any agent. This is acceptable only because the daemon is loopback-bound (ARCHITECTURE §14.1). The PermissionGate still governs every tool call (ARCHITECTURE §4.3) — a client can make the agent ask, but writes still route through the gate, headless policy, and existing allow_always grants. Per-agent ACLs are a future item gated on remote mode.


5. Message Envelope & Correlation #

Every message — command or event — shares this shape:

interface Envelope {
  /** Optional correlation id. If set on a command, the response carries the same id. */
  id?: string;
  /** Message type, e.g. "prompt", "tool_call", "agent.list", "response". */
  type: string;
  /** On daemon connections, identifies the target agent for per-agent messages. */
  agent_id?: string;
  /** Message payload — type-specific fields. */
  [key: string]: unknown;
}

5.1 Two correlation modes #

Mode When How
Request/response Commands needing a direct reply (agent.list, agent.create, session_op, set_mode, list_modes, abort, list_models, set_model) Client sets id; server replies with a response message carrying the same id and command (§9.3).
Fire-and-forget / streamed Commands whose real outcome arrives via events (prompt, steer, permission_response, ui_response) Client may set id; server replies with a response indicating acceptance (success: true = accepted/queued). The actual result streams as events.

Events (tool_call, status, etc.) never carry a correlation id — they are not responses. The exception is permission_resolved / ui_resolved, which carry request_id to close the loop on a broadcast request (§10).

5.2 agent_id rule #

  • Daemon connection: every per-agent command and event carries agent_id. Orchestrator messages (agent.list, agent.spawned, …) do not — they're about the registry. Subagent job commands (subagent.create/wait/steer/relaunch/status/stop, §10.3) are also orchestrator-level: they carry a parent_agent_id payload field (the owning durable parent) and no routing agent_id.
  • Direct connection (stdio / dedicated socket to one agent): agent_id is omitted. The connection is bound to exactly one agent; there's nothing to multiplex.

A client that speaks to the daemon can be attached to many agents simultaneously; it demuxes inbound events by agent_id.


6. Subscriptions & Multi-Client #

6.1 Attach model (replaces a standalone subscribe command) #

Subscription is implicit in agent.attach (daemon path) or in connection-open (direct path). There is no separate subscribe message — this collapses a concept. The set of agents a daemon client watches is exactly the set it has agent.attached and not yet agent.detached.

6.2 Fan-out #

The daemon maintains, per agent, the set of attached clients. Every event the agent emits is fanned out to all attached clients, tagged with agent_id. This is what makes the multi-client model work: two clients watching the same agent see the same stream.

6.3 Broadcast requests, first-response-wins #

permission_request and ui_request are broadcast to all attached clients. The first permission_response / ui_response the daemon accepts wins; late responses are discarded silently (not errored). The agent then emits permission_resolved / ui_resolved to all clients so they can dismiss pending dialogs (ARCHITECTURE §6).

For a delegated child with no interactive responder of its own, the daemon routes the pending permission request to the nearest interactive ancestor while retaining the child's agent_id; the response therefore resolves the child request. Delegation is preserved across ancestor reconnect/sync. Observer attachments and internal subscribers do not count as responders. If no interactive responder exists anywhere in the hierarchy, the daemon's headless compatibility path supplies permission_response { decision: "deny", source: "headless" } for permission requests (and ui_response { value: null } for UI requests).

permission_request request_id req-7permission_request req-7permission_request req-7permission_response req-7 allow_alwaysforward first response drop latepermission_resolved req-7 allow_alwayspermission_resolved req-7permission_resolved req-7both dismiss their dialogsAgentDaemonClient 1Client 2AgentDaemonClient 1Client 2

6.4 Reconnect & resync #

agent.attach resolves durable identity, coalesces startup of a Runtime generation, then keeps the connection reserved while it obtains authoritative sync and drains buffered live Events. The order is always sync first, followed by each buffered event exactly once; only then is the connection promoted to attached. NOT_FOUND only means the durable Agent/session is absent; startup/readiness failures use RUNTIME_START_FAILED and retain identity for retry. Runtime stop/exit, agent.terminate, and agent.kill affect only the Runtime generation and retain session JSONL. The daemon also reaps Runtime generations after the configured period in which the agent is inactive (idle or terminal error). Active states (streaming, waiting_for_permission, waiting_for_ui, compacting) never expire. New work or attach restores a stopped Runtime from durable identity.

On reconnect, a client:

  1. Waits for hello, validates version.
  2. Re-sends agent.attach for each agent it was watching. The daemon re-adds it to the fan-out set.
  3. Receives a sync event per agent (§9.2) carrying the agent's current status and active mode, the live message window (post-compaction), and any pending permission_request / ui_request it should re-show. Pending requests—including delegated child requests surfaced through an ancestor—survive the disconnect (ARCHITECTURE §6.2–§6.3); they stay open until resolved or the headless timeout fires.

This means a client can drop and reattach without losing the thread of an in-flight permission prompt.


7. Versioning & Capability Negotiation #

The hello event (§9.1) carries:

{ type: "hello", protocol_version: "0.1.0", server_version: "<shard build>",
  capabilities: string[] }
  • protocol_version is semver. v0.1 may break freely until the first end-to-end agent. After that, breaking changes bump the major; additive changes bump minor/patch.
  • capabilities is informational in v0.1 — a list of feature strings like ["streaming-deltas", "model-catalog", "model-switch", "mode-catalog", "mode-switch", "ui:select", "ui:editor"]. Servers advertise mode-catalog when list_modes is available (always — every runtime has a ModeController) and mode-switch when set_mode is available; model-catalog when list_models is available and model-switch when set_model is available (the latter two are conditional on a model controller being wired). Clients must not infer any of these capabilities from the protocol version alone. Note the two capability scopes (saga vr7kgx.6): the connection-level hello.capabilities is transport-scoped (the daemon advertises websocket/streaming-deltas, not per-runtime model support); an attached runtime's exact capabilities arrive in sync.capabilities (§9.1), projected from that child runtime's own hello. Gate controls on the sync string, not the top-level hello.
  • A client that doesn't understand the major version disconnects. There is no in-band down-negotiation in v0.1; pick a compatible client/daemon pair.

8. Error Model #

Two error surfaces:

Surface When Shape
Command error A request/response command fails synchronously response with success: false and error: { code, message, data? } (§9.3)
Async error Something fails mid-turn (provider outage, tool crash) error event with { code, message, fatal? } (§9.4)

Error codes (extensible registry) #

Code Meaning
INVALID_REQUEST Malformed message, bad JSON, failed validation
NOT_FOUND No durable Agent/session identity exists
NOT_ATTACHED Per-agent command sent on a daemon connection without a valid agent_id attachment
PERMISSION_DENIED The server refused the operation (rare in v0.1 — the gate runs in-agent)
PROTOCOL_ERROR Version mismatch, message out of sequence
TIMEOUT An ask hit the headless timeout (ARCHITECTURE §6.2)
RUNTIME_START_FAILED Durable Agent exists but its Runtime generation failed to start or become ready; retry is safe
SESSION_CORRUPT A selected session exists but its metadata/transcript cannot be safely restored
INTERNAL_ERROR Unexpected server-side failure
PROVIDER_ERROR The LLM provider call failed (mid-turn)
TOOL_ERROR A tool returned an error (also surfaces as tool_result with is_error)
CONFLICT The operation conflicts with current runtime/session state (e.g. session_op: compact while a turn is queued or in flight)

9. Per-Agent Protocol — Message Catalog #

All commands accept an optional id for correlation. Events never set id.

9.1 Lifecycle events #

hello (event, first on connect)

{ type: "hello", protocol_version: "0.1.0", server_version: string, capabilities: string[] }

sync (agent bootstrap event, sent after agent.attach or immediately after direct hello)

{ type: "sync", agent_id?: string, status: AgentStatus, mode: string,
  capabilities?: string[], runtime_epoch?: number,
  live_window: { messages: Message[], compaction_index?: number },
  pending_requests: (PermissionRequest | UiRequest)[] }

mode is the agent's authoritative active mode. agent_id is present on daemon connections; direct connections have one implicit agent and omit it.

capabilities is the attached runtime's secret-free feature set (saga vr7kgx.6): ["streaming-deltas", "mode-catalog", "mode-switch", ...] plus, when a model controller is wired, ["model-catalog", "model-switch"]. Current servers always emit it; the field is optional so an older or malformed runtime degrades to no controls rather than forcing a client to infer support from the protocol version (§7). On a daemon connection the daemon projects it from the child runtime's own hello.capabilities into this daemon-authored sync — the top-level daemon hello (above) stays transport-scoped (websocket, etc.) and does not advertise per-runtime model support globally. On a direct connection sync mirrors the child's own hello.capabilities. Clients gate model/mode controls on these strings, never on the protocol version.

runtime_epoch is a daemon-authored, non-negative child-runtime generation token. It starts at 0 and increments when a replacement process takes over the same agent_id. After the replacement reaches hello + sync readiness, the daemon emits a fresh authoritative sync to existing attachments with the new epoch and capabilities. Clients use the tuple { connection generation, agent_id, runtime_epoch } to discard catalogs and responses from the superseded process. The field is optional for compatibility and is normally omitted on direct connections.

Gives a reconnecting client enough to render the current state without replaying history.

status (event)

{ type: "status", agent_id: string, status: AgentStatus, detail?: string }
type AgentStatus =
  | "idle" | "streaming" | "waiting_for_permission" | "waiting_for_ui"
  | "compacting" | "error";

9.2 Drive commands #

prompt (command, streamed) — send a user message.

{ type: "prompt", agent_id?: string, message: string, images?: Image[],
  streaming_behavior?: "steer" | "follow_up" }
  • streaming_behavior is required if the agent is mid-turn; omit when idle.
    • "steer" — deliver after the current turn's tool calls, before the next model call.
    • "follow_up" — wait until the agent fully stops, then deliver.
  • Returns response { success } meaning accepted/queued. The turn plays out via events.

steer (command, streamed) — shorthand for prompt with streaming_behavior: "steer".

{ type: "steer", agent_id?: string, message: string, images?: Image[] }

abort (command, request/response) — cancel the current turn.

{ type: "abort", agent_id?: string }
// → response { success: true }

set_mode (command, request/response) — switch mode (plan/build/…).

{ type: "set_mode", agent_id?: string, mode: string }
// → response { success: true, data: { previous_mode: string, mode: string } }

An idempotent set (same mode) is acknowledged by its response without a broadcast. A real change emits the mode_changed event (§10.2) so every attached client updates without polling; the event is emitted before/alongside the successful response. An unknown mode returns a NOT_FOUND command error and preserves the old mode (no event).

list_modes (command, request/response, saga vr7kgx.6) — return the secret-free mode catalog and the session's authoritative active mode. The mode controller is mandatory, so this is always available on a live runtime (unlike list_models). It is read-only and never restores/wakes a stopped runtime on the daemon path: it is only routed when a live stream exists, and the daemon does not revive a runtime for list_modes.

{ type: "list_modes", agent_id?: string }
// → response { success: true, data: { modes: ModeDescriptor[], active: string } }

modes lists every registered built-in and extension mode in registration order as a secret-free { name, description } descriptor (mode functions/policy hooks never cross the wire); built-in and extension modes are described identically. active is the current mode's name and MUST occur in modes. Clients build mode options exclusively from this catalog — there is no free-form/custom mode entry — and the runtime performs final validation for races.

list_models (command, request/response) — return the secret-free model catalog and the session's authoritative active selection.

{ type: "list_models", agent_id?: string }
// → response { success: true, data: { models: ModelDescriptor[], active: ModelDescriptor } }

set_model (command, request/response) — queue a catalog selection for the session.

{ type: "set_model", agent_id?: string, model: string }
// → response { success: true, data: {
//      previous: ModelDescriptor, active: ModelDescriptor,
//      effective: "next_provider_call" } }

A valid selection is acknowledged immediately and emits the model_changed event below. It never interrupts an in-flight provider stream: the pending provider is applied at the next provider-call boundary (including the next iteration after tool execution), and remains the active model for subsequent calls. Unknown selections or provider-construction failures return a NOT_FOUND command error and do not change the active model.

Reasoning level controls (request/response) — available only when the active model advertises reasoning-catalog and reasoning-switch. Clients must use the server-authored catalog and must not hard-code effort names.

{ type: "list_reasoning_levels", id: string, agent_id?: string }
// → { model: ModelDescriptor, levels: { id: string, label: string, description?: string }[],
//     active: string, effective: "next_provider_call" }

{ type: "set_reasoning_level", id: string, agent_id?: string,
  model: string, effort: string }
// → { model: ModelDescriptor, previous: string, active: string,
//     effective: "next_provider_call" }

model is the active descriptor ID and acts as an optimistic-concurrency precondition; a stale picker returns CONFLICT. Unknown efforts return NOT_FOUND. Listing never wakes a stopped runtime; setting follows set_model restoration behavior. A genuine change emits reasoning_level_changed with { model, previous, active, effective }; an idempotent set emits no event. Changes are session-scoped, do not interrupt a stream, and apply at the next provider-call boundary.

session_op (command, request/response) — compact / fork / list.

{ type: "session_op", agent_id?: string,
  op: "list" | "compact" | "fork",
  args?: SessionOpArgs }
// → response { success: true, data: SessionOpResult }

SessionOpArgs/Result are op-specific. compact and fork are fully specified below. branch/rewind were removed from the protocol before implementation (saga 3aliod): fork-to-new-session is the single session mutation primitive — an in-place fork is redundant with rewind, and rewind creates history the model cannot see and has no impetus to recall.

session_op: compact #

Fire compaction on demand (saga fijex6). Manual compaction runs the same deterministic pipeline as automatic compaction but bypasses both the auto usage threshold and compaction.enabled — enabled:false disables only automatic triggering; the compactor/trigger/config seams must still be wired. Compaction is a between-turn operation at the orchestration level, so it is rejected with CONFLICT while a turn is queued or in flight (compaction is never queued).

// client → server
{ type: "session_op", op: "compact",
  args?: { keep_recent_tokens?: number } }   // override config.keepRecentTokens for this call

// server → client (success — a CompactionEntry was appended + a compaction event emitted)
{ type: "response", success: true,
  data: { compacted: true, index, tokens_before, tokens_after,
          first_kept_entry_id, message_range: [string, string] } }

// server → client (successful no-op — empty/too-small window, no safe cut boundary)
{ type: "response", success: true,
  data: { compacted: false, reason: "no_safe_cut" } }

Lifecycle: the server emits a status {status:"compacting"} immediately before running the pipeline, then (on success) the existing compaction event, then the correlated response, then status {status:"idle"} in finally — so direct and daemon-attached clients observe a real running interval and never see a stuck compacting indicator, including on no-op and error outcomes.

Error mapping:

Outcome Response
keep_recent_tokens is present but not a positive integer INVALID_REQUEST
Turn work queued or in flight CONFLICT
Compactor/trigger/config seams not wired (not configured) NOT_FOUND
Compactor threw or produced an empty summary (best-effort; no append/event) INTERNAL_ERROR

session_op: fork #

Split the conversation at a past entry into a new session (saga 3aliod). The live window at from_entry_id is materialized into a fresh session JSONL under a forks/ subdirectory of the source session's directory: a fork-marker checkpoint row carrying provenance (artifactKind: "fork" + { forkId, sourceSessionId, sourceEntryId }) roots the new chain, followed by copies of the window's message/tool_result rows with fresh ids. The source session is never mutated — no appends, no head move. To start talking on the fork, a session-first client refreshes session.list, selects the returned session_id, then uses session.activate to adopt/start the forked session. Legacy administrative clients may still use agent.create with the returned opaque id.

// client → server
{ type: "session_op", op: "fork",
  args: { from_entry_id: string } }        // the entry to fork at (must exist in this session)

// server → client (success)
{ type: "response", success: true,
  data: { fork_id: string, session_id: string, head_entry_id: string } }

Error mapping:

Outcome Response
from_entry_id missing or not a non-empty string INVALID_REQUEST
Turn work queued or in flight (between-turn operation) CONFLICT
No entry with that id in this session NOT_FOUND
Fork file could not be written (IO) INTERNAL_ERROR

9.3 Response envelope #

{ type: "response", id: string, command: string, success: boolean,
  data?: unknown, error?: { code: string, message: string, data?: unknown } }

command echoes the command type being responded to. id correlates with the command's id.

9.4 Streaming & turn events #

turn_start (event)

{ type: "turn_start", agent_id?: string, turn_id: string }

message_delta (event, high-bandwidth) — streaming content block deltas.

{ type: "message_delta", agent_id?: string, message_id: string,
  block_index: number,
  kind: "text" | "tool_call" | "thinking",
  // for text/thinking:
  text_delta?: string,
  // for tool_call (on first delta of the block):
  tool_call_id?: string, name?: string,
  args_delta?: string }
  • Append text_delta to block block_index.
  • For a tool_call block: the first delta carries tool_call_id + name; subsequent deltas carry args_delta (a JSON string fragment — concatenate, then parse when the block completes).
  • Clients render live from deltas; reconcile against message_complete.

message_complete (event) — final snapshot of a message.

{ type: "message_complete", agent_id?: string, message_id: string, message: Message }

tool_call (event) — a tool is about to execute (after permission, if required).

{ type: "tool_call", agent_id?: string, tool_call_id: string, name: string,
  args: Record<string, unknown>, render?: RenderDescriptor,
  read_only?: boolean }

render is the start-time RenderDescriptor (ARCHITECTURE §4.5) for clients that want a rich preview before the result lands.

read_only mirrors the registered tool's isReadOnly (§4.1 Tool contract) — true for read-only tools (read, grep, find, ls), false for mutating tools (write, edit, bash). Clients use it for per-tool-class glyph/indent (a read-only read reads differently at a glance from a mutating write). Absent → treat as mutating (the safer visual).

tool_result (event) — a tool finished.

{ type: "tool_result", agent_id?: string, tool_call_id: string,
  content: ToolResultContent, is_error: boolean,
  exit_code?: number, duration_ms?: number, render?: RenderDescriptor }

The server sends tool-result info as data; the client decides how to render it (saga 26haas). exit_code (shells/processes) and duration_ms (wall-clock) are structured fields the client renders itself — previously bash trapped them inside a command RenderDescriptor that pre-rendered and hid the output. content is the pure output; the model still sees the exit code as [exit code: N] text, injected at the history-assembly seam (the core owns the provider-facing tool-result text format). Both fields are optional/additive — pre-26haas clients omit them and fall back to the plain ↳ footer.

turn_end (event)

{ type: "turn_end", agent_id?: string, turn_id: string, usage?: Usage }

usage (event) — emitted after each provider call (finer-grained than turn-end).

{ type: "usage", agent_id?: string, input: number, output: number,
  cache_read?: number, cache_write?: number, cost?: number,
  context_tokens?: number, model?: string }

compaction (event) — a deterministic compaction ran. Fires between turns (the default tier: context above the reserve threshold, ~80% by default, compacted once the agent goes idle) and mid-turn at an iteration boundary when the context has reached 100% of the window (the emergency tier: the next provider call would otherwise overflow).

{ type: "compaction", agent_id?: string, index: number,
  tokens_before: number, tokens_after: number,
  first_kept_entry_id: string, message_range: [string, string] }

message_range is [firstId, lastId] of the summarized span — what recall uses to map a compaction index back to the exact message range (ARCHITECTURE §12).

error (event, async)

{ type: "error", agent_id?: string, code: string, message: string, fatal?: boolean }

fatal: true means the agent can't continue (e.g. provider down, session corrupt); the client should show the error and offer recovery. fatal: false is recoverable mid-turn.

9.5 Permission & UI requests #

permission_request (event, broadcast)

{ type: "permission_request", agent_id?: string, request_id: string,
  tool_name: string, input: unknown, prompt: string,
  options: ("allow" | "allow_always" | "deny")[],
  details?: PermissionDetail[], choices?: PermissionChoice[] }

options is the legacy semantic presentation. details and choices are optional, server-defined presentation data; clients render them but do not author policy. A detail is { label?: string, value: string, format?: "text" | "command" | "path" | "code" }. A choice is { id: string, label: string, description?: string, accelerator?: string, intent: "allow" | "persist" | "deny" }. Choice IDs are non-empty opaque strings: their meaning and any persisted rule are trusted server semantics, not a client-side DSL.

permission_response (command, fire-and-forget) — client answers a request. Exactly one response form is permitted: the structured opaque choice form or the legacy semantic form; they are mutually exclusive.

{ type: "permission_response", agent_id?: string, request_id: string,
  choice_id: string, reason?: string }
// or
{ type: "permission_response", agent_id?: string, request_id: string,
  decision: "allow" | "allow_always" | "deny", reason?: string }

A client capable of structured permissions uses choice_id in preference to the legacy decision form. Clients never submit rules. The legacy allow_always is always-exact: it means repeat this exact request, not a user-authored or inferred scope. Structured choices may carry other server-defined persistence semantics through their intent.

Resolution is first-valid-response-wins. The daemon validates a choice_id against the currently pending request before consuming the winner; an unknown ID is invalid and leaves the request non-terminal, so another response may win. Direct stdio/gate handling has the same behavior and ignores unknown IDs while waiting. Once a valid response wins, later responses are discarded silently (§6.3).

permission_resolved (event, broadcast) — closes the loop for all clients.

{ type: "permission_resolved", agent_id?: string, request_id: string,
  decision: "allow" | "allow_always" | "deny", source?: string }
// or
{ type: "permission_resolved", agent_id?: string, request_id: string,
  choice_id: string, source?: string }

source optionally identifies which client answered (for UI like "answered from web").

Permission presentation is generic, but runtime policy remains server-side. Backend grant candidate providers construct the available choices; registered runtime matchers interpret persisted rule kinds. Unknown or malformed kinds fail closed. The bash_signature kind is a runtime-grant-only specialized representation, not an authored configuration DSL: version 1 records executable names and an exact sorted flag set, and matches only when the analyzed command's executable/signature set satisfies those records. Other rule kinds are likewise interpreted by registered matchers rather than by clients.

ui_request (event, broadcast) — agent asks for a UI dialog.

{ type: "ui_request", agent_id?: string, request_id: string,
  kind: "select" | "confirm" | "input" | "editor" | "notify",
  title?: string, message?: string,
  options?: string[], placeholder?: string, prefill?: string,
  notify_type?: "info" | "warning" | "error" }

Fields are kind-specific (e.g. options for select, prefill for input/editor).

ui_response (command, fire-and-forget)

{ type: "ui_response", agent_id?: string, request_id: string,
  value: string | string[] | null }

ui_resolved (event, broadcast)

{ type: "ui_resolved", agent_id?: string, request_id: string, value: unknown }

10. Orchestrator Protocol — Message Catalog #

Only on daemon connections. Never carry agent_id (they're about the registry itself).

10.1 Commands #

agent.list (request/response)

{ type: "agent.list", id: string }
// → response { success: true, data: { agents: AgentInfo[] } }

agent.create (request/response) — spawn a new agent process.

{ type: "agent.create", id: string, cwd: string,
  session_id?: string, // opaque legacy session to adopt; daemon resolves ownership/path
  model?: string, provider?: string, mode?: string,
  tools?: string[], skills?: string[],
  headless_policy?: "deny" | "allow_if_rule_matches" | "queue",
  name?: string, parent_agent_id?: string,
  // Managed-child manifest fields (daemon sets these; clients do not pass them):
  system_prompt?: string,            // byte-bounded delegated child system prompt
  depth?: number,                     // durable depth (0 for root)
  launch_policy?: { maxTasks: number; maxDepth: number },
  alias?: string, definition_name?: string, display_name?: string,
  names?: string[]                    // pinned friendly-alias pool for this child's launches
}
// → response { success: true, data: { agent_id: string } }

When session_id is supplied, agent.create is the legacy-adoption operation. The daemon canonicalizes and ownership-checks cwd, resolves the opaque id, and atomically writes one Agent manifest before starting the Runtime. It never accepts a filesystem path from the client and never rewrites the existing JSONL. Repeating adoption returns INVALID_REQUEST (session is already adopted); missing state returns NOT_FOUND; malformed state returns SESSION_CORRUPT. A Runtime failure returns RUNTIME_START_FAILED while retaining the binding for retry. Managed create omits session_id and creates a fresh session id.

parent_agent_id set => this is a subagent; the parent's subagent tool uses this.

agent.attach (request/response) — subscribe to an agent's event stream.

{ type: "agent.attach", id: string, agent_id: string,
  role?: "interactive" | "observer" }   // default interactive; observers never answer prompts
// → response { success: true, data: { status: AgentStatus, info: AgentInfo } }

On success, the client begins receiving that agent's events (tagged with agent_id), including a sync event (§9.1) for current state.

agent.detach (request/response)

{ type: "agent.detach", id: string, agent_id: string }
// → response { success: true }

agent.terminate (request/response)

{ type: "agent.terminate", id: string, agent_id: string }
// → response { success: true, data: { deferred: boolean } }

Normal client-owned teardown. deferred:false means the Runtime was already inactive and shutdown began immediately; deferred:true means active work will finish before shutdown.

agent.kill (request/response)

{ type: "agent.kill", id: string, agent_id: string, force?: boolean }
// → response { success: true }

agent.status (request/response) — poll one or all agents.

{ type: "agent.status", id: string, agent_id?: string }
// → response { success: true, data: { agents: AgentInfo[] } }

Omit agent_id for all agents.

daemon.shutdown (request/response)

{ type: "daemon.shutdown", id: string, force?: boolean }
// → response { success: true }

Graceful by default: signals agents to finish turns, then exits. force kills immediately.

10.1a Workspace commands (protocol v1 additive) #

Workspace commands are daemon-level request/response messages and carry no routing agent_id. They use opaque workspace_id values; response projections do not expose daemon filesystem paths or raw repository/worktree paths. version?: 1 is accepted for forward-compatible clients.

{ type: "workspace.create", id: string, version?: 1, cwd: string,
  label?: string, branch?: string, base_ref?: string, integration_target?: string,
  title?: string, name?: string, model?: string, provider?: string, mode?: string }
{ type: "workspace.attach", id: string, version?: 1, workspace_id: string,
  title?: string, name?: string, model?: string, provider?: string, mode?: string }
{ type: "workspace.list", id: string, version?: 1, cwd?: string, include_retired?: boolean }
{ type: "workspace.status", id: string, version?: 1, workspace_id: string }
{ type: "workspace.retire", id: string, version?: 1, workspace_id: string,
  branch_disposition?: "delete_if_merged" | "keep" | "delete_confirmed" }

Retirement is explicit and safe by default. delete_if_merged only deletes a branch when the daemon proves reachability from the recorded integration target; keep retires without branch deletion. An unproven deletion request returns CONFLICT with typed { kind: "confirmation_required", workspace_id, reason, confirmation_disposition: "delete_confirmed" } and requires an explicit retry using delete_confirmed. Live, busy, dirty, missing-authority, and unsafe workspaces are refused with typed refusal data.

workspace.create and workspace.attach always launch a fresh daemon-generated Agent and Session; clients cannot provide either identity. Successful responses include agent_id, session_id, and workspace detail. Creation is managed-only in this slice. Workspace responses contain transport-safe summaries/details: opaque id, label, worktree cwd, ownership, lifecycle, fingerprints, branch/integration metadata, timestamps, counts, and (for detail) redacted binding ids and tombstone/conflict status. Common-dir and git-dir paths remain omitted. The additive repository_cwd field carries the ORIGINAL PROJECT's main-checkout root (the shared repository root, derived from the common-dir — never the common-dir/git-dir itself) so clients can group sibling worktrees under, and attribute each worktree to, the project it belongs to. It is the one repository path the projection exposes; older daemons omit the field and older clients ignore it.

10.2 Events #

model_changed (event, broadcast) — emitted after a valid set_model is accepted. On a daemon connection it carries the routing agent_id; direct connections omit it.

{ type: "model_changed", agent_id?: string,
  previous: ModelDescriptor, active: ModelDescriptor,
  effective: "next_provider_call", capabilities?: string[] }

The event describes an accepted pending switch; effective explicitly records that the provider changes only at the next provider-call boundary, never in the middle of a stream. Newer runtimes include the active model's post-switch capabilities, allowing clients to reveal or hide model-specific controls immediately. Clients remain compatible with older runtimes that omit this additive field.

mode_changed (event, broadcast, saga vr7kgx.6) — emitted by a runtime after a successful set_mode that actually changed the active mode. An idempotent set (same mode) is acknowledged by its set_mode response without a broadcast. On a daemon connection it carries the routing agent_id; direct connections omit it. The mode authority is the runtime's ModeController; the event mirrors the new effective mode to every attached client so a second browser updates without polling. It is emitted before/alongside the successful set_mode response; an error preserves the old mode and emits no event.

{ type: "mode_changed", agent_id?: string,
  previous_mode: string, mode: string }

Workspace lifecycle events (events, daemon broadcast) — workspace_created, workspace_attached, workspace_retired, and workspace_conflict carry the corresponding transport-safe workspace projection. They have no correlation id; workspace_attached also identifies the new agent_id and session_id, while conflict carries a sanitized reason.

agent_spawned (event)

{ type: "agent_spawned", agent_id: string, info: AgentInfo }

agent_exited (event)

{ type: "agent_exited", agent_id: string, runtime_state: "failed", reason: string,
  exit_code?: number }

agent_status_changed (event)

{ type: "agent_status_changed", agent_id: string, status: AgentStatus,
  runtime_state: RuntimeState, detail?: string }

agent_activity (event, global sanitized overview)

{ type: "agent_activity", agent_id: string, timestamp: number,
  phase: "idle" | "thinking" | "streaming" | "tool" | "waiting" |
         "compacting" | "retrying" | "error",
  status: AgentStatus, tool_name?: string }

This is broadcast independently of agent.attach and contains only coarse activity. It never contains prompts, model text, tool arguments/results, paths, descriptors, or transcripts. Receiving it does not attach, wake, restore, or register a permission responder.

Note: these events do not carry a top-level agent_id field on the message envelope for routing (they're orchestrator-level), but they do carry agent_id in their payload to identify which agent changed. This is the one place agent_id is payload, not routing.

10.3 Subagent job commands (background jobs) #

Daemon connections only. These are orchestrator commands: they carry a parent_agent_id payload (the owning durable parent) and no routing agent_id (§5.2). Ownership never crosses: a parent may target only jobs it launched and children whose manifest carries its parent_agent_id. A handle is an opaque string the daemon resolves — a typed id (job-…/run-…/agent-…) or a friendly alias. Resolution is exact typed IDs first, then parent-scoped aliases (case-insensitive NFKC). Task text is private: it appears only in subagent.create/relaunch payloads and the daemon-private job record — never in response data, events, AgentInfo, logs, or list broadcasts. Full semantics live in ARCHITECTURE §9; this catalog is the wire contract.

subagent.create (request/response) — accept a durable background job. Single form { name, task }; batch form { tasks:[{name,task}…], strategy }. The daemon validates the entire request before side effects, preallocates durable job_id/run_ids + agent_ids and reserved aliases, commits the job record, and returns immediately — it never waits for child startup/output/cleanup.

{ type: "subagent.create", id: string, parent_agent_id: string,
  name?: string, task?: string,                 // single form
  tasks?: { name: string; task: string }[],    // batch form (≥1)
  strategy?: "single" | "parallel" | "chain",  // default single; required for batch
  model?: string, provider?: string, mode?: string }  // inherited/override model
// → response { success: true, data: {
//     job_id: string, strategy: JobStrategy,
//     runs: [{ run_id, index, name, state: "queued", agent_id,
//              alias, definition_name?, display_name? }] } }

subagent.wait (request/response) — wait for handles to reach a terminal state. any returns when any supplied handle is terminal; all (default) when all are. Omitted timeout waits indefinitely; 0 is a non-blocking poll. Timeout stops waiting only — it never cancels work. Agent/alias targets snapshot to the current/queued run, else the latest terminal run, at acceptance (they do not follow a future relaunch). Parent abort/disconnect cancels only the waiter.

{ type: "subagent.wait", id: string, parent_agent_id: string,
  handles: string[],              // ≥1 job_id | run_id | agent_id | alias
  mode?: "any" | "all",           // default all
  timeout?: number }              // seconds; omitted = forever, 0 = poll
// → response { success: true, data: {
//     timed_out: boolean, aggregate_state: JobState | "mixed",
//     runs: RunSnapshot[] } }     // full ordered all-settled detail; final_text for owned parent

subagent.steer (request/response) — strict active-steer. target is run_id, agent_id, or alias. Accepted only while the resolved run is actively executing (including waiting on permission/UI). It drains into the target turn_id at the next provider boundary (injected); if the turn ends first it resolves conflict and is removed — it never silently becomes a future prompt and never leaks into the idle queue or a relaunch. Queued/terminal/unknown/non-owned targets return CONFLICT/NOT_FOUND.

{ type: "subagent.steer", id: string, parent_agent_id: string,
  target: string, message: string }   // lineage-true user message (private)
// → response { success: true, data: {
//     status: "injected" | "conflict", run_id: string, turn_id?: string,
//     correlation_id: string } }

subagent.relaunch (request/response) — relaunch a stopped/idle direct child with no queued/active run on the same durable Agent/session/config/grants/alias; new job_id/run_id and a new task. Active duplicates conflict. Conversation history is preserved. target is agent_id, run_id, or alias.

{ type: "subagent.relaunch", id: string, parent_agent_id: string,
  target: string, task: string }
// → response { success: true, data: {
//     job_id, run_id, agent_id, alias, definition_name?, display_name?, state: "queued" } }

subagent.status (request/response) — authoritative Agent/job/run snapshots. Omitting targets lists the parent's latest 50 directly-owned jobs, newest first (handle + alias recovery after a parent Runtime restart).

{ type: "subagent.status", id: string, parent_agent_id: string,
  targets?: string[] }   // job_id | run_id | agent_id | alias
// → response { success: true, data: {
//     agents: [{ agent_id, alias?, definition_name?, display_name?,
//                parent_agent_id?, run?: RunSnapshot }],
//     jobs?: JobSnapshot[] } }     // omitted when targets supplied; private task specs absent

subagent.stop (request/response) — cancel a job or run. A job_id handle cancels queued + active runs; a run_id/alias handle cancels only that Agent's active/queued run (parallel siblings continue; stopping the active step of a chain cancels the remaining chain). Returns after cancellation is accepted (queued → cancelled; active → abort→graceful→kill escalation). It never deletes durable Agents/sessions/jobs/completed records.

{ type: "subagent.stop", id: string, parent_agent_id: string,
  handle: string }   // job_id | run_id | alias
// → response { success: true, data: {
//     cancelled: [{ run_id, agent_id?, state: RunState }], job_id?: string } }

subagent_run_changed (event, broadcast) — emitted to the owning parent's connections after a run-state transition is persisted (so waiters/status views update). Orchestrator-level: no routing agent_id; parent_agent_id is a payload field. Private final task text is never included.

{ type: "subagent_run_changed", parent_agent_id: string, job_id: string, run_id: string,
  agent_id?: string, alias?: string, definition_name?: string, display_name?: string,
  state: RunState, previous_state?: RunState, error?: string, ended_at?: number }

Async flow + completion delivery + lease guard. subagent.create/relaunch/stop resolve once the durable record is committed / cancellation accepted — never after the run plays out, which is daemon-owned and asynchronous. After a terminal run transition is persisted, the daemon injects a structured completion report into the owning parent: steer when its turn is active, otherwise prompt to restore/wake it. subagent.wait blocks its socket only and is not required for normal completion delivery. The Broker consults a SubagentLeaseGuard before forwarding per-agent commands to a leased child: direct prompt → CONFLICT (one run at a time); permission/UI responses pass; direct human steer/abort → delegated through the coordinator's strict active-turn / cancellation path and recorded against the run. Direct prompt is therefore blocked while a run is leased; unleased agents are unaffected.

Restart rules. Terminal job/run records survive daemon and parent restarts (parent Runtime restart never affects execution). On daemon process restart, formerly nonterminal runs are marked interrupted and dependent chain work fails/cancels rather than replaying a possibly side-effecting prompt; the parent may explicitly subagent.relaunch the durable Agent. Automatic exactly-once crash replay is a non-goal for v1.

The shared JobState/RunState/RunSnapshot/JobSnapshot types are pinned in §11.2.


11. Shared Types #

These map to src/types/ (L0) and src/contracts/ (L1). Typebox schemas in code will mirror these shapes exactly, yielding JSON-schema-for-model + TS types + validation from one source (ARCHITECTURE decision #3).

interface Image { type: "image"; data: string; mime_type: string }  // data = base64

interface ModelDescriptor {
  id: string; provider: string; model: string;
  context_window?: number;
}

interface Message {
  id: string;
  role: "user" | "assistant";
  content: ContentBlock[];
}

type ContentBlock =
  | { type: "text"; text: string }
  | { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
  | { type: "tool_result"; tool_call_id: string; content: ToolResultContent; is_error?: boolean }
  | { type: "thinking"; text: string };

type ToolResultContent =
  | { kind: "text"; text: string }
  | { kind: "json"; value: unknown }
  | { kind: "error"; message: string };

type RenderDescriptor =
  | { kind: "text"; text: string }
  | { kind: "kv"; pairs: { k: string; v: string }[] }
  | { kind: "diff"; path: string; hunks: Hunk[] }
  | { kind: "tree"; root: TreeNode }
  | { kind: "progress"; label: string; current: number; total?: number }
  | { kind: "status"; label: string; tone: "info" | "warn" | "error" }
  | { kind: "command"; command: string; exitCode?: number; durationMs?: number }
  | { kind: "path_list"; paths: { path: string; kind: "file" | "dir"; hint?: string }[] }
  | { kind: "code"; path?: string; lang?: string; text: string };

interface Usage {
  input: number; output: number;
  cache_read?: number; cache_write?: number;
  cost?: number; context_tokens?: number; model?: string;
}

interface AgentInfo {
  agent_id: string;
  name?: string;
  cwd: string;
  model?: string; provider?: string; mode?: string;
  status: AgentStatus;
  runtime_state: RuntimeState;
  created_at: number;
  usage?: Usage;
  parent_agent_id?: string;  // present if a subagent
  /** Durable parent-scoped friendly alias (display-only; never an ID). */
  alias?: string;
  /** Definition name this Agent was launched from. */
  definition_name?: string;
  /** Derived `definition_name · alias` display string. */
  display_name?: string;
}

Hunk and TreeNode are intentionally underspecified in v0.1 — they follow standard unified-diff / nested-list shapes; pin them when the first diff/tree descriptor ships.

command, path_list, and code were added in S3 (additive per §4.5: new kinds never break existing clients). command previews a shell command (the closed-set kind remains available; bash no longer emits it — saga 26haas moved bash to structured exit_code/duration_ms data that the client renders itself, so the command shows once via the call line and the result shows the output + a status footer); path_list is a flat path listing emitted by find/ls/grep (content mode, hint = matched line); code is a file body under an optional path header emitted by read. lang on code is informational in v0.1 (no syntax highlighting; the architecture avoids a color dependency).

11.2 Subagent job/run types #

Pinned for §10.3. Opaque IDs carry prefix families (job-, run-, agent-) and are never derived from display strings. Task text is private and never appears in these wire types.

type JobState = "queued" | "running" | "completed" | "failed" | "cancelled";
type RunState =
  | "queued" | "starting" | "active"
  | "completed" | "failed" | "timed_out" | "cancelled" | "interrupted";
type JobStrategy = "single" | "parallel" | "chain";

interface RunSnapshot {
  job_id: string; run_id: string; index: number; name: string;
  agent_id?: string; alias?: string; definition_name?: string; display_name?: string;
  state: RunState; deadline?: number; final_text?: string;  // final_text only to the owning parent
  usage?: { input: number; output: number };
  error?: string; started_at?: number; ended_at?: number;
}

interface JobSnapshot {
  job_id: string; parent_agent_id: string; strategy: JobStrategy; state: JobState;
  runs: RunSnapshot[]; chain_step?: number; stopped_at?: number;
  created_at: number; ended_at?: number;   // private task_specs omitted
}

interrupted is the post-daemon-restart reconciliation state (§10.3 restart rules); terminal, no outgoing transitions, and explicitly relaunchable.


12. Worked Examples #

12.1 First prompt, happy path (direct stdio connection) #

hello receivedprompt message read the configresponse success true acceptedturn_startmessage_delta text_delta Reading config...tool_call id tc1 name read args file config.jsontool_result tc1 content text ... is_error falsemessage_delta text_delta The config says ...message_completeturn_end usageClientAgentClientAgent

12.2 Permission broadcast, first-response-wins (daemon, two clients) #

permission_request req-7 rm -rf buildpermission_request req-7permission_request req-7permission_response req-7 allow_alwaysforward allow_always drop latepermission_resolved req-7 allow_alwayspermission_resolved req-7permission_resolved req-7AgentDaemonClient 1Client 2 WebAgentDaemonClient 1Client 2 Web

12.3 Attach to a running agent, then detach (tmux-style) #

agent.attach id r1 agent_id a-3response success true status streamingsync agent_id a-3 status streaming live_window ...message_delta agent_id a-3 ...tool_result agent_id a-3 ...agent.detach id r2 agent_id a-3response success trueagent keeps runningClientDaemonAgent runningClientDaemonAgent running

12.4 Orchestrator multi-agent view #

agent.listresponse agents a-1 a-2agent.attach a-1agent.attach a-2tool_call agent_id a-1 ...status agent_id a-2 streamingone socket two streams demuxed by agent_idOrchestrator webDaemonAgent 1Agent 2Orchestrator webDaemonAgent 1Agent 2

12.5 Background subagent launch (daemon-managed) #

sequenceDiagram
    participant P as Parent agent
    participant D as Daemon JobCatalog/Coordinator
    participant S as Subagent child
    P->>D: subagent.create parent_agent_id P name task (unix socket)
    D->>D: validate, preallocate IDs + alias, commit job record
    D-->>P: response job_id run_id agent_id alias (before startup)
    Note over P: parent continues/finishes its turn; jobs keep running
    D->>S: materialize durable Agent (spawn shard agent headless)
    D-->>P: subagent_run_changed state active
    P->>D: subagent.wait handles run_id
    D-->>P: subagent_run_changed state completed
    D->>P: completion report as steer if active, otherwise prompt to wake
    D-->>P: response wait final_text (if parent also chose to wait)

The parent transcript receives no child progress; only the terminal completion report is injected into the parent model conversation. subagent.wait/subagent.status still return structured results for explicit synchronization and recovery. A direct child dashboard (separate agent.attach to the child) is the only place child progress streams; delegated permission/UI controls route up the hierarchy (ARCHITECTURE §6.3). Compare the pre-managed agent.create+agent.attach flow, which still exists for low-level administrative clients outside the model-tool ownership policy.

12.6 Reconnect after a drop #

reconnect with backoffhello protocol versionagent.attach a-3sync a-3 status waiting_for_permission pending_requests req-7permission_request req-7 re-shownpermission_response req-7 allowpermission_resolved req-7connection drops mid permission askClientDaemonClientDaemon

13. Open Protocol Questions #

# Question Notes
P1 args_delta framing for tool calls Concatenate-then-parse is simple but fragile if a delta splits a token. Acceptable for v0.1; revisit if we see parse failures. Alternative: per-arg field deltas.
P2 Backpressure on slow clients If a client can't drain events fast enough, does the daemon drop, buffer, or close? v0.1: buffer up to a cap, then drop the client. Pin the cap.
P3 session_op sub-spec Resolved (saga 3aliod): ops are list/compact/fork; branch/rewind dropped pre-implementation.
P4 Binary asset transport Images are base64 in Image today. If we stream large binary tool results, decide between base64-inline vs. a separate asset fetch endpoint.
P5 Per-agent ACLs v0.1 is single trust level (§4.3). When remote mode lands, the protocol needs an authz layer (who can touch which agent).
P6 Hunk / TreeNode shapes Underspecified (§11). Pin when the first diff/tree RenderDescriptor ships.
P7 Streaming cancellation granularity abort cancels the whole turn. Do we need cancel-single-tool-call? Probably not in v0.1.

14. Relationship to the Architecture #

This spec is the concrete form of ARCHITECTURE §7 (The Protocol). Decisions referenced:

  • Transports + framing — ARCHITECTURE §7.1, decision #9, #16
  • Multi-client broadcast / first-response-wins — ARCHITECTURE §6, decision #11
  • Headless policy & timeouts — ARCHITECTURE §6.2, decision #12
  • Auth token + Origin gate — ARCHITECTURE §14.2–14.3, decisions #13, #17
  • Subagent jobs/runs, aliases, lease guard, strict steer, restart rules — ARCHITECTURE §9
  • Attachment roles + hierarchical permission routing — ARCHITECTURE §6.3
  • RenderDescriptor neutrality — ARCHITECTURE §4.5, decision #4
  • Compaction message_range → recall — ARCHITECTURE §12

When this spec and ARCHITECTURE.md disagree, this spec wins on wire matters (it's the finer-grained contract) and ARCHITECTURE.md wins on architectural invariants. Reconcile discrepancies by editing the appropriate doc, not by silent divergence.