/** * Envelope — the one shape every protocol message shares (PROTOCOL §5). * * Commands and events use the same envelope; the difference is direction and correlation, * not structure (§4 design rule #4). `id` correlates a command with its `response`; events * never carry `id`. `agent_id` is the daemon-multiplexing key (§5.2). * * Wire-safe by construction: open object schema (`[key: string]: unknown`) so type-specific * payloads ride on the same envelope without a special-cased frame (§4 design rule #2). */ import { Type, type Static } from '@sinclair/typebox'; /** * The shared message envelope. `type` selects the message; the rest of the payload is * carried as additional fields (validated by the catalog schema in `catalog.ts`). */ export const EnvelopeSchema = Type.Object( { /** Correlation id. If set on a command, the response carries the same id (§5.1). */ id: Type.Optional(Type.String()), /** Message type, e.g. "prompt", "tool_call", "agent.list", "response" (§5). */ type: Type.String(), /** * On daemon connections, identifies the target/owning agent for per-agent messages. * Omitted on orchestrator messages and on direct (stdio / dedicated-socket) connections * (§5.2). The catalog encodes this rule per message type. */ agent_id: Type.Optional(Type.String()), }, // Allow type-specific payload fields — "no special-cased frames" (§4 rule #2). { additionalProperties: Type.Unknown() }, ); /** * The envelope type carries an open payload index signature (`[key: string]: unknown`), * matching PROTOCOL §5 — type-specific fields ride on the same envelope without a * special-cased frame. `Static` alone only has the declared * fields, so we intersect the index signature for the type-level openness. */ export type Envelope = Static & { [key: string]: unknown }; /** * Connection multiplexing mode — governs the `agent_id` rule (§5.2). * * - `daemon`: per-agent messages REQUIRE `agent_id`; orchestrator messages MUST NOT carry * one (they're about the registry itself). * - `direct`: the connection is bound to exactly one agent; `agent_id` is always omitted. */ export type ConnectionMode = 'daemon' | 'direct'; /** * Correlation mode for a message type (§5.1). * * - `request-response` — command sets `id`; server replies with a `response` carrying the * same `id` (e.g. `agent.list`, `session_op`, `abort`). * - `streamed` — command may set `id`; server replies with a `response` indicating * *acceptance*; the real outcome arrives via events (e.g. `prompt`, `steer`). * - `event` — never carries a correlation `id`; not a response. (The exceptions * `permission_resolved` / `ui_resolved` carry `request_id`, not `id` — §10.) */ export type CorrelationMode = 'request-response' | 'streamed' | 'event'; /** * Command/event/response discrimination requires the full message catalog (it can't be * decided from the envelope alone — e.g. `tool_call` is an event but a naive type check * can't tell). Those helpers live in `catalog.ts` (sub-saga m44qnh.4). The envelope layer * only owns the shape + the agent_id rule. */ /** * Validate a message's `agent_id` against the connection mode (§5.2). * * @param msg the envelope to check * @param mode connection mode * @param requiresAgentId whether this message type carries a per-agent `agent_id` on a * daemon connection (true) or is an orchestrator message that must NOT carry one (false). * @returns `null` if valid, or a human-readable PROTOCOL_ERROR-style reason. * * On `direct` connections `agent_id` must be absent regardless of type. */ export function validateAgentId( msg: Envelope, mode: ConnectionMode, requiresAgentId: boolean, ): string | null { if (mode === 'direct') { if (msg.agent_id !== undefined) { return 'agent_id must be omitted on a direct connection (§5.2)'; } return null; } // daemon if (requiresAgentId) { if (msg.agent_id === undefined || msg.agent_id === '') { return 'per-agent message requires agent_id on a daemon connection (§5.2)'; } } else if (msg.agent_id !== undefined) { return 'orchestrator message must not carry agent_id (§10.2 / §5.2)'; } return null; } /** * Outbound counterpart to {@link validateAgentId} for direct connections (§5.2). * * On a direct connection `agent_id` is always omitted — the connection is bound to * exactly one agent (§5.2). Orchestrator events (`agent_spawned` etc.) never flow on a * direct connection (the daemon is the only source), so unconditionally deleting * `agent_id` is safe for every message a direct server will actually emit. `hello` (§9.1) * already has no `agent_id`, so this is a no-op on it; per-agent events (turn_start, * message_complete, tool_call, …) get the routing key stripped. * * Returns a shallow copy; the input is untouched. Transport-neutral so the future * unix-socket direct path reuses it. * * @param msg the event/response to send on a direct connection * @returns a copy with `agent_id` removed */ export function omitRoutingAgentId(msg: T): T { if (msg.agent_id === undefined) return msg; // Shallow copy without the routing key. We don't deep-clone the payload — events are // plain data and the caller doesn't keep a mutated alias. const { agent_id: _drop, ...rest } = msg; void _drop; return rest as T; }