@dreb/dashboard 2.41.0 → 2.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Open `http://127.0.0.1:5343`.
41
41
  - **Session view** — full chat parity: markdown streaming transcript, tool
42
42
  cards, thinking blocks, compaction summaries, per-message copy, tasks panel,
43
43
  suggest-next chip, slash-command autocomplete, image attach/paste,
44
- queued-message restore, footer-parity info bar (branch, tokens, cost, ctx%,
44
+ queued-message restore, persistent session-header live indicator, footer-parity info bar (branch, tokens, cost, ctx%,
45
45
  median tok/s), stats/loaded-context/fork modals, steer/follow-up composer
46
46
  modes, ■ abort, model/thinking switchers, extension-UI modals, export HTML,
47
47
  and live auto-naming.
@@ -60,12 +60,49 @@ Open `http://127.0.0.1:5343`.
60
60
  global-only nested-context policy: an auditable trusted-roots list with
61
61
  revoke and simple add-by-path controls, plus a prominent expert trust-all
62
62
  warning. The Files view remains the primary trust-grant flow. Most defaults
63
- seed new sessions; trust changes are observed by active processes for future
64
- lazy loads and cannot retract already injected context. Also includes
65
- dashboard-local preferences (thinking expansion and notification permission),
66
- current pairing code, and paired-devices management.
63
+ seed new sessions; opening Settings flushes pending writes and reloads durable
64
+ global + project settings so external edits appear, while read/parse/write
65
+ failures are shown instead of stale values. Trust changes are observed by
66
+ active processes for future lazy loads and cannot retract already injected
67
+ context. Also includes dashboard-local preferences (thinking expansion and
68
+ notification permission), current pairing code, and paired-devices management.
67
69
  - **Pairing** — remote first-login rotating-code flow.
68
70
 
71
+ ## Live connection and recovery
72
+
73
+ The accessible text indicator in the top bar and persistent session header reports
74
+ the SSE connection as **connecting**, **connected**, **retrying**, **resyncing**,
75
+ **disconnected**, or **auth failed** (with retry delay where applicable); color
76
+ is not its only cue. The session-header indicator remains visible when session
77
+ details or composer controls are collapsed.
78
+ The server replays reducer-relevant projected envelopes from history bounded by
79
+ both count and bytes, with a separate byte cap for each replay. A server restart,
80
+ sequence gap, history eviction, or over-budget replay sends only that reconnect
81
+ a resync barrier at the current cursor, not a partial replay; an individually
82
+ oversized event sends a global barrier because every browser missed it.
83
+
84
+ Recovery fetches an authoritative snapshot whose HTTP `barrierSeq` was captured
85
+ synchronously at the RPC snapshot marker, discards queued envelopes through that
86
+ sequence, then replays only later envelopes. A viewed subagent has an additional
87
+ disk-read boundary so intervening relays are not lost. This restores transcripts,
88
+ background-agent state, and the atomically replaced task list after a hard refresh
89
+ or gap without interrupting healthy browsers. Backpressure disconnects a slow
90
+ client and uses the same recovery path; a foreground 60-second liveness watchdog
91
+ does likewise for a stalled stream. Named, unnumbered heartbeats arrive every 25
92
+ seconds.
93
+
94
+ Retries use client-owned capped exponential backoff (maximum 30 seconds) with
95
+ ±25% jitter. The attempt count resets only after 60 seconds of healthy
96
+ liveness, not on socket open. Returning to a visible tab always validates auth;
97
+ validation is aborted after 10 seconds so a black-holed request cannot stall
98
+ recovery. A 401/403 becomes **auth failed**, while timeouts and other failures
99
+ recover normally.
100
+ Optional correlated diagnostics are dashboard-authenticated, metadata-only,
101
+ 4 KiB-capped, and rate-limited (one summary per connection every 30 seconds);
102
+ they never include prompts, cookies, SSE payloads, or tool data.
103
+
104
+ See the full [dashboard recovery contract](../coding-agent/docs/dashboard.md#live-connection-and-recovery) and [RPC snapshot ordering](../coding-agent/docs/rpc.md#get_dashboard_snapshot).
105
+
69
106
  ## Nested context trust
70
107
 
71
108
  The Files trust controls apply only to **lazy nested/out-of-cwd** context
@@ -171,12 +208,11 @@ Browser (SolidJS, hash-routed SPA)
171
208
  ⇄ RpcClient pool — one `dreb --mode rpc` child per live session
172
209
  ```
173
210
 
174
- - Events stream over a single SSE connection carrying `{seq, key, event}`
175
- envelopes; reconnects catch up via `Last-Event-ID` against a bounded ring
176
- buffer (a gap triggers a `dashboard_resync` + full refetch). Deleting a
177
- runtime publishes a synthetic `runtime_removed` event so clients evict that
178
- session's state. Clients that stop draining the stream past a bounded
179
- write buffer are disconnected and rely on reconnect catch-up.
211
+ - Events stream over one SSE connection carrying `{seq, key, event}` envelopes.
212
+ Count/byte-bounded projected replay and an explicitly captured snapshot cursor
213
+ provide recovery; see [Live connection and recovery](#live-connection-and-recovery).
214
+ Deleting a runtime publishes a synthetic `runtime_removed` event so clients
215
+ evict that session's state.
180
216
  - Background subagent transcripts arrive over the same pipe via the
181
217
  `background_agent_event` relay (see `docs/rpc.md` in
182
218
  `@dreb/coding-agent`) — no session-file tailing.
@@ -1,31 +1,87 @@
1
1
  /**
2
- * SSE event hub — fans runtime events out to connected browsers with a bounded
3
- * ring buffer per hub for Last-Event-ID catch-up after reconnects.
2
+ * SSE event hub — projects runtime events for the dashboard, keeps a
3
+ * byte-bounded replay history, and fans serialized frames out to browsers.
4
4
  */
5
5
  import type { EventEnvelope } from "../shared/protocol.js";
6
+ export type SseWriteKind = "live" | "replay" | "resync";
7
+ /** Metadata deliberately excludes the serialized event payload. */
8
+ export interface SseWriteMetadata {
9
+ kind: SseWriteKind;
10
+ seq: number;
11
+ type: string;
12
+ frameBytes: number;
13
+ /** Safe synthesized-barrier classification, never runtime payload data. */
14
+ reason?: string;
15
+ }
16
+ export interface ReplayDiagnostic {
17
+ kind: "replay" | "resync";
18
+ count: number;
19
+ bytes: number;
20
+ fromSeq?: number;
21
+ toSeq?: number;
22
+ reason?: string;
23
+ }
24
+ /** Return false when this connection can no longer accept frames. */
6
25
  export interface SseClient {
7
- write(chunk: string): void;
26
+ write(chunk: string, metadata?: SseWriteMetadata): boolean | undefined;
8
27
  }
28
+ export interface EventHubOptions {
29
+ /** Maximum number of retained frames. */
30
+ bufferSize?: number;
31
+ /** Maximum encoded bytes retained for reconnect replay. */
32
+ bufferBytes?: number;
33
+ /** Maximum encoded bytes written during a single replay. */
34
+ replayBytes?: number;
35
+ /** Largest projected event frame that may be delivered directly. */
36
+ eventBytes?: number;
37
+ }
38
+ export declare const DEFAULT_BUFFER_SIZE = 2000;
39
+ export declare const DEFAULT_BUFFER_BYTES: number;
40
+ /** Kept below the response destruction ceiling in server.ts. */
41
+ export declare const DEFAULT_REPLAY_BYTES: number;
42
+ export declare const DEFAULT_EVENT_BYTES: number;
43
+ /**
44
+ * Dashboard-only transport projection. Each removed field is cumulative data
45
+ * that the dashboard reducer does not read. Unknown event types are returned
46
+ * exactly as received so extensions and future runtimes remain forward-safe.
47
+ */
48
+ export declare function projectDashboardEvent(event: Record<string, unknown>): Record<string, unknown>;
9
49
  export declare class EventHub {
10
- private readonly bufferSize;
11
50
  private seq;
51
+ private bufferedBytes;
12
52
  private readonly buffer;
13
53
  private readonly clients;
14
- constructor(bufferSize?: number);
54
+ private readonly options;
55
+ constructor(options?: number | EventHubOptions);
15
56
  /** Publish an event from a runtime; assigns a sequence number and fans out. */
16
- publish(key: string, event: Record<string, unknown>): EventEnvelope;
57
+ publish(key: string, rawEvent: Record<string, unknown>): EventEnvelope;
17
58
  /**
18
- * Attach a client. When `lastEventId` is provided, buffered events after it
19
- * are replayed first. Returns a detach function.
20
- *
21
- * When the requested id has already been evicted from the buffer, or belongs
22
- * to an older server instance whose sequence is no longer present, a
23
- * `dashboard_resync` event is sent first — the client must refetch state
24
- * because the gap cannot be replayed.
59
+ * Attach a client. Replays only a complete, bounded range. A gap or replay
60
+ * over budget receives a recovery frame only on this connection; it never
61
+ * consumes a global sequence or disturbs healthy clients' ordered stream.
25
62
  */
26
- attach(client: SseClient, lastEventId?: number): () => void;
63
+ attach(client: SseClient, lastEventId?: number, onReplay?: (diagnostic: ReplayDiagnostic) => void): () => void;
27
64
  get clientCount(): number;
65
+ get historyBytes(): number;
66
+ get historyCount(): number;
67
+ /** Current sequence, captured without emitting, retaining, or fanning out. */
68
+ get currentSequence(): number;
69
+ private serialize;
70
+ private retain;
71
+ private replayAfter;
72
+ private resyncReason;
73
+ /** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */
74
+ private publishResync;
75
+ /**
76
+ * A stale reconnect needs the current ordering cursor, not a new global
77
+ * event. With no events yet, establish sequence 1 so it has a usable cursor.
78
+ */
79
+ private targetedResync;
80
+ private fanout;
81
+ private write;
28
82
  }
29
83
  /** Format an envelope as an SSE frame with the sequence number as event id. */
30
84
  export declare function formatSseFrame(envelope: EventEnvelope): string;
85
+ /** Observable liveness signal; intentionally unnumbered and never buffered. */
86
+ export declare function formatHeartbeatFrame(): string;
31
87
  //# sourceMappingURL=event-hub.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"event-hub.d.ts","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,MAAM,WAAW,SAAS;IACzB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAID,qBAAa,QAAQ;IAKR,OAAO,CAAC,QAAQ,CAAC,UAAU;IAJvC,OAAO,CAAC,GAAG,CAAK;IAChB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;IAC9C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAEhD,YAA6B,UAAU,SAAsB,EAAI;IAEjE,+EAA+E;IAC/E,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,aAAa,CAiBlE;IAED;;;;;;;;OAQG;IACH,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,IAAI,CAsB1D;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;CACD;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CAE9D","sourcesContent":["/**\n * SSE event hub — fans runtime events out to connected browsers with a bounded\n * ring buffer per hub for Last-Event-ID catch-up after reconnects.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport interface SseClient {\n\twrite(chunk: string): void;\n}\n\nconst DEFAULT_BUFFER_SIZE = 2000;\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate readonly buffer: EventEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\n\tconstructor(private readonly bufferSize = DEFAULT_BUFFER_SIZE) {}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, event: Record<string, unknown>): EventEnvelope {\n\t\tthis.seq += 1;\n\t\tconst envelope: EventEnvelope = { seq: this.seq, key, event };\n\t\tthis.buffer.push(envelope);\n\t\tif (this.buffer.length > this.bufferSize) {\n\t\t\tthis.buffer.splice(0, this.buffer.length - this.bufferSize);\n\t\t}\n\t\tconst frame = formatSseFrame(envelope);\n\t\tfor (const client of this.clients) {\n\t\t\ttry {\n\t\t\t\tclient.write(frame);\n\t\t\t} catch {\n\t\t\t\t// Dead connections are removed via their close handlers; a write\n\t\t\t\t// failure here must not break the loop for other clients.\n\t\t\t}\n\t\t}\n\t\treturn envelope;\n\t}\n\n\t/**\n\t * Attach a client. When `lastEventId` is provided, buffered events after it\n\t * are replayed first. Returns a detach function.\n\t *\n\t * When the requested id has already been evicted from the buffer, or belongs\n\t * to an older server instance whose sequence is no longer present, a\n\t * `dashboard_resync` event is sent first — the client must refetch state\n\t * because the gap cannot be replayed.\n\t */\n\tattach(client: SseClient, lastEventId?: number): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst oldest = this.buffer[0]?.seq;\n\t\t\tconst newest = this.buffer[this.buffer.length - 1]?.seq;\n\t\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest) {\n\t\t\t\tthis.seq += 1;\n\t\t\t\tclient.write(\n\t\t\t\t\tformatSseFrame({\n\t\t\t\t\t\tseq: this.seq,\n\t\t\t\t\t\tkey: \"\",\n\t\t\t\t\t\tevent: { type: \"dashboard_resync\", reason: oldest === undefined ? \"empty_buffer\" : \"buffer_gap\" },\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tfor (const envelope of this.buffer) {\n\t\t\t\tif (envelope.seq > lastEventId) client.write(formatSseFrame(envelope));\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\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"]}
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\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/** 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 event = projectDashboardEvent(rawEvent);\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,60 +1,101 @@
1
1
  /**
2
- * SSE event hub — fans runtime events out to connected browsers with a bounded
3
- * ring buffer per hub for Last-Event-ID catch-up after reconnects.
2
+ * SSE event hub — projects runtime events for the dashboard, keeps a
3
+ * byte-bounded replay history, and fans serialized frames out to browsers.
4
4
  */
5
- const DEFAULT_BUFFER_SIZE = 2000;
5
+ export const DEFAULT_BUFFER_SIZE = 2000;
6
+ export const DEFAULT_BUFFER_BYTES = 8 * 1024 * 1024;
7
+ /** Kept below the response destruction ceiling in server.ts. */
8
+ export const DEFAULT_REPLAY_BYTES = 3 * 1024 * 1024;
9
+ export const DEFAULT_EVENT_BYTES = 1024 * 1024;
10
+ function omit(event, ...keys) {
11
+ const copy = { ...event };
12
+ for (const key of keys)
13
+ delete copy[key];
14
+ return copy;
15
+ }
16
+ /**
17
+ * Dashboard-only transport projection. Each removed field is cumulative data
18
+ * that the dashboard reducer does not read. Unknown event types are returned
19
+ * exactly as received so extensions and future runtimes remain forward-safe.
20
+ */
21
+ export function projectDashboardEvent(event) {
22
+ switch (event.type) {
23
+ case "agent_end":
24
+ return omit(event, "messages");
25
+ case "turn_end":
26
+ return omit(event, "message", "toolResults");
27
+ case "message_update":
28
+ return omit(event, "message");
29
+ case "tool_execution_update":
30
+ return omit(event, "args");
31
+ case "stream_retry":
32
+ return omit(event, "discardedPartial");
33
+ case "length_retry":
34
+ return omit(event, "discardedPartial");
35
+ case "background_agent_event": {
36
+ const child = event.event;
37
+ return child && typeof child === "object" && !Array.isArray(child)
38
+ ? { ...event, event: projectDashboardEvent(child) }
39
+ : event;
40
+ }
41
+ default:
42
+ return event;
43
+ }
44
+ }
6
45
  export class EventHub {
7
- bufferSize;
8
46
  seq = 0;
47
+ bufferedBytes = 0;
9
48
  buffer = [];
10
49
  clients = new Set();
11
- constructor(bufferSize = DEFAULT_BUFFER_SIZE) {
12
- this.bufferSize = bufferSize;
50
+ options;
51
+ constructor(options = {}) {
52
+ this.options = {
53
+ bufferSize: typeof options === "number" ? options : (options.bufferSize ?? DEFAULT_BUFFER_SIZE),
54
+ bufferBytes: typeof options === "number" ? DEFAULT_BUFFER_BYTES : (options.bufferBytes ?? DEFAULT_BUFFER_BYTES),
55
+ replayBytes: typeof options === "number" ? DEFAULT_REPLAY_BYTES : (options.replayBytes ?? DEFAULT_REPLAY_BYTES),
56
+ eventBytes: typeof options === "number" ? DEFAULT_EVENT_BYTES : (options.eventBytes ?? DEFAULT_EVENT_BYTES),
57
+ };
13
58
  }
14
59
  /** Publish an event from a runtime; assigns a sequence number and fans out. */
15
- publish(key, event) {
16
- this.seq += 1;
17
- const envelope = { seq: this.seq, key, event };
18
- this.buffer.push(envelope);
19
- if (this.buffer.length > this.bufferSize) {
20
- this.buffer.splice(0, this.buffer.length - this.bufferSize);
21
- }
22
- const frame = formatSseFrame(envelope);
23
- for (const client of this.clients) {
24
- try {
25
- client.write(frame);
26
- }
27
- catch {
28
- // Dead connections are removed via their close handlers; a write
29
- // failure here must not break the loop for other clients.
30
- }
60
+ publish(key, rawEvent) {
61
+ const event = projectDashboardEvent(rawEvent);
62
+ const serialized = this.serialize(this.seq + 1, key, event);
63
+ if (serialized.bytes > this.options.eventBytes) {
64
+ return this.publishResync("oversized_event").envelope;
31
65
  }
32
- return envelope;
66
+ this.seq += 1;
67
+ this.retain(serialized);
68
+ this.fanout(serialized, "live");
69
+ return serialized.envelope;
33
70
  }
34
71
  /**
35
- * Attach a client. When `lastEventId` is provided, buffered events after it
36
- * are replayed first. Returns a detach function.
37
- *
38
- * When the requested id has already been evicted from the buffer, or belongs
39
- * to an older server instance whose sequence is no longer present, a
40
- * `dashboard_resync` event is sent first — the client must refetch state
41
- * because the gap cannot be replayed.
72
+ * Attach a client. Replays only a complete, bounded range. A gap or replay
73
+ * over budget receives a recovery frame only on this connection; it never
74
+ * consumes a global sequence or disturbs healthy clients' ordered stream.
42
75
  */
43
- attach(client, lastEventId) {
76
+ attach(client, lastEventId, onReplay) {
44
77
  if (lastEventId !== undefined) {
45
- const oldest = this.buffer[0]?.seq;
46
- const newest = this.buffer[this.buffer.length - 1]?.seq;
47
- if (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest) {
48
- this.seq += 1;
49
- client.write(formatSseFrame({
50
- seq: this.seq,
51
- key: "",
52
- event: { type: "dashboard_resync", reason: oldest === undefined ? "empty_buffer" : "buffer_gap" },
53
- }));
78
+ const replay = this.replayAfter(lastEventId);
79
+ if (!replay) {
80
+ const reason = this.resyncReason(lastEventId);
81
+ const barrier = this.targetedResync(reason);
82
+ onReplay?.({ kind: "resync", count: 1, bytes: barrier.bytes, toSeq: barrier.envelope.seq, reason });
83
+ if (!this.write(client, barrier, "resync"))
84
+ return () => { };
54
85
  }
55
- for (const envelope of this.buffer) {
56
- if (envelope.seq > lastEventId)
57
- client.write(formatSseFrame(envelope));
86
+ else {
87
+ const bytes = replay.reduce((total, item) => total + item.bytes, 0);
88
+ onReplay?.({
89
+ kind: "replay",
90
+ count: replay.length,
91
+ bytes,
92
+ fromSeq: replay[0]?.envelope.seq,
93
+ toSeq: replay[replay.length - 1]?.envelope.seq,
94
+ });
95
+ for (const serialized of replay) {
96
+ if (!this.write(client, serialized, "replay"))
97
+ return () => { };
98
+ }
58
99
  }
59
100
  }
60
101
  this.clients.add(client);
@@ -65,9 +106,95 @@ export class EventHub {
65
106
  get clientCount() {
66
107
  return this.clients.size;
67
108
  }
109
+ get historyBytes() {
110
+ return this.bufferedBytes;
111
+ }
112
+ get historyCount() {
113
+ return this.buffer.length;
114
+ }
115
+ /** Current sequence, captured without emitting, retaining, or fanning out. */
116
+ get currentSequence() {
117
+ return this.seq;
118
+ }
119
+ serialize(seq, key, event) {
120
+ const envelope = { seq, key, event };
121
+ const frame = formatSseFrame(envelope);
122
+ return { envelope, frame, bytes: Buffer.byteLength(frame) };
123
+ }
124
+ retain(serialized) {
125
+ this.buffer.push(serialized);
126
+ this.bufferedBytes += serialized.bytes;
127
+ while (this.buffer.length > this.options.bufferSize || this.bufferedBytes > this.options.bufferBytes) {
128
+ const evicted = this.buffer.shift();
129
+ if (!evicted)
130
+ break;
131
+ this.bufferedBytes -= evicted.bytes;
132
+ }
133
+ }
134
+ replayAfter(lastEventId) {
135
+ const oldest = this.buffer[0]?.envelope.seq;
136
+ const newest = this.buffer[this.buffer.length - 1]?.envelope.seq;
137
+ if (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest)
138
+ return undefined;
139
+ const replay = this.buffer.filter((item) => item.envelope.seq > lastEventId);
140
+ return replay.reduce((bytes, item) => bytes + item.bytes, 0) <= this.options.replayBytes ? replay : undefined;
141
+ }
142
+ resyncReason(lastEventId) {
143
+ const oldest = this.buffer[0]?.envelope.seq;
144
+ const newest = this.buffer[this.buffer.length - 1]?.envelope.seq;
145
+ if (oldest === undefined)
146
+ return "empty_buffer";
147
+ if (lastEventId < oldest - 1 || lastEventId > newest)
148
+ return "buffer_gap";
149
+ return "replay_over_budget";
150
+ }
151
+ /** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */
152
+ publishResync(reason) {
153
+ this.seq += 1;
154
+ const barrier = this.serialize(this.seq, "", { type: "dashboard_resync", reason });
155
+ barrier.resyncReason = reason;
156
+ this.retain(barrier);
157
+ this.fanout(barrier, "resync");
158
+ return barrier;
159
+ }
160
+ /**
161
+ * A stale reconnect needs the current ordering cursor, not a new global
162
+ * event. With no events yet, establish sequence 1 so it has a usable cursor.
163
+ */
164
+ targetedResync(reason) {
165
+ if (this.seq === 0)
166
+ this.seq = 1;
167
+ const barrier = this.serialize(this.seq, "", { type: "dashboard_resync", reason });
168
+ barrier.resyncReason = reason;
169
+ return barrier;
170
+ }
171
+ fanout(serialized, kind) {
172
+ for (const client of this.clients) {
173
+ if (!this.write(client, serialized, kind))
174
+ this.clients.delete(client);
175
+ }
176
+ }
177
+ write(client, serialized, kind) {
178
+ try {
179
+ return (client.write(serialized.frame, {
180
+ kind,
181
+ seq: serialized.envelope.seq,
182
+ type: String(serialized.envelope.event.type ?? "unknown"),
183
+ frameBytes: serialized.bytes,
184
+ ...(serialized.resyncReason ? { reason: serialized.resyncReason } : {}),
185
+ }) !== false);
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ }
68
191
  }
69
192
  /** Format an envelope as an SSE frame with the sequence number as event id. */
70
193
  export function formatSseFrame(envelope) {
71
194
  return `id: ${envelope.seq}\ndata: ${JSON.stringify(envelope)}\n\n`;
72
195
  }
196
+ /** Observable liveness signal; intentionally unnumbered and never buffered. */
197
+ export function formatHeartbeatFrame() {
198
+ return "event: heartbeat\ndata: {}\n\n";
199
+ }
73
200
  //# sourceMappingURL=event-hub.js.map
@@ -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 — fans runtime events out to connected browsers with a bounded\n * ring buffer per hub for Last-Event-ID catch-up after reconnects.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport interface SseClient {\n\twrite(chunk: string): void;\n}\n\nconst DEFAULT_BUFFER_SIZE = 2000;\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate readonly buffer: EventEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\n\tconstructor(private readonly bufferSize = DEFAULT_BUFFER_SIZE) {}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, event: Record<string, unknown>): EventEnvelope {\n\t\tthis.seq += 1;\n\t\tconst envelope: EventEnvelope = { seq: this.seq, key, event };\n\t\tthis.buffer.push(envelope);\n\t\tif (this.buffer.length > this.bufferSize) {\n\t\t\tthis.buffer.splice(0, this.buffer.length - this.bufferSize);\n\t\t}\n\t\tconst frame = formatSseFrame(envelope);\n\t\tfor (const client of this.clients) {\n\t\t\ttry {\n\t\t\t\tclient.write(frame);\n\t\t\t} catch {\n\t\t\t\t// Dead connections are removed via their close handlers; a write\n\t\t\t\t// failure here must not break the loop for other clients.\n\t\t\t}\n\t\t}\n\t\treturn envelope;\n\t}\n\n\t/**\n\t * Attach a client. When `lastEventId` is provided, buffered events after it\n\t * are replayed first. Returns a detach function.\n\t *\n\t * When the requested id has already been evicted from the buffer, or belongs\n\t * to an older server instance whose sequence is no longer present, a\n\t * `dashboard_resync` event is sent first — the client must refetch state\n\t * because the gap cannot be replayed.\n\t */\n\tattach(client: SseClient, lastEventId?: number): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst oldest = this.buffer[0]?.seq;\n\t\t\tconst newest = this.buffer[this.buffer.length - 1]?.seq;\n\t\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest) {\n\t\t\t\tthis.seq += 1;\n\t\t\t\tclient.write(\n\t\t\t\t\tformatSseFrame({\n\t\t\t\t\t\tseq: this.seq,\n\t\t\t\t\t\tkey: \"\",\n\t\t\t\t\t\tevent: { type: \"dashboard_resync\", reason: oldest === undefined ? \"empty_buffer\" : \"buffer_gap\" },\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tfor (const envelope of this.buffer) {\n\t\t\t\tif (envelope.seq > lastEventId) client.write(formatSseFrame(envelope));\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\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"]}
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\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/** 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 event = projectDashboardEvent(rawEvent);\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"]}
@@ -11,6 +11,17 @@ import { type BackgroundAgentDto, MAX_COMPLETED_BACKGROUND_AGENTS, type RuntimeI
11
11
  export declare function resolveDrebCliPath(): string;
12
12
  export type RuntimeEventListener = (key: string, event: Record<string, unknown>) => void;
13
13
  export { MAX_COMPLETED_BACKGROUND_AGENTS };
14
+ interface RpcDashboardSnapshot {
15
+ snapshotId: string;
16
+ state: SessionStateDto;
17
+ messages: unknown[];
18
+ backgroundAgents: BackgroundAgentDto[];
19
+ }
20
+ export interface DashboardRuntimeSnapshot {
21
+ key: string;
22
+ barrierSeq: number;
23
+ snapshot: RpcDashboardSnapshot;
24
+ }
14
25
  export interface RuntimeHandle {
15
26
  key: string;
16
27
  cwd: string;
@@ -27,6 +38,8 @@ export interface RuntimeHandle {
27
38
  /** Background agents seen via events (agentId → latest info). */
28
39
  backgroundAgents: Map<string, BackgroundAgentDto>;
29
40
  }
41
+ export declare const DEFAULT_DASHBOARD_BARRIER_TTL_MS: number;
42
+ export declare const DEFAULT_DASHBOARD_BARRIER_LIMIT = 1000;
30
43
  export interface RuntimePoolOptions {
31
44
  cliPath?: string;
32
45
  /** Extra args for every runtime (e.g. --provider). */
@@ -38,6 +51,11 @@ export interface RuntimePoolOptions {
38
51
  args: string[];
39
52
  }) => RpcClient;
40
53
  logger?: (line: string) => void;
54
+ /** Bounds unclaimed RPC snapshot ordering records. */
55
+ dashboardBarrierTtlMs?: number;
56
+ dashboardBarrierLimit?: number;
57
+ /** Injectable clock for deterministic barrier-expiry tests. */
58
+ now?: () => number;
41
59
  }
42
60
  export declare class RuntimePool {
43
61
  private readonly runtimes;
@@ -56,12 +74,33 @@ export declare class RuntimePool {
56
74
  private readonly starting;
57
75
  private readonly startupPromises;
58
76
  private readonly exitedHandles;
77
+ /** Snapshot ordering records observed synchronously from RpcClient stdout. */
78
+ private readonly dashboardBarriers;
79
+ private readonly dashboardBarrierTtlMs;
80
+ private readonly dashboardBarrierLimit;
81
+ private readonly now;
82
+ private dashboardBarrierPruneTimer;
59
83
  private closing;
60
84
  constructor(options?: RuntimePoolOptions);
61
85
  /** Subscribe to events from every runtime, tagged with the runtime key. */
62
86
  onEvent(listener: RuntimeEventListener): () => void;
63
87
  list(): RuntimeHandle[];
64
88
  get(key: string): RuntimeHandle | undefined;
89
+ /**
90
+ * Record the EventHub sequence synchronously when the RPC snapshot marker
91
+ * arrives. The marker line precedes its response on stdout, so this runs
92
+ * before the RpcClient response continuation even across separate chunks.
93
+ */
94
+ recordDashboardBarrier(runtimeKey: string, snapshotId: string, seq: number): void;
95
+ /**
96
+ * Capture a parent-session recovery snapshot and pair it with the sequence
97
+ * captured at its RPC marker. This deliberately does not infer ordering from
98
+ * await: later EventHub publications naturally have higher sequence numbers.
99
+ */
100
+ snapshotDashboard(handle: RuntimeHandle): Promise<DashboardRuntimeSnapshot>;
101
+ private dashboardBarrierKey;
102
+ private pruneDashboardBarriers;
103
+ private scheduleDashboardBarrierPrune;
65
104
  /** Spawn a new runtime in `cwd`, optionally opening an existing session file. */
66
105
  create(cwd: string, sessionPath?: string): Promise<RuntimeHandle>;
67
106
  private startSessionRuntime;