@cleocode/runtime 2026.6.12 → 2026.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  import { DomainHandler, Middleware, DispatchRequest, DispatchResponse, Gateway, GatewayStreamEvent, Source, DispatchResponseMeta, CanonicalDomain, Tier } from '@cleocode/contracts/gateway';
2
2
  import { BackgroundJobManager } from '@cleocode/core/store/background-jobs.js';
3
3
  import { Subsystem, OperationDef, SuggestedNextOp, Resolution } from '@cleocode/contracts';
4
- import { Server } from 'node:http';
4
+ import { IncomingMessage, Server } from 'node:http';
5
5
  import '@cleocode/contracts/operations/orchestrate';
6
6
  import * as _cleocode_core_internal from '@cleocode/core/internal';
7
7
  import { EngineResult, ProblemDetails } from '@cleocode/core';
8
8
  import { GatewayRpcRequestFrame, GatewayRpcErrorFrame, GatewayRpcErrorCode, GatewayRpcResponseFrame } from '@cleocode/contracts/gateway/rpc';
9
9
  import { Server as Server$1 } from 'node:net';
10
+ import { Duplex } from 'node:stream';
10
11
 
11
12
  /**
12
13
  * Central Dispatcher -- Routes requests through middleware to domain handlers.
@@ -45,6 +46,246 @@ declare class Dispatcher {
45
46
  dispatch(request: DispatchRequest): Promise<DispatchResponse>;
46
47
  }
47
48
 
49
+ /**
50
+ * WebSocket terminal/PTY endpoint on the `/v1` gateway HTTP listener (T11922 ·
51
+ * M5 realtime transport, WS half).
52
+ *
53
+ * The SSE half (T11921) gives the gateway a SERVER→CLIENT push stream; this
54
+ * module adds the BIDIRECTIONAL realtime transport an interactive TUI / terminal
55
+ * session needs: a WebSocket upgrade on the SAME `node:http` listener
56
+ * ({@link import('./listen.js').startHttpServer}) that bridges a PTY both
57
+ * directions over WS frames.
58
+ *
59
+ * Design constraints (all four ACs of T11922):
60
+ *
61
+ * - **AC1 — upgrade on the existing listener.** The WS endpoint is NOT a second
62
+ * server: it attaches to the daemon's existing HTTP server via its
63
+ * `'upgrade'` event ({@link attachWsPtyEndpoint}), so it inherits the loopback
64
+ * bind + the daemon's lifecycle. The endpoint path is `/v1/terminal/pty`.
65
+ * - **AC2 — bidirectional PTY bridge with backpressure.** Inbound WS frames are
66
+ * written to the PTY stdin; PTY output is framed back to the client. A control
67
+ * frame (`{type:'resize',cols,rows}`) resizes the PTY. Backpressure is honored
68
+ * on the socket write path (pause-on-`false`-write, resume on `'drain'`).
69
+ * - **AC3 — leak-free, deterministic teardown.** Every open/close/error/abort
70
+ * path routes through ONE idempotent `teardown()` that kills the PTY, removes
71
+ * its listeners, and ends the socket exactly once. A server `close()` tears
72
+ * down every live session.
73
+ * - **AC4 — auth/origin enforced at the upgrade edge; no secrets on the wire.**
74
+ * The upgrade is rejected with a `401`/`403` BEFORE any PTY is spawned unless
75
+ * it comes from loopback AND (when a token is configured) presents the bearer
76
+ * token AND (when an origin allowlist is configured) presents an allowed
77
+ * `Origin`. The spawned PTY runs under a MINIMAL, explicitly-constructed env
78
+ * ({@link scrubPtyEnv}) — the daemon's secret-bearing `process.env` is NEVER
79
+ * inherited, so a secret cannot be exfiltrated through the terminal stream.
80
+ *
81
+ * To keep the **publish surface clean** (D11142 dependency-discipline):
82
+ * - There is NO `ws` dependency. The minimal RFC 6455 server framing needed
83
+ * (accept handshake, decode masked client frames, encode unmasked server
84
+ * frames, close/ping/pong) is implemented over the raw `node:net` socket the
85
+ * `'upgrade'` event hands us. This adds ZERO weight to `@cleocode/runtime`.
86
+ * - `node-pty` is an OPTIONAL, dynamically-loaded dependency (same pattern as
87
+ * {@link import('../../../../core/src/llm/pi/gondolin-loader.js')} and
88
+ * `packages/core/src/tools/pty.ts`): the npm specifier is held in a variable,
89
+ * its shape is declared LOCALLY (no `import type`), and absence is a graceful
90
+ * close frame ("PTY backend unavailable") — NOT a crash. The endpoint always
91
+ * upgrades; it bridges a PTY only when the backend is present.
92
+ *
93
+ * @packageDocumentation
94
+ * @module @cleocode/runtime/gateway/http/ws-pty
95
+ *
96
+ * @task T11922
97
+ * @epic T11769
98
+ * @saga T10400
99
+ * @see ./sse.ts — the SSE half (server→client push) on the same listener
100
+ */
101
+
102
+ /** The canonical WS terminal/PTY endpoint path on the `/v1` facade. */
103
+ declare const WS_PTY_PATH = "/v1/terminal/pty";
104
+ /**
105
+ * The install hint surfaced to the client (as a WS close reason) when the PTY
106
+ * backend is unavailable. `node-pty` is OPTIONAL — the endpoint still upgrades
107
+ * and then closes cleanly with this reason rather than crashing the daemon.
108
+ */
109
+ declare const NODE_PTY_INSTALL_HINT: string;
110
+ /** A disposable subscription handle returned by `node-pty`'s event registrars. */
111
+ interface PtyDisposable {
112
+ dispose(): void;
113
+ }
114
+ /** The minimal structural shape of a spawned `node-pty` process the bridge uses. */
115
+ interface NodePtyProcess {
116
+ /** Subscribe to interleaved stdout/stderr output. */
117
+ onData(cb: (data: string) => void): PtyDisposable | undefined;
118
+ /** Subscribe to process exit. */
119
+ onExit(cb: (e: {
120
+ exitCode: number;
121
+ signal?: number;
122
+ }) => void): PtyDisposable | undefined;
123
+ /** Write to the PTY stdin. */
124
+ write(data: string): void;
125
+ /** Resize the PTY. */
126
+ resize(cols: number, rows: number): void;
127
+ /** Kill the underlying process (idempotent host-side; signal optional). */
128
+ kill(signal?: string): void;
129
+ }
130
+ /** The minimal structural shape of the `node-pty` module the bridge uses. */
131
+ interface NodePtyModule {
132
+ spawn(file: string, args: readonly string[], options: {
133
+ readonly name?: string;
134
+ readonly cols?: number;
135
+ readonly rows?: number;
136
+ readonly cwd?: string;
137
+ readonly env?: Record<string, string>;
138
+ }): NodePtyProcess;
139
+ }
140
+ /**
141
+ * Attempt to lazily load `node-pty`. Returns `null` when the optional dep is not
142
+ * installed, fails to load, or does not expose the `spawn` surface — so the
143
+ * caller closes the WS cleanly with {@link NODE_PTY_INSTALL_HINT} instead of
144
+ * crashing. NEVER throws.
145
+ *
146
+ * The import specifier is held in a variable (passed to the {@link importer}
147
+ * seam) so bundlers / TS do not treat the missing optional dep as a hard,
148
+ * statically-resolved dependency (same technique as the gondolin loader).
149
+ *
150
+ * @returns The shape-checked module, or `null` when unavailable.
151
+ */
152
+ declare function loadNodePty(): Promise<NodePtyModule | null>;
153
+ /**
154
+ * Build the MINIMAL environment the bridged PTY runs under (AC4).
155
+ *
156
+ * Starts EMPTY, copies only the small {@link PASSTHROUGH_KEYS} allowlist from the
157
+ * parent, pins `PATH` to {@link TRUSTED_PATH}, and forces `TERM=xterm-256color`
158
+ * for a sane terminal. The daemon's secrets are NEVER forwarded.
159
+ *
160
+ * @param parentEnv - The parent process environment (defaults to `process.env`).
161
+ * @returns The scrubbed, secret-free env for the PTY.
162
+ */
163
+ declare function scrubPtyEnv(parentEnv?: NodeJS.ProcessEnv): Record<string, string>;
164
+ /**
165
+ * Derive the `Sec-WebSocket-Accept` response header value from a client
166
+ * `Sec-WebSocket-Key` per RFC 6455 §4.2.2 (SHA-1 of key + GUID, base64).
167
+ *
168
+ * @param key - The client's `Sec-WebSocket-Key` header value.
169
+ * @returns The base64 accept hash.
170
+ */
171
+ declare function computeAcceptKey(key: string): string;
172
+ /**
173
+ * Encode a WS data frame (server→client, UNMASKED per RFC 6455 §5.1). Used for
174
+ * `text` (PTY output) and `close` control frames.
175
+ *
176
+ * @param opcode - The WS opcode (e.g. {@link OPCODE_TEXT}).
177
+ * @param payload - The frame payload bytes.
178
+ * @returns The framed bytes ready to write to the socket.
179
+ */
180
+ declare function encodeWsFrame(opcode: number, payload: Buffer): Buffer;
181
+ /** One decoded inbound WS frame. */
182
+ interface DecodedFrame {
183
+ /** The frame opcode. */
184
+ opcode: number;
185
+ /** The unmasked payload bytes. */
186
+ payload: Buffer;
187
+ /** Total bytes consumed from the input buffer (header + payload). */
188
+ consumed: number;
189
+ }
190
+ /**
191
+ * Decode a single inbound (client→server, MASKED) WS frame from a buffer.
192
+ *
193
+ * Returns `null` when the buffer does not yet hold a complete frame (the caller
194
+ * waits for more bytes). Client frames MUST be masked (RFC 6455 §5.3); the mask
195
+ * is applied to recover the payload. Fragmentation is not expected from a
196
+ * terminal client, so a non-FIN frame is decoded as-is (its opcode handled by
197
+ * the caller). NEVER throws on a partial buffer.
198
+ *
199
+ * @param buf - The accumulated inbound bytes.
200
+ * @returns The decoded frame, or `null` when more bytes are needed.
201
+ */
202
+ declare function decodeWsFrame(buf: Buffer): DecodedFrame | null;
203
+ /** Configuration for the WS terminal/PTY endpoint. */
204
+ interface WsPtyOptions {
205
+ /**
206
+ * Optional bearer token an upgrade MUST present (as `Authorization: Bearer
207
+ * <token>` or `?token=<token>`). When omitted, no token is required (loopback
208
+ * is still enforced unconditionally).
209
+ */
210
+ token?: string;
211
+ /**
212
+ * Optional `Origin` allowlist. When non-empty, an upgrade MUST present an
213
+ * `Origin` header whose value is in the set, else it is rejected `403`. When
214
+ * omitted/empty, the `Origin` header is not checked (loopback already bounds
215
+ * the surface).
216
+ */
217
+ allowedOrigins?: readonly string[];
218
+ /** The shell launched for the PTY (default `process.env.SHELL` or `/bin/sh`). */
219
+ shell?: string;
220
+ /** Working directory for the PTY (default `process.cwd()`). */
221
+ cwd?: string;
222
+ }
223
+ /** The decision of the upgrade-edge gate. */
224
+ type GateDecision = {
225
+ ok: true;
226
+ } | {
227
+ ok: false;
228
+ status: number;
229
+ reason: string;
230
+ };
231
+ /**
232
+ * Authorize a WS upgrade at the edge (AC4) — BEFORE any PTY is spawned.
233
+ *
234
+ * Enforced unconditionally:
235
+ * - the connection MUST originate from loopback (`127.0.0.1` / `::1`); a
236
+ * non-loopback peer is rejected `403` (the gateway is local-process-facing).
237
+ *
238
+ * Enforced when configured:
239
+ * - a `token` (matched against `Authorization: Bearer` or `?token=`); a missing
240
+ * or mismatched token is rejected `401`.
241
+ * - an `Origin` allowlist; a missing or disallowed `Origin` is rejected `403`.
242
+ *
243
+ * @param req - The inbound upgrade request.
244
+ * @param remoteAddress - The socket's remote address.
245
+ * @param opts - The endpoint auth configuration.
246
+ * @returns A {@link GateDecision} — `{ok:true}` to proceed, else a typed reject.
247
+ */
248
+ declare function authorizeUpgrade(req: IncomingMessage, remoteAddress: string | undefined, opts: WsPtyOptions): GateDecision;
249
+ /** Whether a request path is the WS terminal/PTY endpoint. */
250
+ declare function isWsPtyPath(url: string | undefined): boolean;
251
+ /**
252
+ * A handle returned by {@link attachWsPtyEndpoint} so the caller can detach the
253
+ * `'upgrade'` listener and tear down every live session on server close (AC3).
254
+ */
255
+ interface WsPtyEndpointHandle {
256
+ /** The number of live WS-PTY sessions (for tests / health). */
257
+ readonly sessionCount: number;
258
+ /** Detach the listener and tear down every live session. Idempotent. */
259
+ close(): void;
260
+ }
261
+ /**
262
+ * The shape of the HTTP server's `'upgrade'` event surface this module needs.
263
+ * Declared structurally so the module does not couple to a concrete `Server`.
264
+ */
265
+ interface UpgradableServer {
266
+ on(event: 'upgrade', listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): void;
267
+ removeListener(event: 'upgrade', listener: (req: IncomingMessage, socket: Duplex, head: Buffer) => void): void;
268
+ }
269
+ /**
270
+ * Attach the WS terminal/PTY endpoint (AC1) to an existing HTTP server's
271
+ * `'upgrade'` event.
272
+ *
273
+ * Every upgrade to {@link WS_PTY_PATH} is gated ({@link authorizeUpgrade}, AC4)
274
+ * BEFORE any PTY is spawned; a rejected upgrade gets a raw `401`/`403` and the
275
+ * socket is destroyed. An accepted upgrade completes the RFC 6455 handshake and
276
+ * bridges a PTY bidirectionally ({@link bridgePty}, AC2). An upgrade to any OTHER
277
+ * path is left for other upgrade handlers (this listener only claims its own
278
+ * path and never destroys a foreign socket).
279
+ *
280
+ * Each live session is tracked so {@link WsPtyEndpointHandle.close} tears every
281
+ * one down deterministically on server close (AC3).
282
+ *
283
+ * @param server - The HTTP server whose `'upgrade'` event to attach to.
284
+ * @param opts - The endpoint auth + PTY configuration.
285
+ * @returns A handle to detach + tear down on server close.
286
+ */
287
+ declare function attachWsPtyEndpoint(server: UpgradableServer, opts?: WsPtyOptions): WsPtyEndpointHandle;
288
+
48
289
  /**
49
290
  * `node:http` server binding for the framework-agnostic HTTP gateway adapter.
50
291
  *
@@ -96,6 +337,18 @@ interface HttpServerOptions {
96
337
  * explicit caller decision.
97
338
  */
98
339
  host?: string;
340
+ /**
341
+ * WebSocket terminal/PTY endpoint configuration (T11922 · M5 WS half). When
342
+ * provided, a WS upgrade handler for {@link WS_PTY_PATH} (`/v1/terminal/pty`)
343
+ * is attached to this server's `'upgrade'` event — gated at the edge (loopback
344
+ * + optional token/origin) and bridging a PTY bidirectionally over WS frames.
345
+ * When OMITTED, no upgrade handler is attached and the server stays a pure
346
+ * unary+SSE HTTP listener (the capability is opt-in — the daemon enables it
347
+ * explicitly). The optional `node-pty` backend is loaded dynamically; if it is
348
+ * absent, the endpoint still upgrades and reports a clean "PTY backend
349
+ * unavailable" close rather than crashing.
350
+ */
351
+ wsPty?: WsPtyOptions;
99
352
  }
100
353
  /**
101
354
  * A live HTTP server handle returned by {@link startHttpServer}.
@@ -115,14 +368,24 @@ interface HttpServerHandle {
115
368
  close(): Promise<void>;
116
369
  }
117
370
  /**
118
- * The result of parsing a request line into routing coordinates: either a
119
- * resolved unary triple or a rejection reason for the wire edge.
371
+ * The result of parsing a request line into routing coordinates: a resolved
372
+ * unary triple, a recognized health probe, or a rejection for the wire edge.
120
373
  */
121
374
  type ParsedRoute = {
122
375
  ok: true;
376
+ kind: 'unary';
377
+ gateway: Gateway;
378
+ domain: string;
379
+ operation: string;
380
+ } | {
381
+ ok: true;
382
+ kind: 'stream';
123
383
  gateway: Gateway;
124
384
  domain: string;
125
385
  operation: string;
386
+ } | {
387
+ ok: true;
388
+ kind: 'health';
126
389
  } | {
127
390
  ok: false;
128
391
  status: number;
@@ -130,12 +393,33 @@ type ParsedRoute = {
130
393
  message: string;
131
394
  };
132
395
  /**
133
- * Parse `POST /<gateway>/<domain>/<operation>` into a routing triple.
396
+ * Parse a gateway request line into a routing decision.
397
+ *
398
+ * Two POST routing vocabularies are accepted (the wire payload is identical to
399
+ * every other transport — only the framing differs):
400
+ *
401
+ * - `POST /v1/<domain>/<operation>` — the **versioned REST facade** (T11919).
402
+ * The gateway (`query` | `mutate`) is INFERRED from the registry, so the
403
+ * client need not name it. An unregistered `(domain, operation)` pair is a
404
+ * `404`.
405
+ * - `POST /<gateway>/<domain>/<operation>` — the **legacy** form, where the
406
+ * client names the gateway segment explicitly (validated against the two
407
+ * CQRS gateways so a client cannot smuggle an arbitrary value).
408
+ *
409
+ * Two GET routing vocabularies are accepted:
134
410
  *
135
- * Rejects a non-POST method (`405`) and any path that does not resolve to a
136
- * known `(gateway, domain, operation)` triple (`404`). The gateway segment is
137
- * validated against the two CQRS gateways so a client cannot smuggle an
138
- * arbitrary value into the dispatch request.
411
+ * - `GET /v1/health` — the liveness probe.
412
+ * - `GET /v1/<domain>/<operation>` — the **SSE streaming** endpoint (T11921),
413
+ * recognized only when the `(domain, operation)` pair is registered AND
414
+ * flagged `streaming: true` in {@link OPERATIONS} (resolved via
415
+ * {@link resolveStreamRoute}). The SAME pair is dispatched UNARY over POST,
416
+ * so the streaming GET co-exists with the unary POST route. A GET to a
417
+ * unary-only op is a `405` (method-not-allowed), distinguishing "route
418
+ * exists, this method is unsupported" from "no such route" (`404`).
419
+ *
420
+ * A non-POST/GET method is rejected at the edge (`405`); a path that resolves to
421
+ * neither vocabulary is a `404`. The injected {@link GatewayHandler} is never
422
+ * invoked with a malformed request.
139
423
  *
140
424
  * @param method - The HTTP request method.
141
425
  * @param url - The HTTP request URL (path + query).
@@ -348,11 +632,38 @@ interface RpcServerHandle {
348
632
  * handler errors are caught and rendered as a protocol-level `E_RPC_INTERNAL`
349
633
  * error frame — never propagated as an unhandled rejection or `process.exit`.
350
634
  *
635
+ * ## Connection-scoped session binding (T11640 · Epic T11638)
636
+ *
637
+ * The daemon serves many connections over one process, so it cannot rely on the
638
+ * single-process session-context singleton for identity. Instead, a frame on a
639
+ * connection that declares a `sessionId` binds it into the `{connId → sessionId}`
640
+ * registry, and the whole dispatch runs inside {@link runWithConnectionHandle}
641
+ * so `core`'s `resolveCurrentSession` resolves THIS connection's session (its
642
+ * highest-precedence tier) rather than "whoever wrote the DB last". When a frame
643
+ * carries no `sessionId`, no binding is performed and resolution transparently
644
+ * falls through to the env / active tiers — preserving existing behavior for
645
+ * anonymous frames.
646
+ *
647
+ * ### Intra-connection bleed safety
648
+ *
649
+ * Frames on one connection are dispatched CONCURRENTLY (each `routeFrame` is
650
+ * fired without awaiting the prior one), and registry binding is last-write-wins.
651
+ * To stop a still-in-flight Frame A from resolving a sibling Frame B's session,
652
+ * the frame's OWN declared `sessionId` is snapshotted into the per-frame
653
+ * connection handle ({@link runWithConnectionHandle}'s third arg) — that snapshot
654
+ * is authoritative for the dispatch, so a later re-bind of the same `connId`
655
+ * cannot retroactively change who Frame A is. The registry binding remains only
656
+ * for the late-binding path used by frames that declare no session of their own.
657
+ *
351
658
  * @param handler - The injected transport-neutral gateway handler.
352
659
  * @param frame - A validated, version-matched request frame.
660
+ * @param connId - The opaque per-connection id (bound at accept-time). Omitted
661
+ * only by direct/test callers that route a single frame outside a socket; a
662
+ * fresh ephemeral id is minted so the call still runs in an isolated
663
+ * connection-handle scope.
353
664
  * @returns The response or error frame to write back on the same connection.
354
665
  */
355
- declare function routeFrame(handler: GatewayHandler, frame: GatewayRpcRequestFrame): Promise<GatewayRpcResponseFrame | ReturnType<typeof buildErrorFrame>>;
666
+ declare function routeFrame(handler: GatewayHandler, frame: GatewayRpcRequestFrame, connId?: string): Promise<GatewayRpcResponseFrame | ReturnType<typeof buildErrorFrame>>;
356
667
  /**
357
668
  * Start the CLI-RPC unix-socket server over the gateway.
358
669
  *
@@ -1004,6 +1315,112 @@ type SseSource = (emitter: SseEmitter) => SseTeardown | undefined;
1004
1315
  */
1005
1316
  declare function createSseStream(source: SseSource, signal?: AbortSignal): ReadableStream<Uint8Array>;
1006
1317
 
1318
+ /**
1319
+ * Streaming-operation source adapters for the gateway HTTP/SSE transport (T11921).
1320
+ *
1321
+ * A streaming operation (`OperationDef.streaming === true`, served as
1322
+ * `GET /v1/<domain>/<operation>`) needs a *producer*: an async source that tails
1323
+ * an event origin (the agent/worker/board lifecycle bus) and feeds
1324
+ * {@link GatewayStreamEvent} frames into the shared, abort-safe
1325
+ * {@link createSseStream} builder. This module owns ONLY that producer concern —
1326
+ * the wire encoding + stream lifecycle live in `./sse.ts`, and the route parsing
1327
+ * lives in `./listen.ts`.
1328
+ *
1329
+ * The runtime is deliberately DB-free (the runtime invariant — no drizzle, no
1330
+ * `@cleocode/cleo`), so a source here MUST NOT open a store directly. Instead the
1331
+ * listener resolves a source by `<domain>.<operation>` key from the
1332
+ * {@link STREAM_SOURCES} registry; an embedder that owns a richer origin (the
1333
+ * daemon, a SvelteKit route) can inject its own source into the listener at
1334
+ * boot. The default source shipped here is a bounded tick/heartbeat producer that
1335
+ * proves the pipe end-to-end without reaching across the package boundary — and
1336
+ * is exactly what the in-process test exercises.
1337
+ *
1338
+ * Secrets never touch the wire: a source emits only the public lifecycle payload
1339
+ * it is handed; credential resolution (if any) happens server-side before the
1340
+ * frame is built, exactly as it does for a unary dispatch.
1341
+ *
1342
+ * @packageDocumentation
1343
+ * @module @cleocode/runtime/gateway/http/stream-sources
1344
+ *
1345
+ * @task T11921
1346
+ * @epic T11769
1347
+ * @saga T10400
1348
+ */
1349
+
1350
+ /**
1351
+ * The bind coordinates a {@link GatewayStreamSource} receives: the resolved
1352
+ * `(domain, operation)` of the streaming route, the already-validated request
1353
+ * params, and the request id (so emitted frames correlate back to the origin
1354
+ * request).
1355
+ */
1356
+ interface StreamSourceContext {
1357
+ /** The canonical domain segment of the streaming route. */
1358
+ domain: string;
1359
+ /** The operation segment of the streaming route. */
1360
+ operation: string;
1361
+ /** The decoded query params for the streaming request. */
1362
+ params: Record<string, unknown>;
1363
+ /** The request id stamped on every emitted {@link GatewayStreamEvent}. */
1364
+ requestId: string;
1365
+ }
1366
+ /**
1367
+ * A teardown callback a {@link GatewayStreamSource} MAY return; invoked exactly
1368
+ * once when the stream ends (client disconnect, terminal frame, completion).
1369
+ */
1370
+ type StreamSourceTeardown = () => void;
1371
+ /**
1372
+ * A producer for one streaming operation. Receives an {@link SseEmitter}
1373
+ * (`sendStreamEvent` emits one canonical {@link GatewayStreamEvent}) plus the
1374
+ * resolved {@link StreamSourceContext}, and MAY return a teardown callback.
1375
+ *
1376
+ * The source is started synchronously inside the stream builder, mirroring the
1377
+ * Studio SSE endpoints; it may push frames immediately and/or schedule async
1378
+ * work. It MUST drop frames once `emitter.closed` is true (the emitter already
1379
+ * guards this) and MUST clean up timers/listeners in its teardown so a client
1380
+ * disconnect closes the stream leak-free.
1381
+ */
1382
+ type GatewayStreamSource = (emitter: SseEmitter, context: StreamSourceContext) => StreamSourceTeardown | undefined;
1383
+ /**
1384
+ * The default `orchestrate.events` source: a bounded tick/heartbeat producer.
1385
+ *
1386
+ * Emits a monotonic sequence of `data` {@link GatewayStreamEvent} frames at
1387
+ * {@link DEFAULT_TICK_INTERVAL_MS}, then a terminal `done` frame once the
1388
+ * `ticks` budget is exhausted; a client disconnect tears the timer down before
1389
+ * then. It carries no DB handle and no secret — each frame's payload is a public
1390
+ * heartbeat (`{ tick, ts }`) standing in for an agent/worker/board lifecycle
1391
+ * event until the daemon injects its real origin-tailing source.
1392
+ *
1393
+ * A first frame is emitted SYNCHRONOUSLY so a client (and the test) observes a
1394
+ * well-formed `data:` frame immediately, without waiting a full interval.
1395
+ *
1396
+ * @param emitter - The SSE emitter for this stream.
1397
+ * @param context - The resolved streaming-route context.
1398
+ * @returns A teardown that clears the tick timer.
1399
+ */
1400
+ declare const tickStreamSource: GatewayStreamSource;
1401
+ /**
1402
+ * Register (or override) the {@link GatewayStreamSource} for a streaming op.
1403
+ *
1404
+ * Lets an embedder that owns a richer event origin (the daemon tailing the
1405
+ * agent/worker/board lifecycle bus, a SvelteKit route reading a store) replace
1406
+ * the default tick source for a given `(domain, operation)` without changing the
1407
+ * listener. Idempotent — a later call wins.
1408
+ *
1409
+ * @param domain - The canonical domain of the streaming op.
1410
+ * @param operation - The operation name of the streaming op.
1411
+ * @param source - The producer to serve this streaming route with.
1412
+ */
1413
+ declare function registerStreamSource(domain: string, operation: string, source: GatewayStreamSource): void;
1414
+ /**
1415
+ * Resolve the {@link GatewayStreamSource} registered for a streaming op.
1416
+ *
1417
+ * @param domain - The canonical domain of the streaming op.
1418
+ * @param operation - The operation name of the streaming op.
1419
+ * @returns The registered source, or `undefined` when none is registered (the
1420
+ * listener then closes the stream with a single terminal `error` frame).
1421
+ */
1422
+ declare function resolveStreamSource(domain: string, operation: string): GatewayStreamSource | undefined;
1423
+
1007
1424
  /**
1008
1425
  * Singleton accessor for the gateway {@link BackgroundJobManager}.
1009
1426
  *
@@ -1481,6 +1898,51 @@ declare function getGatewayDomains(gateway: Gateway): string[];
1481
1898
  * Resolves a domain + operation to its registered definition.
1482
1899
  */
1483
1900
  declare function resolve(gateway: Gateway, domain: string, operation: string): Resolution | undefined;
1901
+ /**
1902
+ * Infer the CQRS {@link Gateway} for a `(domain, operation)` pair from the
1903
+ * registry, without the caller naming `query`/`mutate` up front.
1904
+ *
1905
+ * This backs the versioned `/v1/<domain>/<operation>` REST facade (T11919),
1906
+ * where — unlike the legacy `/<gateway>/<domain>/<operation>` form — the client
1907
+ * does not spell the gateway segment: the registry is the single source of truth
1908
+ * for whether an operation is a read (`query`) or a write (`mutate`). Each
1909
+ * `(domain, operation)` pair maps to exactly one gateway in {@link OPERATIONS},
1910
+ * so the inference is unambiguous.
1911
+ *
1912
+ * @param domain - The canonical domain segment (e.g. `'tasks'`).
1913
+ * @param operation - The operation segment (e.g. `'show'`).
1914
+ * @returns The owning gateway, or `undefined` when no registered operation
1915
+ * matches the pair (the wire edge then renders a `404`).
1916
+ */
1917
+ declare function inferGateway(domain: string, operation: string): Gateway | undefined;
1918
+ /**
1919
+ * The result of resolving a streaming route: the owning gateway plus the matched
1920
+ * {@link OperationDef} (so the caller has the op's tier/params on hand without a
1921
+ * second lookup).
1922
+ */
1923
+ interface StreamResolution {
1924
+ /** The CQRS gateway the streaming op is registered under. */
1925
+ gateway: Gateway;
1926
+ /** The matched streaming operation definition. */
1927
+ def: OperationDef;
1928
+ }
1929
+ /**
1930
+ * Resolve a `(domain, operation)` pair to a registered **streaming** operation.
1931
+ *
1932
+ * Backs the `GET /v1/<domain>/<operation>` SSE route (T11921): the listener only
1933
+ * opens a `text/event-stream` for a `(domain, operation)` pair that is BOTH
1934
+ * registered AND explicitly flagged `streaming: true` in {@link OPERATIONS}. A
1935
+ * unary-only op (or an unknown pair) resolves to `undefined`, so the listener
1936
+ * never streams an operation that was not declared streamable — a GET to a
1937
+ * unary op stays a `405` (method-not-allowed) rather than silently opening a
1938
+ * never-ending stream.
1939
+ *
1940
+ * @param domain - The canonical domain segment (e.g. `'orchestrate'`).
1941
+ * @param operation - The operation segment (e.g. `'events'`).
1942
+ * @returns The streaming resolution, or `undefined` when no registered op
1943
+ * matches the pair OR the matched op is not flagged `streaming: true`.
1944
+ */
1945
+ declare function resolveStreamRoute(domain: string, operation: string): StreamResolution | undefined;
1484
1946
  /**
1485
1947
  * Validates that all required parameters are present in the request.
1486
1948
  * Returns an array of missing parameter keys.
@@ -1503,6 +1965,103 @@ declare function getCounts(): {
1503
1965
  total: number;
1504
1966
  };
1505
1967
 
1968
+ /**
1969
+ * Daemon-side gateway server bootstrap — the call-site `cleo daemon serve`
1970
+ * drives (T11919).
1971
+ *
1972
+ * The HTTP gateway adapter (`./http/listen.ts`, T11254) and the supervised
1973
+ * `defineGatewaySubsystem` unit (`./daemon-subsystem.ts`, T11451) both already
1974
+ * exist, but nothing started them: the daemon had no `serve` call-site. This
1975
+ * module is that thin bootstrap — it assembles a {@link SubsystemRegistry} with
1976
+ * one scoped gateway subsystem hosting the HTTP transport (loopback by default)
1977
+ * over an INJECTED {@link GatewayHandler}, starts it, and returns a live handle
1978
+ * with the bound port + an idempotent `close()`.
1979
+ *
1980
+ * It carries NO `@cleocode/cleo` dependency and NO drizzle (the runtime
1981
+ * invariant): the caller (`cleo daemon serve`) injects the assembled CLI gateway
1982
+ * handler and the resolved bind coordinates. Secrets never touch the wire —
1983
+ * sealed-handle resolution happens server-side inside the dispatched handler,
1984
+ * exactly as it does for an in-process CLI call. The HTTP listener binds
1985
+ * `127.0.0.1` by default (loopback only); exposing it on a public interface is
1986
+ * an explicit caller decision.
1987
+ *
1988
+ * The streaming transports (SSE — T11921, WS — T11922) extend the SAME
1989
+ * subsystem: when their listeners land they bind alongside the unary HTTP server
1990
+ * here, so this bootstrap deliberately owns only transport assembly + lifecycle,
1991
+ * leaving the per-route seam to the adapter (`parseHttpRoute`).
1992
+ *
1993
+ * @packageDocumentation
1994
+ * @module @cleocode/runtime/gateway
1995
+ *
1996
+ * @task T11919
1997
+ * @epic T11769
1998
+ * @saga T10400
1999
+ */
2000
+
2001
+ /**
2002
+ * Options for {@link serveGateway}.
2003
+ *
2004
+ * The caller injects the assembled {@link GatewayHandler} and resolves the bind
2005
+ * coordinates so the runtime stays free of `@cleocode/paths`/`@cleocode/cleo`.
2006
+ */
2007
+ interface ServeGatewayOptions {
2008
+ /** The transport-neutral gateway handler every hosted transport routes through. */
2009
+ handler: GatewayHandler;
2010
+ /** TCP port to bind. `0` selects an ephemeral port (readable from the handle). */
2011
+ port: number;
2012
+ /** Host/interface to bind. Defaults to `127.0.0.1` (loopback only). */
2013
+ host?: string;
2014
+ /** The scope this gateway process serves. Defaults to `'global'`. */
2015
+ scope?: GatewayScope;
2016
+ }
2017
+ /**
2018
+ * A live gateway server handle returned by {@link serveGateway}.
2019
+ *
2020
+ * Exposes the actually-bound `port`/`host` (resolved even when `0` was
2021
+ * requested) and an idempotent {@link close} for deterministic teardown
2022
+ * (daemon shutdown, tests). Mirrors the lower-level
2023
+ * {@link import('./http/listen.js').HttpServerHandle} so a test can boot, probe,
2024
+ * and tear down on an ephemeral port.
2025
+ */
2026
+ interface ServeGatewayHandle {
2027
+ /** The actually-bound HTTP port (resolved when `0` was requested). */
2028
+ port: number;
2029
+ /** Host/interface the HTTP listener is bound to. */
2030
+ host: string;
2031
+ /** The scope the gateway process serves. */
2032
+ scope: GatewayScope;
2033
+ /** Stop every bound listener (best-effort, idempotent); resolves once closed. */
2034
+ close(): Promise<void>;
2035
+ }
2036
+ /**
2037
+ * Boot the HTTP gateway as a supervised subsystem and return a live handle.
2038
+ *
2039
+ * Declares one scoped gateway subsystem hosting the HTTP transport (loopback
2040
+ * default) over the injected handler via {@link defineGatewaySubsystem}, starts
2041
+ * it, and reads the actually-bound port from the start context (so the caller —
2042
+ * and tests — learn the ephemeral port when `0` was requested). The returned
2043
+ * `close()` drives the subsystem's `shutdown(context)` so teardown is the same
2044
+ * lifecycle the daemon registry uses for every other supervised concern.
2045
+ *
2046
+ * Using `defineGatewaySubsystem` directly (rather than a full
2047
+ * `SubsystemRegistry`) keeps the bound HTTP handle in reach for the port
2048
+ * resolution while still routing through the canonical start→shutdown contract.
2049
+ *
2050
+ * @param opts - The injected handler + resolved bind coordinates.
2051
+ * @returns A promise resolving to the live {@link ServeGatewayHandle}.
2052
+ *
2053
+ * @example
2054
+ * ```ts
2055
+ * const handle = await serveGateway({
2056
+ * handler: createCliGatewayHandler(),
2057
+ * port: 0, // ephemeral
2058
+ * });
2059
+ * // POST http://127.0.0.1:${handle.port}/v1/tasks/show
2060
+ * await handle.close();
2061
+ * ```
2062
+ */
2063
+ declare function serveGateway(opts: ServeGatewayOptions): Promise<ServeGatewayHandle>;
2064
+
1506
2065
  /**
1507
2066
  * `@cleocode/runtime/gateway` — the transport-agnostic CQRS dispatch core.
1508
2067
  *
@@ -1542,4 +2101,4 @@ interface GatewayHandler {
1542
2101
  */
1543
2102
  declare function createGatewayHandler(config: DispatcherConfig): GatewayHandler;
1544
2103
 
1545
- export { getByDomain as $, type GatewayHandler as A, BRAIN_DB_FILENAME as B, CLEO_DIR_NAME as C, Dispatcher as D, type DecodeResult, type ErrorMeta as E, type GatewayScope as F, type GatewayOperationKey as G, type HttpServerHandle as H, type GatewaySubsystemContext as I, type GatewaySubsystemOptions as J, STRING_TO_EXIT as K, buildNexusMetaExtensions as L, LineBuffer, MCP_TOOL_PREFIX as M, cleoErrorToEngineError as N, OPERATIONS as O, compose as P, createDispatchMeta as Q, createGatewayHandler as R, type RpcServerHandle, type RpcServerOptions, SSE_HEADERS as S, defineGatewaySubsystem as T, deriveGatewayMatrix as U, engineError as V, WORKFLOWS_SUBDIR as W, formatSuggestedNext as X, gatewaySubsystemName as Y, generateTemplateConfig as Z, getActiveDomains as _, type HttpServerOptions as a, getByGateway as a0, getByTier as a1, getCounts as a2, getGatewayDomains as a3, getJobManager as a4, getTemplateForSubcommand as a5, mapNumericExitCodeToString as a6, parseIssueTemplates as a7, pickDecoratorMetaExtensions as a8, resolve as a9, setJobManager as aa, stampNexusMeta as ab, validateLabels as ac, validateRequiredParams as ad, validateSuggestedNext as ae, type HttpUnaryRequest as b, buildErrorFrame, type HttpUnaryResult as c, type SseEmitter as d, decodeLine, type SseFrame as e, encodeFrame, type SseSource as f, createSseStream as g, encodeSseFrame as h, encodeStreamEvent as i, statusForResponse as j, type McpContent as k, type McpInputProperty as l, type McpServerOptions as m, type McpTool as n, type McpToolResult as o, parseHttpRoute as p, buildToolsList as q, routeUnary as r, routeFrame, startHttpServer as s, startRpcServer, callTool as t, exposedOperations as u, operationToMcpTool as v, operationToToolName as w, startMcpServer as x, toolNameToOperationKey as y, type DispatcherConfig as z };
2104
+ export { type ErrorMeta as $, startHttpServer as A, statusForResponse as B, tickStreamSource as C, type GatewayOperationKey as D, type DecodeResult, type McpContent as E, type McpInputProperty as F, type GateDecision as G, type HttpServerHandle as H, type McpServerOptions as I, type McpTool as J, type McpToolResult as K, buildToolsList as L, LineBuffer, MCP_TOOL_PREFIX as M, NODE_PTY_INSTALL_HINT as N, callTool as O, exposedOperations as P, operationToMcpTool as Q, operationToToolName as R, type RpcServerHandle, type RpcServerOptions, SSE_HEADERS as S, startMcpServer as T, type UpgradableServer as U, toolNameToOperationKey as V, WS_PTY_PATH as W, BRAIN_DB_FILENAME as X, CLEO_DIR_NAME as Y, Dispatcher as Z, type DispatcherConfig as _, type GatewayStreamSource as a, type GatewayHandler as a0, type GatewayScope as a1, type GatewaySubsystemContext as a2, type GatewaySubsystemOptions as a3, OPERATIONS as a4, STRING_TO_EXIT as a5, type ServeGatewayHandle as a6, type ServeGatewayOptions as a7, type StreamResolution as a8, WORKFLOWS_SUBDIR as a9, setJobManager as aA, stampNexusMeta as aB, validateLabels as aC, validateRequiredParams as aD, validateSuggestedNext as aE, buildNexusMetaExtensions as aa, cleoErrorToEngineError as ab, compose as ac, createDispatchMeta as ad, createGatewayHandler as ae, defineGatewaySubsystem as af, deriveGatewayMatrix as ag, engineError as ah, formatSuggestedNext as ai, gatewaySubsystemName as aj, generateTemplateConfig as ak, getActiveDomains as al, getByDomain as am, getByGateway as an, getByTier as ao, getCounts as ap, getGatewayDomains as aq, getJobManager as ar, getTemplateForSubcommand as as, inferGateway as at, mapNumericExitCodeToString as au, parseIssueTemplates as av, pickDecoratorMetaExtensions as aw, resolve as ax, resolveStreamRoute as ay, serveGateway as az, type HttpServerOptions as b, buildErrorFrame, type HttpUnaryRequest as c, type HttpUnaryResult as d, decodeLine, type SseEmitter as e, encodeFrame, type SseFrame as f, type SseSource as g, type StreamSourceContext as h, type StreamSourceTeardown as i, type WsPtyEndpointHandle as j, type WsPtyOptions as k, attachWsPtyEndpoint as l, authorizeUpgrade as m, computeAcceptKey as n, createSseStream as o, decodeWsFrame as p, encodeSseFrame as q, encodeStreamEvent as r, routeFrame, encodeWsFrame as s, startRpcServer, isWsPtyPath as t, loadNodePty as u, parseHttpRoute as v, registerStreamSource as w, resolveStreamSource as x, routeUnary as y, scrubPtyEnv as z };
@@ -5,7 +5,7 @@ import {
5
5
  encodeFrame,
6
6
  routeFrame,
7
7
  startRpcServer
8
- } from "../../chunk-5ZXRYEQ5.js";
8
+ } from "../../chunk-KQBVXGOL.js";
9
9
  export {
10
10
  LineBuffer,
11
11
  buildErrorFrame,