/** * Relay envelope dispatcher (CLI side). * * Translates inbound `RelayFrame`s from the backend into calls against the * pure REST handlers (`handlers/projects.ts`, `handlers/sessions.ts`) and * the live `SessionStreamManager`. Pure module — no socket, no timers, * no logging side-channels except what the caller passes in. The caller * (`commands/serve.ts`) owns lifecycle and the `RelayClient`. * * Two entry points, one per inbound frame kind we route: * * - `handleRestRequest(frame, deps)` — synchronous-style request/response. * Resolves to a `RestResponseFrame` the caller sends back via * `relay.send(...)`. Errors thrown by handlers are mapped to status * codes (400/404/500) and returned in the body, never re-thrown — the * backend has a 30 s pending-request timeout and we want every request * to terminate with a wire response so the browser doesn't sit on a * stale spinner. * * - `handleClientMessage(frame, deps)` — fire-and-forget. Attaches a * `Subscriber` (the relay socket, wrapped) to the session if not * already attached, then calls `manager.prompt()`. Outbound * `ServerEvent`s flow back through the subscriber as `WsEventFrame`s * via `deps.relay.send()`. * * Subscriber bookkeeping lives in `serve.ts`, NOT here. The dispatcher * is given the subscriber by the caller so it stays pure-ish (depends * only on `manager` + `relay.send`). This also keeps the lifecycle for * "detach all on relay disconnect" in one place. * * Path matching is a 30-line inline switch; we deliberately do not pull * in `path-to-regexp` for five literal patterns. The matcher returns * the parsed `id` param when applicable. * * Close-code policy (mirroring backend `router.ts`): * - The dispatcher itself never closes the relay; that's serve.ts. * - Backend cancels pending REST requests with `code:"timeout"` (30 s) * or `code:"machine_offline"` (CLI socket dropped). Both are * transparent to this layer — we just respond when we can. */ import type { AutoOptimizeFrame, AutoResearchFrame, CancelTurnFrame, ClientMessageFrame, MetaEvent, RestRequestFrame, RestResponseFrame, SubscribeFrame } from "@aexol/relay-protocol"; import type { McpExtensionState } from "../mcp/state.js"; import type { SessionStreamManager, Subscriber } from "../server/session-stream.js"; import type { SessionStore } from "../server/storage.js"; import { coerceImages, hasImages, MAX_INLINE_IMAGE_BASE64_CHARS } from "../server/image-attachments.js"; import type { DevProcessRegistry } from "../server/dev-process-registry.js"; import type { RelayClient } from "./client.js"; /** * Result of matching a request path against a route table entry. * `id` is populated when the route had an `:id` placeholder. */ interface RouteMatch { route: "list_projects" | "create_project" | "update_project" | "delete_project" | "bind_studio" | "unbind_studio" | "get_project_observations" | "list_project_sessions" | "cleanup_project_sessions" | "cleanup_all_sessions" | "create_session" | "get_session" | "get_session_active_agent" | "update_session_active_agent" | "get_session_messages" | "update_session" | "delete_session" | "get_session_memory" | "get_session_memory_details" | "compact_session" | "remember_and_delete_session" | "fork_session" | "list_path_autocomplete" | "list_directory" | "get_home_directory" | "search_project_files" | "enqueue_prompt" | "get_prompt_queue" | "remove_prompt" | "clear_prompt_queue" | "list_mcp_status" | "reload_mcp_config" | "refresh_native_extensions" | "refresh_cloud_agents" | "get_settings" | "put_settings" | "get_agent_settings" | "put_agent_settings" | "get_prompt_mutation_settings" | "put_prompt_mutation_settings" | "wizard_login" | "list_agents" | "list_dev_processes" | "start_dev_process" | "kill_dev_process" | "restart_dev_process" | "scan_dev_servers" | "kill_dev_server"; id?: string; /** Parsed query params, if the path carried a `?...` suffix. */ query?: URLSearchParams; /** For `remove_prompt`: the queue item id extracted from the path. */ itemId?: string; /** For `kill_dev_server`: optional target port from the query string. */ port?: number; } /** * Inline path matcher. Returns `null` for any path/method combination we * don't recognise; the caller turns that into a `404 Unknown route`. * * Intentionally literal — a regex table would be marginally fancier but * also marginally slower and harder to read for ~11 routes. */ export declare function matchRoute(method: string, path: string): RouteMatch | null; /** * Best-effort publisher for project/session lifecycle meta events. Bound * by `serve.ts` to `relay.send({kind:"meta_event", machineId, event})`; * tests pass a recording stub. Always called inside a try/catch by the * dispatcher so a publish failure never breaks the REST response — the * mutation already succeeded. * * Omit (`undefined`) to disable publishing entirely (used in the handler * unit tests where there's no relay). */ export type PublishMetaEvent = (event: MetaEvent) => void; export interface RestRequestDeps { store: SessionStore; manager: SessionStreamManager; /** Working directory for this machine. Used by settings persistence. Defaults to homedir. */ cwd?: string; /** Agent config dir (`~/.spectral/agent` by default). Passed through to agent-settings persistence. */ agentDir?: string; /** * Optional logger for unexpected errors and per-request timing. * Defaults to `console`; optional methods are guarded so a minimal * `{ error }` logger (used by tests) remains valid. */ logger?: { error?: (...args: unknown[]) => void; info?: (...args: unknown[]) => void; }; /** * Optional MCP extension state. Passed through by `serve.ts` from the * `AgentBridge`. When set, `GET /api/mcp-status` returns the current * machine-local MCP snapshot. */ mcpState?: McpExtensionState; /** Machine-level dev process registry. Used by `/api/dev-processes`. */ devProcessRegistry?: DevProcessRegistry; /** * Optional meta-event publisher. Invoked AFTER each successful mutation * (create/update/delete project, create/update/delete session) so all * tabs of all browsers subscribed to this machine refetch. Read paths * (`list_*`, `get_*`) never publish. * * Failure handling: any throw is caught + logged; we never propagate * publish failures into the REST response (the SQLite mutation has * already committed and the caller deserves the success it asked for). */ publishMetaEvent?: PublishMetaEvent; refreshNativeExtensions?: () => Promise; /** * Refresh the cloud-agent cache. Returns a result describing whether the * backend rejected auth; callers that just need a cache warm may ignore it * (the bridge handles auth-rejection separately via onCloudAgentsAuthRejected). */ refreshCloudAgents?: (teamId: string) => Promise; startWizardLogin?: () => Promise; } export interface WizardLoginResult { status: "configured" | "login_url_observed" | "started" | "completed" | "already_running" | "package_unavailable" | "spawn_failed" | "exited"; packageName: string; message: string; loginUrl?: string; startedAt?: number; } export declare function startWizardLogin(directory?: string): Promise; /** * Dispatch a `rest_request` frame. Always resolves with a * `RestResponseFrame` — handler exceptions are caught and translated. * * Status mapping: * - 200: handler returned successfully (body is the handler's return value * or `{ ok: true }` for void returns) * - 400: `BadRequestError` thrown by handler, OR malformed body for a * route that requires one, OR unknown route * - 404: `NotFoundError` thrown by handler * - 405: route matches but method doesn't (returned as 404 since we don't * distinguish — the matcher treats it as "no route", consistent * with the original Hono router which also 404'd) * - 500: anything else; `error` field carries a sanitized message */ export declare function handleRestRequest(frame: RestRequestFrame, deps: RestRequestDeps): Promise; /** * Wire-boundary coercion of an `images` payload. Accepts url-hosted * attachments (preferred — only the pointer travels over the relay) and * inline base64 up to `MAX_INLINE_IMAGE_BASE64_CHARS` (measured on the * base64 string, i.e. on the wire payload). Rejected entries are * reported through `onReject`. * * Re-exported from `../server/image-attachments.js` so the relay and the * local HTTP/queue paths share one implementation. */ export { coerceImages, hasImages, MAX_INLINE_IMAGE_BASE64_CHARS }; export interface ClientMessageDeps { manager: SessionStreamManager; /** Live relay client used to send `WsEventFrame`s back. */ relay: Pick; /** * Subscriber registry. The dispatcher attaches per `(sessionId)` exactly * once and reuses the same `Subscriber` instance for subsequent * `client_message` frames addressed to the same session. The caller is * responsible for clearing this on relay disconnect via * `detachAll(deps)`. * * Keyed by `sessionId` (machine routing already happened at the backend; * the CLI only ever sees frames addressed to itself). */ subscribers: Map; /** Optional logger for prompt-side errors. Defaults to console.error. */ logger?: { error: (...args: unknown[]) => void; }; } /** * Dispatch a `client_message` frame. Idempotent w.r.t. attach: the same * `Subscriber` is reused across messages for a given session. * * Errors: * - Unknown sessionId → emits a `ws_event` carrying an `error`-typed * `ServerEvent` so the browser sees it on the established stream * (mirrors how the old WS route surfaced `manager.attach` failures). * - Unknown message shape → silently ignored with a logger warning; * the wire contract is `{type:"user_message", content:string}` and * anything else is a protocol violation we don't escalate. * - `manager.prompt()` rejection → logged; the manager itself broadcasts * an `error` event to subscribers, so we don't double-report. */ export declare function handleClientMessage(frame: ClientMessageFrame, deps: ClientMessageDeps): Promise; /** * Dispatch a `subscribe` frame from the backend. Handles the case where a * browser enters an old session — we load history from SQLite immediately * via `manager.attach()` and synthesize `session_ready` so the browser sees * the full chat history without needing to send a first `client_message`. * * Idempotent: if a subscriber already exists for this session, we re-send * `session_ready` (history may have changed, and the newly-joined browser * tab needs it). The `ready.catch` handler is only registered once — on the * first subscriber creation — to avoid duplicate error events. */ export declare function handleSubscribe(frame: SubscribeFrame, deps: ClientMessageDeps): Promise; /** * Dispatch a `cancel_turn` frame. Disposes the session's agent bridge and * removes the stream so the next user message creates a fresh one. The * bridge dispose triggers `agent_end` broadcast to all subscribers. * * Idempotent: no-ops when no stream exists for the session. */ export declare function handleCancelTurn(frame: CancelTurnFrame, deps: { manager: SessionStreamManager; logger?: { error: (...args: unknown[]) => void; }; }): void; /** * Detach every subscriber the dispatcher has attached. Called by * `serve.ts` on relay disconnect / shutdown so the underlying spectral * processes don't keep an unreachable subscriber pinned. * * NOTE: this does NOT dispose the streams themselves — spectral keeps running * so a future browser reconnect can resume mid-turn. Use * `manager.dispose()` at full shutdown. */ export declare function detachAllSubscribers(manager: SessionStreamManager, subscribers: Map): void; export interface AutoResearchDeps { store: SessionStore; manager: SessionStreamManager; relay: Pick; /** Subscriber map shared with handleClientMessage. */ subscribers: Map; /** Project working directory. */ cwd: string; /** Optional logger. Defaults to console.error. */ logger?: { error: (...args: unknown[]) => void; }; } /** * Dispatch an `auto_research` frame. Sends the auto-research task through * the existing AgentBridge (backend proxy) instead of spawning a separate spectral * subprocess. This ensures auto-research uses the same model and API keys * as the active session. * * Progress is streamed back as `ws_event` frames carrying * `auto_research_*` ServerEvent types on the `sessionId` channel. * * Errors are surfaced as `auto_research_error` events. */ export declare function handleAutoResearchFrame(frame: AutoResearchFrame, deps: AutoResearchDeps): void; /** Dependencies for auto-optimizer — mirror of AutoResearchDeps. */ export interface AutoOptimizeDeps { store: SessionStore; manager: SessionStreamManager; relay: Pick; subscribers: Map; cwd: string; logger?: { error: (...args: unknown[]) => void; }; } /** * Dispatch an `auto_optimize` frame. Two-phase flow controlled by `action`: * - "analyze" scans the project and streams back `auto_optimize_analysis_complete`. * - "execute" applies approved recommendations and runs gate checks. * Progress/results are streamed as `auto_optimize_*` ws_events on `sessionId`. */ export declare function handleAutoOptimizeFrame(frame: AutoOptimizeFrame, deps: AutoOptimizeDeps): void; //# sourceMappingURL=dispatcher.d.ts.map