# 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 ```mermaid sequenceDiagram participant C as Client participant S as Server daemon or agent C->>S: open transport with token WS only S->>C: hello event protocol version and capabilities C->>S: agent.attach one or many daemon path only S->>C: streamed events tagged with agent_id C->>S: commands tagged with agent_id Note over C,S: client may detach or drop S->>C: events continue until detach or agent exits ``` 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:`, `expectedHost` is `127.0.0.1:`, the implicit `http://127.0.0.1:` 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** | `` (non-loopback) | plaintext, network-facing | none | `--web-host `, `--public-host `, `--allowed-origin `, `--allow-insecure-remote` | | **Trusted reverse proxy** | `127.0.0.1` (or a configured ``) | TLS at the proxy; plaintext to the daemon | `--trust-proxy ` | `--trust-proxy `, `--public-host `, `--allowed-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 ` and `--allowed-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:` 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 --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: ```ts 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.attach`ed and not yet `agent.detach`ed. ### 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). ```mermaid sequenceDiagram participant A as Agent participant D as Daemon participant C1 as Client 1 participant C2 as Client 2 A->>D: permission_request request_id req-7 D->>C1: permission_request req-7 D->>C2: permission_request req-7 C2->>D: permission_response req-7 allow_always D->>A: forward first response drop late A->>D: permission_resolved req-7 allow_always D->>C1: permission_resolved req-7 D->>C2: permission_resolved req-7 Note over C1,C2: both dismiss their dialogs ``` ### 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: ```ts { type: "hello", protocol_version: "0.1.0", server_version: "", 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)* ```ts { 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`)* ```ts { 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)* ```ts { type: "status", agent_id: string, status: AgentStatus, detail?: string } ``` ```ts type AgentStatus = | "idle" | "streaming" | "waiting_for_permission" | "waiting_for_ui" | "compacting" | "error"; ``` ### 9.2 Drive commands **`prompt`** *(command, streamed)* — send a user message. ```ts { 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"`. ```ts { type: "steer", agent_id?: string, message: string, images?: Image[] } ``` **`abort`** *(command, request/response)* — cancel the current turn. ```ts { type: "abort", agent_id?: string } // → response { success: true } ``` **`set_mode`** *(command, request/response)* — switch mode (plan/build/…). ```ts { 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`. ```ts { 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. ```ts { 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. ```ts { 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. ```ts { 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. ```ts { 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). ```ts // 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. ```ts // 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 ```ts { 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)* ```ts { type: "turn_start", agent_id?: string, turn_id: string } ``` **`message_delta`** *(event, high-bandwidth)* — streaming content block deltas. ```ts { 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. ```ts { type: "message_complete", agent_id?: string, message_id: string, message: Message } ``` **`tool_call`** *(event)* — a tool is about to execute (after permission, if required). ```ts { type: "tool_call", agent_id?: string, tool_call_id: string, name: string, args: Record, 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. ```ts { 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)* ```ts { type: "turn_end", agent_id?: string, turn_id: string, usage?: Usage } ``` **`usage`** *(event)* — emitted after each provider call (finer-grained than turn-end). ```ts { 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). ```ts { 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)* ```ts { 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)* ```ts { 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. ```ts { 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. ```ts { 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. ```ts { 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)* ```ts { type: "ui_response", agent_id?: string, request_id: string, value: string | string[] | null } ``` **`ui_resolved`** *(event, broadcast)* ```ts { 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)* ```ts { type: "agent.list", id: string } // → response { success: true, data: { agents: AgentInfo[] } } ``` **`agent.create`** *(request/response)* — spawn a new agent process. ```ts { 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. ```ts { 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)* ```ts { type: "agent.detach", id: string, agent_id: string } // → response { success: true } ``` **`agent.terminate`** *(request/response)* ```ts { 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)* ```ts { type: "agent.kill", id: string, agent_id: string, force?: boolean } // → response { success: true } ``` **`agent.status`** *(request/response)* — poll one or all agents. ```ts { type: "agent.status", id: string, agent_id?: string } // → response { success: true, data: { agents: AgentInfo[] } } ``` Omit `agent_id` for all agents. **`daemon.shutdown`** *(request/response)* ```ts { 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. ```ts { 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. ```ts { 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. ```ts { 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)* ```ts { type: "agent_spawned", agent_id: string, info: AgentInfo } ``` **`agent_exited`** *(event)* ```ts { type: "agent_exited", agent_id: string, runtime_state: "failed", reason: string, exit_code?: number } ``` **`agent_status_changed`** *(event)* ```ts { type: "agent_status_changed", agent_id: string, status: AgentStatus, runtime_state: RuntimeState, detail?: string } ``` **`agent_activity`** *(event, global sanitized overview)* ```ts { 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_id`s + `agent_id`s and reserved aliases, commits the job record, and returns immediately — it **never** waits for child startup/output/cleanup. ```ts { 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. ```ts { 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`. ```ts { 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. ```ts { 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). ```ts { 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. ```ts { 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. ```ts { 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). ```ts 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 } | { 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. ```ts 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) ```mermaid sequenceDiagram participant C as Client participant A as Agent C->>A: hello received C->>A: prompt message read the config A->>C: response success true accepted A->>C: turn_start A->>C: message_delta text_delta Reading config... A->>C: tool_call id tc1 name read args file config.json A->>C: tool_result tc1 content text ... is_error false A->>C: message_delta text_delta The config says ... A->>C: message_complete A->>C: turn_end usage ``` ### 12.2 Permission broadcast, first-response-wins (daemon, two clients) ```mermaid sequenceDiagram participant A as Agent participant D as Daemon participant C1 as Client 1 participant W as Client 2 Web A->>D: permission_request req-7 rm -rf build D->>C1: permission_request req-7 D->>W: permission_request req-7 W->>D: permission_response req-7 allow_always D->>A: forward allow_always drop late A->>D: permission_resolved req-7 allow_always D->>C1: permission_resolved req-7 D->>W: permission_resolved req-7 ``` ### 12.3 Attach to a running agent, then detach (tmux-style) ```mermaid sequenceDiagram participant C as Client participant D as Daemon participant A as Agent running C->>D: agent.attach id r1 agent_id a-3 D->>C: response success true status streaming D->>C: sync agent_id a-3 status streaming live_window ... D->>C: message_delta agent_id a-3 ... D->>C: tool_result agent_id a-3 ... C->>D: agent.detach id r2 agent_id a-3 D->>C: response success true Note over A: agent keeps running ``` ### 12.4 Orchestrator multi-agent view ```mermaid sequenceDiagram participant O as Orchestrator web participant D as Daemon participant A1 as Agent 1 participant A2 as Agent 2 O->>D: agent.list D->>O: response agents a-1 a-2 O->>D: agent.attach a-1 O->>D: agent.attach a-2 D->>O: tool_call agent_id a-1 ... D->>O: status agent_id a-2 streaming Note over O: one socket two streams demuxed by agent_id ``` ### 12.5 Background subagent launch (daemon-managed) ```mermaid 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 ```mermaid sequenceDiagram participant C as Client participant D as Daemon Note over C: connection drops mid permission ask C->>D: reconnect with backoff D->>C: hello protocol version C->>D: agent.attach a-3 D->>C: sync a-3 status waiting_for_permission pending_requests req-7 D->>C: permission_request req-7 re-shown C->>D: permission_response req-7 allow D->>C: permission_resolved req-7 ``` --- ## 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.