@cleocode/runtime 2026.6.13 → 2026.6.15

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, ServerResponse } 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,33 @@ 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;
352
+ /**
353
+ * Studio static asset directory for the bundled Web Studio (T11979).
354
+ *
355
+ * When provided, requests whose path starts with `/studio` are served as
356
+ * static files from this directory (the `build/client/` output from an
357
+ * `adapter-node` Studio build), with SPA fallback to `index.html` for
358
+ * sub-routes. When `undefined` or the path does not exist on disk, a clean
359
+ * JSON 503 envelope is returned instead of a stack trace — allowing the
360
+ * gateway to start without a Studio bundle present (dev checkouts).
361
+ *
362
+ * The value is resolved and injected by the caller (`cleo daemon serve`)
363
+ * using {@link resolveStudioStaticDir} from `@cleocode/cleo` so the runtime
364
+ * layer stays free of any path-resolution logic.
365
+ */
366
+ studioStaticDir?: string;
99
367
  }
100
368
  /**
101
369
  * A live HTTP server handle returned by {@link startHttpServer}.
@@ -115,14 +383,24 @@ interface HttpServerHandle {
115
383
  close(): Promise<void>;
116
384
  }
117
385
  /**
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.
386
+ * The result of parsing a request line into routing coordinates: a resolved
387
+ * unary triple, a recognized health probe, or a rejection for the wire edge.
120
388
  */
121
389
  type ParsedRoute = {
122
390
  ok: true;
391
+ kind: 'unary';
392
+ gateway: Gateway;
393
+ domain: string;
394
+ operation: string;
395
+ } | {
396
+ ok: true;
397
+ kind: 'stream';
123
398
  gateway: Gateway;
124
399
  domain: string;
125
400
  operation: string;
401
+ } | {
402
+ ok: true;
403
+ kind: 'health';
126
404
  } | {
127
405
  ok: false;
128
406
  status: number;
@@ -130,12 +408,33 @@ type ParsedRoute = {
130
408
  message: string;
131
409
  };
132
410
  /**
133
- * Parse `POST /<gateway>/<domain>/<operation>` into a routing triple.
411
+ * Parse a gateway request line into a routing decision.
412
+ *
413
+ * Two POST routing vocabularies are accepted (the wire payload is identical to
414
+ * every other transport — only the framing differs):
415
+ *
416
+ * - `POST /v1/<domain>/<operation>` — the **versioned REST facade** (T11919).
417
+ * The gateway (`query` | `mutate`) is INFERRED from the registry, so the
418
+ * client need not name it. An unregistered `(domain, operation)` pair is a
419
+ * `404`.
420
+ * - `POST /<gateway>/<domain>/<operation>` — the **legacy** form, where the
421
+ * client names the gateway segment explicitly (validated against the two
422
+ * CQRS gateways so a client cannot smuggle an arbitrary value).
423
+ *
424
+ * Two GET routing vocabularies are accepted:
134
425
  *
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.
426
+ * - `GET /v1/health` — the liveness probe.
427
+ * - `GET /v1/<domain>/<operation>` — the **SSE streaming** endpoint (T11921),
428
+ * recognized only when the `(domain, operation)` pair is registered AND
429
+ * flagged `streaming: true` in {@link OPERATIONS} (resolved via
430
+ * {@link resolveStreamRoute}). The SAME pair is dispatched UNARY over POST,
431
+ * so the streaming GET co-exists with the unary POST route. A GET to a
432
+ * unary-only op is a `405` (method-not-allowed), distinguishing "route
433
+ * exists, this method is unsupported" from "no such route" (`404`).
434
+ *
435
+ * A non-POST/GET method is rejected at the edge (`405`); a path that resolves to
436
+ * neither vocabulary is a `404`. The injected {@link GatewayHandler} is never
437
+ * invoked with a malformed request.
139
438
  *
140
439
  * @param method - The HTTP request method.
141
440
  * @param url - The HTTP request URL (path + query).
@@ -1031,6 +1330,239 @@ type SseSource = (emitter: SseEmitter) => SseTeardown | undefined;
1031
1330
  */
1032
1331
  declare function createSseStream(source: SseSource, signal?: AbortSignal): ReadableStream<Uint8Array>;
1033
1332
 
1333
+ /**
1334
+ * Streaming-operation source adapters for the gateway HTTP/SSE transport (T11921).
1335
+ *
1336
+ * A streaming operation (`OperationDef.streaming === true`, served as
1337
+ * `GET /v1/<domain>/<operation>`) needs a *producer*: an async source that tails
1338
+ * an event origin (the agent/worker/board lifecycle bus) and feeds
1339
+ * {@link GatewayStreamEvent} frames into the shared, abort-safe
1340
+ * {@link createSseStream} builder. This module owns ONLY that producer concern —
1341
+ * the wire encoding + stream lifecycle live in `./sse.ts`, and the route parsing
1342
+ * lives in `./listen.ts`.
1343
+ *
1344
+ * The runtime is deliberately DB-free (the runtime invariant — no drizzle, no
1345
+ * `@cleocode/cleo`), so a source here MUST NOT open a store directly. Instead the
1346
+ * listener resolves a source by `<domain>.<operation>` key from the
1347
+ * {@link STREAM_SOURCES} registry; an embedder that owns a richer origin (the
1348
+ * daemon, a SvelteKit route) can inject its own source into the listener at
1349
+ * boot. The default source shipped here is a bounded tick/heartbeat producer that
1350
+ * proves the pipe end-to-end without reaching across the package boundary — and
1351
+ * is exactly what the in-process test exercises.
1352
+ *
1353
+ * Secrets never touch the wire: a source emits only the public lifecycle payload
1354
+ * it is handed; credential resolution (if any) happens server-side before the
1355
+ * frame is built, exactly as it does for a unary dispatch.
1356
+ *
1357
+ * @packageDocumentation
1358
+ * @module @cleocode/runtime/gateway/http/stream-sources
1359
+ *
1360
+ * @task T11921
1361
+ * @epic T11769
1362
+ * @saga T10400
1363
+ */
1364
+
1365
+ /**
1366
+ * The bind coordinates a {@link GatewayStreamSource} receives: the resolved
1367
+ * `(domain, operation)` of the streaming route, the already-validated request
1368
+ * params, and the request id (so emitted frames correlate back to the origin
1369
+ * request).
1370
+ */
1371
+ interface StreamSourceContext {
1372
+ /** The canonical domain segment of the streaming route. */
1373
+ domain: string;
1374
+ /** The operation segment of the streaming route. */
1375
+ operation: string;
1376
+ /** The decoded query params for the streaming request. */
1377
+ params: Record<string, unknown>;
1378
+ /** The request id stamped on every emitted {@link GatewayStreamEvent}. */
1379
+ requestId: string;
1380
+ }
1381
+ /**
1382
+ * A teardown callback a {@link GatewayStreamSource} MAY return; invoked exactly
1383
+ * once when the stream ends (client disconnect, terminal frame, completion).
1384
+ */
1385
+ type StreamSourceTeardown = () => void;
1386
+ /**
1387
+ * A producer for one streaming operation. Receives an {@link SseEmitter}
1388
+ * (`sendStreamEvent` emits one canonical {@link GatewayStreamEvent}) plus the
1389
+ * resolved {@link StreamSourceContext}, and MAY return a teardown callback.
1390
+ *
1391
+ * The source is started synchronously inside the stream builder, mirroring the
1392
+ * Studio SSE endpoints; it may push frames immediately and/or schedule async
1393
+ * work. It MUST drop frames once `emitter.closed` is true (the emitter already
1394
+ * guards this) and MUST clean up timers/listeners in its teardown so a client
1395
+ * disconnect closes the stream leak-free.
1396
+ */
1397
+ type GatewayStreamSource = (emitter: SseEmitter, context: StreamSourceContext) => StreamSourceTeardown | undefined;
1398
+ /**
1399
+ * The default `orchestrate.events` source: a bounded tick/heartbeat producer.
1400
+ *
1401
+ * Emits a monotonic sequence of `data` {@link GatewayStreamEvent} frames at
1402
+ * {@link DEFAULT_TICK_INTERVAL_MS}, then a terminal `done` frame once the
1403
+ * `ticks` budget is exhausted; a client disconnect tears the timer down before
1404
+ * then. It carries no DB handle and no secret — each frame's payload is a public
1405
+ * heartbeat (`{ tick, ts }`) standing in for an agent/worker/board lifecycle
1406
+ * event until the daemon injects its real origin-tailing source.
1407
+ *
1408
+ * A first frame is emitted SYNCHRONOUSLY so a client (and the test) observes a
1409
+ * well-formed `data:` frame immediately, without waiting a full interval.
1410
+ *
1411
+ * @param emitter - The SSE emitter for this stream.
1412
+ * @param context - The resolved streaming-route context.
1413
+ * @returns A teardown that clears the tick timer.
1414
+ */
1415
+ declare const tickStreamSource: GatewayStreamSource;
1416
+ /**
1417
+ * Register (or override) the {@link GatewayStreamSource} for a streaming op.
1418
+ *
1419
+ * Lets an embedder that owns a richer event origin (the daemon tailing the
1420
+ * agent/worker/board lifecycle bus, a SvelteKit route reading a store) replace
1421
+ * the default tick source for a given `(domain, operation)` without changing the
1422
+ * listener. Idempotent — a later call wins.
1423
+ *
1424
+ * @param domain - The canonical domain of the streaming op.
1425
+ * @param operation - The operation name of the streaming op.
1426
+ * @param source - The producer to serve this streaming route with.
1427
+ */
1428
+ declare function registerStreamSource(domain: string, operation: string, source: GatewayStreamSource): void;
1429
+ /**
1430
+ * Resolve the {@link GatewayStreamSource} registered for a streaming op.
1431
+ *
1432
+ * @param domain - The canonical domain of the streaming op.
1433
+ * @param operation - The operation name of the streaming op.
1434
+ * @returns The registered source, or `undefined` when none is registered (the
1435
+ * listener then closes the stream with a single terminal `error` frame).
1436
+ */
1437
+ declare function resolveStreamSource(domain: string, operation: string): GatewayStreamSource | undefined;
1438
+
1439
+ /**
1440
+ * Static file serving for the bundled CLEO Web Studio assets.
1441
+ *
1442
+ * The gateway HTTP server calls {@link serveStudioStatic} for any request whose
1443
+ * path starts with the `/studio` prefix. The handler:
1444
+ *
1445
+ * 1. Strips the `/studio` prefix and resolves the remainder against the
1446
+ * `studioStaticDir` (the `build/client/` output of the Studio
1447
+ * `adapter-node` build bundled inside `@cleocode/cleo`).
1448
+ * 2. Serves the matched file with the correct MIME type (inferred from the
1449
+ * extension via the built-in {@link MIME_TYPES} map).
1450
+ * 3. Returns `index.html` for any path that does not resolve to a static file
1451
+ * — the SPA fallback that lets the SvelteKit client-side router handle
1452
+ * sub-routes.
1453
+ * 4. Returns a clean JSON error envelope (not a stack trace) when
1454
+ * `studioStaticDir` is absent (dev checkout without a Studio build, or an
1455
+ * npm install where the postbuild copy was skipped).
1456
+ *
1457
+ * The handler deliberately has NO `process.exit`, NO drizzle, NO
1458
+ * `@cleocode/cleo` dependency — it is a pure file-system responder that can be
1459
+ * embedded in any `node:http` server (the gateway daemon, a test harness).
1460
+ *
1461
+ * The `build/client/` static tree is served under `/studio/` so all of its
1462
+ * relative asset references (`/_app/immutable/...`) resolve correctly. The root
1463
+ * `/studio` (no trailing slash) redirects to `/studio/` to keep the base URL
1464
+ * consistent with the SvelteKit assets.
1465
+ *
1466
+ * @packageDocumentation
1467
+ * @module @cleocode/runtime/gateway/http
1468
+ *
1469
+ * @task T11979
1470
+ * @epic T11261
1471
+ */
1472
+
1473
+ /** The URL prefix the Studio static handler claims. */
1474
+ declare const STUDIO_PREFIX = "/studio";
1475
+ /**
1476
+ * Options for the Studio static handler, injected at server startup.
1477
+ *
1478
+ * The caller resolves the absolute path to the static asset directory and
1479
+ * passes it here — the handler never reads environment variables or resolves
1480
+ * paths itself, keeping concerns separated and the handler testable.
1481
+ */
1482
+ interface StudioStaticOptions {
1483
+ /**
1484
+ * Absolute path to the Studio static asset directory. This is the
1485
+ * `build/client/` subdirectory from an `adapter-node` Studio build, bundled
1486
+ * inside `@cleocode/cleo` at `studio-dist/client/`.
1487
+ *
1488
+ * When `undefined` or the path does not exist on disk, every request under
1489
+ * `/studio` returns a clean JSON 503 "bundle absent" envelope.
1490
+ */
1491
+ staticDir: string | undefined;
1492
+ }
1493
+ /**
1494
+ * Determine whether a request path falls under the Studio prefix and should be
1495
+ * handled by the static asset handler rather than the API gateway.
1496
+ *
1497
+ * @param pathname - The URL pathname (without query string).
1498
+ * @returns `true` when the path is `/studio`, `/studio/`, or any
1499
+ * `/studio/<subpath>`.
1500
+ */
1501
+ declare function isStudioPath(pathname: string): boolean;
1502
+ /**
1503
+ * Resolve the file-system path for a Studio request within `staticDir`.
1504
+ *
1505
+ * Strips the `/studio` prefix and normalises the remainder; guards against
1506
+ * path-traversal (a normalised path that escapes `staticDir` is rejected).
1507
+ *
1508
+ * @param staticDir - Absolute path to the Studio static asset directory.
1509
+ * @param pathname - The request path (already verified to start with `/studio`).
1510
+ * @returns The absolute candidate path, or `undefined` when path traversal is
1511
+ * detected.
1512
+ */
1513
+ declare function resolveStudioAssetPath(staticDir: string, pathname: string): string | undefined;
1514
+ /**
1515
+ * Infer the MIME type for a file from its extension.
1516
+ *
1517
+ * @param filePath - The file path (only the extension is used).
1518
+ * @returns The appropriate `Content-Type` header value.
1519
+ */
1520
+ declare function mimeType(filePath: string): string;
1521
+ /**
1522
+ * Write the "bundle absent" JSON error envelope and close the response.
1523
+ *
1524
+ * Used when `staticDir` is `undefined` or does not exist on disk — the Studio
1525
+ * build was not found. The response is a `503 Service Unavailable` with a clean
1526
+ * LAFS-flavoured JSON body so clients can detect the condition programmatically.
1527
+ *
1528
+ * @param res - The server response to write to.
1529
+ * @param hint - Optional developer hint (e.g. the expected bundle path).
1530
+ */
1531
+ declare function writeBundleAbsentError(res: ServerResponse, hint?: string): void;
1532
+ /**
1533
+ * Handle a single HTTP request whose path falls under `/studio`.
1534
+ *
1535
+ * Routing logic:
1536
+ * - `GET /studio` → 302 redirect to `/studio/` (keep asset refs consistent)
1537
+ * - `GET /studio/<static-asset>` → serve the file from `staticDir`
1538
+ * - `GET /studio/*` (no matching file) → serve `index.html` (SPA fallback)
1539
+ * - Any request when `staticDir` is absent → 503 JSON envelope
1540
+ * - Non-GET method → 405 JSON envelope (Studio is read-only from the gateway)
1541
+ *
1542
+ * @param req - The inbound HTTP request.
1543
+ * @param res - The outbound HTTP response.
1544
+ * @param opts - Static handler options (resolved at server startup).
1545
+ * @returns A promise that resolves once the response has been written.
1546
+ */
1547
+ declare function serveStudioStatic(req: IncomingMessage, res: ServerResponse, opts: StudioStaticOptions): Promise<void>;
1548
+ /**
1549
+ * Resolve the bundled Studio static directory from the `@cleocode/cleo` package
1550
+ * location.
1551
+ *
1552
+ * When running from an npm install, the Studio build is bundled at
1553
+ * `<cleo-package-root>/studio-dist/client/`. When running from a monorepo dev
1554
+ * checkout, the directory may not exist (developers must run
1555
+ * `pnpm --filter @cleocode/studio run build` manually) — `undefined` is
1556
+ * returned in that case, and the gateway handler will serve the bundle-absent
1557
+ * error instead of crashing.
1558
+ *
1559
+ * @param cleoPackageDir - Absolute path to the `@cleocode/cleo` package root.
1560
+ * Typically derived from `import.meta.url` at the call site.
1561
+ * @returns The absolute path to `studio-dist/client/`, or `undefined` if the
1562
+ * directory does not exist.
1563
+ */
1564
+ declare function resolveStudioStaticDir(cleoPackageDir: string): string | undefined;
1565
+
1034
1566
  /**
1035
1567
  * Singleton accessor for the gateway {@link BackgroundJobManager}.
1036
1568
  *
@@ -1508,6 +2040,51 @@ declare function getGatewayDomains(gateway: Gateway): string[];
1508
2040
  * Resolves a domain + operation to its registered definition.
1509
2041
  */
1510
2042
  declare function resolve(gateway: Gateway, domain: string, operation: string): Resolution | undefined;
2043
+ /**
2044
+ * Infer the CQRS {@link Gateway} for a `(domain, operation)` pair from the
2045
+ * registry, without the caller naming `query`/`mutate` up front.
2046
+ *
2047
+ * This backs the versioned `/v1/<domain>/<operation>` REST facade (T11919),
2048
+ * where — unlike the legacy `/<gateway>/<domain>/<operation>` form — the client
2049
+ * does not spell the gateway segment: the registry is the single source of truth
2050
+ * for whether an operation is a read (`query`) or a write (`mutate`). Each
2051
+ * `(domain, operation)` pair maps to exactly one gateway in {@link OPERATIONS},
2052
+ * so the inference is unambiguous.
2053
+ *
2054
+ * @param domain - The canonical domain segment (e.g. `'tasks'`).
2055
+ * @param operation - The operation segment (e.g. `'show'`).
2056
+ * @returns The owning gateway, or `undefined` when no registered operation
2057
+ * matches the pair (the wire edge then renders a `404`).
2058
+ */
2059
+ declare function inferGateway(domain: string, operation: string): Gateway | undefined;
2060
+ /**
2061
+ * The result of resolving a streaming route: the owning gateway plus the matched
2062
+ * {@link OperationDef} (so the caller has the op's tier/params on hand without a
2063
+ * second lookup).
2064
+ */
2065
+ interface StreamResolution {
2066
+ /** The CQRS gateway the streaming op is registered under. */
2067
+ gateway: Gateway;
2068
+ /** The matched streaming operation definition. */
2069
+ def: OperationDef;
2070
+ }
2071
+ /**
2072
+ * Resolve a `(domain, operation)` pair to a registered **streaming** operation.
2073
+ *
2074
+ * Backs the `GET /v1/<domain>/<operation>` SSE route (T11921): the listener only
2075
+ * opens a `text/event-stream` for a `(domain, operation)` pair that is BOTH
2076
+ * registered AND explicitly flagged `streaming: true` in {@link OPERATIONS}. A
2077
+ * unary-only op (or an unknown pair) resolves to `undefined`, so the listener
2078
+ * never streams an operation that was not declared streamable — a GET to a
2079
+ * unary op stays a `405` (method-not-allowed) rather than silently opening a
2080
+ * never-ending stream.
2081
+ *
2082
+ * @param domain - The canonical domain segment (e.g. `'orchestrate'`).
2083
+ * @param operation - The operation segment (e.g. `'events'`).
2084
+ * @returns The streaming resolution, or `undefined` when no registered op
2085
+ * matches the pair OR the matched op is not flagged `streaming: true`.
2086
+ */
2087
+ declare function resolveStreamRoute(domain: string, operation: string): StreamResolution | undefined;
1511
2088
  /**
1512
2089
  * Validates that all required parameters are present in the request.
1513
2090
  * Returns an array of missing parameter keys.
@@ -1530,6 +2107,114 @@ declare function getCounts(): {
1530
2107
  total: number;
1531
2108
  };
1532
2109
 
2110
+ /**
2111
+ * Daemon-side gateway server bootstrap — the call-site `cleo daemon serve`
2112
+ * drives (T11919).
2113
+ *
2114
+ * The HTTP gateway adapter (`./http/listen.ts`, T11254) and the supervised
2115
+ * `defineGatewaySubsystem` unit (`./daemon-subsystem.ts`, T11451) both already
2116
+ * exist, but nothing started them: the daemon had no `serve` call-site. This
2117
+ * module is that thin bootstrap — it assembles a {@link SubsystemRegistry} with
2118
+ * one scoped gateway subsystem hosting the HTTP transport (loopback by default)
2119
+ * over an INJECTED {@link GatewayHandler}, starts it, and returns a live handle
2120
+ * with the bound port + an idempotent `close()`.
2121
+ *
2122
+ * It carries NO `@cleocode/cleo` dependency and NO drizzle (the runtime
2123
+ * invariant): the caller (`cleo daemon serve`) injects the assembled CLI gateway
2124
+ * handler and the resolved bind coordinates. Secrets never touch the wire —
2125
+ * sealed-handle resolution happens server-side inside the dispatched handler,
2126
+ * exactly as it does for an in-process CLI call. The HTTP listener binds
2127
+ * `127.0.0.1` by default (loopback only); exposing it on a public interface is
2128
+ * an explicit caller decision.
2129
+ *
2130
+ * The streaming transports (SSE — T11921, WS — T11922) extend the SAME
2131
+ * subsystem: when their listeners land they bind alongside the unary HTTP server
2132
+ * here, so this bootstrap deliberately owns only transport assembly + lifecycle,
2133
+ * leaving the per-route seam to the adapter (`parseHttpRoute`).
2134
+ *
2135
+ * @packageDocumentation
2136
+ * @module @cleocode/runtime/gateway
2137
+ *
2138
+ * @task T11919
2139
+ * @epic T11769
2140
+ * @saga T10400
2141
+ */
2142
+
2143
+ /**
2144
+ * Options for {@link serveGateway}.
2145
+ *
2146
+ * The caller injects the assembled {@link GatewayHandler} and resolves the bind
2147
+ * coordinates so the runtime stays free of `@cleocode/paths`/`@cleocode/cleo`.
2148
+ */
2149
+ interface ServeGatewayOptions {
2150
+ /** The transport-neutral gateway handler every hosted transport routes through. */
2151
+ handler: GatewayHandler;
2152
+ /** TCP port to bind. `0` selects an ephemeral port (readable from the handle). */
2153
+ port: number;
2154
+ /** Host/interface to bind. Defaults to `127.0.0.1` (loopback only). */
2155
+ host?: string;
2156
+ /** The scope this gateway process serves. Defaults to `'global'`. */
2157
+ scope?: GatewayScope;
2158
+ /**
2159
+ * Absolute path to the Studio static asset directory (T11979).
2160
+ *
2161
+ * When set, the HTTP server serves the bundled CLEO Web Studio at `/studio`
2162
+ * using the `build/client/` static assets from an `adapter-node` Studio build.
2163
+ * When `undefined`, `/studio` routes return a clean 503 "bundle absent" JSON
2164
+ * envelope. The caller resolves this path (typically via
2165
+ * `resolveStudioStaticDir` from `@cleocode/cleo`) so the runtime stays free of
2166
+ * any path-resolution policy.
2167
+ */
2168
+ studioStaticDir?: string;
2169
+ }
2170
+ /**
2171
+ * A live gateway server handle returned by {@link serveGateway}.
2172
+ *
2173
+ * Exposes the actually-bound `port`/`host` (resolved even when `0` was
2174
+ * requested) and an idempotent {@link close} for deterministic teardown
2175
+ * (daemon shutdown, tests). Mirrors the lower-level
2176
+ * {@link import('./http/listen.js').HttpServerHandle} so a test can boot, probe,
2177
+ * and tear down on an ephemeral port.
2178
+ */
2179
+ interface ServeGatewayHandle {
2180
+ /** The actually-bound HTTP port (resolved when `0` was requested). */
2181
+ port: number;
2182
+ /** Host/interface the HTTP listener is bound to. */
2183
+ host: string;
2184
+ /** The scope the gateway process serves. */
2185
+ scope: GatewayScope;
2186
+ /** Stop every bound listener (best-effort, idempotent); resolves once closed. */
2187
+ close(): Promise<void>;
2188
+ }
2189
+ /**
2190
+ * Boot the HTTP gateway as a supervised subsystem and return a live handle.
2191
+ *
2192
+ * Declares one scoped gateway subsystem hosting the HTTP transport (loopback
2193
+ * default) over the injected handler via {@link defineGatewaySubsystem}, starts
2194
+ * it, and reads the actually-bound port from the start context (so the caller —
2195
+ * and tests — learn the ephemeral port when `0` was requested). The returned
2196
+ * `close()` drives the subsystem's `shutdown(context)` so teardown is the same
2197
+ * lifecycle the daemon registry uses for every other supervised concern.
2198
+ *
2199
+ * Using `defineGatewaySubsystem` directly (rather than a full
2200
+ * `SubsystemRegistry`) keeps the bound HTTP handle in reach for the port
2201
+ * resolution while still routing through the canonical start→shutdown contract.
2202
+ *
2203
+ * @param opts - The injected handler + resolved bind coordinates.
2204
+ * @returns A promise resolving to the live {@link ServeGatewayHandle}.
2205
+ *
2206
+ * @example
2207
+ * ```ts
2208
+ * const handle = await serveGateway({
2209
+ * handler: createCliGatewayHandler(),
2210
+ * port: 0, // ephemeral
2211
+ * });
2212
+ * // POST http://127.0.0.1:${handle.port}/v1/tasks/show
2213
+ * await handle.close();
2214
+ * ```
2215
+ */
2216
+ declare function serveGateway(opts: ServeGatewayOptions): Promise<ServeGatewayHandle>;
2217
+
1533
2218
  /**
1534
2219
  * `@cleocode/runtime/gateway` — the transport-agnostic CQRS dispatch core.
1535
2220
  *
@@ -1569,4 +2254,4 @@ interface GatewayHandler {
1569
2254
  */
1570
2255
  declare function createGatewayHandler(config: DispatcherConfig): GatewayHandler;
1571
2256
 
1572
- 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 };
2257
+ export { operationToMcpTool as $, registerStreamSource as A, resolveStreamSource as B, resolveStudioAssetPath as C, resolveStudioStaticDir as D, type DecodeResult, routeUnary as E, scrubPtyEnv as F, type GateDecision as G, type HttpServerHandle as H, serveStudioStatic as I, startHttpServer as J, statusForResponse as K, tickStreamSource as L, LineBuffer, writeBundleAbsentError as M, NODE_PTY_INSTALL_HINT as N, type GatewayOperationKey as O, MCP_TOOL_PREFIX as P, type McpContent as Q, type McpInputProperty as R, type RpcServerHandle, type RpcServerOptions, SSE_HEADERS as S, type McpServerOptions as T, type UpgradableServer as U, type McpTool as V, WS_PTY_PATH as W, type McpToolResult as X, buildToolsList as Y, callTool as Z, exposedOperations as _, type GatewayStreamSource as a, operationToToolName as a0, startMcpServer as a1, toolNameToOperationKey as a2, BRAIN_DB_FILENAME as a3, CLEO_DIR_NAME as a4, Dispatcher as a5, type DispatcherConfig as a6, type ErrorMeta as a7, type GatewayHandler as a8, type GatewayScope as a9, getTemplateForSubcommand as aA, inferGateway as aB, mapNumericExitCodeToString as aC, parseIssueTemplates as aD, pickDecoratorMetaExtensions as aE, resolve as aF, resolveStreamRoute as aG, serveGateway as aH, setJobManager as aI, stampNexusMeta as aJ, validateLabels as aK, validateRequiredParams as aL, validateSuggestedNext as aM, type GatewaySubsystemContext as aa, type GatewaySubsystemOptions as ab, OPERATIONS as ac, STRING_TO_EXIT as ad, type ServeGatewayHandle as ae, type ServeGatewayOptions as af, type StreamResolution as ag, WORKFLOWS_SUBDIR as ah, buildNexusMetaExtensions as ai, cleoErrorToEngineError as aj, compose as ak, createDispatchMeta as al, createGatewayHandler as am, defineGatewaySubsystem as an, deriveGatewayMatrix as ao, engineError as ap, formatSuggestedNext as aq, gatewaySubsystemName as ar, generateTemplateConfig as as, getActiveDomains as at, getByDomain as au, getByGateway as av, getByTier as aw, getCounts as ax, getGatewayDomains as ay, getJobManager as az, type HttpServerOptions as b, buildErrorFrame, type HttpUnaryRequest as c, type HttpUnaryResult as d, decodeLine, STUDIO_PREFIX as e, encodeFrame, type SseEmitter as f, type SseFrame as g, type SseSource as h, type StreamSourceContext as i, type StreamSourceTeardown as j, type StudioStaticOptions as k, type WsPtyEndpointHandle as l, type WsPtyOptions as m, attachWsPtyEndpoint as n, authorizeUpgrade as o, computeAcceptKey as p, createSseStream as q, decodeWsFrame as r, routeFrame, encodeSseFrame as s, startRpcServer, encodeStreamEvent as t, encodeWsFrame as u, isStudioPath as v, isWsPtyPath as w, loadNodePty as x, mimeType as y, parseHttpRoute as z };