@dreb/dashboard 2.55.2 → 2.55.4
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.
- package/README.md +7 -3
- package/dist/server/event-hub.d.ts.map +1 -1
- package/dist/server/event-hub.js +7 -2
- package/dist/server/event-hub.js.map +1 -1
- package/dist/server/server.d.ts +11 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +25 -2
- package/dist/server/server.js.map +1 -1
- package/dist/shared/protocol.d.ts +3 -1
- package/dist/shared/protocol.d.ts.map +1 -1
- package/dist/shared/protocol.js +2 -0
- package/dist/shared/protocol.js.map +1 -1
- package/dist/static/sw.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -153,9 +153,13 @@ live runtime cards are updated by global, event-derived `fleet_snapshot` SSE
|
|
|
153
153
|
frames, debounced by 200 ms. Those frames are built from the pool's in-memory
|
|
154
154
|
runtime state, so they do not trigger child RPC calls or a disk inventory scan.
|
|
155
155
|
|
|
156
|
-
Disk inventory is separate from live-runtime state.
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
Disk inventory is separate from live-runtime state. Before fleet, inventory, or
|
|
157
|
+
resync serialization, the server projects each on-disk session to the declared
|
|
158
|
+
browser DTO and bounds its first-message preview to 256 Unicode characters;
|
|
159
|
+
internal parent paths and complete searchable transcript text never cross this
|
|
160
|
+
boundary. The client narrowly refreshes inventory with `GET /api/sessions` after
|
|
161
|
+
create, resume, stop, or delete, rather than reloading the whole fleet. While the
|
|
162
|
+
Fleet screen is visible, it refreshes
|
|
159
163
|
per-runtime stats no more often than every 30 seconds; the refresh is
|
|
160
164
|
single-flight, preserves each card's last good values, and exposes refresh
|
|
161
165
|
failures in the UI.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"event-hub.d.ts","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SSE event hub — projects runtime events for the dashboard, keeps a\n * byte-bounded replay history, and fans serialized frames out to browsers.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport type SseWriteKind = \"live\" | \"replay\" | \"resync\";\n\n/** Metadata deliberately excludes the serialized event payload. */\nexport interface SseWriteMetadata {\n\tkind: SseWriteKind;\n\tseq: number;\n\ttype: string;\n\tframeBytes: number;\n\t/** Safe synthesized-barrier classification, never runtime payload data. */\n\treason?: string;\n}\n\nexport interface ReplayDiagnostic {\n\tkind: \"replay\" | \"resync\";\n\tcount: number;\n\tbytes: number;\n\tfromSeq?: number;\n\ttoSeq?: number;\n\treason?: string;\n}\n\n/** Return false when this connection can no longer accept frames. */\nexport interface SseClient {\n\twrite(chunk: string, metadata?: SseWriteMetadata): boolean | undefined;\n}\n\nexport interface EventHubOptions {\n\t/** Maximum number of retained frames. */\n\tbufferSize?: number;\n\t/** Maximum encoded bytes retained for reconnect replay. */\n\tbufferBytes?: number;\n\t/** Maximum encoded bytes written during a single replay. */\n\treplayBytes?: number;\n\t/** Largest projected event frame that may be delivered directly. */\n\teventBytes?: number;\n}\n\nexport const DEFAULT_BUFFER_SIZE = 2000;\nexport const DEFAULT_BUFFER_BYTES = 8 * 1024 * 1024;\n/** Kept below the response destruction ceiling in server.ts. */\nexport const DEFAULT_REPLAY_BYTES = 3 * 1024 * 1024;\nexport const DEFAULT_EVENT_BYTES = 1024 * 1024;\n\ninterface SerializedEnvelope {\n\tenvelope: EventEnvelope;\n\tframe: string;\n\tbytes: number;\n\t/** Present only for barriers synthesized by this hub, never runtime data. */\n\tresyncReason?: string;\n}\n\nfunction omit<T extends Record<string, unknown>>(event: T, ...keys: string[]): Record<string, unknown> {\n\tconst copy = { ...event };\n\tfor (const key of keys) delete copy[key];\n\treturn copy;\n}\n\n/**\n * Dashboard-only transport projection. Each removed field is cumulative data\n * that the dashboard reducer does not read. Unknown event types are returned\n * exactly as received so extensions and future runtimes remain forward-safe.\n */\nexport function projectDashboardEvent(event: Record<string, unknown>): Record<string, unknown> {\n\tswitch (event.type) {\n\t\tcase \"agent_end\":\n\t\t\treturn omit(event, \"messages\");\n\t\tcase \"turn_end\":\n\t\t\treturn omit(event, \"message\", \"toolResults\");\n\t\tcase \"message_update\":\n\t\t\treturn omit(event, \"message\");\n\t\tcase \"tool_execution_update\":\n\t\t\treturn omit(event, \"args\");\n\t\tcase \"stream_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"length_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"background_agent_event\": {\n\t\t\tconst child = event.event;\n\t\t\treturn child && typeof child === \"object\" && !Array.isArray(child)\n\t\t\t\t? { ...event, event: projectDashboardEvent(child as Record<string, unknown>) }\n\t\t\t\t: event;\n\t\t}\n\t\tdefault:\n\t\t\treturn event;\n\t}\n}\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate bufferedBytes = 0;\n\tprivate readonly buffer: SerializedEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\tprivate readonly options: Required<EventHubOptions>;\n\tprivate eventProjector?: (key: string, event: Record<string, unknown>) => Record<string, unknown>;\n\n\tconstructor(options: number | EventHubOptions = {}) {\n\t\tthis.options = {\n\t\t\tbufferSize: typeof options === \"number\" ? options : (options.bufferSize ?? DEFAULT_BUFFER_SIZE),\n\t\t\tbufferBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_BUFFER_BYTES : (options.bufferBytes ?? DEFAULT_BUFFER_BYTES),\n\t\t\treplayBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_REPLAY_BYTES : (options.replayBytes ?? DEFAULT_REPLAY_BYTES),\n\t\t\teventBytes: typeof options === \"number\" ? DEFAULT_EVENT_BYTES : (options.eventBytes ?? DEFAULT_EVENT_BYTES),\n\t\t};\n\t}\n\n\t/** Install the server-owned browser projection before frame sizing/replay retention. */\n\tsetEventProjector(projector: (key: string, event: Record<string, unknown>) => Record<string, unknown>): void {\n\t\tthis.eventProjector = projector;\n\t}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, rawEvent: Record<string, unknown>): EventEnvelope {\n\t\tconst baseProjection = projectDashboardEvent(rawEvent);\n\t\tconst event = this.eventProjector ? this.eventProjector(key, baseProjection) : baseProjection;\n\t\tconst serialized = this.serialize(this.seq + 1, key, event);\n\t\tif (serialized.bytes > this.options.eventBytes) {\n\t\t\treturn this.publishResync(\"oversized_event\").envelope;\n\t\t}\n\t\tthis.seq += 1;\n\t\tthis.retain(serialized);\n\t\tthis.fanout(serialized, \"live\");\n\t\treturn serialized.envelope;\n\t}\n\n\t/**\n\t * Attach a client. Replays only a complete, bounded range. A gap or replay\n\t * over budget receives a recovery frame only on this connection; it never\n\t * consumes a global sequence or disturbs healthy clients' ordered stream.\n\t */\n\tattach(client: SseClient, lastEventId?: number, onReplay?: (diagnostic: ReplayDiagnostic) => void): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst replay = this.replayAfter(lastEventId);\n\t\t\tif (!replay) {\n\t\t\t\tconst reason = this.resyncReason(lastEventId);\n\t\t\t\tconst barrier = this.targetedResync(reason);\n\t\t\t\tonReplay?.({ kind: \"resync\", count: 1, bytes: barrier.bytes, toSeq: barrier.envelope.seq, reason });\n\t\t\t\tif (!this.write(client, barrier, \"resync\")) return () => {};\n\t\t\t} else {\n\t\t\t\tconst bytes = replay.reduce((total, item) => total + item.bytes, 0);\n\t\t\t\tonReplay?.({\n\t\t\t\t\tkind: \"replay\",\n\t\t\t\t\tcount: replay.length,\n\t\t\t\t\tbytes,\n\t\t\t\t\tfromSeq: replay[0]?.envelope.seq,\n\t\t\t\t\ttoSeq: replay[replay.length - 1]?.envelope.seq,\n\t\t\t\t});\n\t\t\t\tfor (const serialized of replay) {\n\t\t\t\t\tif (!this.write(client, serialized, \"replay\")) return () => {};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n\n\tget historyBytes(): number {\n\t\treturn this.bufferedBytes;\n\t}\n\n\tget historyCount(): number {\n\t\treturn this.buffer.length;\n\t}\n\n\t/** Current sequence, captured without emitting, retaining, or fanning out. */\n\tget currentSequence(): number {\n\t\treturn this.seq;\n\t}\n\n\tprivate serialize(seq: number, key: string, event: Record<string, unknown>): SerializedEnvelope {\n\t\tconst envelope: EventEnvelope = { seq, key, event };\n\t\tconst frame = formatSseFrame(envelope);\n\t\treturn { envelope, frame, bytes: Buffer.byteLength(frame) };\n\t}\n\n\tprivate retain(serialized: SerializedEnvelope): void {\n\t\tthis.buffer.push(serialized);\n\t\tthis.bufferedBytes += serialized.bytes;\n\t\twhile (this.buffer.length > this.options.bufferSize || this.bufferedBytes > this.options.bufferBytes) {\n\t\t\tconst evicted = this.buffer.shift();\n\t\t\tif (!evicted) break;\n\t\t\tthis.bufferedBytes -= evicted.bytes;\n\t\t}\n\t}\n\n\tprivate replayAfter(lastEventId: number): SerializedEnvelope[] | undefined {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest)\n\t\t\treturn undefined;\n\t\tconst replay = this.buffer.filter((item) => item.envelope.seq > lastEventId);\n\t\treturn replay.reduce((bytes, item) => bytes + item.bytes, 0) <= this.options.replayBytes ? replay : undefined;\n\t}\n\n\tprivate resyncReason(lastEventId: number): string {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined) return \"empty_buffer\";\n\t\tif (lastEventId < oldest - 1 || lastEventId > newest!) return \"buffer_gap\";\n\t\treturn \"replay_over_budget\";\n\t}\n\n\t/** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */\n\tprivate publishResync(reason: string): SerializedEnvelope {\n\t\tthis.seq += 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\tthis.retain(barrier);\n\t\tthis.fanout(barrier, \"resync\");\n\t\treturn barrier;\n\t}\n\n\t/**\n\t * A stale reconnect needs the current ordering cursor, not a new global\n\t * event. With no events yet, establish sequence 1 so it has a usable cursor.\n\t */\n\tprivate targetedResync(reason: string): SerializedEnvelope {\n\t\tif (this.seq === 0) this.seq = 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\treturn barrier;\n\t}\n\n\tprivate fanout(serialized: SerializedEnvelope, kind: SseWriteKind): void {\n\t\tfor (const client of this.clients) {\n\t\t\tif (!this.write(client, serialized, kind)) this.clients.delete(client);\n\t\t}\n\t}\n\n\tprivate write(client: SseClient, serialized: SerializedEnvelope, kind: SseWriteKind): boolean {\n\t\ttry {\n\t\t\treturn (\n\t\t\t\tclient.write(serialized.frame, {\n\t\t\t\t\tkind,\n\t\t\t\t\tseq: serialized.envelope.seq,\n\t\t\t\t\ttype: String(serialized.envelope.event.type ?? \"unknown\"),\n\t\t\t\t\tframeBytes: serialized.bytes,\n\t\t\t\t\t...(serialized.resyncReason ? { reason: serialized.resyncReason } : {}),\n\t\t\t\t}) !== false\n\t\t\t);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n\n/** Observable liveness signal; intentionally unnumbered and never buffered. */\nexport function formatHeartbeatFrame(): string {\n\treturn \"event: heartbeat\\ndata: {}\\n\\n\";\n}\n"]}
|
|
1
|
+
{"version":3,"file":"event-hub.d.ts","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SSE event hub — projects runtime events for the dashboard, keeps a\n * byte-bounded replay history, and fans serialized frames out to browsers.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport type SseWriteKind = \"live\" | \"replay\" | \"resync\";\n\n/** Metadata deliberately excludes the serialized event payload. */\nexport interface SseWriteMetadata {\n\tkind: SseWriteKind;\n\tseq: number;\n\ttype: string;\n\tframeBytes: number;\n\t/** Safe synthesized-barrier classification, never runtime payload data. */\n\treason?: string;\n}\n\nexport interface ReplayDiagnostic {\n\tkind: \"replay\" | \"resync\";\n\tcount: number;\n\tbytes: number;\n\tfromSeq?: number;\n\ttoSeq?: number;\n\treason?: string;\n}\n\n/** Return false when this connection can no longer accept frames. */\nexport interface SseClient {\n\twrite(chunk: string, metadata?: SseWriteMetadata): boolean | undefined;\n}\n\nexport interface EventHubOptions {\n\t/** Maximum number of retained frames. */\n\tbufferSize?: number;\n\t/** Maximum encoded bytes retained for reconnect replay. */\n\tbufferBytes?: number;\n\t/** Maximum encoded bytes written during a single replay. */\n\treplayBytes?: number;\n\t/** Largest projected event frame that may be delivered directly. */\n\teventBytes?: number;\n}\n\nexport const DEFAULT_BUFFER_SIZE = 2000;\nexport const DEFAULT_BUFFER_BYTES = 8 * 1024 * 1024;\n/** Kept below the response destruction ceiling in server.ts. */\nexport const DEFAULT_REPLAY_BYTES = 3 * 1024 * 1024;\nexport const DEFAULT_EVENT_BYTES = 1024 * 1024;\n\ninterface SerializedEnvelope {\n\tenvelope: EventEnvelope;\n\tframe: string;\n\tbytes: number;\n\t/** Present only for barriers synthesized by this hub, never runtime data. */\n\tresyncReason?: string;\n}\n\nfunction omit<T extends Record<string, unknown>>(event: T, ...keys: string[]): Record<string, unknown> {\n\tconst copy = { ...event };\n\tfor (const key of keys) delete copy[key];\n\treturn copy;\n}\n\n/**\n * Dashboard-only transport projection. Each removed field is cumulative data\n * that the dashboard reducer does not read. Unknown event types are returned\n * exactly as received so extensions and future runtimes remain forward-safe.\n */\nexport function projectDashboardEvent(event: Record<string, unknown>): Record<string, unknown> {\n\tswitch (event.type) {\n\t\tcase \"agent_end\":\n\t\t\treturn omit(event, \"messages\");\n\t\tcase \"turn_end\":\n\t\t\treturn omit(event, \"message\", \"toolResults\");\n\t\tcase \"message_update\": {\n\t\t\tconst projected = omit(event, \"message\");\n\t\t\tconst streamEvent = event.assistantMessageEvent;\n\t\t\treturn streamEvent && typeof streamEvent === \"object\" && !Array.isArray(streamEvent)\n\t\t\t\t? { ...projected, assistantMessageEvent: omit(streamEvent as Record<string, unknown>, \"partial\") }\n\t\t\t\t: projected;\n\t\t}\n\t\tcase \"tool_execution_update\":\n\t\t\treturn omit(event, \"args\");\n\t\tcase \"stream_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"length_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"background_agent_event\": {\n\t\t\tconst child = event.event;\n\t\t\treturn child && typeof child === \"object\" && !Array.isArray(child)\n\t\t\t\t? { ...event, event: projectDashboardEvent(child as Record<string, unknown>) }\n\t\t\t\t: event;\n\t\t}\n\t\tdefault:\n\t\t\treturn event;\n\t}\n}\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate bufferedBytes = 0;\n\tprivate readonly buffer: SerializedEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\tprivate readonly options: Required<EventHubOptions>;\n\tprivate eventProjector?: (key: string, event: Record<string, unknown>) => Record<string, unknown>;\n\n\tconstructor(options: number | EventHubOptions = {}) {\n\t\tthis.options = {\n\t\t\tbufferSize: typeof options === \"number\" ? options : (options.bufferSize ?? DEFAULT_BUFFER_SIZE),\n\t\t\tbufferBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_BUFFER_BYTES : (options.bufferBytes ?? DEFAULT_BUFFER_BYTES),\n\t\t\treplayBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_REPLAY_BYTES : (options.replayBytes ?? DEFAULT_REPLAY_BYTES),\n\t\t\teventBytes: typeof options === \"number\" ? DEFAULT_EVENT_BYTES : (options.eventBytes ?? DEFAULT_EVENT_BYTES),\n\t\t};\n\t}\n\n\t/** Install the server-owned browser projection before frame sizing/replay retention. */\n\tsetEventProjector(projector: (key: string, event: Record<string, unknown>) => Record<string, unknown>): void {\n\t\tthis.eventProjector = projector;\n\t}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, rawEvent: Record<string, unknown>): EventEnvelope {\n\t\tconst baseProjection = projectDashboardEvent(rawEvent);\n\t\tconst event = this.eventProjector ? this.eventProjector(key, baseProjection) : baseProjection;\n\t\tconst serialized = this.serialize(this.seq + 1, key, event);\n\t\tif (serialized.bytes > this.options.eventBytes) {\n\t\t\treturn this.publishResync(\"oversized_event\").envelope;\n\t\t}\n\t\tthis.seq += 1;\n\t\tthis.retain(serialized);\n\t\tthis.fanout(serialized, \"live\");\n\t\treturn serialized.envelope;\n\t}\n\n\t/**\n\t * Attach a client. Replays only a complete, bounded range. A gap or replay\n\t * over budget receives a recovery frame only on this connection; it never\n\t * consumes a global sequence or disturbs healthy clients' ordered stream.\n\t */\n\tattach(client: SseClient, lastEventId?: number, onReplay?: (diagnostic: ReplayDiagnostic) => void): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst replay = this.replayAfter(lastEventId);\n\t\t\tif (!replay) {\n\t\t\t\tconst reason = this.resyncReason(lastEventId);\n\t\t\t\tconst barrier = this.targetedResync(reason);\n\t\t\t\tonReplay?.({ kind: \"resync\", count: 1, bytes: barrier.bytes, toSeq: barrier.envelope.seq, reason });\n\t\t\t\tif (!this.write(client, barrier, \"resync\")) return () => {};\n\t\t\t} else {\n\t\t\t\tconst bytes = replay.reduce((total, item) => total + item.bytes, 0);\n\t\t\t\tonReplay?.({\n\t\t\t\t\tkind: \"replay\",\n\t\t\t\t\tcount: replay.length,\n\t\t\t\t\tbytes,\n\t\t\t\t\tfromSeq: replay[0]?.envelope.seq,\n\t\t\t\t\ttoSeq: replay[replay.length - 1]?.envelope.seq,\n\t\t\t\t});\n\t\t\t\tfor (const serialized of replay) {\n\t\t\t\t\tif (!this.write(client, serialized, \"replay\")) return () => {};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n\n\tget historyBytes(): number {\n\t\treturn this.bufferedBytes;\n\t}\n\n\tget historyCount(): number {\n\t\treturn this.buffer.length;\n\t}\n\n\t/** Current sequence, captured without emitting, retaining, or fanning out. */\n\tget currentSequence(): number {\n\t\treturn this.seq;\n\t}\n\n\tprivate serialize(seq: number, key: string, event: Record<string, unknown>): SerializedEnvelope {\n\t\tconst envelope: EventEnvelope = { seq, key, event };\n\t\tconst frame = formatSseFrame(envelope);\n\t\treturn { envelope, frame, bytes: Buffer.byteLength(frame) };\n\t}\n\n\tprivate retain(serialized: SerializedEnvelope): void {\n\t\tthis.buffer.push(serialized);\n\t\tthis.bufferedBytes += serialized.bytes;\n\t\twhile (this.buffer.length > this.options.bufferSize || this.bufferedBytes > this.options.bufferBytes) {\n\t\t\tconst evicted = this.buffer.shift();\n\t\t\tif (!evicted) break;\n\t\t\tthis.bufferedBytes -= evicted.bytes;\n\t\t}\n\t}\n\n\tprivate replayAfter(lastEventId: number): SerializedEnvelope[] | undefined {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest)\n\t\t\treturn undefined;\n\t\tconst replay = this.buffer.filter((item) => item.envelope.seq > lastEventId);\n\t\treturn replay.reduce((bytes, item) => bytes + item.bytes, 0) <= this.options.replayBytes ? replay : undefined;\n\t}\n\n\tprivate resyncReason(lastEventId: number): string {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined) return \"empty_buffer\";\n\t\tif (lastEventId < oldest - 1 || lastEventId > newest!) return \"buffer_gap\";\n\t\treturn \"replay_over_budget\";\n\t}\n\n\t/** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */\n\tprivate publishResync(reason: string): SerializedEnvelope {\n\t\tthis.seq += 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\tthis.retain(barrier);\n\t\tthis.fanout(barrier, \"resync\");\n\t\treturn barrier;\n\t}\n\n\t/**\n\t * A stale reconnect needs the current ordering cursor, not a new global\n\t * event. With no events yet, establish sequence 1 so it has a usable cursor.\n\t */\n\tprivate targetedResync(reason: string): SerializedEnvelope {\n\t\tif (this.seq === 0) this.seq = 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\treturn barrier;\n\t}\n\n\tprivate fanout(serialized: SerializedEnvelope, kind: SseWriteKind): void {\n\t\tfor (const client of this.clients) {\n\t\t\tif (!this.write(client, serialized, kind)) this.clients.delete(client);\n\t\t}\n\t}\n\n\tprivate write(client: SseClient, serialized: SerializedEnvelope, kind: SseWriteKind): boolean {\n\t\ttry {\n\t\t\treturn (\n\t\t\t\tclient.write(serialized.frame, {\n\t\t\t\t\tkind,\n\t\t\t\t\tseq: serialized.envelope.seq,\n\t\t\t\t\ttype: String(serialized.envelope.event.type ?? \"unknown\"),\n\t\t\t\t\tframeBytes: serialized.bytes,\n\t\t\t\t\t...(serialized.resyncReason ? { reason: serialized.resyncReason } : {}),\n\t\t\t\t}) !== false\n\t\t\t);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n\n/** Observable liveness signal; intentionally unnumbered and never buffered. */\nexport function formatHeartbeatFrame(): string {\n\treturn \"event: heartbeat\\ndata: {}\\n\\n\";\n}\n"]}
|
package/dist/server/event-hub.js
CHANGED
|
@@ -24,8 +24,13 @@ export function projectDashboardEvent(event) {
|
|
|
24
24
|
return omit(event, "messages");
|
|
25
25
|
case "turn_end":
|
|
26
26
|
return omit(event, "message", "toolResults");
|
|
27
|
-
case "message_update":
|
|
28
|
-
|
|
27
|
+
case "message_update": {
|
|
28
|
+
const projected = omit(event, "message");
|
|
29
|
+
const streamEvent = event.assistantMessageEvent;
|
|
30
|
+
return streamEvent && typeof streamEvent === "object" && !Array.isArray(streamEvent)
|
|
31
|
+
? { ...projected, assistantMessageEvent: omit(streamEvent, "partial") }
|
|
32
|
+
: projected;
|
|
33
|
+
}
|
|
29
34
|
case "tool_execution_update":
|
|
30
35
|
return omit(event, "args");
|
|
31
36
|
case "stream_retry":
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"event-hub.js","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SSE event hub — projects runtime events for the dashboard, keeps a\n * byte-bounded replay history, and fans serialized frames out to browsers.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport type SseWriteKind = \"live\" | \"replay\" | \"resync\";\n\n/** Metadata deliberately excludes the serialized event payload. */\nexport interface SseWriteMetadata {\n\tkind: SseWriteKind;\n\tseq: number;\n\ttype: string;\n\tframeBytes: number;\n\t/** Safe synthesized-barrier classification, never runtime payload data. */\n\treason?: string;\n}\n\nexport interface ReplayDiagnostic {\n\tkind: \"replay\" | \"resync\";\n\tcount: number;\n\tbytes: number;\n\tfromSeq?: number;\n\ttoSeq?: number;\n\treason?: string;\n}\n\n/** Return false when this connection can no longer accept frames. */\nexport interface SseClient {\n\twrite(chunk: string, metadata?: SseWriteMetadata): boolean | undefined;\n}\n\nexport interface EventHubOptions {\n\t/** Maximum number of retained frames. */\n\tbufferSize?: number;\n\t/** Maximum encoded bytes retained for reconnect replay. */\n\tbufferBytes?: number;\n\t/** Maximum encoded bytes written during a single replay. */\n\treplayBytes?: number;\n\t/** Largest projected event frame that may be delivered directly. */\n\teventBytes?: number;\n}\n\nexport const DEFAULT_BUFFER_SIZE = 2000;\nexport const DEFAULT_BUFFER_BYTES = 8 * 1024 * 1024;\n/** Kept below the response destruction ceiling in server.ts. */\nexport const DEFAULT_REPLAY_BYTES = 3 * 1024 * 1024;\nexport const DEFAULT_EVENT_BYTES = 1024 * 1024;\n\ninterface SerializedEnvelope {\n\tenvelope: EventEnvelope;\n\tframe: string;\n\tbytes: number;\n\t/** Present only for barriers synthesized by this hub, never runtime data. */\n\tresyncReason?: string;\n}\n\nfunction omit<T extends Record<string, unknown>>(event: T, ...keys: string[]): Record<string, unknown> {\n\tconst copy = { ...event };\n\tfor (const key of keys) delete copy[key];\n\treturn copy;\n}\n\n/**\n * Dashboard-only transport projection. Each removed field is cumulative data\n * that the dashboard reducer does not read. Unknown event types are returned\n * exactly as received so extensions and future runtimes remain forward-safe.\n */\nexport function projectDashboardEvent(event: Record<string, unknown>): Record<string, unknown> {\n\tswitch (event.type) {\n\t\tcase \"agent_end\":\n\t\t\treturn omit(event, \"messages\");\n\t\tcase \"turn_end\":\n\t\t\treturn omit(event, \"message\", \"toolResults\");\n\t\tcase \"message_update\":\n\t\t\treturn omit(event, \"message\");\n\t\tcase \"tool_execution_update\":\n\t\t\treturn omit(event, \"args\");\n\t\tcase \"stream_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"length_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"background_agent_event\": {\n\t\t\tconst child = event.event;\n\t\t\treturn child && typeof child === \"object\" && !Array.isArray(child)\n\t\t\t\t? { ...event, event: projectDashboardEvent(child as Record<string, unknown>) }\n\t\t\t\t: event;\n\t\t}\n\t\tdefault:\n\t\t\treturn event;\n\t}\n}\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate bufferedBytes = 0;\n\tprivate readonly buffer: SerializedEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\tprivate readonly options: Required<EventHubOptions>;\n\tprivate eventProjector?: (key: string, event: Record<string, unknown>) => Record<string, unknown>;\n\n\tconstructor(options: number | EventHubOptions = {}) {\n\t\tthis.options = {\n\t\t\tbufferSize: typeof options === \"number\" ? options : (options.bufferSize ?? DEFAULT_BUFFER_SIZE),\n\t\t\tbufferBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_BUFFER_BYTES : (options.bufferBytes ?? DEFAULT_BUFFER_BYTES),\n\t\t\treplayBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_REPLAY_BYTES : (options.replayBytes ?? DEFAULT_REPLAY_BYTES),\n\t\t\teventBytes: typeof options === \"number\" ? DEFAULT_EVENT_BYTES : (options.eventBytes ?? DEFAULT_EVENT_BYTES),\n\t\t};\n\t}\n\n\t/** Install the server-owned browser projection before frame sizing/replay retention. */\n\tsetEventProjector(projector: (key: string, event: Record<string, unknown>) => Record<string, unknown>): void {\n\t\tthis.eventProjector = projector;\n\t}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, rawEvent: Record<string, unknown>): EventEnvelope {\n\t\tconst baseProjection = projectDashboardEvent(rawEvent);\n\t\tconst event = this.eventProjector ? this.eventProjector(key, baseProjection) : baseProjection;\n\t\tconst serialized = this.serialize(this.seq + 1, key, event);\n\t\tif (serialized.bytes > this.options.eventBytes) {\n\t\t\treturn this.publishResync(\"oversized_event\").envelope;\n\t\t}\n\t\tthis.seq += 1;\n\t\tthis.retain(serialized);\n\t\tthis.fanout(serialized, \"live\");\n\t\treturn serialized.envelope;\n\t}\n\n\t/**\n\t * Attach a client. Replays only a complete, bounded range. A gap or replay\n\t * over budget receives a recovery frame only on this connection; it never\n\t * consumes a global sequence or disturbs healthy clients' ordered stream.\n\t */\n\tattach(client: SseClient, lastEventId?: number, onReplay?: (diagnostic: ReplayDiagnostic) => void): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst replay = this.replayAfter(lastEventId);\n\t\t\tif (!replay) {\n\t\t\t\tconst reason = this.resyncReason(lastEventId);\n\t\t\t\tconst barrier = this.targetedResync(reason);\n\t\t\t\tonReplay?.({ kind: \"resync\", count: 1, bytes: barrier.bytes, toSeq: barrier.envelope.seq, reason });\n\t\t\t\tif (!this.write(client, barrier, \"resync\")) return () => {};\n\t\t\t} else {\n\t\t\t\tconst bytes = replay.reduce((total, item) => total + item.bytes, 0);\n\t\t\t\tonReplay?.({\n\t\t\t\t\tkind: \"replay\",\n\t\t\t\t\tcount: replay.length,\n\t\t\t\t\tbytes,\n\t\t\t\t\tfromSeq: replay[0]?.envelope.seq,\n\t\t\t\t\ttoSeq: replay[replay.length - 1]?.envelope.seq,\n\t\t\t\t});\n\t\t\t\tfor (const serialized of replay) {\n\t\t\t\t\tif (!this.write(client, serialized, \"replay\")) return () => {};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n\n\tget historyBytes(): number {\n\t\treturn this.bufferedBytes;\n\t}\n\n\tget historyCount(): number {\n\t\treturn this.buffer.length;\n\t}\n\n\t/** Current sequence, captured without emitting, retaining, or fanning out. */\n\tget currentSequence(): number {\n\t\treturn this.seq;\n\t}\n\n\tprivate serialize(seq: number, key: string, event: Record<string, unknown>): SerializedEnvelope {\n\t\tconst envelope: EventEnvelope = { seq, key, event };\n\t\tconst frame = formatSseFrame(envelope);\n\t\treturn { envelope, frame, bytes: Buffer.byteLength(frame) };\n\t}\n\n\tprivate retain(serialized: SerializedEnvelope): void {\n\t\tthis.buffer.push(serialized);\n\t\tthis.bufferedBytes += serialized.bytes;\n\t\twhile (this.buffer.length > this.options.bufferSize || this.bufferedBytes > this.options.bufferBytes) {\n\t\t\tconst evicted = this.buffer.shift();\n\t\t\tif (!evicted) break;\n\t\t\tthis.bufferedBytes -= evicted.bytes;\n\t\t}\n\t}\n\n\tprivate replayAfter(lastEventId: number): SerializedEnvelope[] | undefined {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest)\n\t\t\treturn undefined;\n\t\tconst replay = this.buffer.filter((item) => item.envelope.seq > lastEventId);\n\t\treturn replay.reduce((bytes, item) => bytes + item.bytes, 0) <= this.options.replayBytes ? replay : undefined;\n\t}\n\n\tprivate resyncReason(lastEventId: number): string {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined) return \"empty_buffer\";\n\t\tif (lastEventId < oldest - 1 || lastEventId > newest!) return \"buffer_gap\";\n\t\treturn \"replay_over_budget\";\n\t}\n\n\t/** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */\n\tprivate publishResync(reason: string): SerializedEnvelope {\n\t\tthis.seq += 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\tthis.retain(barrier);\n\t\tthis.fanout(barrier, \"resync\");\n\t\treturn barrier;\n\t}\n\n\t/**\n\t * A stale reconnect needs the current ordering cursor, not a new global\n\t * event. With no events yet, establish sequence 1 so it has a usable cursor.\n\t */\n\tprivate targetedResync(reason: string): SerializedEnvelope {\n\t\tif (this.seq === 0) this.seq = 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\treturn barrier;\n\t}\n\n\tprivate fanout(serialized: SerializedEnvelope, kind: SseWriteKind): void {\n\t\tfor (const client of this.clients) {\n\t\t\tif (!this.write(client, serialized, kind)) this.clients.delete(client);\n\t\t}\n\t}\n\n\tprivate write(client: SseClient, serialized: SerializedEnvelope, kind: SseWriteKind): boolean {\n\t\ttry {\n\t\t\treturn (\n\t\t\t\tclient.write(serialized.frame, {\n\t\t\t\t\tkind,\n\t\t\t\t\tseq: serialized.envelope.seq,\n\t\t\t\t\ttype: String(serialized.envelope.event.type ?? \"unknown\"),\n\t\t\t\t\tframeBytes: serialized.bytes,\n\t\t\t\t\t...(serialized.resyncReason ? { reason: serialized.resyncReason } : {}),\n\t\t\t\t}) !== false\n\t\t\t);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n\n/** Observable liveness signal; intentionally unnumbered and never buffered. */\nexport function formatHeartbeatFrame(): string {\n\treturn \"event: heartbeat\\ndata: {}\\n\\n\";\n}\n"]}
|
|
1
|
+
{"version":3,"file":"event-hub.js","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SSE event hub — projects runtime events for the dashboard, keeps a\n * byte-bounded replay history, and fans serialized frames out to browsers.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport type SseWriteKind = \"live\" | \"replay\" | \"resync\";\n\n/** Metadata deliberately excludes the serialized event payload. */\nexport interface SseWriteMetadata {\n\tkind: SseWriteKind;\n\tseq: number;\n\ttype: string;\n\tframeBytes: number;\n\t/** Safe synthesized-barrier classification, never runtime payload data. */\n\treason?: string;\n}\n\nexport interface ReplayDiagnostic {\n\tkind: \"replay\" | \"resync\";\n\tcount: number;\n\tbytes: number;\n\tfromSeq?: number;\n\ttoSeq?: number;\n\treason?: string;\n}\n\n/** Return false when this connection can no longer accept frames. */\nexport interface SseClient {\n\twrite(chunk: string, metadata?: SseWriteMetadata): boolean | undefined;\n}\n\nexport interface EventHubOptions {\n\t/** Maximum number of retained frames. */\n\tbufferSize?: number;\n\t/** Maximum encoded bytes retained for reconnect replay. */\n\tbufferBytes?: number;\n\t/** Maximum encoded bytes written during a single replay. */\n\treplayBytes?: number;\n\t/** Largest projected event frame that may be delivered directly. */\n\teventBytes?: number;\n}\n\nexport const DEFAULT_BUFFER_SIZE = 2000;\nexport const DEFAULT_BUFFER_BYTES = 8 * 1024 * 1024;\n/** Kept below the response destruction ceiling in server.ts. */\nexport const DEFAULT_REPLAY_BYTES = 3 * 1024 * 1024;\nexport const DEFAULT_EVENT_BYTES = 1024 * 1024;\n\ninterface SerializedEnvelope {\n\tenvelope: EventEnvelope;\n\tframe: string;\n\tbytes: number;\n\t/** Present only for barriers synthesized by this hub, never runtime data. */\n\tresyncReason?: string;\n}\n\nfunction omit<T extends Record<string, unknown>>(event: T, ...keys: string[]): Record<string, unknown> {\n\tconst copy = { ...event };\n\tfor (const key of keys) delete copy[key];\n\treturn copy;\n}\n\n/**\n * Dashboard-only transport projection. Each removed field is cumulative data\n * that the dashboard reducer does not read. Unknown event types are returned\n * exactly as received so extensions and future runtimes remain forward-safe.\n */\nexport function projectDashboardEvent(event: Record<string, unknown>): Record<string, unknown> {\n\tswitch (event.type) {\n\t\tcase \"agent_end\":\n\t\t\treturn omit(event, \"messages\");\n\t\tcase \"turn_end\":\n\t\t\treturn omit(event, \"message\", \"toolResults\");\n\t\tcase \"message_update\": {\n\t\t\tconst projected = omit(event, \"message\");\n\t\t\tconst streamEvent = event.assistantMessageEvent;\n\t\t\treturn streamEvent && typeof streamEvent === \"object\" && !Array.isArray(streamEvent)\n\t\t\t\t? { ...projected, assistantMessageEvent: omit(streamEvent as Record<string, unknown>, \"partial\") }\n\t\t\t\t: projected;\n\t\t}\n\t\tcase \"tool_execution_update\":\n\t\t\treturn omit(event, \"args\");\n\t\tcase \"stream_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"length_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"background_agent_event\": {\n\t\t\tconst child = event.event;\n\t\t\treturn child && typeof child === \"object\" && !Array.isArray(child)\n\t\t\t\t? { ...event, event: projectDashboardEvent(child as Record<string, unknown>) }\n\t\t\t\t: event;\n\t\t}\n\t\tdefault:\n\t\t\treturn event;\n\t}\n}\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate bufferedBytes = 0;\n\tprivate readonly buffer: SerializedEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\tprivate readonly options: Required<EventHubOptions>;\n\tprivate eventProjector?: (key: string, event: Record<string, unknown>) => Record<string, unknown>;\n\n\tconstructor(options: number | EventHubOptions = {}) {\n\t\tthis.options = {\n\t\t\tbufferSize: typeof options === \"number\" ? options : (options.bufferSize ?? DEFAULT_BUFFER_SIZE),\n\t\t\tbufferBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_BUFFER_BYTES : (options.bufferBytes ?? DEFAULT_BUFFER_BYTES),\n\t\t\treplayBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_REPLAY_BYTES : (options.replayBytes ?? DEFAULT_REPLAY_BYTES),\n\t\t\teventBytes: typeof options === \"number\" ? DEFAULT_EVENT_BYTES : (options.eventBytes ?? DEFAULT_EVENT_BYTES),\n\t\t};\n\t}\n\n\t/** Install the server-owned browser projection before frame sizing/replay retention. */\n\tsetEventProjector(projector: (key: string, event: Record<string, unknown>) => Record<string, unknown>): void {\n\t\tthis.eventProjector = projector;\n\t}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, rawEvent: Record<string, unknown>): EventEnvelope {\n\t\tconst baseProjection = projectDashboardEvent(rawEvent);\n\t\tconst event = this.eventProjector ? this.eventProjector(key, baseProjection) : baseProjection;\n\t\tconst serialized = this.serialize(this.seq + 1, key, event);\n\t\tif (serialized.bytes > this.options.eventBytes) {\n\t\t\treturn this.publishResync(\"oversized_event\").envelope;\n\t\t}\n\t\tthis.seq += 1;\n\t\tthis.retain(serialized);\n\t\tthis.fanout(serialized, \"live\");\n\t\treturn serialized.envelope;\n\t}\n\n\t/**\n\t * Attach a client. Replays only a complete, bounded range. A gap or replay\n\t * over budget receives a recovery frame only on this connection; it never\n\t * consumes a global sequence or disturbs healthy clients' ordered stream.\n\t */\n\tattach(client: SseClient, lastEventId?: number, onReplay?: (diagnostic: ReplayDiagnostic) => void): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst replay = this.replayAfter(lastEventId);\n\t\t\tif (!replay) {\n\t\t\t\tconst reason = this.resyncReason(lastEventId);\n\t\t\t\tconst barrier = this.targetedResync(reason);\n\t\t\t\tonReplay?.({ kind: \"resync\", count: 1, bytes: barrier.bytes, toSeq: barrier.envelope.seq, reason });\n\t\t\t\tif (!this.write(client, barrier, \"resync\")) return () => {};\n\t\t\t} else {\n\t\t\t\tconst bytes = replay.reduce((total, item) => total + item.bytes, 0);\n\t\t\t\tonReplay?.({\n\t\t\t\t\tkind: \"replay\",\n\t\t\t\t\tcount: replay.length,\n\t\t\t\t\tbytes,\n\t\t\t\t\tfromSeq: replay[0]?.envelope.seq,\n\t\t\t\t\ttoSeq: replay[replay.length - 1]?.envelope.seq,\n\t\t\t\t});\n\t\t\t\tfor (const serialized of replay) {\n\t\t\t\t\tif (!this.write(client, serialized, \"replay\")) return () => {};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n\n\tget historyBytes(): number {\n\t\treturn this.bufferedBytes;\n\t}\n\n\tget historyCount(): number {\n\t\treturn this.buffer.length;\n\t}\n\n\t/** Current sequence, captured without emitting, retaining, or fanning out. */\n\tget currentSequence(): number {\n\t\treturn this.seq;\n\t}\n\n\tprivate serialize(seq: number, key: string, event: Record<string, unknown>): SerializedEnvelope {\n\t\tconst envelope: EventEnvelope = { seq, key, event };\n\t\tconst frame = formatSseFrame(envelope);\n\t\treturn { envelope, frame, bytes: Buffer.byteLength(frame) };\n\t}\n\n\tprivate retain(serialized: SerializedEnvelope): void {\n\t\tthis.buffer.push(serialized);\n\t\tthis.bufferedBytes += serialized.bytes;\n\t\twhile (this.buffer.length > this.options.bufferSize || this.bufferedBytes > this.options.bufferBytes) {\n\t\t\tconst evicted = this.buffer.shift();\n\t\t\tif (!evicted) break;\n\t\t\tthis.bufferedBytes -= evicted.bytes;\n\t\t}\n\t}\n\n\tprivate replayAfter(lastEventId: number): SerializedEnvelope[] | undefined {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest)\n\t\t\treturn undefined;\n\t\tconst replay = this.buffer.filter((item) => item.envelope.seq > lastEventId);\n\t\treturn replay.reduce((bytes, item) => bytes + item.bytes, 0) <= this.options.replayBytes ? replay : undefined;\n\t}\n\n\tprivate resyncReason(lastEventId: number): string {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined) return \"empty_buffer\";\n\t\tif (lastEventId < oldest - 1 || lastEventId > newest!) return \"buffer_gap\";\n\t\treturn \"replay_over_budget\";\n\t}\n\n\t/** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */\n\tprivate publishResync(reason: string): SerializedEnvelope {\n\t\tthis.seq += 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\tthis.retain(barrier);\n\t\tthis.fanout(barrier, \"resync\");\n\t\treturn barrier;\n\t}\n\n\t/**\n\t * A stale reconnect needs the current ordering cursor, not a new global\n\t * event. With no events yet, establish sequence 1 so it has a usable cursor.\n\t */\n\tprivate targetedResync(reason: string): SerializedEnvelope {\n\t\tif (this.seq === 0) this.seq = 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\treturn barrier;\n\t}\n\n\tprivate fanout(serialized: SerializedEnvelope, kind: SseWriteKind): void {\n\t\tfor (const client of this.clients) {\n\t\t\tif (!this.write(client, serialized, kind)) this.clients.delete(client);\n\t\t}\n\t}\n\n\tprivate write(client: SseClient, serialized: SerializedEnvelope, kind: SseWriteKind): boolean {\n\t\ttry {\n\t\t\treturn (\n\t\t\t\tclient.write(serialized.frame, {\n\t\t\t\t\tkind,\n\t\t\t\t\tseq: serialized.envelope.seq,\n\t\t\t\t\ttype: String(serialized.envelope.event.type ?? \"unknown\"),\n\t\t\t\t\tframeBytes: serialized.bytes,\n\t\t\t\t\t...(serialized.resyncReason ? { reason: serialized.resyncReason } : {}),\n\t\t\t\t}) !== false\n\t\t\t);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n\n/** Observable liveness signal; intentionally unnumbered and never buffered. */\nexport function formatHeartbeatFrame(): string {\n\treturn \"event: heartbeat\\ndata: {}\\n\\n\";\n}\n"]}
|
package/dist/server/server.d.ts
CHANGED
|
@@ -15,13 +15,23 @@ export type DashboardServerApp = express.Express & {
|
|
|
15
15
|
/** Close dashboard-owned services. Safe to call more than once during shutdown. */
|
|
16
16
|
closeDashboard(): Promise<void>;
|
|
17
17
|
};
|
|
18
|
+
export interface DashboardSessionInfoSource {
|
|
19
|
+
path: string;
|
|
20
|
+
id: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
created: Date;
|
|
24
|
+
modified: Date;
|
|
25
|
+
messageCount: number;
|
|
26
|
+
firstMessage: string;
|
|
27
|
+
}
|
|
18
28
|
export interface DashboardServerOptions {
|
|
19
29
|
auth: DashboardAuth;
|
|
20
30
|
pool: RuntimePool;
|
|
21
31
|
/** Directory of built client assets; omit to skip static serving (tests). */
|
|
22
32
|
staticDir?: string;
|
|
23
33
|
/** Session listing (cross-project) — injected so tests can stub it. */
|
|
24
|
-
listAllSessions: () => Promise<
|
|
34
|
+
listAllSessions: () => Promise<DashboardSessionInfoSource[]>;
|
|
25
35
|
deleteSession: (path: string) => Promise<unknown>;
|
|
26
36
|
logger?: (line: string) => void;
|
|
27
37
|
/** Build version of the running server process (for the settings footer / stale-server detection). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server/server.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,OAAO,MAAM,SAAS,CAAC;AAc9B,OAAO,KAAK,EAAgB,aAAa,EAAE,MAAM,WAAW,CAAC;AAC7D,OAAO,EAKN,qBAAqB,EACrB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,QAAQ,EAA+C,MAAM,gBAAgB,CAAC;AAIvF,OAAO,KAAK,EAA4B,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAG/E,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,OAAO,GAAG;IAClD,mFAAmF;IACnF,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAChC,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,WAAW,CAAC;IAClB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yEAAuE;IACvE,eAAe,EAAE,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC1C,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,sGAAsG;IACtG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2HAAyH;IACzH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,2GAA2G;IAC3G,YAAY,CAAC,EAAE,qBAAqB,CAAC;IACrC,wDAAwD;IACxD,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAGD,eAAO,MAAM,sBAAsB,QAAkB,CAAC;AACtD,eAAO,MAAM,+BAA+B,QAAS,CAAC;AA2CtD,oDAAoD;AACpD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAQtF;AAQD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,kBAAkB,CA+iCzF","sourcesContent":["/**\n * Dashboard HTTP server — Express app wiring auth, the runtime pool, the SSE\n * hub, and the file API into the REST surface the browser client consumes.\n *\n * Bind address discipline: local mode binds 127.0.0.1 only. The\n * caller decides the bind address; `createDashboardServer` never listens by\n * itself. Remote mode still passes every request through DashboardAuth.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport { existsSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport type { NextFunction, Request, Response } from \"express\";\nimport express from \"express\";\nimport type {\n\tActiveRuntimeSnapshotDto,\n\tAuthStatusDto,\n\tClientConnectionDiagnosticDto,\n\tDashboardResyncDto,\n\tFleetDto,\n\tImageAttachmentDto,\n\tPairingCodeDto,\n\tRuntimeHydrationDto,\n\tSessionInfoDto,\n\tSessionInventoryDto,\n} from \"../shared/protocol.js\";\nimport { MAX_CLIENT_DIAGNOSTIC_BYTES, MAX_PROMPT_BODY_BYTES } from \"../shared/protocol.js\";\nimport type { AuthDecision, DashboardAuth } from \"./auth.js\";\nimport {\n\tDASHBOARD_IMAGE_ID_PATTERN,\n\tDashboardImageNotFoundError,\n\tDashboardImagePreviewError,\n\ttype DashboardImageScope,\n\tDashboardImageService,\n} from \"./dashboard-images.js\";\nimport { EventHub, formatHeartbeatFrame, type SseWriteMetadata } from \"./event-hub.js\";\nimport { defaultPlaces, FileApi } from \"./files.js\";\nimport { ImagePreviewWorker } from \"./image-preview.js\";\nimport { MemoryApi } from \"./memories.js\";\nimport type { DashboardRuntimeSnapshot, RuntimePool } from \"./runtime-pool.js\";\nimport { readSubagentMessages, SubagentSessionLogNotFoundError } from \"./subagent-log.js\";\n\nexport type DashboardServerApp = express.Express & {\n\t/** Close dashboard-owned services. Safe to call more than once during shutdown. */\n\tcloseDashboard(): Promise<void>;\n};\n\nexport interface DashboardServerOptions {\n\tauth: DashboardAuth;\n\tpool: RuntimePool;\n\t/** Directory of built client assets; omit to skip static serving (tests). */\n\tstaticDir?: string;\n\t/** Session listing (cross-project) — injected so tests can stub it. */\n\tlistAllSessions: () => Promise<unknown[]>;\n\tdeleteSession: (path: string) => Promise<unknown>;\n\tlogger?: (line: string) => void;\n\t/** Build version of the running server process (for the settings footer / stale-server detection). */\n\tserverVersion?: string;\n\t/** Restart hook — when set, POST /api/server/restart invokes it (typically process exit for a supervisor to respawn). */\n\tonRestart?: () => void;\n\t/** Injectable only to make SSE limits deterministic in integration tests. */\n\teventHub?: EventHub;\n\t/** Injectable bounded image repository/preview service for deterministic tests and lifecycle ownership. */\n\timageService?: DashboardImageService;\n\t/** Named heartbeat interval; defaults to 25 seconds. */\n\theartbeatIntervalMs?: number;\n\t/** Test-only override for the global dreb memory home directory. */\n\tmemoryHomeDir?: string;\n}\n\nconst DEVICE_COOKIE = \"dreb_dashboard_device\";\nexport const MAX_SSE_BUFFERED_BYTES = 4 * 1024 * 1024;\nexport const CLIENT_DIAGNOSTIC_RATE_LIMIT_MS = 30_000;\nconst CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS = 10 * 60_000;\n\nfunction isClientDiagnostic(value: unknown): value is ClientConnectionDiagnosticDto {\n\tif (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n\tconst body = value as Record<string, unknown>;\n\tconst allowed = new Set([\n\t\t\"connectionId\",\n\t\t\"state\",\n\t\t\"previousState\",\n\t\t\"attempt\",\n\t\t\"delayMs\",\n\t\t\"visibility\",\n\t\t\"lastAppliedSeq\",\n\t\t\"heartbeatAgeMs\",\n\t\t\"eventCount\",\n\t\t\"eventRatePerMinute\",\n\t\t\"processingLagTotalMs\",\n\t\t\"processingLagMaxMs\",\n\t]);\n\tif (Object.keys(body).some((key) => !allowed.has(key))) return false;\n\tconst states = new Set([\"connecting\", \"connected\", \"retrying\", \"resyncing\", \"disconnected\", \"auth_failed\"]);\n\tconst nonNegativeNumber = (item: unknown) => typeof item === \"number\" && Number.isFinite(item) && item >= 0;\n\tconst nonNegativeInteger = (item: unknown) => typeof item === \"number\" && Number.isSafeInteger(item) && item >= 0;\n\treturn (\n\t\ttypeof body.connectionId === \"string\" &&\n\t\t/^[0-9a-f-]{36}$/i.test(body.connectionId) &&\n\t\ttypeof body.state === \"string\" &&\n\t\tstates.has(body.state) &&\n\t\t(body.previousState === undefined ||\n\t\t\t(typeof body.previousState === \"string\" && states.has(body.previousState))) &&\n\t\tnonNegativeInteger(body.attempt) &&\n\t\tnonNegativeNumber(body.eventCount) &&\n\t\tnonNegativeNumber(body.eventRatePerMinute) &&\n\t\tnonNegativeNumber(body.processingLagTotalMs) &&\n\t\tnonNegativeNumber(body.processingLagMaxMs) &&\n\t\t(body.delayMs === undefined || nonNegativeNumber(body.delayMs)) &&\n\t\t(body.lastAppliedSeq === undefined || nonNegativeInteger(body.lastAppliedSeq)) &&\n\t\t(body.heartbeatAgeMs === undefined || nonNegativeNumber(body.heartbeatAgeMs)) &&\n\t\t(body.visibility === \"visible\" || body.visibility === \"hidden\")\n\t);\n}\n\n/** Parse the device cookie from a Cookie header. */\nexport function parseDeviceCookie(cookieHeader: string | undefined): string | undefined {\n\tif (!cookieHeader) return undefined;\n\tfor (const part of cookieHeader.split(\";\")) {\n\t\tconst eq = part.indexOf(\"=\");\n\t\tif (eq === -1) continue;\n\t\tif (part.slice(0, eq).trim() === DEVICE_COOKIE) return part.slice(eq + 1).trim();\n\t}\n\treturn undefined;\n}\n\ninterface AuthedRequest extends Request {\n\tauthDecision?: AuthDecision;\n\t/** Per-SSE-request opaque diagnostic correlation id. */\n\tsseConnectionId?: string;\n}\n\nexport function createDashboardServer(options: DashboardServerOptions): DashboardServerApp {\n\tconst { auth, pool } = options;\n\tconst serverStartedAt = new Date().toISOString();\n\tconst diagnosticConnections = new Map<string, { issuedAt: number; lastAt?: number }>();\n\tconst log = options.logger ?? ((line: string) => console.log(`[dashboard] ${line}`));\n\tconst files = new FileApi((op, path, detail) => log(`file ${op}: ${path}${detail ? ` (${detail})` : \"\"}`));\n\tconst memories = new MemoryApi(options.memoryHomeDir ?? homedir(), (op, scopeId, detail) =>\n\t\tlog(`memory ${op}: ${scopeId}${detail ? ` (${detail})` : \"\"}`),\n\t);\n\tconst hub = options.eventHub ?? new EventHub();\n\tconst images = options.imageService ?? new DashboardImageService(new ImagePreviewWorker());\n\thub.setEventProjector((key, event) => (key ? images.projectEvent(event, { runtimeKey: key }) : event));\n\tpool.onEvent((key, event) => {\n\t\tif (event.type === \"dashboard_snapshot_barrier\" && typeof event.snapshotId === \"string\") {\n\t\t\t// This RPC marker has no browser frame: its synchronous sequence capture\n\t\t\t// orders the HTTP snapshot before all later EventHub publications.\n\t\t\tpool.recordDashboardBarrier(key, event.snapshotId, hub.currentSequence);\n\t\t\treturn;\n\t\t}\n\t\thub.publish(key, event);\n\t\tif (event.type === \"runtime_removed\") images.removeRuntime(key);\n\t});\n\tpool.onFleetSnapshot((event) => hub.publish(\"\", { ...event }));\n\n\tconst app = express() as DashboardServerApp;\n\tlet closePromise: Promise<void> | undefined;\n\tapp.closeDashboard = () => {\n\t\tclosePromise ??= images.close();\n\t\treturn closePromise;\n\t};\n\tapp.disable(\"x-powered-by\");\n\n\t// -- auth middleware (every route, fail-closed) ---------------------------\n\tapp.use((req: AuthedRequest, res: Response, next: NextFunction) => {\n\t\tif (req.path === \"/api/events\") req.sseConnectionId = randomUUID();\n\t\tauth\n\t\t\t.authenticate({\n\t\t\t\tremoteAddress: req.socket.remoteAddress,\n\t\t\t\thostHeader: req.headers.host,\n\t\t\t\toriginHeader: req.headers.origin,\n\t\t\t\tdeviceToken: parseDeviceCookie(req.headers.cookie),\n\t\t\t})\n\t\t\t.then((decision) => {\n\t\t\t\treq.authDecision = decision;\n\t\t\t\tif (decision.allowed) return next();\n\t\t\t\tconst canRenderAuthScreen = decision.needsPairing || Boolean(decision.identity);\n\t\t\t\tif (canRenderAuthScreen) {\n\t\t\t\t\t// The auth/pairing endpoints must be reachable by allowed-but-unpaired\n\t\t\t\t\t// identities, and /api/auth must also be reachable by rejected\n\t\t\t\t\t// Tailscale identities so the SPA denial screen can name them.\n\t\t\t\t\tif (req.path === \"/api/auth\" || (decision.needsPairing && req.path === \"/api/pair\")) return next();\n\t\t\t\t\t// Let the SPA shell + static assets load so the client-side pairing or\n\t\t\t\t\t// denial screen can render. No data exposure: every /api/* data route\n\t\t\t\t\t// below stays fail-closed — only non-API GETs (the app shell) are allowed.\n\t\t\t\t\tif (req.method === \"GET\" && !req.path.startsWith(\"/api/\")) return next();\n\t\t\t\t}\n\t\t\t\tif (req.sseConnectionId) {\n\t\t\t\t\tlog(\n\t\t\t\t\t\t`sse ${JSON.stringify({\n\t\t\t\t\t\t\tconnectionId: req.sseConnectionId,\n\t\t\t\t\t\t\tkind: \"auth_denial\",\n\t\t\t\t\t\t\tmethod: req.method,\n\t\t\t\t\t\t\tpath: req.path,\n\t\t\t\t\t\t\tstatus: decision.status,\n\t\t\t\t\t\t})}`,\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tlog(`denied ${req.method} ${req.path}: ${decision.reason}`);\n\t\t\t\t}\n\t\t\t\tres.status(decision.status).json({\n\t\t\t\t\terror: decision.reason,\n\t\t\t\t\tneedsPairing: decision.needsPairing ?? false,\n\t\t\t\t\tidentity: decision.identity?.loginName,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\t// authenticate() already catches internally; this is belt-and-suspenders.\n\t\t\t\tlog(`auth middleware error — denying: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\tres.status(500).json({ error: \"Auth subsystem error — denied\" });\n\t\t\t});\n\t});\n\n\t// Authenticate before consuming request bodies. Diagnostics have their own\n\t// small parser limit; the larger limit exists only for prompt image payloads.\n\tapp.use(\"/api/events/diagnostic\", express.json({ limit: MAX_CLIENT_DIAGNOSTIC_BYTES }));\n\tapp.use(express.json({ limit: MAX_PROMPT_BODY_BYTES }));\n\tapp.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {\n\t\tif ((err as { type?: string }).type === \"entity.too.large\") {\n\t\t\tres.status(413).json({ error: \"Request body is too large\" });\n\t\t\treturn;\n\t\t}\n\t\tnext(err);\n\t});\n\n\t// -- auth/pairing ----------------------------------------------------------\n\tapp.get(\"/api/auth\", (req: AuthedRequest, res) => {\n\t\tconst decision = req.authDecision!;\n\t\tif (decision.allowed) {\n\t\t\tconst status: AuthStatusDto =\n\t\t\t\tdecision.mode === \"local\"\n\t\t\t\t\t? { mode: \"local\" }\n\t\t\t\t\t: { mode: \"remote\", identity: decision.identity.loginName, device: decision.identity.device };\n\t\t\tres.json({ ...status, needsPairing: false });\n\t\t\treturn;\n\t\t}\n\t\tres.status(decision.status).json({\n\t\t\terror: decision.reason,\n\t\t\tneedsPairing: decision.needsPairing ?? false,\n\t\t\tidentity: decision.identity?.loginName,\n\t\t});\n\t});\n\n\tapp.get(\"/api/pairing-code\", (req: AuthedRequest, res) => {\n\t\tconst decision = req.authDecision!;\n\t\tif (!decision.allowed || decision.mode !== \"local\") {\n\t\t\tres.status(403).json({ error: \"Pairing code is only available from the host machine\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!auth.isRemoteEnabled) {\n\t\t\tconst body: PairingCodeDto = { enabled: false };\n\t\t\tres.json(body);\n\t\t\treturn;\n\t\t}\n\t\tconst body: PairingCodeDto = { enabled: true, ...auth.currentPairingCode() };\n\t\tres.json(body);\n\t});\n\n\tapp.post(\"/api/pair\", (req: AuthedRequest, res) => {\n\t\tconst pin = typeof req.body?.pin === \"string\" ? req.body.pin : \"\";\n\t\tauth\n\t\t\t.pair(\n\t\t\t\t{\n\t\t\t\t\tremoteAddress: req.socket.remoteAddress,\n\t\t\t\t\thostHeader: req.headers.host,\n\t\t\t\t\toriginHeader: req.headers.origin,\n\t\t\t\t\tdeviceToken: undefined,\n\t\t\t\t},\n\t\t\t\tpin,\n\t\t\t)\n\t\t\t.then(({ token, device }) => {\n\t\t\t\tlog(`paired device ${device.id} (${device.identity})`);\n\t\t\t\tres.cookie(DEVICE_COOKIE, token, {\n\t\t\t\t\thttpOnly: true,\n\t\t\t\t\tsameSite: \"strict\",\n\t\t\t\t\tsecure: false, // Tailscale already encrypts; the dashboard serves plain HTTP on the tailnet.\n\t\t\t\t\texpires: new Date(device.expiresAt),\n\t\t\t\t}).json({ device });\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tconst status = typeof err?.status === \"number\" ? err.status : 500;\n\t\t\t\tlog(`pairing failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\tres.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n\t\t\t});\n\t});\n\n\tapp.get(\"/api/devices\", (_req, res) => {\n\t\tauth\n\t\t\t.listDevices()\n\t\t\t.then((devices) => res.json({ devices }))\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.delete(\"/api/devices/:id\", (req, res) => {\n\t\tauth\n\t\t\t.unpair(req.params.id)\n\t\t\t.then((removed) => {\n\t\t\t\tif (!removed) {\n\t\t\t\t\tres.status(404).json({ error: `No paired device with id ${String(req.params.id)}` });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tlog(`unpaired device ${String(req.params.id)}`);\n\t\t\t\tres.json({ ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- events (SSE) ----------------------------------------------------------\n\tapp.get(\"/api/events\", (req: AuthedRequest, res) => {\n\t\tconst connectionId = req.sseConnectionId ?? randomUUID();\n\t\tconst diagnostic = (kind: string, metadata: object = {}) =>\n\t\t\tlog(`sse ${JSON.stringify({ connectionId, kind, ...metadata })}`);\n\t\tres.writeHead(200, {\n\t\t\t\"content-type\": \"text/event-stream\",\n\t\t\t\"cache-control\": \"no-cache\",\n\t\t\tconnection: \"keep-alive\",\n\t\t});\n\n\t\tconst guardedWrite = (\n\t\t\tchunk: string,\n\t\t\tmetadata: SseWriteMetadata | { kind: \"handshake\" | \"heartbeat\" | \"connection\" },\n\t\t): boolean => {\n\t\t\tif (res.destroyed || res.writableEnded) {\n\t\t\t\tdiagnostic(\"write_closed\", { writeKind: metadata.kind });\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst accepted = res.write(chunk);\n\t\t\tconst details = {\n\t\t\t\twriteKind: metadata.kind,\n\t\t\t\t...(\"seq\" in metadata\n\t\t\t\t\t? { seq: metadata.seq, type: metadata.type, frameBytes: metadata.frameBytes, reason: metadata.reason }\n\t\t\t\t\t: {}),\n\t\t\t\twritableLength: res.writableLength,\n\t\t\t};\n\t\t\tdiagnostic(\"write\", details);\n\t\t\tif (!accepted && res.writableLength > MAX_SSE_BUFFERED_BYTES) {\n\t\t\t\tdiagnostic(\"backpressure\", details);\n\t\t\t\tres.destroy();\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\treturn true;\n\t\t};\n\n\t\tconst lastIdRaw = req.headers[\"last-event-id\"] ?? req.query.lastEventId;\n\t\tconst lastEventId =\n\t\t\ttypeof lastIdRaw === \"string\" && /^\\d+$/.test(lastIdRaw) ? Number.parseInt(lastIdRaw, 10) : undefined;\n\t\tdiagnostic(\"connect\", { cursor: lastEventId });\n\t\tif (!guardedWrite(\":ok\\n\\n\", { kind: \"handshake\" })) return;\n\t\t// Unnumbered connection metadata lets a browser correlate optional,\n\t\t// payload-free diagnostics without mutating its application SSE cursor.\n\t\tconst issuedAt = Date.now();\n\t\tfor (const [id, record] of diagnosticConnections) {\n\t\t\tif (issuedAt - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS) diagnosticConnections.delete(id);\n\t\t}\n\t\tdiagnosticConnections.set(connectionId, { issuedAt });\n\t\tif (!guardedWrite(`event: connection\\ndata: ${JSON.stringify({ connectionId })}\\n\\n`, { kind: \"connection\" }))\n\t\t\treturn;\n\t\tlet detach = () => {};\n\t\tlet keepAlive: ReturnType<typeof setInterval> | undefined;\n\t\tconst stop = () => {\n\t\t\tif (keepAlive) clearInterval(keepAlive);\n\t\t\tdetach();\n\t\t};\n\t\tlet usable = true;\n\t\tdetach = hub.attach(\n\t\t\t{\n\t\t\t\twrite: (chunk, metadata) => {\n\t\t\t\t\tif (!metadata) return false;\n\t\t\t\t\tusable = guardedWrite(chunk, metadata);\n\t\t\t\t\treturn usable;\n\t\t\t\t},\n\t\t\t},\n\t\t\tlastEventId,\n\t\t\t(replay) => diagnostic(replay.kind, replay),\n\t\t);\n\t\t// A rejected/destroyed replay must not leave a timer or live client behind.\n\t\tif (!usable) return;\n\t\t// Named heartbeats are visible to EventSource but have no id, so they do\n\t\t// not alter the application cursor or consume replay history.\n\t\tkeepAlive = setInterval(() => {\n\t\t\tif (!guardedWrite(formatHeartbeatFrame(), { kind: \"heartbeat\" })) stop();\n\t\t}, options.heartbeatIntervalMs ?? 25_000);\n\t\treq.on(\"close\", () => {\n\t\t\tdiagnostic(\"close\", { writableLength: res.writableLength });\n\t\t\tstop();\n\t\t});\n\t});\n\n\t// -- optional client stream diagnostics -----------------------------------\n\tapp.post(\"/api/events/diagnostic\", (req, res) => {\n\t\tconst declaredLength = Number(req.headers[\"content-length\"] ?? 0);\n\t\tconst encodedBytes = Buffer.byteLength(JSON.stringify(req.body ?? null));\n\t\tif (declaredLength > MAX_CLIENT_DIAGNOSTIC_BYTES || encodedBytes > MAX_CLIENT_DIAGNOSTIC_BYTES) {\n\t\t\tres.status(413).json({ error: \"Diagnostic summary exceeds the 4 KiB limit\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!isClientDiagnostic(req.body)) {\n\t\t\tres.status(400).json({ error: \"Invalid diagnostic summary\" });\n\t\t\treturn;\n\t\t}\n\t\tconst now = Date.now();\n\t\tfor (const [id, record] of diagnosticConnections) {\n\t\t\tif (now - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS) diagnosticConnections.delete(id);\n\t\t}\n\t\tconst record = diagnosticConnections.get(req.body.connectionId);\n\t\tif (!record) {\n\t\t\tres.status(400).json({ error: \"Unknown or expired SSE connection\" });\n\t\t\treturn;\n\t\t}\n\t\tif (record.lastAt !== undefined && now - record.lastAt < CLIENT_DIAGNOSTIC_RATE_LIMIT_MS) {\n\t\t\tres.status(429).json({ error: \"Diagnostic summary rate limited\" });\n\t\t\treturn;\n\t\t}\n\t\trecord.lastAt = now;\n\t\t// Never log the request body wholesale. The schema is intentionally only\n\t\t// connection metadata, and this explicit projection prevents future fields\n\t\t// from accidentally turning diagnostics into a payload side-channel.\n\t\tlog(\n\t\t\t`sse ${JSON.stringify({\n\t\t\t\tconnectionId: req.body.connectionId,\n\t\t\t\tkind: \"client_diagnostic\",\n\t\t\t\tstate: req.body.state,\n\t\t\t\tpreviousState: req.body.previousState,\n\t\t\t\tattempt: req.body.attempt,\n\t\t\t\tdelayMs: req.body.delayMs,\n\t\t\t\tvisibility: req.body.visibility,\n\t\t\t\tlastAppliedSeq: req.body.lastAppliedSeq,\n\t\t\t\theartbeatAgeMs: req.body.heartbeatAgeMs,\n\t\t\t\teventCount: req.body.eventCount,\n\t\t\t\teventRatePerMinute: req.body.eventRatePerMinute,\n\t\t\t\tprocessingLagTotalMs: req.body.processingLagTotalMs,\n\t\t\t\tprocessingLagMaxMs: req.body.processingLagMaxMs,\n\t\t\t})}`,\n\t\t);\n\t\tres.json({ ok: true });\n\t});\n\n\t// -- fleet -----------------------------------------------------------------\n\tconst listDiskSessions = async (): Promise<SessionInfoDto[]> =>\n\t\t((await options.listAllSessions()) as SessionInfoDto[]).filter((session) => existsSync(session.cwd));\n\n\tconst currentCwdInventory = async (): Promise<string[]> => [\n\t\t...pool.list().map((handle) => handle.cwd),\n\t\t...(await listDiskSessions()).map((session) => session.cwd),\n\t];\n\n\tconst handleMemoryError = (res: Response, err: unknown): void => {\n\t\tconst status =\n\t\t\ttypeof (err as { status?: unknown })?.status === \"number\" ? (err as { status: number }).status : 500;\n\t\tres.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n\t};\n\n\tapp.get(\"/api/memories/scopes\", (_req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.scopes(inventory))\n\t\t\t.then((scopes) => res.json({ scopes }))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tapp.get(\"/api/memories/:scopeId\", (req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.listing(req.params.scopeId, inventory))\n\t\t\t.then((listing) => res.json(listing))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tapp.get(\"/api/memories/:scopeId/documents/:file\", (req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.readDocument(req.params.scopeId, req.params.file, inventory))\n\t\t\t.then((document) => res.json(document))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tapp.put(\"/api/memories/:scopeId/documents/:file\", (req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.saveDocument(req.params.scopeId, req.params.file, req.body, inventory))\n\t\t\t.then((result) => res.json(result))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tapp.delete(\"/api/memories/:scopeId/entries/:file\", (req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.deleteEntry(req.params.scopeId, req.params.file, req.body, inventory))\n\t\t\t.then((result) => res.json(result))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tconst getFleet = async (): Promise<FleetDto> => {\n\t\tconst runtimes = await Promise.all(pool.list().map((h) => pool.describe(h)));\n\t\treturn { runtimes, diskSessions: await listDiskSessions() };\n\t};\n\n\t/** Map the one-RPC parent snapshot consistently for recovery and drill-in hydration. */\n\tconst toRuntimeHydration = (snapshot: DashboardRuntimeSnapshot): RuntimeHydrationDto => ({\n\t\tkey: snapshot.key,\n\t\tstate: snapshot.snapshot.state,\n\t\tmessages: images.project(snapshot.snapshot.messages, { runtimeKey: snapshot.key }),\n\t\tbackgroundAgents: snapshot.snapshot.backgroundAgents,\n\t\tpendingExtensionUiRequests: snapshot.snapshot.pendingExtensionUiRequests ?? [],\n\t\tbarrierSeq: snapshot.barrierSeq,\n\t});\n\n\tapp.get(\"/api/fleet\", (_req, res) => {\n\t\tconst startedAt = Date.now();\n\t\tgetFleet()\n\t\t\t.then((fleet) => {\n\t\t\t\t// Serialize once so the diagnostic reports the exact JSON response size\n\t\t\t\t// without retaining or logging any fleet payload fields.\n\t\t\t\tconst body = JSON.stringify(fleet);\n\t\t\t\tconst diagnostic = {\n\t\t\t\t\telapsedMs: Date.now() - startedAt,\n\t\t\t\t\tencodedBytes: Buffer.byteLength(body),\n\t\t\t\t\truntimeCount: fleet.runtimes.length,\n\t\t\t\t\tdiskSessionCount: fleet.diskSessions.length,\n\t\t\t\t};\n\t\t\t\tres.type(\"json\").send(body);\n\t\t\t\tlog(`fleet ${JSON.stringify(diagnostic)}`);\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/** On-disk inventory only; does not query or describe live runtimes. */\n\tapp.get(\"/api/sessions\", (_req, res) => {\n\t\tlistDiskSessions()\n\t\t\t.then((sessions) => {\n\t\t\t\tconst body: SessionInventoryDto = { sessions };\n\t\t\t\tres.json(body);\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/**\n\t * Full recovery snapshot. For an active runtime, its RPC marker captures the\n\t * current EventHub sequence before the response; later publications have a\n\t * higher sequence. This is an ordering contract, not a timing heuristic.\n\t */\n\tapp.get(\"/api/resync\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst activeKey = typeof req.query.key === \"string\" ? req.query.key : undefined;\n\t\t\tconst activeAgentId = typeof req.query.agentId === \"string\" ? req.query.agentId : undefined;\n\t\t\tlet active: DashboardResyncDto[\"active\"];\n\t\t\tlet barrierSeq: number;\n\t\t\tif (activeKey) {\n\t\t\t\tconst handle = pool.get(activeKey);\n\t\t\t\tif (!handle) {\n\t\t\t\t\tconst body: DashboardResyncDto = { fleet: await getFleet(), barrierSeq: hub.currentSequence };\n\t\t\t\t\tres.json(body);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// The disk transcript has its own sequence boundary because it is read\n\t\t\t\t// before the parent RPC snapshot. Relays between these two barriers must\n\t\t\t\t// be reapplied so a subagent delta cannot disappear during recovery.\n\t\t\t\tlet preBarrierSubagent: NonNullable<ActiveRuntimeSnapshotDto[\"subagent\"]> | undefined;\n\t\t\t\tif (activeAgentId) {\n\t\t\t\t\tconst agents = await handle.client.listBackgroundAgents();\n\t\t\t\t\tconst agent = agents.find((candidate) => candidate.agentId === activeAgentId);\n\t\t\t\t\tif (!agent) throw new Error(`No background agent ${activeAgentId} in this runtime`);\n\t\t\t\t\tconst messages = readSubagentMessages(agent);\n\t\t\t\t\tpreBarrierSubagent = {\n\t\t\t\t\t\tagentId: activeAgentId,\n\t\t\t\t\t\tagent,\n\t\t\t\t\t\tmessages: images.project(messages, { runtimeKey: activeKey, agentId: activeAgentId }),\n\t\t\t\t\t\tbarrierSeq: hub.currentSequence,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tconst snapshot = await pool.snapshotDashboard(handle);\n\t\t\t\tbarrierSeq = snapshot.barrierSeq;\n\t\t\t\tactive = {\n\t\t\t\t\t...toRuntimeHydration(snapshot),\n\t\t\t\t\t...(preBarrierSubagent ? { subagent: preBarrierSubagent } : {}),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbarrierSeq = hub.currentSequence;\n\t\t\t}\n\t\t\tconst body: DashboardResyncDto = { fleet: await getFleet(), ...(active ? { active } : {}), barrierSeq };\n\t\t\tres.json(body);\n\t\t})().catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- runtimes ---------------------------------------------------------------\n\tapp.post(\"/api/runtimes\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst cwd = typeof req.body?.cwd === \"string\" ? req.body.cwd : \"\";\n\t\t\tif (!cwd || !existsSync(cwd)) {\n\t\t\t\tres.status(400).json({ error: `Working directory does not exist: ${cwd || \"(empty)\"}` });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst sessionPath = typeof req.body?.sessionPath === \"string\" ? req.body.sessionPath : undefined;\n\t\t\tconst handle = await pool.create(cwd, sessionPath);\n\t\t\tlog(`runtime ${handle.key} started in ${cwd}${sessionPath ? ` (resume ${basename(sessionPath)})` : \"\"}`);\n\t\t\tconst firstPrompt = typeof req.body?.firstPrompt === \"string\" ? req.body.firstPrompt : undefined;\n\t\t\tif (firstPrompt) await handle.client.prompt(firstPrompt);\n\t\t\tres.status(201).json(await pool.describe(handle));\n\t\t})().catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.delete(\"/api/runtimes/:key\", (req, res) => {\n\t\tpool\n\t\t\t.stop(req.params.key)\n\t\t\t.then((stopped) => {\n\t\t\t\tif (!stopped) {\n\t\t\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\timages.removeRuntime(String(req.params.key));\n\t\t\t\tlog(`runtime ${String(req.params.key)} stopped`);\n\t\t\t\tres.json({ ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/** Helper: run an async op against a pooled runtime with uniform errors. */\n\tfunction withRuntime(\n\t\treq: Request,\n\t\tres: Response,\n\t\tfn: (handle: NonNullable<ReturnType<RuntimePool[\"get\"]>>) => Promise<unknown>,\n\t): void {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\tfn(handle)\n\t\t\t.then((data) => res.json(data ?? { ok: true }))\n\t\t\t.catch((err) => {\n\t\t\t\tres.status(502).json({ error: String(err?.message ?? err) });\n\t\t\t});\n\t}\n\n\tapp.get(\"/api/runtimes/:key\", (req, res) => {\n\t\twithRuntime(req, res, (h) => pool.describe(h));\n\t});\n\n\t/**\n\t * Atomic drill-in snapshot. snapshotDashboard performs exactly one RPC and\n\t * consumes its marker barrier, so no independently-read runtime fields can\n\t * describe different moments in a live turn.\n\t */\n\tapp.get(\"/api/runtimes/:key/hydrate\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => toRuntimeHydration(await pool.snapshotDashboard(h)));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/messages\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({\n\t\t\tmessages: images.project(await h.client.getMessages(), { runtimeKey: h.key }),\n\t\t}));\n\t});\n\n\tconst sendImage = async (\n\t\treq: Request,\n\t\tres: Response,\n\t\tscope: DashboardImageScope,\n\t\tvariant: \"preview\" | \"original\",\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<void> => {\n\t\tconst id = String(req.params.id);\n\t\tif (!DASHBOARD_IMAGE_ID_PATTERN.test(id)) {\n\t\t\tres.status(400).json({ error: \"Invalid dashboard image ID\" });\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tconst image =\n\t\t\t\tvariant === \"preview\"\n\t\t\t\t\t? await images.preview(scope, id, loadAuthoritative)\n\t\t\t\t\t: await images.original(scope, id, loadAuthoritative);\n\t\t\tres.set({\n\t\t\t\t\"Content-Type\": image.mimeType,\n\t\t\t\t\"Content-Length\": String(image.bytes.byteLength),\n\t\t\t\t\"X-Content-Type-Options\": \"nosniff\",\n\t\t\t\t\"Cache-Control\": \"private, max-age=31536000, immutable\",\n\t\t\t});\n\t\t\tres.send(Buffer.from(image.bytes));\n\t\t} catch (error) {\n\t\t\tif (error instanceof DashboardImageNotFoundError) {\n\t\t\t\tres.status(404).json({ error: error.message });\n\t\t\t} else if (error instanceof DashboardImagePreviewError) {\n\t\t\t\tres.status(422).json({ error: error.message });\n\t\t\t} else {\n\t\t\t\tres.status(502).json({\n\t\t\t\t\terror: `Image source unavailable: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t};\n\n\tapp.get(\"/api/runtimes/:key/images/:id/:variant\", (req, res) => {\n\t\tconst key = String(req.params.key);\n\t\tconst variant = String(req.params.variant);\n\t\tif (variant !== \"preview\" && variant !== \"original\") {\n\t\t\tres.status(404).json({ error: \"Unknown dashboard image variant\" });\n\t\t\treturn;\n\t\t}\n\t\tconst handle = pool.get(key);\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${key}` });\n\t\t\treturn;\n\t\t}\n\t\tvoid sendImage(req, res, { runtimeKey: key }, variant, () => handle.client.getMessages());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/subagents/:agentId/images/:id/:variant\", (req, res) => {\n\t\tconst key = String(req.params.key);\n\t\tconst agentId = String(req.params.agentId);\n\t\tconst variant = String(req.params.variant);\n\t\tif (variant !== \"preview\" && variant !== \"original\") {\n\t\t\tres.status(404).json({ error: \"Unknown dashboard image variant\" });\n\t\t\treturn;\n\t\t}\n\t\tconst handle = pool.get(key);\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${key}` });\n\t\t\treturn;\n\t\t}\n\t\tvoid sendImage(req, res, { runtimeKey: key, agentId }, variant, async () => {\n\t\t\tconst agents = await handle.client.listBackgroundAgents();\n\t\t\tconst agent = agents.find((candidate) => candidate.agentId === agentId);\n\t\t\tif (!agent) throw new DashboardImageNotFoundError(`No background agent ${agentId} in this runtime`);\n\t\t\treturn readSubagentMessages(agent);\n\t\t});\n\t});\n\n\tapp.get(\"/api/runtimes/:key/pending\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getPendingMessages());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/dequeue\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.clearPendingMessages());\n\t});\n\n\tfunction parseImages(body: unknown): ImageAttachmentDto[] | undefined | \"invalid\" {\n\t\tconst images = (body as { images?: unknown } | undefined)?.images;\n\t\tif (images === undefined) return undefined;\n\t\tif (!Array.isArray(images)) return \"invalid\";\n\t\tconst parsed: ImageAttachmentDto[] = [];\n\t\tfor (const image of images) {\n\t\t\tif (\n\t\t\t\t!image ||\n\t\t\t\ttypeof image !== \"object\" ||\n\t\t\t\ttypeof (image as { data?: unknown }).data !== \"string\" ||\n\t\t\t\ttypeof (image as { mimeType?: unknown }).mimeType !== \"string\"\n\t\t\t) {\n\t\t\t\treturn \"invalid\";\n\t\t\t}\n\t\t\tparsed.push({ data: (image as ImageAttachmentDto).data, mimeType: (image as ImageAttachmentDto).mimeType });\n\t\t}\n\t\treturn parsed;\n\t}\n\n\tapp.post(\"/api/runtimes/:key/prompt\", (req, res) => {\n\t\tconst { message, mode } = req.body ?? {};\n\t\tif (typeof message !== \"string\" || message.length === 0) {\n\t\t\tres.status(400).json({ error: \"message is required\" });\n\t\t\treturn;\n\t\t}\n\t\tconst images = parseImages(req.body);\n\t\tif (images === \"invalid\") {\n\t\t\tres.status(400).json({ error: \"images must be an array of {data, mimeType} objects\" });\n\t\t\treturn;\n\t\t}\n\t\tconst rpcImages = images?.map((image) => ({\n\t\t\ttype: \"image\" as const,\n\t\t\tdata: image.data,\n\t\t\tmimeType: image.mimeType,\n\t\t}));\n\t\twithRuntime(req, res, async (h) => {\n\t\t\tif (mode === \"steer\") await h.client.steer(message, rpcImages);\n\t\t\telse if (mode === \"follow_up\") await h.client.followUp(message, rpcImages);\n\t\t\telse await h.client.prompt(message, rpcImages);\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abort());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort-compaction\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abortCompaction());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort-retry\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abortRetry());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/model\", (req, res) => {\n\t\tconst { provider, modelId } = req.body ?? {};\n\t\tif (typeof provider !== \"string\" || typeof modelId !== \"string\") {\n\t\t\tres.status(400).json({ error: \"provider and modelId are required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setModel(provider, modelId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/models\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ models: await h.client.getAvailableModels() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/thinking\", (req, res) => {\n\t\tconst { level } = req.body ?? {};\n\t\tif (typeof level !== \"string\") {\n\t\t\tres.status(400).json({ error: \"level is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setThinkingLevel(level as never));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/compact\", (req, res) => {\n\t\tconst instructions = typeof req.body?.instructions === \"string\" ? req.body.instructions : undefined;\n\t\twithRuntime(req, res, (h) => h.client.compact(instructions));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/new-session\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.newSession());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/reload\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => {\n\t\t\tawait h.client.reload();\n\t\t\treturn { ok: true };\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/dream\", (req, res) => {\n\t\tconst args = typeof req.body?.args === \"string\" ? req.body.args : undefined;\n\t\twithRuntime(req, res, (h) => h.client.dream(args));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/import\", (req, res) => {\n\t\tconst inputPath = typeof req.body?.inputPath === \"string\" ? req.body.inputPath.trim() : \"\";\n\t\tif (!inputPath) {\n\t\t\tres.status(400).json({ error: \"inputPath is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.importJsonl(inputPath));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/name\", (req, res) => {\n\t\tconst { name } = req.body ?? {};\n\t\tif (typeof name !== \"string\" || name.length === 0) {\n\t\t\tres.status(400).json({ error: \"name is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setSessionName(name));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/stats\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getSessionStats());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/performance\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getPerformanceStats());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/resources\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getResources());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/commands\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ commands: await h.client.getCommands() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/branch\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ branch: await h.client.getGitBranch() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/fork-messages\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ messages: await h.client.getForkMessages() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/fork\", (req, res) => {\n\t\tconst { entryId } = req.body ?? {};\n\t\tif (typeof entryId !== \"string\") {\n\t\t\tres.status(400).json({ error: \"entryId is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.fork(entryId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/tree\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getTree());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/tree\", (req, res) => {\n\t\tconst targetId = typeof req.body?.targetId === \"string\" ? req.body.targetId : \"\";\n\t\tif (!targetId) {\n\t\t\tres.status(400).json({ error: \"targetId is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.navigateTree(targetId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/sessions\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ sessions: await h.client.listSessions() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/resume\", (req, res) => {\n\t\tconst sessionPath = typeof req.body?.sessionPath === \"string\" ? req.body.sessionPath : \"\";\n\t\tif (!sessionPath) {\n\t\t\tres.status(400).json({ error: \"sessionPath is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.switchSession(sessionPath));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/export-html\", (req, res) => {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\thandle.client\n\t\t\t.exportHtml()\n\t\t\t.then(({ path }) => {\n\t\t\t\tres.download(path);\n\t\t\t})\n\t\t\t.catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/background-agents\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ agents: await h.client.listBackgroundAgents() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/subagents/:agentId/messages\", (req, res) => {\n\t\tconst agentId = String(req.params.agentId);\n\t\twithRuntime(req, res, async (h) => {\n\t\t\t// The runtime's registry is authoritative for status + log location.\n\t\t\tconst agents = await h.client.listBackgroundAgents();\n\t\t\tconst agent = agents.find((a) => a.agentId === agentId);\n\t\t\tif (!agent) throw new Error(`No background agent ${agentId} in this runtime`);\n\t\t\tlet messages: unknown[];\n\t\t\ttry {\n\t\t\t\tmessages = readSubagentMessages(agent);\n\t\t\t} catch (error) {\n\t\t\t\tconst failedBeforeSpawn =\n\t\t\t\t\tagent.arbitrations !== undefined &&\n\t\t\t\t\tagent.arbitrations.length > 0 &&\n\t\t\t\t\tagent.arbitrations.every((record) => record.status === \"failure\");\n\t\t\t\tif (!(error instanceof SubagentSessionLogNotFoundError) || !failedBeforeSpawn) throw error;\n\t\t\t\tmessages = [];\n\t\t\t}\n\t\t\treturn { agent, messages: images.project(messages, { runtimeKey: h.key, agentId }) };\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/extension-ui-response\", (req, res) => {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\thandle.client.sendExtensionUIResponse(req.body);\n\t\t\tres.json({ ok: true });\n\t\t} catch (err) {\n\t\t\tres.status(502).json({ error: String((err as Error)?.message ?? err) });\n\t\t}\n\t});\n\n\t// -- disk sessions -----------------------------------------------------------\n\tapp.delete(\"/api/sessions\", (req, res) => {\n\t\tconst path = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!path) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\toptions\n\t\t\t.deleteSession(path)\n\t\t\t.then((result) => {\n\t\t\t\tlog(`session deleted: ${path}`);\n\t\t\t\thub.publish(\"\", { type: \"disk_sessions_changed\" });\n\t\t\t\tres.json(result ?? { ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- settings ------------------------------------------------------------------\n\t// Settings are process-global persistent defaults. They route through hidden\n\t// utility runtimes instead of whichever user session happened to open first.\n\t// Agent-definition discovery is cwd-sensitive, so callers may pass an explicit\n\t// project cwd for endpoints that need project-local .dreb/agents.\n\tfunction withAnyRuntime(\n\t\tres: Response,\n\t\tfn: (h: NonNullable<ReturnType<RuntimePool[\"get\"]>>) => Promise<unknown>,\n\t\tcwd?: string,\n\t) {\n\t\tpool\n\t\t\t.ensureUtilityRuntime(cwd)\n\t\t\t.then((handle) => fn(handle))\n\t\t\t.then((data) => res.json(data ?? { ok: true }))\n\t\t\t.catch((err) => {\n\t\t\t\tres.status(502).json({ error: String(err?.message ?? err) });\n\t\t\t});\n\t}\n\n\tfunction optionalSettingsCwd(req: Request, res: Response): string | undefined | null {\n\t\tif (req.query.cwd === undefined) return undefined;\n\t\tif (typeof req.query.cwd !== \"string\" || !req.query.cwd.trim()) {\n\t\t\tres.status(400).json({ error: \"cwd must be a non-empty path\" });\n\t\t\treturn null;\n\t\t}\n\t\tif (!existsSync(req.query.cwd)) {\n\t\t\tres.status(400).json({ error: `cwd does not exist: ${req.query.cwd}` });\n\t\t\treturn null;\n\t\t}\n\t\ttry {\n\t\t\tif (!statSync(req.query.cwd).isDirectory()) {\n\t\t\t\tres.status(400).json({ error: `cwd is not a directory: ${req.query.cwd}` });\n\t\t\t\treturn null;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tres.status(400).json({ error: `cannot access cwd ${req.query.cwd}: ${(error as Error).message}` });\n\t\t\treturn null;\n\t\t}\n\t\treturn req.query.cwd;\n\t}\n\n\tapp.get(\"/api/settings\", (req, res) => {\n\t\tconst cwd = optionalSettingsCwd(req, res);\n\t\tif (cwd === null) return;\n\t\twithAnyRuntime(res, (h) => h.client.getSettings(), cwd);\n\t});\n\n\tapp.get(\"/api/settings/models\", (req, res) => {\n\t\tconst cwd = optionalSettingsCwd(req, res);\n\t\tif (cwd === null) return;\n\t\twithAnyRuntime(res, async (h) => ({ models: await h.client.getAvailableModels() }), cwd);\n\t});\n\n\tapp.get(\"/api/settings/agent-types\", (req, res) => {\n\t\tconst cwd = typeof req.query.cwd === \"string\" && req.query.cwd.trim() ? req.query.cwd : undefined;\n\t\tif (cwd && !existsSync(cwd)) {\n\t\t\tres.status(400).json({ error: `cwd does not exist: ${cwd}` });\n\t\t\treturn;\n\t\t}\n\t\twithAnyRuntime(res, async (h) => ({ agentTypes: await h.client.listAgentTypes() }), cwd);\n\t});\n\n\tapp.get(\"/api/daily-cost\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ cost: await h.client.getDailyCost() }));\n\t});\n\n\tapp.put(\"/api/settings\", (req, res) => {\n\t\tconst cwd = optionalSettingsCwd(req, res);\n\t\tif (cwd === null) return;\n\t\twithAnyRuntime(res, (h) => h.client.setSettings(req.body ?? {}), cwd);\n\t});\n\n\tapp.get(\"/api/version\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ version: await h.client.getVersion() }));\n\t});\n\n\tapp.post(\"/api/settings/remove-trusted\", (req, res) => {\n\t\tconst rawPath = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!rawPath) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\tpool\n\t\t\t.ensureUtilityRuntime()\n\t\t\t.then(async (handle) => {\n\t\t\t\tconst result = await handle.client.removeTrustedContextFolder(rawPath);\n\t\t\t\tlog(`context trust configured remove: ${rawPath}`);\n\t\t\t\tres.json(result);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- server lifecycle ----------------------------------------------------------\n\t// Build/version of the *server* process (distinct from a freshly-spawned RPC\n\t// child's version) so a stale long-running service is visible at a glance.\n\tapp.get(\"/api/server/info\", (_req, res) => {\n\t\tres.json({\n\t\t\tversion: options.serverVersion ?? null,\n\t\t\tstartedAt: serverStartedAt,\n\t\t\t// systemd sets INVOCATION_ID; other supervisors set LISTEN_PID. Best-effort.\n\t\t\tsupervised: Boolean(process.env.INVOCATION_ID || process.env.LISTEN_PID),\n\t\t\trestartable: Boolean(options.onRestart),\n\t\t});\n\t});\n\n\tapp.post(\"/api/server/restart\", (_req, res) => {\n\t\tif (!options.onRestart) {\n\t\t\tres.status(501).json({\n\t\t\t\terror: \"Restart is unavailable — the dashboard is not running under a supervisor that can respawn it\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tlog(\"restart requested via API\");\n\t\tres.json({ ok: true, restarting: true });\n\t\t// Defer so the HTTP response flushes before the process exits.\n\t\tsetTimeout(() => options.onRestart?.(), 100);\n\t});\n\n\t// -- files -----------------------------------------------------------------------\n\tapp.get(\"/api/files\", (req, res) => {\n\t\tconst path = typeof req.query.path === \"string\" ? req.query.path : homedir();\n\t\tfiles\n\t\t\t.list(path)\n\t\t\t.then(async (listing) => {\n\t\t\t\tconst handle = await pool.ensureUtilityRuntime();\n\t\t\t\tconst contextTrust = await handle.client.evaluateContextTrust(listing.path);\n\t\t\t\tres.json({ ...listing, contextTrust });\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tfunction contextTrustMutation(\n\t\treq: Request,\n\t\tres: Response,\n\t\toperation: \"trustContextFolder\" | \"untrustContextFolder\",\n\t): void {\n\t\tconst rawPath = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!rawPath) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\tfiles\n\t\t\t.resolveDirectory(rawPath)\n\t\t\t.then(async (path) => {\n\t\t\t\tconst handle = await pool.ensureUtilityRuntime();\n\t\t\t\tconst result = await handle.client[operation](path);\n\t\t\t\tlog(`context trust ${operation === \"trustContextFolder\" ? \"add\" : \"remove\"}: ${path}`);\n\t\t\t\tres.json(result);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t}\n\n\tapp.post(\"/api/files/trust\", (req, res) => contextTrustMutation(req, res, \"trustContextFolder\"));\n\tapp.post(\"/api/files/untrust\", (req, res) => contextTrustMutation(req, res, \"untrustContextFolder\"));\n\n\tapp.get(\"/api/files/places\", (_req, res) => {\n\t\tconst roots = [...new Set(pool.list().map((h) => h.cwd))];\n\t\tres.json({ places: defaultPlaces(homedir(), roots) });\n\t});\n\n\tapp.get(\"/api/files/download\", (req, res) => {\n\t\tconst path = typeof req.query.path === \"string\" ? req.query.path : \"\";\n\t\tfiles\n\t\t\t.resolveDownload(path)\n\t\t\t.then(({ path: real }) => {\n\t\t\t\tres.download(real);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.post(\"/api/files/upload\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst dir = typeof req.query.dir === \"string\" ? req.query.dir : \"\";\n\t\t\tconst name = typeof req.query.name === \"string\" ? req.query.name : \"\";\n\t\t\tconst overwrite = req.query.overwrite === \"true\";\n\t\t\tconst upload = await files.prepareUpload(dir, name, overwrite);\n\t\t\ttry {\n\t\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\t\tlet settled = false;\n\t\t\t\t\tconst fail = (err: unknown) => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\tupload.stream.destroy();\n\t\t\t\t\t\treject(err);\n\t\t\t\t\t};\n\t\t\t\t\treq.pipe(upload.stream);\n\t\t\t\t\tupload.stream.on(\"finish\", () => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t});\n\t\t\t\t\tupload.stream.on(\"error\", fail);\n\t\t\t\t\treq.on(\"error\", fail);\n\t\t\t\t\treq.on(\"aborted\", () => fail(Object.assign(new Error(\"Upload aborted\"), { status: 499 })));\n\t\t\t\t});\n\t\t\t\tawait upload.commit();\n\t\t\t\tres.json({ path: upload.path });\n\t\t\t} catch (err) {\n\t\t\t\tawait upload.cleanup();\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t})().catch((err) => {\n\t\t\tif (!res.headersSent) res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) });\n\t\t});\n\t});\n\n\tapp.post(\"/api/files/mkdir\", (req, res) => {\n\t\tconst { dir, name } = req.body ?? {};\n\t\tif (typeof dir !== \"string\" || typeof name !== \"string\") {\n\t\t\tres.status(400).json({ error: \"dir and name are required\" });\n\t\t\treturn;\n\t\t}\n\t\tfiles\n\t\t\t.mkdir(dir, name)\n\t\t\t.then((path) => res.json({ path }))\n\t\t\t.catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- static client -----------------------------------------------------------------\n\tif (options.staticDir) {\n\t\tapp.use(express.static(options.staticDir));\n\t\t// SPA fallback: serve index.html for non-API GETs (client-side routing).\n\t\tapp.get(/^\\/(?!api\\/).*/, (_req, res) => {\n\t\t\tres.sendFile(join(options.staticDir!, \"index.html\"));\n\t\t});\n\t}\n\n\treturn app;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/server/server.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAOH,OAAO,OAAO,MAAM,SAAS,CAAC;AAkB9B,OAAO,KAAK,EAAgB,aAAa,EAAE,MAAM,WAAW,CAAC;AAC7D,OAAO,EAKN,qBAAqB,EACrB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,QAAQ,EAA+C,MAAM,gBAAgB,CAAC;AAIvF,OAAO,KAAK,EAA4B,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAG/E,MAAM,MAAM,kBAAkB,GAAG,OAAO,CAAC,OAAO,GAAG;IAClD,mFAAmF;IACnF,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAChC,CAAC;AAEF,MAAM,WAAW,0BAA0B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,IAAI,CAAC;IACd,QAAQ,EAAE,IAAI,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,sBAAsB;IACtC,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,WAAW,CAAC;IAClB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yEAAuE;IACvE,eAAe,EAAE,MAAM,OAAO,CAAC,0BAA0B,EAAE,CAAC,CAAC;IAC7D,aAAa,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,sGAAsG;IACtG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2HAAyH;IACzH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,2GAA2G;IAC3G,YAAY,CAAC,EAAE,qBAAqB,CAAC;IACrC,wDAAwD;IACxD,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;CACvB;AAGD,eAAO,MAAM,sBAAsB,QAAkB,CAAC;AACtD,eAAO,MAAM,+BAA+B,QAAS,CAAC;AAmEtD,oDAAoD;AACpD,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAQtF;AAQD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,kBAAkB,CA+iCzF","sourcesContent":["/**\n * Dashboard HTTP server — Express app wiring auth, the runtime pool, the SSE\n * hub, and the file API into the REST surface the browser client consumes.\n *\n * Bind address discipline: local mode binds 127.0.0.1 only. The\n * caller decides the bind address; `createDashboardServer` never listens by\n * itself. Remote mode still passes every request through DashboardAuth.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport { existsSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { basename, join } from \"node:path\";\nimport type { NextFunction, Request, Response } from \"express\";\nimport express from \"express\";\nimport type {\n\tActiveRuntimeSnapshotDto,\n\tAuthStatusDto,\n\tClientConnectionDiagnosticDto,\n\tDashboardResyncDto,\n\tFleetDto,\n\tImageAttachmentDto,\n\tPairingCodeDto,\n\tRuntimeHydrationDto,\n\tSessionInfoDto,\n\tSessionInventoryDto,\n} from \"../shared/protocol.js\";\nimport {\n\tMAX_CLIENT_DIAGNOSTIC_BYTES,\n\tMAX_PROMPT_BODY_BYTES,\n\tMAX_SESSION_PREVIEW_CHARACTERS,\n} from \"../shared/protocol.js\";\nimport type { AuthDecision, DashboardAuth } from \"./auth.js\";\nimport {\n\tDASHBOARD_IMAGE_ID_PATTERN,\n\tDashboardImageNotFoundError,\n\tDashboardImagePreviewError,\n\ttype DashboardImageScope,\n\tDashboardImageService,\n} from \"./dashboard-images.js\";\nimport { EventHub, formatHeartbeatFrame, type SseWriteMetadata } from \"./event-hub.js\";\nimport { defaultPlaces, FileApi } from \"./files.js\";\nimport { ImagePreviewWorker } from \"./image-preview.js\";\nimport { MemoryApi } from \"./memories.js\";\nimport type { DashboardRuntimeSnapshot, RuntimePool } from \"./runtime-pool.js\";\nimport { readSubagentMessages, SubagentSessionLogNotFoundError } from \"./subagent-log.js\";\n\nexport type DashboardServerApp = express.Express & {\n\t/** Close dashboard-owned services. Safe to call more than once during shutdown. */\n\tcloseDashboard(): Promise<void>;\n};\n\nexport interface DashboardSessionInfoSource {\n\tpath: string;\n\tid: string;\n\tcwd: string;\n\tname?: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n}\n\nexport interface DashboardServerOptions {\n\tauth: DashboardAuth;\n\tpool: RuntimePool;\n\t/** Directory of built client assets; omit to skip static serving (tests). */\n\tstaticDir?: string;\n\t/** Session listing (cross-project) — injected so tests can stub it. */\n\tlistAllSessions: () => Promise<DashboardSessionInfoSource[]>;\n\tdeleteSession: (path: string) => Promise<unknown>;\n\tlogger?: (line: string) => void;\n\t/** Build version of the running server process (for the settings footer / stale-server detection). */\n\tserverVersion?: string;\n\t/** Restart hook — when set, POST /api/server/restart invokes it (typically process exit for a supervisor to respawn). */\n\tonRestart?: () => void;\n\t/** Injectable only to make SSE limits deterministic in integration tests. */\n\teventHub?: EventHub;\n\t/** Injectable bounded image repository/preview service for deterministic tests and lifecycle ownership. */\n\timageService?: DashboardImageService;\n\t/** Named heartbeat interval; defaults to 25 seconds. */\n\theartbeatIntervalMs?: number;\n\t/** Test-only override for the global dreb memory home directory. */\n\tmemoryHomeDir?: string;\n}\n\nconst DEVICE_COOKIE = \"dreb_dashboard_device\";\nexport const MAX_SSE_BUFFERED_BYTES = 4 * 1024 * 1024;\nexport const CLIENT_DIAGNOSTIC_RATE_LIMIT_MS = 30_000;\nconst CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS = 10 * 60_000;\n\nfunction boundedSessionPreview(text: string): string {\n\tlet preview = \"\";\n\tlet characters = 0;\n\tfor (const character of text) {\n\t\tif (characters === MAX_SESSION_PREVIEW_CHARACTERS) break;\n\t\tpreview += character;\n\t\tcharacters++;\n\t}\n\treturn preview;\n}\n\nfunction toSessionInfoDto(session: DashboardSessionInfoSource): SessionInfoDto {\n\treturn {\n\t\tpath: session.path,\n\t\tid: session.id,\n\t\tcwd: session.cwd,\n\t\tname: session.name,\n\t\tcreated: session.created.toISOString(),\n\t\tmodified: session.modified.toISOString(),\n\t\tmessageCount: session.messageCount,\n\t\tfirstMessage: boundedSessionPreview(session.firstMessage),\n\t};\n}\n\nfunction isClientDiagnostic(value: unknown): value is ClientConnectionDiagnosticDto {\n\tif (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n\tconst body = value as Record<string, unknown>;\n\tconst allowed = new Set([\n\t\t\"connectionId\",\n\t\t\"state\",\n\t\t\"previousState\",\n\t\t\"attempt\",\n\t\t\"delayMs\",\n\t\t\"visibility\",\n\t\t\"lastAppliedSeq\",\n\t\t\"heartbeatAgeMs\",\n\t\t\"eventCount\",\n\t\t\"eventRatePerMinute\",\n\t\t\"processingLagTotalMs\",\n\t\t\"processingLagMaxMs\",\n\t]);\n\tif (Object.keys(body).some((key) => !allowed.has(key))) return false;\n\tconst states = new Set([\"connecting\", \"connected\", \"retrying\", \"resyncing\", \"disconnected\", \"auth_failed\"]);\n\tconst nonNegativeNumber = (item: unknown) => typeof item === \"number\" && Number.isFinite(item) && item >= 0;\n\tconst nonNegativeInteger = (item: unknown) => typeof item === \"number\" && Number.isSafeInteger(item) && item >= 0;\n\treturn (\n\t\ttypeof body.connectionId === \"string\" &&\n\t\t/^[0-9a-f-]{36}$/i.test(body.connectionId) &&\n\t\ttypeof body.state === \"string\" &&\n\t\tstates.has(body.state) &&\n\t\t(body.previousState === undefined ||\n\t\t\t(typeof body.previousState === \"string\" && states.has(body.previousState))) &&\n\t\tnonNegativeInteger(body.attempt) &&\n\t\tnonNegativeNumber(body.eventCount) &&\n\t\tnonNegativeNumber(body.eventRatePerMinute) &&\n\t\tnonNegativeNumber(body.processingLagTotalMs) &&\n\t\tnonNegativeNumber(body.processingLagMaxMs) &&\n\t\t(body.delayMs === undefined || nonNegativeNumber(body.delayMs)) &&\n\t\t(body.lastAppliedSeq === undefined || nonNegativeInteger(body.lastAppliedSeq)) &&\n\t\t(body.heartbeatAgeMs === undefined || nonNegativeNumber(body.heartbeatAgeMs)) &&\n\t\t(body.visibility === \"visible\" || body.visibility === \"hidden\")\n\t);\n}\n\n/** Parse the device cookie from a Cookie header. */\nexport function parseDeviceCookie(cookieHeader: string | undefined): string | undefined {\n\tif (!cookieHeader) return undefined;\n\tfor (const part of cookieHeader.split(\";\")) {\n\t\tconst eq = part.indexOf(\"=\");\n\t\tif (eq === -1) continue;\n\t\tif (part.slice(0, eq).trim() === DEVICE_COOKIE) return part.slice(eq + 1).trim();\n\t}\n\treturn undefined;\n}\n\ninterface AuthedRequest extends Request {\n\tauthDecision?: AuthDecision;\n\t/** Per-SSE-request opaque diagnostic correlation id. */\n\tsseConnectionId?: string;\n}\n\nexport function createDashboardServer(options: DashboardServerOptions): DashboardServerApp {\n\tconst { auth, pool } = options;\n\tconst serverStartedAt = new Date().toISOString();\n\tconst diagnosticConnections = new Map<string, { issuedAt: number; lastAt?: number }>();\n\tconst log = options.logger ?? ((line: string) => console.log(`[dashboard] ${line}`));\n\tconst files = new FileApi((op, path, detail) => log(`file ${op}: ${path}${detail ? ` (${detail})` : \"\"}`));\n\tconst memories = new MemoryApi(options.memoryHomeDir ?? homedir(), (op, scopeId, detail) =>\n\t\tlog(`memory ${op}: ${scopeId}${detail ? ` (${detail})` : \"\"}`),\n\t);\n\tconst hub = options.eventHub ?? new EventHub();\n\tconst images = options.imageService ?? new DashboardImageService(new ImagePreviewWorker());\n\thub.setEventProjector((key, event) => (key ? images.projectEvent(event, { runtimeKey: key }) : event));\n\tpool.onEvent((key, event) => {\n\t\tif (event.type === \"dashboard_snapshot_barrier\" && typeof event.snapshotId === \"string\") {\n\t\t\t// This RPC marker has no browser frame: its synchronous sequence capture\n\t\t\t// orders the HTTP snapshot before all later EventHub publications.\n\t\t\tpool.recordDashboardBarrier(key, event.snapshotId, hub.currentSequence);\n\t\t\treturn;\n\t\t}\n\t\thub.publish(key, event);\n\t\tif (event.type === \"runtime_removed\") images.removeRuntime(key);\n\t});\n\tpool.onFleetSnapshot((event) => hub.publish(\"\", { ...event }));\n\n\tconst app = express() as DashboardServerApp;\n\tlet closePromise: Promise<void> | undefined;\n\tapp.closeDashboard = () => {\n\t\tclosePromise ??= images.close();\n\t\treturn closePromise;\n\t};\n\tapp.disable(\"x-powered-by\");\n\n\t// -- auth middleware (every route, fail-closed) ---------------------------\n\tapp.use((req: AuthedRequest, res: Response, next: NextFunction) => {\n\t\tif (req.path === \"/api/events\") req.sseConnectionId = randomUUID();\n\t\tauth\n\t\t\t.authenticate({\n\t\t\t\tremoteAddress: req.socket.remoteAddress,\n\t\t\t\thostHeader: req.headers.host,\n\t\t\t\toriginHeader: req.headers.origin,\n\t\t\t\tdeviceToken: parseDeviceCookie(req.headers.cookie),\n\t\t\t})\n\t\t\t.then((decision) => {\n\t\t\t\treq.authDecision = decision;\n\t\t\t\tif (decision.allowed) return next();\n\t\t\t\tconst canRenderAuthScreen = decision.needsPairing || Boolean(decision.identity);\n\t\t\t\tif (canRenderAuthScreen) {\n\t\t\t\t\t// The auth/pairing endpoints must be reachable by allowed-but-unpaired\n\t\t\t\t\t// identities, and /api/auth must also be reachable by rejected\n\t\t\t\t\t// Tailscale identities so the SPA denial screen can name them.\n\t\t\t\t\tif (req.path === \"/api/auth\" || (decision.needsPairing && req.path === \"/api/pair\")) return next();\n\t\t\t\t\t// Let the SPA shell + static assets load so the client-side pairing or\n\t\t\t\t\t// denial screen can render. No data exposure: every /api/* data route\n\t\t\t\t\t// below stays fail-closed — only non-API GETs (the app shell) are allowed.\n\t\t\t\t\tif (req.method === \"GET\" && !req.path.startsWith(\"/api/\")) return next();\n\t\t\t\t}\n\t\t\t\tif (req.sseConnectionId) {\n\t\t\t\t\tlog(\n\t\t\t\t\t\t`sse ${JSON.stringify({\n\t\t\t\t\t\t\tconnectionId: req.sseConnectionId,\n\t\t\t\t\t\t\tkind: \"auth_denial\",\n\t\t\t\t\t\t\tmethod: req.method,\n\t\t\t\t\t\t\tpath: req.path,\n\t\t\t\t\t\t\tstatus: decision.status,\n\t\t\t\t\t\t})}`,\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tlog(`denied ${req.method} ${req.path}: ${decision.reason}`);\n\t\t\t\t}\n\t\t\t\tres.status(decision.status).json({\n\t\t\t\t\terror: decision.reason,\n\t\t\t\t\tneedsPairing: decision.needsPairing ?? false,\n\t\t\t\t\tidentity: decision.identity?.loginName,\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\t// authenticate() already catches internally; this is belt-and-suspenders.\n\t\t\t\tlog(`auth middleware error — denying: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\tres.status(500).json({ error: \"Auth subsystem error — denied\" });\n\t\t\t});\n\t});\n\n\t// Authenticate before consuming request bodies. Diagnostics have their own\n\t// small parser limit; the larger limit exists only for prompt image payloads.\n\tapp.use(\"/api/events/diagnostic\", express.json({ limit: MAX_CLIENT_DIAGNOSTIC_BYTES }));\n\tapp.use(express.json({ limit: MAX_PROMPT_BODY_BYTES }));\n\tapp.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {\n\t\tif ((err as { type?: string }).type === \"entity.too.large\") {\n\t\t\tres.status(413).json({ error: \"Request body is too large\" });\n\t\t\treturn;\n\t\t}\n\t\tnext(err);\n\t});\n\n\t// -- auth/pairing ----------------------------------------------------------\n\tapp.get(\"/api/auth\", (req: AuthedRequest, res) => {\n\t\tconst decision = req.authDecision!;\n\t\tif (decision.allowed) {\n\t\t\tconst status: AuthStatusDto =\n\t\t\t\tdecision.mode === \"local\"\n\t\t\t\t\t? { mode: \"local\" }\n\t\t\t\t\t: { mode: \"remote\", identity: decision.identity.loginName, device: decision.identity.device };\n\t\t\tres.json({ ...status, needsPairing: false });\n\t\t\treturn;\n\t\t}\n\t\tres.status(decision.status).json({\n\t\t\terror: decision.reason,\n\t\t\tneedsPairing: decision.needsPairing ?? false,\n\t\t\tidentity: decision.identity?.loginName,\n\t\t});\n\t});\n\n\tapp.get(\"/api/pairing-code\", (req: AuthedRequest, res) => {\n\t\tconst decision = req.authDecision!;\n\t\tif (!decision.allowed || decision.mode !== \"local\") {\n\t\t\tres.status(403).json({ error: \"Pairing code is only available from the host machine\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!auth.isRemoteEnabled) {\n\t\t\tconst body: PairingCodeDto = { enabled: false };\n\t\t\tres.json(body);\n\t\t\treturn;\n\t\t}\n\t\tconst body: PairingCodeDto = { enabled: true, ...auth.currentPairingCode() };\n\t\tres.json(body);\n\t});\n\n\tapp.post(\"/api/pair\", (req: AuthedRequest, res) => {\n\t\tconst pin = typeof req.body?.pin === \"string\" ? req.body.pin : \"\";\n\t\tauth\n\t\t\t.pair(\n\t\t\t\t{\n\t\t\t\t\tremoteAddress: req.socket.remoteAddress,\n\t\t\t\t\thostHeader: req.headers.host,\n\t\t\t\t\toriginHeader: req.headers.origin,\n\t\t\t\t\tdeviceToken: undefined,\n\t\t\t\t},\n\t\t\t\tpin,\n\t\t\t)\n\t\t\t.then(({ token, device }) => {\n\t\t\t\tlog(`paired device ${device.id} (${device.identity})`);\n\t\t\t\tres.cookie(DEVICE_COOKIE, token, {\n\t\t\t\t\thttpOnly: true,\n\t\t\t\t\tsameSite: \"strict\",\n\t\t\t\t\tsecure: false, // Tailscale already encrypts; the dashboard serves plain HTTP on the tailnet.\n\t\t\t\t\texpires: new Date(device.expiresAt),\n\t\t\t\t}).json({ device });\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tconst status = typeof err?.status === \"number\" ? err.status : 500;\n\t\t\t\tlog(`pairing failed: ${err instanceof Error ? err.message : String(err)}`);\n\t\t\t\tres.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n\t\t\t});\n\t});\n\n\tapp.get(\"/api/devices\", (_req, res) => {\n\t\tauth\n\t\t\t.listDevices()\n\t\t\t.then((devices) => res.json({ devices }))\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.delete(\"/api/devices/:id\", (req, res) => {\n\t\tauth\n\t\t\t.unpair(req.params.id)\n\t\t\t.then((removed) => {\n\t\t\t\tif (!removed) {\n\t\t\t\t\tres.status(404).json({ error: `No paired device with id ${String(req.params.id)}` });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tlog(`unpaired device ${String(req.params.id)}`);\n\t\t\t\tres.json({ ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- events (SSE) ----------------------------------------------------------\n\tapp.get(\"/api/events\", (req: AuthedRequest, res) => {\n\t\tconst connectionId = req.sseConnectionId ?? randomUUID();\n\t\tconst diagnostic = (kind: string, metadata: object = {}) =>\n\t\t\tlog(`sse ${JSON.stringify({ connectionId, kind, ...metadata })}`);\n\t\tres.writeHead(200, {\n\t\t\t\"content-type\": \"text/event-stream\",\n\t\t\t\"cache-control\": \"no-cache\",\n\t\t\tconnection: \"keep-alive\",\n\t\t});\n\n\t\tconst guardedWrite = (\n\t\t\tchunk: string,\n\t\t\tmetadata: SseWriteMetadata | { kind: \"handshake\" | \"heartbeat\" | \"connection\" },\n\t\t): boolean => {\n\t\t\tif (res.destroyed || res.writableEnded) {\n\t\t\t\tdiagnostic(\"write_closed\", { writeKind: metadata.kind });\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst accepted = res.write(chunk);\n\t\t\tconst details = {\n\t\t\t\twriteKind: metadata.kind,\n\t\t\t\t...(\"seq\" in metadata\n\t\t\t\t\t? { seq: metadata.seq, type: metadata.type, frameBytes: metadata.frameBytes, reason: metadata.reason }\n\t\t\t\t\t: {}),\n\t\t\t\twritableLength: res.writableLength,\n\t\t\t};\n\t\t\tdiagnostic(\"write\", details);\n\t\t\tif (!accepted && res.writableLength > MAX_SSE_BUFFERED_BYTES) {\n\t\t\t\tdiagnostic(\"backpressure\", details);\n\t\t\t\tres.destroy();\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\treturn true;\n\t\t};\n\n\t\tconst lastIdRaw = req.headers[\"last-event-id\"] ?? req.query.lastEventId;\n\t\tconst lastEventId =\n\t\t\ttypeof lastIdRaw === \"string\" && /^\\d+$/.test(lastIdRaw) ? Number.parseInt(lastIdRaw, 10) : undefined;\n\t\tdiagnostic(\"connect\", { cursor: lastEventId });\n\t\tif (!guardedWrite(\":ok\\n\\n\", { kind: \"handshake\" })) return;\n\t\t// Unnumbered connection metadata lets a browser correlate optional,\n\t\t// payload-free diagnostics without mutating its application SSE cursor.\n\t\tconst issuedAt = Date.now();\n\t\tfor (const [id, record] of diagnosticConnections) {\n\t\t\tif (issuedAt - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS) diagnosticConnections.delete(id);\n\t\t}\n\t\tdiagnosticConnections.set(connectionId, { issuedAt });\n\t\tif (!guardedWrite(`event: connection\\ndata: ${JSON.stringify({ connectionId })}\\n\\n`, { kind: \"connection\" }))\n\t\t\treturn;\n\t\tlet detach = () => {};\n\t\tlet keepAlive: ReturnType<typeof setInterval> | undefined;\n\t\tconst stop = () => {\n\t\t\tif (keepAlive) clearInterval(keepAlive);\n\t\t\tdetach();\n\t\t};\n\t\tlet usable = true;\n\t\tdetach = hub.attach(\n\t\t\t{\n\t\t\t\twrite: (chunk, metadata) => {\n\t\t\t\t\tif (!metadata) return false;\n\t\t\t\t\tusable = guardedWrite(chunk, metadata);\n\t\t\t\t\treturn usable;\n\t\t\t\t},\n\t\t\t},\n\t\t\tlastEventId,\n\t\t\t(replay) => diagnostic(replay.kind, replay),\n\t\t);\n\t\t// A rejected/destroyed replay must not leave a timer or live client behind.\n\t\tif (!usable) return;\n\t\t// Named heartbeats are visible to EventSource but have no id, so they do\n\t\t// not alter the application cursor or consume replay history.\n\t\tkeepAlive = setInterval(() => {\n\t\t\tif (!guardedWrite(formatHeartbeatFrame(), { kind: \"heartbeat\" })) stop();\n\t\t}, options.heartbeatIntervalMs ?? 25_000);\n\t\treq.on(\"close\", () => {\n\t\t\tdiagnostic(\"close\", { writableLength: res.writableLength });\n\t\t\tstop();\n\t\t});\n\t});\n\n\t// -- optional client stream diagnostics -----------------------------------\n\tapp.post(\"/api/events/diagnostic\", (req, res) => {\n\t\tconst declaredLength = Number(req.headers[\"content-length\"] ?? 0);\n\t\tconst encodedBytes = Buffer.byteLength(JSON.stringify(req.body ?? null));\n\t\tif (declaredLength > MAX_CLIENT_DIAGNOSTIC_BYTES || encodedBytes > MAX_CLIENT_DIAGNOSTIC_BYTES) {\n\t\t\tres.status(413).json({ error: \"Diagnostic summary exceeds the 4 KiB limit\" });\n\t\t\treturn;\n\t\t}\n\t\tif (!isClientDiagnostic(req.body)) {\n\t\t\tres.status(400).json({ error: \"Invalid diagnostic summary\" });\n\t\t\treturn;\n\t\t}\n\t\tconst now = Date.now();\n\t\tfor (const [id, record] of diagnosticConnections) {\n\t\t\tif (now - record.issuedAt > CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS) diagnosticConnections.delete(id);\n\t\t}\n\t\tconst record = diagnosticConnections.get(req.body.connectionId);\n\t\tif (!record) {\n\t\t\tres.status(400).json({ error: \"Unknown or expired SSE connection\" });\n\t\t\treturn;\n\t\t}\n\t\tif (record.lastAt !== undefined && now - record.lastAt < CLIENT_DIAGNOSTIC_RATE_LIMIT_MS) {\n\t\t\tres.status(429).json({ error: \"Diagnostic summary rate limited\" });\n\t\t\treturn;\n\t\t}\n\t\trecord.lastAt = now;\n\t\t// Never log the request body wholesale. The schema is intentionally only\n\t\t// connection metadata, and this explicit projection prevents future fields\n\t\t// from accidentally turning diagnostics into a payload side-channel.\n\t\tlog(\n\t\t\t`sse ${JSON.stringify({\n\t\t\t\tconnectionId: req.body.connectionId,\n\t\t\t\tkind: \"client_diagnostic\",\n\t\t\t\tstate: req.body.state,\n\t\t\t\tpreviousState: req.body.previousState,\n\t\t\t\tattempt: req.body.attempt,\n\t\t\t\tdelayMs: req.body.delayMs,\n\t\t\t\tvisibility: req.body.visibility,\n\t\t\t\tlastAppliedSeq: req.body.lastAppliedSeq,\n\t\t\t\theartbeatAgeMs: req.body.heartbeatAgeMs,\n\t\t\t\teventCount: req.body.eventCount,\n\t\t\t\teventRatePerMinute: req.body.eventRatePerMinute,\n\t\t\t\tprocessingLagTotalMs: req.body.processingLagTotalMs,\n\t\t\t\tprocessingLagMaxMs: req.body.processingLagMaxMs,\n\t\t\t})}`,\n\t\t);\n\t\tres.json({ ok: true });\n\t});\n\n\t// -- fleet -----------------------------------------------------------------\n\tconst listDiskSessions = async (): Promise<SessionInfoDto[]> =>\n\t\t(await options.listAllSessions()).filter((session) => existsSync(session.cwd)).map(toSessionInfoDto);\n\n\tconst currentCwdInventory = async (): Promise<string[]> => [\n\t\t...pool.list().map((handle) => handle.cwd),\n\t\t...(await listDiskSessions()).map((session) => session.cwd),\n\t];\n\n\tconst handleMemoryError = (res: Response, err: unknown): void => {\n\t\tconst status =\n\t\t\ttypeof (err as { status?: unknown })?.status === \"number\" ? (err as { status: number }).status : 500;\n\t\tres.status(status).json({ error: err instanceof Error ? err.message : String(err) });\n\t};\n\n\tapp.get(\"/api/memories/scopes\", (_req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.scopes(inventory))\n\t\t\t.then((scopes) => res.json({ scopes }))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tapp.get(\"/api/memories/:scopeId\", (req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.listing(req.params.scopeId, inventory))\n\t\t\t.then((listing) => res.json(listing))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tapp.get(\"/api/memories/:scopeId/documents/:file\", (req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.readDocument(req.params.scopeId, req.params.file, inventory))\n\t\t\t.then((document) => res.json(document))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tapp.put(\"/api/memories/:scopeId/documents/:file\", (req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.saveDocument(req.params.scopeId, req.params.file, req.body, inventory))\n\t\t\t.then((result) => res.json(result))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tapp.delete(\"/api/memories/:scopeId/entries/:file\", (req, res) => {\n\t\tcurrentCwdInventory()\n\t\t\t.then((inventory) => memories.deleteEntry(req.params.scopeId, req.params.file, req.body, inventory))\n\t\t\t.then((result) => res.json(result))\n\t\t\t.catch((err) => handleMemoryError(res, err));\n\t});\n\n\tconst getFleet = async (): Promise<FleetDto> => {\n\t\tconst runtimes = await Promise.all(pool.list().map((h) => pool.describe(h)));\n\t\treturn { runtimes, diskSessions: await listDiskSessions() };\n\t};\n\n\t/** Map the one-RPC parent snapshot consistently for recovery and drill-in hydration. */\n\tconst toRuntimeHydration = (snapshot: DashboardRuntimeSnapshot): RuntimeHydrationDto => ({\n\t\tkey: snapshot.key,\n\t\tstate: snapshot.snapshot.state,\n\t\tmessages: images.project(snapshot.snapshot.messages, { runtimeKey: snapshot.key }),\n\t\tbackgroundAgents: snapshot.snapshot.backgroundAgents,\n\t\tpendingExtensionUiRequests: snapshot.snapshot.pendingExtensionUiRequests ?? [],\n\t\tbarrierSeq: snapshot.barrierSeq,\n\t});\n\n\tapp.get(\"/api/fleet\", (_req, res) => {\n\t\tconst startedAt = Date.now();\n\t\tgetFleet()\n\t\t\t.then((fleet) => {\n\t\t\t\t// Serialize once so the diagnostic reports the exact JSON response size\n\t\t\t\t// without retaining or logging any fleet payload fields.\n\t\t\t\tconst body = JSON.stringify(fleet);\n\t\t\t\tconst diagnostic = {\n\t\t\t\t\telapsedMs: Date.now() - startedAt,\n\t\t\t\t\tencodedBytes: Buffer.byteLength(body),\n\t\t\t\t\truntimeCount: fleet.runtimes.length,\n\t\t\t\t\tdiskSessionCount: fleet.diskSessions.length,\n\t\t\t\t};\n\t\t\t\tres.type(\"json\").send(body);\n\t\t\t\tlog(`fleet ${JSON.stringify(diagnostic)}`);\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/** On-disk inventory only; does not query or describe live runtimes. */\n\tapp.get(\"/api/sessions\", (_req, res) => {\n\t\tlistDiskSessions()\n\t\t\t.then((sessions) => {\n\t\t\t\tconst body: SessionInventoryDto = { sessions };\n\t\t\t\tres.json(body);\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/**\n\t * Full recovery snapshot. For an active runtime, its RPC marker captures the\n\t * current EventHub sequence before the response; later publications have a\n\t * higher sequence. This is an ordering contract, not a timing heuristic.\n\t */\n\tapp.get(\"/api/resync\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst activeKey = typeof req.query.key === \"string\" ? req.query.key : undefined;\n\t\t\tconst activeAgentId = typeof req.query.agentId === \"string\" ? req.query.agentId : undefined;\n\t\t\tlet active: DashboardResyncDto[\"active\"];\n\t\t\tlet barrierSeq: number;\n\t\t\tif (activeKey) {\n\t\t\t\tconst handle = pool.get(activeKey);\n\t\t\t\tif (!handle) {\n\t\t\t\t\tconst body: DashboardResyncDto = { fleet: await getFleet(), barrierSeq: hub.currentSequence };\n\t\t\t\t\tres.json(body);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// The disk transcript has its own sequence boundary because it is read\n\t\t\t\t// before the parent RPC snapshot. Relays between these two barriers must\n\t\t\t\t// be reapplied so a subagent delta cannot disappear during recovery.\n\t\t\t\tlet preBarrierSubagent: NonNullable<ActiveRuntimeSnapshotDto[\"subagent\"]> | undefined;\n\t\t\t\tif (activeAgentId) {\n\t\t\t\t\tconst agents = await handle.client.listBackgroundAgents();\n\t\t\t\t\tconst agent = agents.find((candidate) => candidate.agentId === activeAgentId);\n\t\t\t\t\tif (!agent) throw new Error(`No background agent ${activeAgentId} in this runtime`);\n\t\t\t\t\tconst messages = readSubagentMessages(agent);\n\t\t\t\t\tpreBarrierSubagent = {\n\t\t\t\t\t\tagentId: activeAgentId,\n\t\t\t\t\t\tagent,\n\t\t\t\t\t\tmessages: images.project(messages, { runtimeKey: activeKey, agentId: activeAgentId }),\n\t\t\t\t\t\tbarrierSeq: hub.currentSequence,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tconst snapshot = await pool.snapshotDashboard(handle);\n\t\t\t\tbarrierSeq = snapshot.barrierSeq;\n\t\t\t\tactive = {\n\t\t\t\t\t...toRuntimeHydration(snapshot),\n\t\t\t\t\t...(preBarrierSubagent ? { subagent: preBarrierSubagent } : {}),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tbarrierSeq = hub.currentSequence;\n\t\t\t}\n\t\t\tconst body: DashboardResyncDto = { fleet: await getFleet(), ...(active ? { active } : {}), barrierSeq };\n\t\t\tres.json(body);\n\t\t})().catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- runtimes ---------------------------------------------------------------\n\tapp.post(\"/api/runtimes\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst cwd = typeof req.body?.cwd === \"string\" ? req.body.cwd : \"\";\n\t\t\tif (!cwd || !existsSync(cwd)) {\n\t\t\t\tres.status(400).json({ error: `Working directory does not exist: ${cwd || \"(empty)\"}` });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst sessionPath = typeof req.body?.sessionPath === \"string\" ? req.body.sessionPath : undefined;\n\t\t\tconst handle = await pool.create(cwd, sessionPath);\n\t\t\tlog(`runtime ${handle.key} started in ${cwd}${sessionPath ? ` (resume ${basename(sessionPath)})` : \"\"}`);\n\t\t\tconst firstPrompt = typeof req.body?.firstPrompt === \"string\" ? req.body.firstPrompt : undefined;\n\t\t\tif (firstPrompt) await handle.client.prompt(firstPrompt);\n\t\t\tres.status(201).json(await pool.describe(handle));\n\t\t})().catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.delete(\"/api/runtimes/:key\", (req, res) => {\n\t\tpool\n\t\t\t.stop(req.params.key)\n\t\t\t.then((stopped) => {\n\t\t\t\tif (!stopped) {\n\t\t\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\timages.removeRuntime(String(req.params.key));\n\t\t\t\tlog(`runtime ${String(req.params.key)} stopped`);\n\t\t\t\tres.json({ ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t/** Helper: run an async op against a pooled runtime with uniform errors. */\n\tfunction withRuntime(\n\t\treq: Request,\n\t\tres: Response,\n\t\tfn: (handle: NonNullable<ReturnType<RuntimePool[\"get\"]>>) => Promise<unknown>,\n\t): void {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\tfn(handle)\n\t\t\t.then((data) => res.json(data ?? { ok: true }))\n\t\t\t.catch((err) => {\n\t\t\t\tres.status(502).json({ error: String(err?.message ?? err) });\n\t\t\t});\n\t}\n\n\tapp.get(\"/api/runtimes/:key\", (req, res) => {\n\t\twithRuntime(req, res, (h) => pool.describe(h));\n\t});\n\n\t/**\n\t * Atomic drill-in snapshot. snapshotDashboard performs exactly one RPC and\n\t * consumes its marker barrier, so no independently-read runtime fields can\n\t * describe different moments in a live turn.\n\t */\n\tapp.get(\"/api/runtimes/:key/hydrate\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => toRuntimeHydration(await pool.snapshotDashboard(h)));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/messages\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({\n\t\t\tmessages: images.project(await h.client.getMessages(), { runtimeKey: h.key }),\n\t\t}));\n\t});\n\n\tconst sendImage = async (\n\t\treq: Request,\n\t\tres: Response,\n\t\tscope: DashboardImageScope,\n\t\tvariant: \"preview\" | \"original\",\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<void> => {\n\t\tconst id = String(req.params.id);\n\t\tif (!DASHBOARD_IMAGE_ID_PATTERN.test(id)) {\n\t\t\tres.status(400).json({ error: \"Invalid dashboard image ID\" });\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tconst image =\n\t\t\t\tvariant === \"preview\"\n\t\t\t\t\t? await images.preview(scope, id, loadAuthoritative)\n\t\t\t\t\t: await images.original(scope, id, loadAuthoritative);\n\t\t\tres.set({\n\t\t\t\t\"Content-Type\": image.mimeType,\n\t\t\t\t\"Content-Length\": String(image.bytes.byteLength),\n\t\t\t\t\"X-Content-Type-Options\": \"nosniff\",\n\t\t\t\t\"Cache-Control\": \"private, max-age=31536000, immutable\",\n\t\t\t});\n\t\t\tres.send(Buffer.from(image.bytes));\n\t\t} catch (error) {\n\t\t\tif (error instanceof DashboardImageNotFoundError) {\n\t\t\t\tres.status(404).json({ error: error.message });\n\t\t\t} else if (error instanceof DashboardImagePreviewError) {\n\t\t\t\tres.status(422).json({ error: error.message });\n\t\t\t} else {\n\t\t\t\tres.status(502).json({\n\t\t\t\t\terror: `Image source unavailable: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t};\n\n\tapp.get(\"/api/runtimes/:key/images/:id/:variant\", (req, res) => {\n\t\tconst key = String(req.params.key);\n\t\tconst variant = String(req.params.variant);\n\t\tif (variant !== \"preview\" && variant !== \"original\") {\n\t\t\tres.status(404).json({ error: \"Unknown dashboard image variant\" });\n\t\t\treturn;\n\t\t}\n\t\tconst handle = pool.get(key);\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${key}` });\n\t\t\treturn;\n\t\t}\n\t\tvoid sendImage(req, res, { runtimeKey: key }, variant, () => handle.client.getMessages());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/subagents/:agentId/images/:id/:variant\", (req, res) => {\n\t\tconst key = String(req.params.key);\n\t\tconst agentId = String(req.params.agentId);\n\t\tconst variant = String(req.params.variant);\n\t\tif (variant !== \"preview\" && variant !== \"original\") {\n\t\t\tres.status(404).json({ error: \"Unknown dashboard image variant\" });\n\t\t\treturn;\n\t\t}\n\t\tconst handle = pool.get(key);\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${key}` });\n\t\t\treturn;\n\t\t}\n\t\tvoid sendImage(req, res, { runtimeKey: key, agentId }, variant, async () => {\n\t\t\tconst agents = await handle.client.listBackgroundAgents();\n\t\t\tconst agent = agents.find((candidate) => candidate.agentId === agentId);\n\t\t\tif (!agent) throw new DashboardImageNotFoundError(`No background agent ${agentId} in this runtime`);\n\t\t\treturn readSubagentMessages(agent);\n\t\t});\n\t});\n\n\tapp.get(\"/api/runtimes/:key/pending\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getPendingMessages());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/dequeue\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.clearPendingMessages());\n\t});\n\n\tfunction parseImages(body: unknown): ImageAttachmentDto[] | undefined | \"invalid\" {\n\t\tconst images = (body as { images?: unknown } | undefined)?.images;\n\t\tif (images === undefined) return undefined;\n\t\tif (!Array.isArray(images)) return \"invalid\";\n\t\tconst parsed: ImageAttachmentDto[] = [];\n\t\tfor (const image of images) {\n\t\t\tif (\n\t\t\t\t!image ||\n\t\t\t\ttypeof image !== \"object\" ||\n\t\t\t\ttypeof (image as { data?: unknown }).data !== \"string\" ||\n\t\t\t\ttypeof (image as { mimeType?: unknown }).mimeType !== \"string\"\n\t\t\t) {\n\t\t\t\treturn \"invalid\";\n\t\t\t}\n\t\t\tparsed.push({ data: (image as ImageAttachmentDto).data, mimeType: (image as ImageAttachmentDto).mimeType });\n\t\t}\n\t\treturn parsed;\n\t}\n\n\tapp.post(\"/api/runtimes/:key/prompt\", (req, res) => {\n\t\tconst { message, mode } = req.body ?? {};\n\t\tif (typeof message !== \"string\" || message.length === 0) {\n\t\t\tres.status(400).json({ error: \"message is required\" });\n\t\t\treturn;\n\t\t}\n\t\tconst images = parseImages(req.body);\n\t\tif (images === \"invalid\") {\n\t\t\tres.status(400).json({ error: \"images must be an array of {data, mimeType} objects\" });\n\t\t\treturn;\n\t\t}\n\t\tconst rpcImages = images?.map((image) => ({\n\t\t\ttype: \"image\" as const,\n\t\t\tdata: image.data,\n\t\t\tmimeType: image.mimeType,\n\t\t}));\n\t\twithRuntime(req, res, async (h) => {\n\t\t\tif (mode === \"steer\") await h.client.steer(message, rpcImages);\n\t\t\telse if (mode === \"follow_up\") await h.client.followUp(message, rpcImages);\n\t\t\telse await h.client.prompt(message, rpcImages);\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abort());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort-compaction\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abortCompaction());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/abort-retry\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.abortRetry());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/model\", (req, res) => {\n\t\tconst { provider, modelId } = req.body ?? {};\n\t\tif (typeof provider !== \"string\" || typeof modelId !== \"string\") {\n\t\t\tres.status(400).json({ error: \"provider and modelId are required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setModel(provider, modelId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/models\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ models: await h.client.getAvailableModels() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/thinking\", (req, res) => {\n\t\tconst { level } = req.body ?? {};\n\t\tif (typeof level !== \"string\") {\n\t\t\tres.status(400).json({ error: \"level is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setThinkingLevel(level as never));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/compact\", (req, res) => {\n\t\tconst instructions = typeof req.body?.instructions === \"string\" ? req.body.instructions : undefined;\n\t\twithRuntime(req, res, (h) => h.client.compact(instructions));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/new-session\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.newSession());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/reload\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => {\n\t\t\tawait h.client.reload();\n\t\t\treturn { ok: true };\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/dream\", (req, res) => {\n\t\tconst args = typeof req.body?.args === \"string\" ? req.body.args : undefined;\n\t\twithRuntime(req, res, (h) => h.client.dream(args));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/import\", (req, res) => {\n\t\tconst inputPath = typeof req.body?.inputPath === \"string\" ? req.body.inputPath.trim() : \"\";\n\t\tif (!inputPath) {\n\t\t\tres.status(400).json({ error: \"inputPath is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.importJsonl(inputPath));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/name\", (req, res) => {\n\t\tconst { name } = req.body ?? {};\n\t\tif (typeof name !== \"string\" || name.length === 0) {\n\t\t\tres.status(400).json({ error: \"name is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.setSessionName(name));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/stats\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getSessionStats());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/performance\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getPerformanceStats());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/resources\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getResources());\n\t});\n\n\tapp.get(\"/api/runtimes/:key/commands\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ commands: await h.client.getCommands() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/branch\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ branch: await h.client.getGitBranch() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/fork-messages\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ messages: await h.client.getForkMessages() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/fork\", (req, res) => {\n\t\tconst { entryId } = req.body ?? {};\n\t\tif (typeof entryId !== \"string\") {\n\t\t\tres.status(400).json({ error: \"entryId is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.fork(entryId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/tree\", (req, res) => {\n\t\twithRuntime(req, res, (h) => h.client.getTree());\n\t});\n\n\tapp.post(\"/api/runtimes/:key/tree\", (req, res) => {\n\t\tconst targetId = typeof req.body?.targetId === \"string\" ? req.body.targetId : \"\";\n\t\tif (!targetId) {\n\t\t\tres.status(400).json({ error: \"targetId is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.navigateTree(targetId));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/sessions\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ sessions: await h.client.listSessions() }));\n\t});\n\n\tapp.post(\"/api/runtimes/:key/resume\", (req, res) => {\n\t\tconst sessionPath = typeof req.body?.sessionPath === \"string\" ? req.body.sessionPath : \"\";\n\t\tif (!sessionPath) {\n\t\t\tres.status(400).json({ error: \"sessionPath is required\" });\n\t\t\treturn;\n\t\t}\n\t\twithRuntime(req, res, (h) => h.client.switchSession(sessionPath));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/export-html\", (req, res) => {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\thandle.client\n\t\t\t.exportHtml()\n\t\t\t.then(({ path }) => {\n\t\t\t\tres.download(path);\n\t\t\t})\n\t\t\t.catch((err) => res.status(502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/background-agents\", (req, res) => {\n\t\twithRuntime(req, res, async (h) => ({ agents: await h.client.listBackgroundAgents() }));\n\t});\n\n\tapp.get(\"/api/runtimes/:key/subagents/:agentId/messages\", (req, res) => {\n\t\tconst agentId = String(req.params.agentId);\n\t\twithRuntime(req, res, async (h) => {\n\t\t\t// The runtime's registry is authoritative for status + log location.\n\t\t\tconst agents = await h.client.listBackgroundAgents();\n\t\t\tconst agent = agents.find((a) => a.agentId === agentId);\n\t\t\tif (!agent) throw new Error(`No background agent ${agentId} in this runtime`);\n\t\t\tlet messages: unknown[];\n\t\t\ttry {\n\t\t\t\tmessages = readSubagentMessages(agent);\n\t\t\t} catch (error) {\n\t\t\t\tconst failedBeforeSpawn =\n\t\t\t\t\tagent.arbitrations !== undefined &&\n\t\t\t\t\tagent.arbitrations.length > 0 &&\n\t\t\t\t\tagent.arbitrations.every((record) => record.status === \"failure\");\n\t\t\t\tif (!(error instanceof SubagentSessionLogNotFoundError) || !failedBeforeSpawn) throw error;\n\t\t\t\tmessages = [];\n\t\t\t}\n\t\t\treturn { agent, messages: images.project(messages, { runtimeKey: h.key, agentId }) };\n\t\t});\n\t});\n\n\tapp.post(\"/api/runtimes/:key/extension-ui-response\", (req, res) => {\n\t\tconst handle = pool.get(String(req.params.key));\n\t\tif (!handle) {\n\t\t\tres.status(404).json({ error: `No runtime ${String(req.params.key)}` });\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\thandle.client.sendExtensionUIResponse(req.body);\n\t\t\tres.json({ ok: true });\n\t\t} catch (err) {\n\t\t\tres.status(502).json({ error: String((err as Error)?.message ?? err) });\n\t\t}\n\t});\n\n\t// -- disk sessions -----------------------------------------------------------\n\tapp.delete(\"/api/sessions\", (req, res) => {\n\t\tconst path = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!path) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\toptions\n\t\t\t.deleteSession(path)\n\t\t\t.then((result) => {\n\t\t\t\tlog(`session deleted: ${path}`);\n\t\t\t\thub.publish(\"\", { type: \"disk_sessions_changed\" });\n\t\t\t\tres.json(result ?? { ok: true });\n\t\t\t})\n\t\t\t.catch((err) => res.status(500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- settings ------------------------------------------------------------------\n\t// Settings are process-global persistent defaults. They route through hidden\n\t// utility runtimes instead of whichever user session happened to open first.\n\t// Agent-definition discovery is cwd-sensitive, so callers may pass an explicit\n\t// project cwd for endpoints that need project-local .dreb/agents.\n\tfunction withAnyRuntime(\n\t\tres: Response,\n\t\tfn: (h: NonNullable<ReturnType<RuntimePool[\"get\"]>>) => Promise<unknown>,\n\t\tcwd?: string,\n\t) {\n\t\tpool\n\t\t\t.ensureUtilityRuntime(cwd)\n\t\t\t.then((handle) => fn(handle))\n\t\t\t.then((data) => res.json(data ?? { ok: true }))\n\t\t\t.catch((err) => {\n\t\t\t\tres.status(502).json({ error: String(err?.message ?? err) });\n\t\t\t});\n\t}\n\n\tfunction optionalSettingsCwd(req: Request, res: Response): string | undefined | null {\n\t\tif (req.query.cwd === undefined) return undefined;\n\t\tif (typeof req.query.cwd !== \"string\" || !req.query.cwd.trim()) {\n\t\t\tres.status(400).json({ error: \"cwd must be a non-empty path\" });\n\t\t\treturn null;\n\t\t}\n\t\tif (!existsSync(req.query.cwd)) {\n\t\t\tres.status(400).json({ error: `cwd does not exist: ${req.query.cwd}` });\n\t\t\treturn null;\n\t\t}\n\t\ttry {\n\t\t\tif (!statSync(req.query.cwd).isDirectory()) {\n\t\t\t\tres.status(400).json({ error: `cwd is not a directory: ${req.query.cwd}` });\n\t\t\t\treturn null;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tres.status(400).json({ error: `cannot access cwd ${req.query.cwd}: ${(error as Error).message}` });\n\t\t\treturn null;\n\t\t}\n\t\treturn req.query.cwd;\n\t}\n\n\tapp.get(\"/api/settings\", (req, res) => {\n\t\tconst cwd = optionalSettingsCwd(req, res);\n\t\tif (cwd === null) return;\n\t\twithAnyRuntime(res, (h) => h.client.getSettings(), cwd);\n\t});\n\n\tapp.get(\"/api/settings/models\", (req, res) => {\n\t\tconst cwd = optionalSettingsCwd(req, res);\n\t\tif (cwd === null) return;\n\t\twithAnyRuntime(res, async (h) => ({ models: await h.client.getAvailableModels() }), cwd);\n\t});\n\n\tapp.get(\"/api/settings/agent-types\", (req, res) => {\n\t\tconst cwd = typeof req.query.cwd === \"string\" && req.query.cwd.trim() ? req.query.cwd : undefined;\n\t\tif (cwd && !existsSync(cwd)) {\n\t\t\tres.status(400).json({ error: `cwd does not exist: ${cwd}` });\n\t\t\treturn;\n\t\t}\n\t\twithAnyRuntime(res, async (h) => ({ agentTypes: await h.client.listAgentTypes() }), cwd);\n\t});\n\n\tapp.get(\"/api/daily-cost\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ cost: await h.client.getDailyCost() }));\n\t});\n\n\tapp.put(\"/api/settings\", (req, res) => {\n\t\tconst cwd = optionalSettingsCwd(req, res);\n\t\tif (cwd === null) return;\n\t\twithAnyRuntime(res, (h) => h.client.setSettings(req.body ?? {}), cwd);\n\t});\n\n\tapp.get(\"/api/version\", (_req, res) => {\n\t\twithAnyRuntime(res, async (h) => ({ version: await h.client.getVersion() }));\n\t});\n\n\tapp.post(\"/api/settings/remove-trusted\", (req, res) => {\n\t\tconst rawPath = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!rawPath) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\tpool\n\t\t\t.ensureUtilityRuntime()\n\t\t\t.then(async (handle) => {\n\t\t\t\tconst result = await handle.client.removeTrustedContextFolder(rawPath);\n\t\t\t\tlog(`context trust configured remove: ${rawPath}`);\n\t\t\t\tres.json(result);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- server lifecycle ----------------------------------------------------------\n\t// Build/version of the *server* process (distinct from a freshly-spawned RPC\n\t// child's version) so a stale long-running service is visible at a glance.\n\tapp.get(\"/api/server/info\", (_req, res) => {\n\t\tres.json({\n\t\t\tversion: options.serverVersion ?? null,\n\t\t\tstartedAt: serverStartedAt,\n\t\t\t// systemd sets INVOCATION_ID; other supervisors set LISTEN_PID. Best-effort.\n\t\t\tsupervised: Boolean(process.env.INVOCATION_ID || process.env.LISTEN_PID),\n\t\t\trestartable: Boolean(options.onRestart),\n\t\t});\n\t});\n\n\tapp.post(\"/api/server/restart\", (_req, res) => {\n\t\tif (!options.onRestart) {\n\t\t\tres.status(501).json({\n\t\t\t\terror: \"Restart is unavailable — the dashboard is not running under a supervisor that can respawn it\",\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tlog(\"restart requested via API\");\n\t\tres.json({ ok: true, restarting: true });\n\t\t// Defer so the HTTP response flushes before the process exits.\n\t\tsetTimeout(() => options.onRestart?.(), 100);\n\t});\n\n\t// -- files -----------------------------------------------------------------------\n\tapp.get(\"/api/files\", (req, res) => {\n\t\tconst path = typeof req.query.path === \"string\" ? req.query.path : homedir();\n\t\tfiles\n\t\t\t.list(path)\n\t\t\t.then(async (listing) => {\n\t\t\t\tconst handle = await pool.ensureUtilityRuntime();\n\t\t\t\tconst contextTrust = await handle.client.evaluateContextTrust(listing.path);\n\t\t\t\tres.json({ ...listing, contextTrust });\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tfunction contextTrustMutation(\n\t\treq: Request,\n\t\tres: Response,\n\t\toperation: \"trustContextFolder\" | \"untrustContextFolder\",\n\t): void {\n\t\tconst rawPath = typeof req.body?.path === \"string\" ? req.body.path : \"\";\n\t\tif (!rawPath) {\n\t\t\tres.status(400).json({ error: \"path is required\" });\n\t\t\treturn;\n\t\t}\n\t\tfiles\n\t\t\t.resolveDirectory(rawPath)\n\t\t\t.then(async (path) => {\n\t\t\t\tconst handle = await pool.ensureUtilityRuntime();\n\t\t\t\tconst result = await handle.client[operation](path);\n\t\t\t\tlog(`context trust ${operation === \"trustContextFolder\" ? \"add\" : \"remove\"}: ${path}`);\n\t\t\t\tres.json(result);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 502).json({ error: String(err?.message ?? err) }));\n\t}\n\n\tapp.post(\"/api/files/trust\", (req, res) => contextTrustMutation(req, res, \"trustContextFolder\"));\n\tapp.post(\"/api/files/untrust\", (req, res) => contextTrustMutation(req, res, \"untrustContextFolder\"));\n\n\tapp.get(\"/api/files/places\", (_req, res) => {\n\t\tconst roots = [...new Set(pool.list().map((h) => h.cwd))];\n\t\tres.json({ places: defaultPlaces(homedir(), roots) });\n\t});\n\n\tapp.get(\"/api/files/download\", (req, res) => {\n\t\tconst path = typeof req.query.path === \"string\" ? req.query.path : \"\";\n\t\tfiles\n\t\t\t.resolveDownload(path)\n\t\t\t.then(({ path: real }) => {\n\t\t\t\tres.download(real);\n\t\t\t})\n\t\t\t.catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\tapp.post(\"/api/files/upload\", (req, res) => {\n\t\t(async () => {\n\t\t\tconst dir = typeof req.query.dir === \"string\" ? req.query.dir : \"\";\n\t\t\tconst name = typeof req.query.name === \"string\" ? req.query.name : \"\";\n\t\t\tconst overwrite = req.query.overwrite === \"true\";\n\t\t\tconst upload = await files.prepareUpload(dir, name, overwrite);\n\t\t\ttry {\n\t\t\t\tawait new Promise<void>((resolve, reject) => {\n\t\t\t\t\tlet settled = false;\n\t\t\t\t\tconst fail = (err: unknown) => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\tupload.stream.destroy();\n\t\t\t\t\t\treject(err);\n\t\t\t\t\t};\n\t\t\t\t\treq.pipe(upload.stream);\n\t\t\t\t\tupload.stream.on(\"finish\", () => {\n\t\t\t\t\t\tif (settled) return;\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t});\n\t\t\t\t\tupload.stream.on(\"error\", fail);\n\t\t\t\t\treq.on(\"error\", fail);\n\t\t\t\t\treq.on(\"aborted\", () => fail(Object.assign(new Error(\"Upload aborted\"), { status: 499 })));\n\t\t\t\t});\n\t\t\t\tawait upload.commit();\n\t\t\t\tres.json({ path: upload.path });\n\t\t\t} catch (err) {\n\t\t\t\tawait upload.cleanup();\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t})().catch((err) => {\n\t\t\tif (!res.headersSent) res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) });\n\t\t});\n\t});\n\n\tapp.post(\"/api/files/mkdir\", (req, res) => {\n\t\tconst { dir, name } = req.body ?? {};\n\t\tif (typeof dir !== \"string\" || typeof name !== \"string\") {\n\t\t\tres.status(400).json({ error: \"dir and name are required\" });\n\t\t\treturn;\n\t\t}\n\t\tfiles\n\t\t\t.mkdir(dir, name)\n\t\t\t.then((path) => res.json({ path }))\n\t\t\t.catch((err) => res.status(err?.status ?? 500).json({ error: String(err?.message ?? err) }));\n\t});\n\n\t// -- static client -----------------------------------------------------------------\n\tif (options.staticDir) {\n\t\tapp.use(express.static(options.staticDir));\n\t\t// SPA fallback: serve index.html for non-API GETs (client-side routing).\n\t\tapp.get(/^\\/(?!api\\/).*/, (_req, res) => {\n\t\t\tres.sendFile(join(options.staticDir!, \"index.html\"));\n\t\t});\n\t}\n\n\treturn app;\n}\n"]}
|
package/dist/server/server.js
CHANGED
|
@@ -11,7 +11,7 @@ import { existsSync, statSync } from "node:fs";
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { basename, join } from "node:path";
|
|
13
13
|
import express from "express";
|
|
14
|
-
import { MAX_CLIENT_DIAGNOSTIC_BYTES, MAX_PROMPT_BODY_BYTES } from "../shared/protocol.js";
|
|
14
|
+
import { MAX_CLIENT_DIAGNOSTIC_BYTES, MAX_PROMPT_BODY_BYTES, MAX_SESSION_PREVIEW_CHARACTERS, } from "../shared/protocol.js";
|
|
15
15
|
import { DASHBOARD_IMAGE_ID_PATTERN, DashboardImageNotFoundError, DashboardImagePreviewError, DashboardImageService, } from "./dashboard-images.js";
|
|
16
16
|
import { EventHub, formatHeartbeatFrame } from "./event-hub.js";
|
|
17
17
|
import { defaultPlaces, FileApi } from "./files.js";
|
|
@@ -22,6 +22,29 @@ const DEVICE_COOKIE = "dreb_dashboard_device";
|
|
|
22
22
|
export const MAX_SSE_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
23
23
|
export const CLIENT_DIAGNOSTIC_RATE_LIMIT_MS = 30_000;
|
|
24
24
|
const CLIENT_DIAGNOSTIC_CONNECTION_TTL_MS = 10 * 60_000;
|
|
25
|
+
function boundedSessionPreview(text) {
|
|
26
|
+
let preview = "";
|
|
27
|
+
let characters = 0;
|
|
28
|
+
for (const character of text) {
|
|
29
|
+
if (characters === MAX_SESSION_PREVIEW_CHARACTERS)
|
|
30
|
+
break;
|
|
31
|
+
preview += character;
|
|
32
|
+
characters++;
|
|
33
|
+
}
|
|
34
|
+
return preview;
|
|
35
|
+
}
|
|
36
|
+
function toSessionInfoDto(session) {
|
|
37
|
+
return {
|
|
38
|
+
path: session.path,
|
|
39
|
+
id: session.id,
|
|
40
|
+
cwd: session.cwd,
|
|
41
|
+
name: session.name,
|
|
42
|
+
created: session.created.toISOString(),
|
|
43
|
+
modified: session.modified.toISOString(),
|
|
44
|
+
messageCount: session.messageCount,
|
|
45
|
+
firstMessage: boundedSessionPreview(session.firstMessage),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
25
48
|
function isClientDiagnostic(value) {
|
|
26
49
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
27
50
|
return false;
|
|
@@ -362,7 +385,7 @@ export function createDashboardServer(options) {
|
|
|
362
385
|
res.json({ ok: true });
|
|
363
386
|
});
|
|
364
387
|
// -- fleet -----------------------------------------------------------------
|
|
365
|
-
const listDiskSessions = async () => (await options.listAllSessions()).filter((session) => existsSync(session.cwd));
|
|
388
|
+
const listDiskSessions = async () => (await options.listAllSessions()).filter((session) => existsSync(session.cwd)).map(toSessionInfoDto);
|
|
366
389
|
const currentCwdInventory = async () => [
|
|
367
390
|
...pool.list().map((handle) => handle.cwd),
|
|
368
391
|
...(await listDiskSessions()).map((session) => session.cwd),
|