/** * Message catalog — the single union of every protocol message (PROTOCOL §5 cross-ref). * * Ties together the command catalog (§9.2/§9.5/§10.1), response (§9.3), lifecycle events * (§9.1), the streaming/turn/permission/UI events from `contracts/event.ts` (§9.4/§9.5), * and the orchestrator events (§10.2). `Message` is the closed set a transport can carry. * * Also provides: * - runtime dispatch helpers (isCommand/isEvent/isResponse/isOrchestratorMessage) — the * full envelope-level discrimination that `envelope.ts` deliberately omits. * - a `MESSAGE_TYPE_RULES` table encoding the §5 correlation mode + agent_id requirement * per message type, so transports can validate without a separate hand-maintained map. */ import { Type, type Static } from '@sinclair/typebox'; import { Value } from '@sinclair/typebox/value'; import { EventSchema } from '@/contracts/event'; import { CommandSchema } from '@/protocol/commands'; import { ResponseSchema } from '@/protocol/response'; import { LifecycleEventSchema } from '@/protocol/lifecycle'; import { OrchestratorEventSchema } from '@/protocol/orchestrator-events'; import { SubagentRunChangedSchema } from '@/protocol/subagent-commands'; import type { Envelope } from '@/protocol/envelope'; import type { CorrelationMode } from '@/protocol/envelope'; /** * The full v0.1 message catalog. Order is conventional (commands, response, lifecycle, * streaming/permission events, orchestrator events) and does not affect matching — typebox * `Type.Union` validates against any member. */ export const WireMessageSchema = Type.Union([ CommandSchema, ResponseSchema, LifecycleEventSchema, EventSchema, OrchestratorEventSchema, SubagentRunChangedSchema, ]); /** The full v0.1 wire message (any command, response, or event). */ export type WireMessage = Static; // ─── Dispatch helpers (envelope-level) ─────────────────────────────────────────── /** All command `type` values (client→server). */ const COMMAND_TYPES = new Set([ 'prompt', 'steer', 'abort', 'set_mode', 'list_modes', 'list_models', 'set_model', 'list_reasoning_levels', 'set_reasoning_level', 'session_op', 'permission_response', 'ui_response', 'agent.list', 'agent.create', 'agent.attach', 'agent.detach', 'agent.terminate', 'agent.kill', 'agent.status', 'daemon.shutdown', 'web.approve', 'web.token.create', 'web.clients', 'web.revoke', 'web.token.rotate', 'session.create', 'session.status', 'session.terminate', 'session.kill', 'session.activate', 'session.list', 'session.replay', // Tool-usage stats aggregate (PROTOCOL §10 stats surface). 'stats.tool_usage', // Token-usage stats aggregate (PROTOCOL §10 stats surface; sibling to tool-usage). 'stats.token_usage', 'workspace.create', 'workspace.attach', 'workspace.list', 'workspace.status', 'workspace.retire', // Subagent job orchestrator commands (saga u4uo9m.1). 'subagent.create', 'subagent.wait', 'subagent.steer', 'subagent.relaunch', 'subagent.status', 'subagent.stop', ]); /** All response / lifecycle / streaming / orchestrator event `type` values (server→client). */ const EVENT_TYPES = new Set([ // response 'response', // lifecycle (§9.1) 'hello', 'sync', 'status', // streaming & turn (§9.4) 'turn_start', 'message_delta', 'message_complete', 'tool_call', 'tool_result', 'turn_end', 'usage', 'compaction', 'provider_retry', 'model_changed', 'mode_changed', 'reasoning_level_changed', 'error', // permission / ui (§9.5) 'permission_request', 'permission_resolved', 'ui_request', 'ui_resolved', // managed strict steer ack (saga u4uo9m.3) 'managed_steer_ack', // orchestrator (§10.2) 'agent_spawned', 'agent_exited', 'agent_status_changed', 'agent_activity', 'session_runtime_changed', 'workspace_created', 'workspace_attached', 'workspace_retired', 'workspace_conflict', // subagent job run-state changes (saga u4uo9m.1). 'subagent_run_changed', ]); /** Whether a message is a client→server command. */ export function isCommand(msg: Envelope): boolean { return COMMAND_TYPES.has(msg.type); } /** Whether a message is a server→client response (§9.3). */ export function isResponse(msg: Envelope): boolean { return msg.type === 'response'; } /** Whether a message is a server→client event (lifecycle, streaming, permission/ui, orchestrator). */ export function isEvent(msg: Envelope): boolean { return EVENT_TYPES.has(msg.type); } /** Whether a message is an orchestrator-level event (§10.2, daemon-only, no routing agent_id). */ export function isOrchestratorMessage(msg: Envelope): boolean { return ( msg.type === 'agent_spawned' || msg.type === 'agent_exited' || msg.type === 'agent_status_changed' || msg.type === 'agent_activity' || msg.type === 'session_runtime_changed' || msg.type === 'workspace_created' || msg.type === 'workspace_attached' || msg.type === 'workspace_retired' || msg.type === 'workspace_conflict' || msg.type === 'subagent_run_changed' ); } // ─── §5 rule table (correlation mode + agent_id requirement) ──────────────────── /** * Whether a per-agent message type requires `agent_id` on a daemon connection (§5.2). * Orchestrator messages (agent.* / daemon.*) do NOT carry routing agent_id. This mirrors * the type-level rule encoded in `commands.ts` (additionalProperties:false on orchestrator * commands) for runtime validation of inbound messages. */ export function requiresAgentIdOnDaemon(type: string): boolean { // Orchestrator commands + events never carry a routing agent_id. if (ORCHESTRATOR_TYPES.has(type)) return false; // hello is connection-level (no agent context yet). if (type === 'hello') return false; // Everything else is per-agent: prompt, steer, abort, set_mode, list_models, set_model, // list_reasoning_levels, set_reasoning_level, session_op, permission_response, // ui_response, and all §9.4/§9.5 events. On a daemon connection these carry agent_id; // on a direct connection it's omitted (envelope.validateAgentId). return true; } /** * Whether an orchestrator command carries `agent_id` as a PAYLOAD field (the target), NOT * a routing key (§10.1). `agent.attach`/`detach`/`kill`/`status` name their target via * `agent_id`; `agent.list`/`agent.create`/`daemon.shutdown` do not carry it at all. The * daemon-side broker dispatches orchestrator commands BEFORE the routing-agent_id check * (§5.2), so this distinction matters only on the CLIENT side (a client wire must not * reject `agent.attach`'s target `agent_id` as a spurious routing key — saga 8usqif). */ export function orchestratorCarriesAgentIdPayload(type: string): boolean { return ORCHESTRATOR_AGENT_ID_PAYLOAD_TYPES.has(type); } const ORCHESTRATOR_TYPES = new Set([ 'agent.list', 'agent.create', 'agent.attach', 'agent.detach', 'agent.terminate', 'agent.kill', 'agent.status', 'daemon.shutdown', 'web.approve', 'web.token.create', 'web.clients', 'web.revoke', 'web.token.rotate', 'session.create', 'session.status', 'session.terminate', 'session.kill', 'session.activate', 'session.list', 'session.replay', // Tool-usage stats aggregate (PROTOCOL §10 stats surface). 'stats.tool_usage', // Token-usage stats aggregate (PROTOCOL §10 stats surface; sibling to tool-usage). 'stats.token_usage', // Workspace lifecycle commands (saga j8dtju.6). 'workspace.create', 'workspace.attach', 'workspace.list', 'workspace.status', 'workspace.retire', // Subagent job orchestrator commands (saga u4uo9m.1). 'subagent.create', 'subagent.wait', 'subagent.steer', 'subagent.relaunch', 'subagent.status', 'subagent.stop', 'agent_spawned', 'agent_exited', 'agent_status_changed', 'agent_activity', 'subagent_run_changed', ]); /** Orchestrator commands that name a target agent via `agent_id` (payload, not routing). */ const ORCHESTRATOR_AGENT_ID_PAYLOAD_TYPES = new Set([ 'agent.attach', 'agent.detach', 'agent.terminate', 'agent.kill', 'agent.status', ]); /** * The §5.1 correlation mode for a message type. Commands are request/response or streamed; * events never carry a correlation `id`. */ export function correlationModeFor(type: string): CorrelationMode { if (type === 'response') return 'request-response'; // reply direction if (!COMMAND_TYPES.has(type)) return 'event'; // Streamed commands: their real outcome arrives via events (§5.1). if ( type === 'prompt' || type === 'steer' || type === 'permission_response' || type === 'ui_response' ) { return 'streamed'; } // request/response commands: agent.list/create/attach/detach/kill/status, // daemon.shutdown, session.list, abort, set_mode, list_models, set_model, // list_reasoning_levels, set_reasoning_level, session_op. return 'request-response'; } /** Validate an inbound message against the catalog + the §5 rules. */ export function validateMessage( msg: unknown, ): { ok: true; message: WireMessage } | { ok: false; error: string } { if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) { return { ok: false, error: 'message must be a JSON object' }; } const env = msg as Envelope; if (typeof env.type !== 'string') { return { ok: false, error: 'message missing string `type`' }; } if (!COMMAND_TYPES.has(env.type) && !EVENT_TYPES.has(env.type)) { return { ok: false, error: `unknown message type "${env.type}"` }; } if (COMMAND_TYPES.has(env.type) && !Value.Check(CommandSchema, msg)) { return { ok: false, error: `invalid command payload for "${env.type}"` }; } if (EVENT_TYPES.has(env.type) && !Value.Check(WireMessageSchema, msg)) { return { ok: false, error: `invalid event payload for "${env.type}"` }; } // Events must not carry a correlation `id` (§5.1). if (EVENT_TYPES.has(env.type) && env.type !== 'response' && env.id !== undefined) { return { ok: false, error: `event "${env.type}" must not carry a correlation id (§5.1)`, }; } return { ok: true, message: env as WireMessage }; }