@shendeguize/dsh-agent-sidecar 0.1.0

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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +8062 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.d.ts +396 -0
  7. package/lib/index.js +4166 -0
  8. package/package.json +101 -0
  9. package/src/analysis.ts +782 -0
  10. package/src/bridge.ts +841 -0
  11. package/src/client/analysis/AnalysisPanel.tsx +191 -0
  12. package/src/client/analysis/analysis.module.css +183 -0
  13. package/src/client/analysis-glue.ts +331 -0
  14. package/src/client/api.ts +380 -0
  15. package/src/client/board/Board.tsx +214 -0
  16. package/src/client/board/board.module.css +302 -0
  17. package/src/client/board/logic.ts +556 -0
  18. package/src/client/board/project-view-logic.ts +361 -0
  19. package/src/client/board/project-view.module.css +307 -0
  20. package/src/client/board/project-view.tsx +189 -0
  21. package/src/client/board/strings.ts +112 -0
  22. package/src/client/commands.ts +484 -0
  23. package/src/client/controller.ts +360 -0
  24. package/src/client/css-modules.d.ts +11 -0
  25. package/src/client/detail/SessionDetail.tsx +270 -0
  26. package/src/client/detail/detail.module.css +433 -0
  27. package/src/client/detail/logic.ts +779 -0
  28. package/src/client/detail/strings.ts +98 -0
  29. package/src/client/detail/transport.ts +175 -0
  30. package/src/client/detail-glue.ts +397 -0
  31. package/src/client/detail-view.module.css +79 -0
  32. package/src/client/detail-view.tsx +233 -0
  33. package/src/client/dsh-tools/LineageTree.tsx +210 -0
  34. package/src/client/dsh-tools/SearchPanel.tsx +169 -0
  35. package/src/client/dsh-tools/dsh-tools.module.css +374 -0
  36. package/src/client/dsh-tools/logic.ts +596 -0
  37. package/src/client/dsh-tools/strings.ts +90 -0
  38. package/src/client/index.ts +315 -0
  39. package/src/client/inject/InjectPanel.tsx +482 -0
  40. package/src/client/inject/inject.module.css +446 -0
  41. package/src/client/inject/logic.ts +516 -0
  42. package/src/client/inject/overlay.module.css +22 -0
  43. package/src/client/inject-glue.ts +171 -0
  44. package/src/client/locales/command.ts +48 -0
  45. package/src/client/locales/en.ts +385 -0
  46. package/src/client/locales/index.ts +123 -0
  47. package/src/client/locales/zh.ts +402 -0
  48. package/src/client/m3-transport.ts +151 -0
  49. package/src/client/mount.tsx +307 -0
  50. package/src/client/project-glue.ts +134 -0
  51. package/src/client/search-glue.ts +143 -0
  52. package/src/client/settings-card.module.css +359 -0
  53. package/src/client/settings-card.tsx +565 -0
  54. package/src/client/settings-glue.ts +130 -0
  55. package/src/client/sidebar-tab.tsx +494 -0
  56. package/src/client/sse.ts +366 -0
  57. package/src/client/widget.tsx +80 -0
  58. package/src/config.ts +193 -0
  59. package/src/dsh-inject.ts +240 -0
  60. package/src/fusion.ts +988 -0
  61. package/src/guard.ts +274 -0
  62. package/src/index.ts +950 -0
  63. package/src/inject-gateway.ts +574 -0
  64. package/src/routes.ts +1133 -0
  65. package/src/send-cli.ts +340 -0
  66. package/src/session-store.ts +184 -0
  67. package/src/skills-provider.ts +293 -0
  68. package/src/supervisor.ts +463 -0
package/src/routes.ts ADDED
@@ -0,0 +1,1133 @@
1
+ /**
2
+ * HTTP route layer for the plugin's self-registered namespace (design §4.f).
3
+ *
4
+ * M1 scope: three read endpoints (`GET state`, `GET session/<id>`,
5
+ * `GET stream` SSE) plus a `POST action` placeholder behind the write gate.
6
+ *
7
+ * M2 scope: when an {@link InjectGatewayApi} is wired into the deps,
8
+ * `POST action` becomes a dispatcher over a bounded (≤64 KiB) JSON body
9
+ * `{ type: 'inject.prepare' | 'inject.execute' | 'daemon.retry', ... }`.
10
+ * The inject types pass the write-action gate and drive the gateway's
11
+ * two-phase confirm; `daemon.retry` is daemon management — independent of
12
+ * the injection capability (design §6: `inject.enabled` gates injection
13
+ * only) — so it passes guard layers 1-4 without the write gate. Without a
14
+ * gateway the M1 placeholder contract (write gate, then 501) is preserved.
15
+ * Message bodies never reach the route log (S8), and `outcome: 'unknown'`
16
+ * is answered 200 as a terminal "do not retry" — this layer never re-fires
17
+ * the gateway on it (S6).
18
+ *
19
+ * M3 scope: when a {@link FusionApi} is wired into the deps, the read
20
+ * surface widens (design §4.e / §5, all GET, guard layers 1-4, no write
21
+ * gate):
22
+ * - `GET session/<id>` upgrades its `timeline: null` placeholder to the
23
+ * newest fused timeline page plus a `nextCursor` pagination token, and
24
+ * gains a `unified` row (dsh-live sessions the sidecar has not seen
25
+ * yet resolve here instead of 404).
26
+ * - `GET session/<id>/timeline?cursor=&limit=` pages backward through
27
+ * history (fusion pulls dsh live/cold logs, the daemon `replay` op and
28
+ * the bounded event ring on demand).
29
+ * - `GET lineage/<id>` → fusion.getLineage; ALWAYS 200 with
30
+ * `{available:false, reason}` when sessionQuery is absent or the trace
31
+ * fails (degradation is data, not an error; design §4.e.4).
32
+ * - `GET search?q=&project=&limit=` → fusion.searchSessions (`mode`
33
+ * reports 'full-text' vs the 'filter-only' degradation); a
34
+ * project-only query filters the unified list without the engine.
35
+ * - `GET projects` → fusion.getProjectGroups.
36
+ * Without a fusion the four new paths answer 501 `fusion_not_wired` and
37
+ * `GET session/<id>` keeps the M1 placeholder contract. The SSE stream
38
+ * contract is untouched: live updates stay full-snapshot frames and the
39
+ * client pairs them with timeline pagination (no per-session filter —
40
+ * recorded as not needed for M3).
41
+ *
42
+ * M3 analysis scope (design §4.e.3 / §7-B): when an {@link AnalysisApi} is
43
+ * wired into the deps, `POST action` also dispatches
44
+ * `{ type: 'analysis.request' | 'analysis.followup' | 'analysis.cancel' }`.
45
+ * These pass guard layers 1-4 and then a WRITE GATE OF THEIR OWN —
46
+ * `deps.analysisEnabled` (live `analysis.enabled`, default false), parallel
47
+ * to and independent of the guard's inject gate — closed (or absent) means
48
+ * 403 `analysis_disabled`. Past the gate, a missing/unavailable analysis
49
+ * surface (agents-less composition) answers 501 `analysis_unavailable`
50
+ * (honest degradation, never a crash). `analysis.request` additionally
51
+ * pre-checks that an analysis model is resolvable — no model anywhere
52
+ * answers 403 `analysis_model_unconfigured` before any session is created
53
+ * (A-1) — then resolves its target through the wiring's fusion-backed
54
+ * input adapter (unknown target
55
+ * → 404 `target_not_found`) and drives the engine; result error codes map
56
+ * to `analysis_disabled` 403, `too_many_active` 429, `timeout` 504,
57
+ * `create_failed` 502 — while `cancelled` stays 200 (a terminal fact about
58
+ * the analysis session, carried in the outcome, not a transport failure).
59
+ * Analyzed content (summaries, questions, model replies) never reaches the
60
+ * route log (S8). Note the M1 no-gateway placeholder still wins: analysis
61
+ * dispatch lives inside the gateway-backed action handler.
62
+ *
63
+ * Timeline cursors are opaque `<seq|'-'>~<epoch-ms>` tokens minted by
64
+ * {@link encodeCursor}; clients must round-trip them verbatim.
65
+ *
66
+ * Contract (verified against the dsh source, not docs): the webServer
67
+ * prefix-route handler is plain `node:http` —
68
+ * `(req: IncomingMessage, res: ServerResponse) => void | Promise<void>`,
69
+ * and it owns the full response lifecycle, so SSE long-holds are legal
70
+ * (`packages/host/webserver/src/index.ts:42-48`). A `prefix` route `p`
71
+ * receives `p` and `p/<anything>` (`:38`, `:262`), and handler rejections
72
+ * are caught by the carrier (logged, answered 400; `:181-194`). Internal
73
+ * dispatch via `new URL(req.url, ...)` follows the better-sidebar
74
+ * precedent (`DSH-better-sidebar/src/index.ts:665-694`).
75
+ *
76
+ * Like guard/store/supervisor, this module imports nothing from
77
+ * cordis/dsh: the plugin entry (index.ts) wires it via
78
+ * `ctx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler })`
79
+ * and puts `dispose()` inside a `ctx.effect` disposer.
80
+ *
81
+ * @module
82
+ */
83
+
84
+ import type { IncomingMessage, ServerResponse } from 'node:http'
85
+ import {
86
+ guardRequest,
87
+ guardWriteAction,
88
+ type GuardOptions,
89
+ type GuardVerdict,
90
+ } from './guard.ts'
91
+ import type { AnalysisEngine, AnalysisInput, AnalysisResult } from './analysis.ts'
92
+ import type { FusionQuery, TimelineCursor, TimelinePage } from './fusion.ts'
93
+ import type { InjectGateway } from './inject-gateway.ts'
94
+ import type { BoardState, SessionStore } from './session-store.ts'
95
+ import type { DaemonSupervisor, PingInfo, SupervisorState } from './supervisor.ts'
96
+
97
+ /** dsh webServer route handler shape (see module doc for the evidence). */
98
+ export type WebRouteHandler = (
99
+ req: IncomingMessage,
100
+ res: ServerResponse,
101
+ ) => void | Promise<void>
102
+
103
+ /** Route namespace, per the `/plugins/<package>/` convention (design §4.f). */
104
+ export const API_PREFIX = '/plugins/agent-sidecar/api'
105
+
106
+ /**
107
+ * The slice of {@link InjectGateway} the routes drive. A `Pick` keeps the
108
+ * dependency structural (the class has private state), so the real gateway
109
+ * and plain-object test fakes are equally assignable.
110
+ */
111
+ export type InjectGatewayApi = Pick<InjectGateway, 'prepare' | 'execute'>
112
+
113
+ /**
114
+ * The slice of {@link FusionQuery} the M3 read endpoints drive. Structural
115
+ * for the same reason as {@link InjectGatewayApi}: the entry wires the real
116
+ * fusion (possibly behind a holder facade) and tests wire plain objects.
117
+ */
118
+ export type FusionApi = Pick<
119
+ FusionQuery,
120
+ | 'getUnifiedSessions'
121
+ | 'getSessionTimeline'
122
+ | 'getProjectGroups'
123
+ | 'getLineage'
124
+ | 'searchSessions'
125
+ | 'getCapabilities'
126
+ >
127
+
128
+ /** The slice of {@link AnalysisEngine} the analysis actions drive. */
129
+ export type AnalysisEngineApi = Pick<AnalysisEngine, 'request' | 'followup' | 'cancel'>
130
+
131
+ /** Target selector carried by one `analysis.request` action envelope. */
132
+ export interface AnalysisTargetRequest {
133
+ targetKind: 'session' | 'project' | 'cross-agent'
134
+ /** Session id / project path; required for session and project kinds. */
135
+ targetId?: string
136
+ /** Optional user question folded into the analysis input by the adapter. */
137
+ question?: string
138
+ }
139
+
140
+ /**
141
+ * M3 analysis wiring handed in by the entry: the engine plus the adapter
142
+ * that assembles a bounded {@link AnalysisInput} from fusion data. The
143
+ * routes stay dumb — target resolution and summary assembly are the
144
+ * wiring's job, request/result vocabulary is the engine's.
145
+ */
146
+ export interface AnalysisApi {
147
+ engine: AnalysisEngineApi
148
+ /**
149
+ * Assemble the bounded analysis input for a target. `null` means the
150
+ * target is unknown to fusion (the routes answer 404 `target_not_found`).
151
+ */
152
+ buildInput(req: AnalysisTargetRequest): Promise<AnalysisInput | null>
153
+ /**
154
+ * Whether the underlying `ctx.agents` service is bound. `false` answers
155
+ * 501 `analysis_unavailable` before touching the engine (agents-less
156
+ * composition — honest degradation).
157
+ */
158
+ available(): boolean
159
+ /**
160
+ * Whether an analysis model is resolvable (explicit analysis.provider/
161
+ * model config, or the host's default model selection). `false` answers
162
+ * 403 `analysis_model_unconfigured` on `analysis.request` BEFORE the
163
+ * engine creates a session — honest pre-rejection instead of a modelless
164
+ * agent completing with an empty summary (A-1). Followup/cancel are not
165
+ * gated: an established analysis session carries its model already.
166
+ * Optional: an absent probe skips the pre-check (the create adapter
167
+ * still fails honestly as `create_failed`).
168
+ */
169
+ modelConfigured?(): boolean
170
+ }
171
+
172
+ /** Everything the route layer consumes; all live objects, none owned here. */
173
+ export interface RoutesDeps {
174
+ store: SessionStore
175
+ supervisor: DaemonSupervisor
176
+ /** Live `inject.enabled` reader shared with the guard's write gate. */
177
+ guardOptions: GuardOptions
178
+ /**
179
+ * M2 injection gateway. When absent (M1 wiring, injection not assembled)
180
+ * `POST action` keeps the placeholder contract: write gate, then 501.
181
+ */
182
+ injectGateway?: InjectGatewayApi
183
+ /**
184
+ * M3 fusion query surface. When absent the timeline/lineage/search/
185
+ * projects endpoints answer 501 `fusion_not_wired` and `GET session/<id>`
186
+ * keeps the M1 placeholder contract.
187
+ */
188
+ fusion?: FusionApi
189
+ /**
190
+ * Live `analysis.enabled` reader — the analysis write gate, parallel to
191
+ * (and independent of) the guard's inject gate. Absent reads as CLOSED
192
+ * (fail-closed): every `analysis.*` action answers 403 `analysis_disabled`.
193
+ */
194
+ analysisEnabled?: () => boolean
195
+ /**
196
+ * M3 analysis surface. When absent (analysis not assembled), `analysis.*`
197
+ * actions that pass the gate answer 501 `analysis_unavailable`.
198
+ */
199
+ analysis?: AnalysisApi
200
+ log(level: 'info' | 'warn' | 'error', msg: string, meta?: object): void
201
+ }
202
+
203
+ export interface RoutesOptions {
204
+ /** Concurrent SSE connection cap; extra connects get 503. Default 8. */
205
+ maxSseClients?: number
206
+ /** SSE comment-frame heartbeat cadence. Default 15000. */
207
+ sseHeartbeatMs?: number
208
+ /**
209
+ * Per-connection bound on frames queued behind a slow socket; exceeding
210
+ * it destroys that connection (the client reconnects and resnapshots).
211
+ * Default 256.
212
+ */
213
+ sseBufferLimit?: number
214
+ }
215
+
216
+ /** Body of `GET state` and of every SSE `state` event (full snapshot). */
217
+ export interface StateSnapshot {
218
+ daemon: { state: SupervisorState; lastPing: PingInfo | null }
219
+ board: BoardState
220
+ capabilities: { inject: boolean }
221
+ }
222
+
223
+ /** What `createRoutes` hands back to the plugin entry. */
224
+ export interface Routes {
225
+ /** Mount as the `prefix` route handler for {@link API_PREFIX}. */
226
+ handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>
227
+ /** Close all SSE connections, unsubscribe, clear timers. Idempotent. */
228
+ dispose(): void
229
+ }
230
+
231
+ const DEFAULT_MAX_SSE_CLIENTS = 8
232
+ const DEFAULT_SSE_HEARTBEAT_MS = 15_000
233
+ const DEFAULT_SSE_BUFFER_LIMIT = 256
234
+
235
+ const HEARTBEAT_FRAME = ': hb\n\n'
236
+
237
+ /** Bound on the `POST action` JSON body (message cap is 16 KiB + envelope). */
238
+ export const MAX_ACTION_BODY_BYTES = 64 * 1024
239
+
240
+ /** `inject.prepare` rejection code → HTTP status (task spec mapping). */
241
+ const PREPARE_ERROR_STATUS: Readonly<Record<string, number>> = {
242
+ inject_disabled: 403,
243
+ invalid_message: 422,
244
+ target_not_found: 404,
245
+ target_dead: 409,
246
+ too_many_pending: 429,
247
+ // Issued at prepare since M2 review F-6 (no injection path for this
248
+ // agent); same 422 the execute-side defense-in-depth check maps to.
249
+ unsupported_agent: 422,
250
+ }
251
+
252
+ /**
253
+ * `inject.execute` failed-outcome code → HTTP status. Unlisted codes
254
+ * (executor-native vocab) and codeless failures fall back to 502.
255
+ */
256
+ const EXECUTE_ERROR_STATUS: Readonly<Record<string, number>> = {
257
+ token_missing: 401,
258
+ token_expired: 401,
259
+ token_reused: 409,
260
+ token_mismatch: 409,
261
+ unsupported_agent: 422,
262
+ executor_error: 502,
263
+ }
264
+
265
+ /**
266
+ * Analysis-engine error code → HTTP status (task spec mapping). `cancelled`
267
+ * stays 200: it is a terminal fact about the analysis session carried in
268
+ * the result outcome, not a transport failure. Unknown codes fall back to
269
+ * 502 like the execute map does.
270
+ */
271
+ const ANALYSIS_ERROR_STATUS: Readonly<Record<string, number>> = {
272
+ analysis_disabled: 403,
273
+ too_many_active: 429,
274
+ timeout: 504,
275
+ create_failed: 502,
276
+ cancelled: 200,
277
+ }
278
+
279
+ const ANALYSIS_ACTION_TYPES = new Set([
280
+ 'analysis.request',
281
+ 'analysis.followup',
282
+ 'analysis.cancel',
283
+ ])
284
+
285
+ interface SseClient {
286
+ res: ServerResponse
287
+ /** Frames queued while the socket is backpressured. */
288
+ pending: string[]
289
+ /** True after a `res.write` returned false, until the next 'drain'. */
290
+ blocked: boolean
291
+ closed: boolean
292
+ heartbeat: ReturnType<typeof setInterval> | null
293
+ }
294
+
295
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
296
+ res.writeHead(status, {
297
+ 'content-type': 'application/json; charset=utf-8',
298
+ 'cache-control': 'no-store',
299
+ })
300
+ res.end(JSON.stringify(body))
301
+ }
302
+
303
+ function writeMethodNotAllowed(res: ServerResponse, allow: string): void {
304
+ res.writeHead(405, {
305
+ allow,
306
+ 'content-type': 'application/json; charset=utf-8',
307
+ })
308
+ res.end(JSON.stringify({ reason: 'method_not_allowed' }))
309
+ }
310
+
311
+ /**
312
+ * Path inside the namespace ('' for the bare prefix), or null when the
313
+ * request is outside {@link API_PREFIX} or the URL is unparsable. The
314
+ * carrier already parsed the same string to match the route, so the null
315
+ * arms only matter when `handle` is exercised directly.
316
+ */
317
+ function subpathOf(rawUrl: string | undefined): string | null {
318
+ let pathname: string
319
+ try {
320
+ pathname = new URL(rawUrl ?? '/', 'http://dsh.internal').pathname
321
+ } catch {
322
+ return null
323
+ }
324
+ if (pathname === API_PREFIX) return ''
325
+ if (pathname.startsWith(`${API_PREFIX}/`)) return pathname.slice(API_PREFIX.length + 1)
326
+ return null
327
+ }
328
+
329
+ /** Query string of the request (empty params when the URL is unparsable). */
330
+ function queryOf(rawUrl: string | undefined): URLSearchParams {
331
+ try {
332
+ return new URL(rawUrl ?? '/', 'http://dsh.internal').searchParams
333
+ } catch {
334
+ return new URLSearchParams()
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Timeline pagination token: `<seq|'-'>~<epoch-ms>`. Deliberately not the
340
+ * raw JSON cursor object so the query-string round-trip stays trivial and
341
+ * the wire shape is decoupled from fusion's internal cursor type.
342
+ */
343
+ function encodeCursor(cursor: TimelineCursor): string {
344
+ return `${cursor.seq === null ? '-' : cursor.seq}~${cursor.ts}`
345
+ }
346
+
347
+ function decodeCursor(raw: string): TimelineCursor | null {
348
+ const sep = raw.indexOf('~')
349
+ if (sep <= 0 || sep === raw.length - 1) return null
350
+ const seqPart = raw.slice(0, sep)
351
+ const tsPart = raw.slice(sep + 1)
352
+ const ts = Number(tsPart)
353
+ if (!Number.isInteger(ts) || ts < 0) return null
354
+ if (seqPart === '-') return { seq: null, ts }
355
+ const seq = Number(seqPart)
356
+ if (!Number.isInteger(seq) || seq < 0) return null
357
+ return { seq, ts }
358
+ }
359
+
360
+ /** Bound on caller-supplied page sizes (timeline entries / search hits). */
361
+ const MAX_PAGE_LIMIT = 500
362
+
363
+ /** Search result bound when the caller supplies no limit (fusion default). */
364
+ const DEFAULT_SEARCH_ROUTE_LIMIT = 50
365
+
366
+ /** Match fusion's project correlation key: strip trailing slashes (keep `/`). */
367
+ function normalizeProjectKey(project: string): string {
368
+ if (project.length > 1 && project.endsWith('/')) {
369
+ const stripped = project.replace(/\/+$/, '')
370
+ return stripped === '' ? '/' : stripped
371
+ }
372
+ return project
373
+ }
374
+
375
+ /**
376
+ * Parse an optional positive-integer query param bounded by
377
+ * {@link MAX_PAGE_LIMIT}. `undefined` when absent, `null` when invalid.
378
+ */
379
+ function parseLimit(params: URLSearchParams, name: string): number | undefined | null {
380
+ const raw = params.get(name)
381
+ if (raw === null || raw === '') return undefined
382
+ const value = Number(raw)
383
+ if (!Number.isInteger(value) || value < 1 || value > MAX_PAGE_LIMIT) return null
384
+ return value
385
+ }
386
+
387
+ /** JSON wire shape of one timeline page (adds the encoded `nextCursor`). */
388
+ function timelineBody(page: TimelinePage): Record<string, unknown> {
389
+ return {
390
+ sessionId: page.sessionId,
391
+ entries: page.entries,
392
+ cursor: page.cursor,
393
+ nextCursor: page.cursor === null ? null : encodeCursor(page.cursor),
394
+ sources: page.sources,
395
+ }
396
+ }
397
+
398
+ /** `event: <name>` + single-line JSON data (JSON.stringify never emits raw newlines). */
399
+ function sseFrame(event: string, data: string): string {
400
+ return `event: ${event}\ndata: ${data}\n\n`
401
+ }
402
+
403
+ /** Outcome of the bounded body read for `POST action`. */
404
+ type BodyRead = { kind: 'ok'; text: string } | { kind: 'too_large' } | { kind: 'error' }
405
+
406
+ /**
407
+ * Read the request body up to {@link MAX_ACTION_BODY_BYTES}. On overflow the
408
+ * promise settles immediately ('too_large') while the rest of the stream
409
+ * keeps draining, so the keep-alive connection is left in a clean state.
410
+ */
411
+ function readActionBody(req: IncomingMessage): Promise<BodyRead> {
412
+ return new Promise((resolve) => {
413
+ const chunks: Buffer[] = []
414
+ let size = 0
415
+ let settled = false
416
+ const settle = (result: BodyRead): void => {
417
+ if (settled) return
418
+ settled = true
419
+ resolve(result)
420
+ }
421
+ req.on('data', (chunk: Buffer | string) => {
422
+ if (settled) return // overflow already answered; keep draining
423
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
424
+ size += buf.length
425
+ if (size > MAX_ACTION_BODY_BYTES) {
426
+ settle({ kind: 'too_large' })
427
+ return
428
+ }
429
+ chunks.push(buf)
430
+ })
431
+ req.on('end', () => settle({ kind: 'ok', text: Buffer.concat(chunks).toString('utf8') }))
432
+ req.on('error', () => settle({ kind: 'error' }))
433
+ })
434
+ }
435
+
436
+ /**
437
+ * Build the M1 route surface. All state lives in the returned closure;
438
+ * multiple instances never share anything.
439
+ */
440
+ export function createRoutes(deps: RoutesDeps, opts: RoutesOptions = {}): Routes {
441
+ const maxSseClients = opts.maxSseClients ?? DEFAULT_MAX_SSE_CLIENTS
442
+ const sseHeartbeatMs = opts.sseHeartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS
443
+ const sseBufferLimit = opts.sseBufferLimit ?? DEFAULT_SSE_BUFFER_LIMIT
444
+
445
+ const clients = new Set<SseClient>()
446
+ let disposed = false
447
+
448
+ const buildSnapshot = (): StateSnapshot => ({
449
+ daemon: { state: deps.supervisor.state, lastPing: deps.supervisor.lastPing },
450
+ board: deps.store.getBoardState(),
451
+ capabilities: { inject: deps.guardOptions.allowWriteActions() },
452
+ })
453
+
454
+ // ------------------------------------------------------------------ SSE
455
+
456
+ const cleanupClient = (client: SseClient): void => {
457
+ if (client.closed) return
458
+ client.closed = true
459
+ if (client.heartbeat !== null) clearInterval(client.heartbeat)
460
+ client.heartbeat = null
461
+ client.pending.length = 0
462
+ clients.delete(client)
463
+ deps.log('info', 'sse client disconnected', { clients: clients.size })
464
+ }
465
+
466
+ const dropClient = (client: SseClient, reason: string): void => {
467
+ deps.log('warn', 'sse client dropped', {
468
+ reason,
469
+ pending: client.pending.length,
470
+ limit: sseBufferLimit,
471
+ })
472
+ cleanupClient(client)
473
+ client.res.destroy()
474
+ }
475
+
476
+ const push = (client: SseClient, frame: string): void => {
477
+ if (client.closed) return
478
+ if (client.blocked) {
479
+ client.pending.push(frame)
480
+ if (client.pending.length > sseBufferLimit) {
481
+ dropClient(client, 'buffer_overflow')
482
+ }
483
+ return
484
+ }
485
+ if (!client.res.write(frame)) client.blocked = true
486
+ }
487
+
488
+ const flush = (client: SseClient): void => {
489
+ if (client.closed) return
490
+ client.blocked = false
491
+ while (!client.blocked) {
492
+ const frame = client.pending.shift()
493
+ if (frame === undefined) return
494
+ if (!client.res.write(frame)) client.blocked = true
495
+ }
496
+ }
497
+
498
+ const acceptStream = (res: ServerResponse): void => {
499
+ if (clients.size >= maxSseClients) {
500
+ deps.log('warn', 'sse connection rejected: client limit reached', {
501
+ max: maxSseClients,
502
+ })
503
+ writeJson(res, 503, { reason: 'too_many_stream_clients' })
504
+ return
505
+ }
506
+ res.writeHead(200, {
507
+ 'content-type': 'text/event-stream',
508
+ 'cache-control': 'no-cache',
509
+ connection: 'keep-alive',
510
+ })
511
+ const client: SseClient = {
512
+ res,
513
+ pending: [],
514
+ blocked: false,
515
+ closed: false,
516
+ heartbeat: null,
517
+ }
518
+ clients.add(client)
519
+ // 'close' fires for both client aborts and our own end()/destroy().
520
+ res.on('close', () => cleanupClient(client))
521
+ res.on('drain', () => flush(client))
522
+ client.heartbeat = setInterval(() => push(client, HEARTBEAT_FRAME), sseHeartbeatMs)
523
+ deps.log('info', 'sse client connected', { clients: clients.size })
524
+ push(client, sseFrame('state', JSON.stringify(buildSnapshot())))
525
+ }
526
+
527
+ /** One change → one full snapshot frame to every client (M1 granularity). */
528
+ const onMutation = (): void => {
529
+ if (disposed || clients.size === 0) return
530
+ const frame = sseFrame('state', JSON.stringify(buildSnapshot()))
531
+ for (const client of [...clients]) push(client, frame)
532
+ }
533
+
534
+ const unsubscribes: Array<() => void> = [
535
+ deps.store.onChange(onMutation),
536
+ deps.supervisor.onStateChange(onMutation),
537
+ ]
538
+
539
+ // --------------------------------------------------------------- routes
540
+
541
+ /** Decoded session id, or null (already answered 404) on a bad escape. */
542
+ const decodeId = (res: ServerResponse, rawId: string): string | null => {
543
+ try {
544
+ const id = decodeURIComponent(rawId)
545
+ if (id !== '') return id
546
+ } catch {
547
+ // fall through to the 404
548
+ }
549
+ writeJson(res, 404, { reason: 'session_not_found' })
550
+ return null
551
+ }
552
+
553
+ const handleSession = async (res: ServerResponse, rawId: string): Promise<void> => {
554
+ const id = decodeId(res, rawId)
555
+ if (id === null) return
556
+ const view = deps.store.getBoardState().sessions.find((s) => s.session_id === id)
557
+ const fusion = deps.fusion
558
+ if (fusion === undefined) {
559
+ // M1 contract preserved: detail == card data, timeline placeholder.
560
+ if (view === undefined) {
561
+ writeJson(res, 404, { reason: 'session_not_found' })
562
+ return
563
+ }
564
+ writeJson(res, 200, {
565
+ session: view,
566
+ timeline: null,
567
+ timelineNote: 'timeline_not_available_until_m3',
568
+ })
569
+ return
570
+ }
571
+ // M3: the unified view also resolves dsh-live sessions the sidecar has
572
+ // not observed on disk yet, so those answer 200 instead of 404.
573
+ const unified = fusion.getUnifiedSessions().find((s) => s.sessionId === id) ?? null
574
+ if (view === undefined && unified === null) {
575
+ writeJson(res, 404, { reason: 'session_not_found' })
576
+ return
577
+ }
578
+ const page = await fusion.getSessionTimeline(id)
579
+ writeJson(res, 200, {
580
+ session: view ?? null,
581
+ unified,
582
+ timeline: timelineBody(page),
583
+ })
584
+ }
585
+
586
+ const handleTimeline = async (
587
+ res: ServerResponse,
588
+ rawId: string,
589
+ params: URLSearchParams,
590
+ ): Promise<void> => {
591
+ const fusion = deps.fusion
592
+ if (fusion === undefined) {
593
+ writeJson(res, 501, { reason: 'fusion_not_wired' })
594
+ return
595
+ }
596
+ const id = decodeId(res, rawId)
597
+ if (id === null) return
598
+ const rawCursor = params.get('cursor')
599
+ let before: TimelineCursor | null = null
600
+ if (rawCursor !== null && rawCursor !== '') {
601
+ before = decodeCursor(rawCursor)
602
+ if (before === null) {
603
+ writeJson(res, 400, { reason: 'invalid_cursor' })
604
+ return
605
+ }
606
+ }
607
+ const limit = parseLimit(params, 'limit')
608
+ if (limit === null) {
609
+ writeJson(res, 400, { reason: 'invalid_limit' })
610
+ return
611
+ }
612
+ const page = await fusion.getSessionTimeline(id, { before, limit })
613
+ // Unknown id: no source contributed, nothing buffered, and the board
614
+ // does not list it either → an honest 404 instead of an empty page.
615
+ const anySource =
616
+ page.sources.dshLive ||
617
+ page.sources.dshCold ||
618
+ page.sources.sidecarReplay ||
619
+ page.sources.sidecarBuffer
620
+ if (!anySource && page.entries.length === 0) {
621
+ const known =
622
+ deps.store.getBoardState().sessions.some((s) => s.session_id === id) ||
623
+ fusion.getUnifiedSessions().some((s) => s.sessionId === id)
624
+ if (!known) {
625
+ writeJson(res, 404, { reason: 'session_not_found' })
626
+ return
627
+ }
628
+ }
629
+ writeJson(res, 200, timelineBody(page))
630
+ }
631
+
632
+ const handleLineage = async (res: ServerResponse, rawId: string): Promise<void> => {
633
+ const fusion = deps.fusion
634
+ if (fusion === undefined) {
635
+ writeJson(res, 501, { reason: 'fusion_not_wired' })
636
+ return
637
+ }
638
+ const id = decodeId(res, rawId)
639
+ if (id === null) return
640
+ // Degradation (sessionQuery absent / trace failed) is DATA, not an
641
+ // error: always 200 with {available, trace, reason} (design §4.e.4).
642
+ writeJson(res, 200, await fusion.getLineage(id))
643
+ }
644
+
645
+ const handleSearch = async (res: ServerResponse, params: URLSearchParams): Promise<void> => {
646
+ const fusion = deps.fusion
647
+ if (fusion === undefined) {
648
+ writeJson(res, 501, { reason: 'fusion_not_wired' })
649
+ return
650
+ }
651
+ const query = (params.get('q') ?? '').trim()
652
+ const project = (params.get('project') ?? '').trim()
653
+ if (query === '' && project === '') {
654
+ writeJson(res, 400, {
655
+ reason: 'invalid_request',
656
+ detail: 'search needs q= (text query) and/or project= (project filter)',
657
+ })
658
+ return
659
+ }
660
+ const limit = parseLimit(params, 'limit')
661
+ if (limit === null) {
662
+ writeJson(res, 400, { reason: 'invalid_limit' })
663
+ return
664
+ }
665
+ let mode: 'full-text' | 'filter-only'
666
+ let items: Array<{ session: { project: string }; matchedBy: string; snippet: string | null }>
667
+ if (query !== '') {
668
+ const result = await fusion.searchSessions(query, limit === undefined ? {} : { limit })
669
+ mode = result.mode
670
+ items = result.items
671
+ } else {
672
+ // Project-only search: a plain filter over the unified view.
673
+ mode = 'filter-only'
674
+ items = fusion
675
+ .getUnifiedSessions()
676
+ .map((session) => ({ session, matchedBy: 'project', snippet: null }))
677
+ }
678
+ if (project !== '') {
679
+ // Exact-path filter after trailing-slash normalization (`project` is
680
+ // the group key handed out by GET projects).
681
+ const wanted = normalizeProjectKey(project)
682
+ items = items.filter((item) => normalizeProjectKey(item.session.project) === wanted)
683
+ }
684
+ items = items.slice(0, limit ?? DEFAULT_SEARCH_ROUTE_LIMIT)
685
+ writeJson(res, 200, {
686
+ mode,
687
+ query,
688
+ project: project === '' ? null : project,
689
+ items,
690
+ })
691
+ }
692
+
693
+ const handleProjects = (res: ServerResponse): void => {
694
+ const fusion = deps.fusion
695
+ if (fusion === undefined) {
696
+ writeJson(res, 501, { reason: 'fusion_not_wired' })
697
+ return
698
+ }
699
+ writeJson(res, 200, { groups: fusion.getProjectGroups() })
700
+ }
701
+
702
+ // -------------------------------------------------------------- actions
703
+
704
+ /**
705
+ * Route-log discipline (S8): only the action type, status and vocabulary
706
+ * codes — never the message body, preview, or gateway detail text.
707
+ */
708
+ const logAction = (type: string, status: number, meta: object = {}): void => {
709
+ deps.log('info', 'action handled', { type, status, ...meta })
710
+ }
711
+
712
+ const handlePrepare = async (
713
+ gateway: InjectGatewayApi,
714
+ envelope: Record<string, unknown>,
715
+ res: ServerResponse,
716
+ ): Promise<void> => {
717
+ const rawTarget = envelope.target
718
+ const targetObj =
719
+ typeof rawTarget === 'object' && rawTarget !== null
720
+ ? (rawTarget as Record<string, unknown>)
721
+ : undefined
722
+ const agent = targetObj?.agent
723
+ const sessionId = targetObj?.sessionId
724
+ const mode = envelope.mode
725
+ const message = envelope.message
726
+ if (
727
+ typeof agent !== 'string' ||
728
+ typeof sessionId !== 'string' ||
729
+ (mode !== 'queue' && mode !== 'steer') ||
730
+ typeof message !== 'string'
731
+ ) {
732
+ logAction('inject.prepare', 400, { reason: 'invalid_request' })
733
+ writeJson(res, 400, {
734
+ reason: 'invalid_request',
735
+ detail: 'inject.prepare needs target{agent,sessionId}, mode queue|steer, and a string message',
736
+ })
737
+ return
738
+ }
739
+ const result = await gateway.prepare({ target: { agent, sessionId }, mode, message })
740
+ if (result.ok) {
741
+ logAction('inject.prepare', 200, { requestId: result.requestId })
742
+ writeJson(res, 200, {
743
+ requestId: result.requestId,
744
+ confirmToken: result.confirmToken,
745
+ plan: result.plan,
746
+ expiresAt: result.expiresAt,
747
+ })
748
+ return
749
+ }
750
+ const status = PREPARE_ERROR_STATUS[result.errorCode] ?? 400
751
+ logAction('inject.prepare', status, { errorCode: result.errorCode })
752
+ writeJson(res, status, {
753
+ reason: result.errorCode,
754
+ ...(result.detail !== undefined ? { detail: result.detail } : {}),
755
+ })
756
+ }
757
+
758
+ const handleExecute = async (
759
+ gateway: InjectGatewayApi,
760
+ envelope: Record<string, unknown>,
761
+ res: ServerResponse,
762
+ ): Promise<void> => {
763
+ const { requestId, confirmToken, message } = envelope
764
+ if (
765
+ typeof requestId !== 'string' ||
766
+ typeof confirmToken !== 'string' ||
767
+ typeof message !== 'string'
768
+ ) {
769
+ logAction('inject.execute', 400, { reason: 'invalid_request' })
770
+ writeJson(res, 400, {
771
+ reason: 'invalid_request',
772
+ detail: 'inject.execute needs string requestId, confirmToken and message',
773
+ })
774
+ return
775
+ }
776
+ // Exactly one gateway dispatch per HTTP request. `outcome: 'unknown'` is
777
+ // answered 200 as a terminal "do not retry" — never re-fired here (S6).
778
+ const result = await gateway.execute({ requestId, confirmToken, message })
779
+ const status =
780
+ result.outcome === 'failed'
781
+ ? (EXECUTE_ERROR_STATUS[result.errorCode ?? ''] ?? 502)
782
+ : 200
783
+ logAction('inject.execute', status, {
784
+ outcome: result.outcome,
785
+ ...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
786
+ ...(result.replayed !== undefined ? { replayed: result.replayed } : {}),
787
+ })
788
+ writeJson(res, status, result)
789
+ }
790
+
791
+ // ------------------------------------------------------ analysis actions
792
+
793
+ /**
794
+ * Answer one engine result: status per {@link ANALYSIS_ERROR_STATUS},
795
+ * body is the result verbatim (it already carries outcome /
796
+ * analysisSessionId / summary / truncated / disclaimer). The log line
797
+ * keeps only outcome/codes/ids — never summaries or questions (S8).
798
+ */
799
+ const respondAnalysisResult = (
800
+ type: string,
801
+ res: ServerResponse,
802
+ result: AnalysisResult,
803
+ ): void => {
804
+ const status =
805
+ result.errorCode !== undefined
806
+ ? (ANALYSIS_ERROR_STATUS[result.errorCode] ?? 502)
807
+ : 200
808
+ logAction(type, status, {
809
+ outcome: result.outcome,
810
+ ...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
811
+ ...(result.analysisSessionId !== undefined
812
+ ? { analysisSessionId: result.analysisSessionId }
813
+ : {}),
814
+ ...(result.truncated ? { truncated: true } : {}),
815
+ })
816
+ writeJson(res, status, result)
817
+ }
818
+
819
+ const rejectInvalidAnalysis = (type: string, res: ServerResponse, detail: string): void => {
820
+ logAction(type, 400, { reason: 'invalid_request' })
821
+ writeJson(res, 400, { reason: 'invalid_request', detail })
822
+ }
823
+
824
+ const handleAnalysisRequest = async (
825
+ analysis: AnalysisApi,
826
+ envelope: Record<string, unknown>,
827
+ res: ServerResponse,
828
+ ): Promise<void> => {
829
+ const { targetKind, targetId, question } = envelope
830
+ if (
831
+ (targetKind !== 'session' && targetKind !== 'project' && targetKind !== 'cross-agent') ||
832
+ (targetId !== undefined && typeof targetId !== 'string') ||
833
+ (question !== undefined && typeof question !== 'string')
834
+ ) {
835
+ rejectInvalidAnalysis(
836
+ 'analysis.request',
837
+ res,
838
+ 'analysis.request needs targetKind session|project|cross-agent, optional string targetId and question',
839
+ )
840
+ return
841
+ }
842
+ if ((targetKind === 'session' || targetKind === 'project') && (targetId === undefined || targetId === '')) {
843
+ rejectInvalidAnalysis(
844
+ 'analysis.request',
845
+ res,
846
+ `analysis.request with targetKind ${targetKind} needs a non-empty targetId`,
847
+ )
848
+ return
849
+ }
850
+ const input = await analysis.buildInput({
851
+ targetKind,
852
+ ...(targetId !== undefined ? { targetId } : {}),
853
+ ...(question !== undefined ? { question } : {}),
854
+ })
855
+ if (input === null) {
856
+ logAction('analysis.request', 404, { reason: 'target_not_found', targetKind })
857
+ writeJson(res, 404, { reason: 'target_not_found' })
858
+ return
859
+ }
860
+ respondAnalysisResult('analysis.request', res, await analysis.engine.request(input))
861
+ }
862
+
863
+ const handleAnalysisFollowup = async (
864
+ analysis: AnalysisApi,
865
+ envelope: Record<string, unknown>,
866
+ res: ServerResponse,
867
+ ): Promise<void> => {
868
+ const { analysisSessionId, question } = envelope
869
+ if (
870
+ typeof analysisSessionId !== 'string' ||
871
+ analysisSessionId === '' ||
872
+ typeof question !== 'string' ||
873
+ question === ''
874
+ ) {
875
+ rejectInvalidAnalysis(
876
+ 'analysis.followup',
877
+ res,
878
+ 'analysis.followup needs non-empty string analysisSessionId and question',
879
+ )
880
+ return
881
+ }
882
+ respondAnalysisResult(
883
+ 'analysis.followup',
884
+ res,
885
+ await analysis.engine.followup(analysisSessionId, question),
886
+ )
887
+ }
888
+
889
+ const handleAnalysisCancel = async (
890
+ analysis: AnalysisApi,
891
+ envelope: Record<string, unknown>,
892
+ res: ServerResponse,
893
+ ): Promise<void> => {
894
+ const { analysisSessionId } = envelope
895
+ if (typeof analysisSessionId !== 'string' || analysisSessionId === '') {
896
+ rejectInvalidAnalysis(
897
+ 'analysis.cancel',
898
+ res,
899
+ 'analysis.cancel needs a non-empty string analysisSessionId',
900
+ )
901
+ return
902
+ }
903
+ // Idempotent by engine contract: an unknown id is a logged no-op.
904
+ await analysis.engine.cancel(analysisSessionId)
905
+ logAction('analysis.cancel', 200, { analysisSessionId })
906
+ writeJson(res, 200, { ok: true, analysisSessionId })
907
+ }
908
+
909
+ /** M2 dispatcher over the action envelope (gateway present, guard 1-4 passed). */
910
+ const handleAction = async (
911
+ gateway: InjectGatewayApi,
912
+ verdict: GuardVerdict,
913
+ req: IncomingMessage,
914
+ res: ServerResponse,
915
+ ): Promise<void> => {
916
+ const body = await readActionBody(req)
917
+ if (body.kind === 'too_large') {
918
+ deps.log('warn', 'action rejected', { reason: 'body_too_large', limit: MAX_ACTION_BODY_BYTES })
919
+ writeJson(res, 400, { reason: 'body_too_large' })
920
+ return
921
+ }
922
+ if (body.kind === 'error') {
923
+ deps.log('warn', 'action rejected', { reason: 'body_read_error' })
924
+ writeJson(res, 400, { reason: 'body_read_error' })
925
+ return
926
+ }
927
+ let parsed: unknown
928
+ try {
929
+ parsed = JSON.parse(body.text)
930
+ } catch {
931
+ deps.log('warn', 'action rejected', { reason: 'invalid_json' })
932
+ writeJson(res, 400, { reason: 'invalid_json' })
933
+ return
934
+ }
935
+ const envelope =
936
+ typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
937
+ ? (parsed as Record<string, unknown>)
938
+ : null
939
+
940
+ if (envelope !== null) {
941
+ const type = typeof envelope.type === 'string' ? envelope.type : null
942
+
943
+ if (type === 'daemon.retry') {
944
+ // Daemon management is a capability of its own: `inject.enabled`
945
+ // gates injection only (design §6), so retry passes guard layers
946
+ // 1-4 without the write-action gate. retry() itself is a no-op
947
+ // outside FAILED.
948
+ deps.supervisor.retry()
949
+ const state = deps.supervisor.state
950
+ logAction('daemon.retry', 200, { state })
951
+ writeJson(res, 200, { state })
952
+ return
953
+ }
954
+
955
+ if (type === 'inject.prepare' || type === 'inject.execute') {
956
+ const writeVerdict = guardWriteAction(verdict, deps.guardOptions)
957
+ if (!writeVerdict.ok) {
958
+ logAction(type, writeVerdict.status, { reason: writeVerdict.reason })
959
+ writeJson(res, writeVerdict.status, { reason: writeVerdict.reason })
960
+ return
961
+ }
962
+ if (type === 'inject.prepare') {
963
+ await handlePrepare(gateway, envelope, res)
964
+ } else {
965
+ await handleExecute(gateway, envelope, res)
966
+ }
967
+ return
968
+ }
969
+
970
+ if (type !== null && ANALYSIS_ACTION_TYPES.has(type)) {
971
+ // The analysis write gate (guard layers 1-4 already passed via
972
+ // `verdict`): request/followup are gated by analysis.enabled —
973
+ // NOT the guard's inject.enabled gate — and fail-closed when the
974
+ // dep is absent. analysis.cancel deliberately BYPASSES the gate
975
+ // (F3): it is a cleanup/stop-loss action that spends no tokens,
976
+ // and flipping the kill switch off must not lock in-flight
977
+ // sessions out of cancellation until their timeout.
978
+ if (
979
+ type !== 'analysis.cancel' &&
980
+ (deps.analysisEnabled === undefined || !deps.analysisEnabled())
981
+ ) {
982
+ logAction(type, 403, { reason: 'analysis_disabled' })
983
+ writeJson(res, 403, { reason: 'analysis_disabled' })
984
+ return
985
+ }
986
+ // Past the gate: no engine wired, or the agents service is not
987
+ // bound in this composition → honest degradation, never a crash.
988
+ const analysis = deps.analysis
989
+ if (analysis === undefined || !analysis.available()) {
990
+ logAction(type, 501, { reason: 'analysis_unavailable' })
991
+ writeJson(res, 501, { reason: 'analysis_unavailable' })
992
+ return
993
+ }
994
+ // Model pre-check (A-1), request only: no explicit analysis
995
+ // provider/model and no host default → 403 before any session is
996
+ // created. Same status family as analysis_disabled: the action is
997
+ // well-formed but the deployment configuration forbids running it.
998
+ if (
999
+ type === 'analysis.request' &&
1000
+ analysis.modelConfigured !== undefined &&
1001
+ !analysis.modelConfigured()
1002
+ ) {
1003
+ logAction(type, 403, { reason: 'analysis_model_unconfigured' })
1004
+ writeJson(res, 403, { reason: 'analysis_model_unconfigured' })
1005
+ return
1006
+ }
1007
+ if (type === 'analysis.request') {
1008
+ await handleAnalysisRequest(analysis, envelope, res)
1009
+ } else if (type === 'analysis.followup') {
1010
+ await handleAnalysisFollowup(analysis, envelope, res)
1011
+ } else {
1012
+ await handleAnalysisCancel(analysis, envelope, res)
1013
+ }
1014
+ return
1015
+ }
1016
+ }
1017
+
1018
+ deps.log('warn', 'action rejected', { reason: 'unknown_action' })
1019
+ writeJson(res, 400, { reason: 'unknown_action' })
1020
+ }
1021
+
1022
+ const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
1023
+ if (disposed) {
1024
+ writeJson(res, 503, { reason: 'shutting_down' })
1025
+ return
1026
+ }
1027
+
1028
+ const verdict = guardRequest(req, deps.guardOptions)
1029
+ const method = (req.method ?? '').toUpperCase()
1030
+ const subpath = subpathOf(req.url)
1031
+ // The gateway-backed action handler reads its own bounded body; every
1032
+ // other body-bearing request is drained so the keep-alive connection is
1033
+ // left in a clean state either way.
1034
+ const actionReadsBody =
1035
+ verdict.ok &&
1036
+ subpath === 'action' &&
1037
+ method === 'POST' &&
1038
+ deps.injectGateway !== undefined
1039
+ if (
1040
+ !actionReadsBody &&
1041
+ (method === 'POST' || method === 'PUT' || method === 'PATCH')
1042
+ ) {
1043
+ req.resume()
1044
+ }
1045
+ if (!verdict.ok) {
1046
+ writeJson(res, verdict.status, { reason: verdict.reason })
1047
+ return
1048
+ }
1049
+
1050
+ if (subpath === null || subpath === '') {
1051
+ writeJson(res, 404, { reason: 'not_found' })
1052
+ return
1053
+ }
1054
+
1055
+ if (subpath === 'state') {
1056
+ if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
1057
+ writeJson(res, 200, buildSnapshot())
1058
+ return
1059
+ }
1060
+
1061
+ if (subpath === 'stream') {
1062
+ if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
1063
+ acceptStream(res)
1064
+ return
1065
+ }
1066
+
1067
+ if (subpath === 'action') {
1068
+ if (method !== 'POST') return writeMethodNotAllowed(res, 'POST')
1069
+ const gateway = deps.injectGateway
1070
+ if (gateway === undefined) {
1071
+ // M1 wiring (no gateway assembled): keep the placeholder contract —
1072
+ // the write gate is live (403 when inject is off), then 501.
1073
+ const writeVerdict = guardWriteAction(verdict, deps.guardOptions)
1074
+ if (!writeVerdict.ok) {
1075
+ writeJson(res, writeVerdict.status, { reason: writeVerdict.reason })
1076
+ return
1077
+ }
1078
+ writeJson(res, 501, { reason: 'not_implemented_until_m2' })
1079
+ return
1080
+ }
1081
+ await handleAction(gateway, verdict, req, res)
1082
+ return
1083
+ }
1084
+
1085
+ if (subpath === 'projects') {
1086
+ if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
1087
+ handleProjects(res)
1088
+ return
1089
+ }
1090
+
1091
+ if (subpath === 'search') {
1092
+ if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
1093
+ await handleSearch(res, queryOf(req.url))
1094
+ return
1095
+ }
1096
+
1097
+ if (subpath.startsWith('lineage/')) {
1098
+ if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
1099
+ await handleLineage(res, subpath.slice('lineage/'.length))
1100
+ return
1101
+ }
1102
+
1103
+ if (subpath.startsWith('session/')) {
1104
+ if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
1105
+ const rest = subpath.slice('session/'.length)
1106
+ // `<id>/timeline` splits on the LAST path segment; percent-encoded
1107
+ // ids can never contain a literal '/' so the split is unambiguous.
1108
+ if (rest.endsWith('/timeline')) {
1109
+ await handleTimeline(res, rest.slice(0, -'/timeline'.length), queryOf(req.url))
1110
+ return
1111
+ }
1112
+ await handleSession(res, rest)
1113
+ return
1114
+ }
1115
+
1116
+ writeJson(res, 404, { reason: 'not_found' })
1117
+ }
1118
+
1119
+ const dispose = (): void => {
1120
+ if (disposed) return
1121
+ disposed = true
1122
+ for (const unsubscribe of unsubscribes) unsubscribe()
1123
+ for (const client of [...clients]) {
1124
+ cleanupClient(client)
1125
+ // Graceful end: the terminal chunk lets EventSource/fetch readers see
1126
+ // a clean stream end. Socket lifecycle belongs to the webServer.
1127
+ client.res.end()
1128
+ }
1129
+ deps.log('info', 'routes disposed')
1130
+ }
1131
+
1132
+ return { handle, dispose }
1133
+ }