@dreb/dashboard 2.40.2 → 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.
@@ -50,14 +50,88 @@ Open `http://127.0.0.1:5343`.
50
50
  view survives browser reloads. No composer: the parent session controls the
51
51
  agent.
52
52
  - **Files** — host-wide browse with places shortcuts, upload (collision
53
- prompts before overwrite), download, new-folder, "new session here".
53
+ prompts before overwrite), download, new-folder, "new session here", and the
54
+ effective global nested-context trust for the viewed canonical folder. Trust
55
+ the folder and descendants, or untrust the actual granting root (including
56
+ its inherited descendants).
54
57
  - **Settings** — persistent defaults (provider-grouped model dropdown,
55
- thinking, queue modes, image handling, skill commands, nested context,
56
- transport, hide-thinking, compaction/retry), per-agent model fallback editor,
57
- dashboard-local preferences (thinking expansion and notification permission),
58
- current pairing code, and paired-devices management.
58
+ thinking, queue modes, image handling, skill commands, transport,
59
+ hide-thinking, compaction/retry), per-agent model fallback editor, and the
60
+ global-only nested-context policy: an auditable trusted-roots list with
61
+ revoke and simple add-by-path controls, plus a prominent expert trust-all
62
+ warning. The Files view remains the primary trust-grant flow. Most defaults
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.
59
69
  - **Pairing** — remote first-login rotating-code flow.
60
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
+
106
+ ## Nested context trust
107
+
108
+ The Files trust controls apply only to **lazy nested/out-of-cwd** context
109
+ loading. They do not control dreb's separate initial upward scan for
110
+ `AGENTS.md`/`CLAUDE.md` from a session's launch cwd.
111
+
112
+ Lazy loading is off by default. The Files view is the primary grant flow:
113
+ trust the viewed folder and descendants, or untrust its actual granting root.
114
+ Settings also lists every configured root for audit and revoke, and offers a
115
+ simple add-by-path control. Trusting through either screen writes an explicit,
116
+ global-only `context.trustedFolders` root in
117
+ `~/.dreb/agent/settings.json`; that root covers itself and descendants after
118
+ canonical native-`realpath` resolution. A symlink that lexically appears below
119
+ a root but resolves outside it is not trusted. Project `.dreb/settings.json`
120
+ cannot enable, disable, or extend nested-context trust; only global settings
121
+ and the dashboard Files/Settings controls can, so a cloned repository cannot
122
+ grant itself trust.
123
+
124
+ The Files view reports the actual state returned by RPC: `untrusted`,
125
+ `trusted-root` (including an inherited canonical granting root), or
126
+ `unrestricted`. Its **untrust** action removes the granting root, not merely
127
+ the selected descendant. `context.autoLoadNested: true` is a global-only expert
128
+ trust-all override; it allows any resolvable directory and can inject
129
+ prompt-injection content from untrusted repositories, so folder controls cannot
130
+ narrow it. Main agents and subagents share this policy. Active processes see
131
+ trust changes before future lazy loads, but already injected context cannot be
132
+ removed. Permitted lazy context is secret-scrubbed, appended after extension
133
+ `tool_result` transforms, and deduplicated per session.
134
+
61
135
  ## Security model — exactly two modes
62
136
 
63
137
  **Local (default).** The server binds the loopback interface only. LAN packets
@@ -134,12 +208,11 @@ Browser (SolidJS, hash-routed SPA)
134
208
  ⇄ RpcClient pool — one `dreb --mode rpc` child per live session
135
209
  ```
136
210
 
137
- - Events stream over a single SSE connection carrying `{seq, key, event}`
138
- envelopes; reconnects catch up via `Last-Event-ID` against a bounded ring
139
- buffer (a gap triggers a `dashboard_resync` + full refetch). Deleting a
140
- runtime publishes a synthetic `runtime_removed` event so clients evict that
141
- session's state. Clients that stop draining the stream past a bounded
142
- 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.
143
216
  - Background subagent transcripts arrive over the same pipe via the
144
217
  `background_agent_event` relay (see `docs/rpc.md` in
145
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"]}
@@ -22,7 +22,9 @@ export declare function canonicalizePath(raw: string, opts: {
22
22
  export declare class FileApi {
23
23
  private readonly log;
24
24
  constructor(log: FileOpLogger);
25
- list(rawPath: string): Promise<DirListingDto>;
25
+ /** Resolve an existing directory for listing and context-trust RPC operations. */
26
+ resolveDirectory(rawPath: string): Promise<string>;
27
+ list(rawPath: string): Promise<Omit<DirListingDto, "contextTrust">>;
26
28
  /** Resolve a download target; returns the canonical path (must be a file). */
27
29
  resolveDownload(rawPath: string): Promise<{
28
30
  path: string;
@@ -1 +1 @@
1
- {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/server/files.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,uBAAuB,CAAC;AAEzE,MAAM,MAAM,YAAY,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;AAQtF;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCjG;AAED,qBAAa,OAAO;IACP,OAAO,CAAC,QAAQ,CAAC,GAAG;IAAhC,YAA6B,GAAG,EAAE,YAAY,EAAI;IAE5C,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA+BlD;IAED,8EAA8E;IACxE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAQ9E;IAED;;;OAGG;IACG,aAAa,CAClB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,OAAO,GAChB,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,QAAQ,CAAC;QAAC,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;QAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC,CAkDxG;IAEK,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOzD;CACD;AAED,kDAAkD;AAClD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAS7G","sourcesContent":["/**\n * Host-wide file API — browse, download, upload, mkdir.\n *\n * The dashboard is a trusted-operator surface: a paired device\n * already equals terminal access, so there is no project jail. Path handling\n * still canonicalizes and rejects confusion tricks so the *API* cannot be\n * abused: percent-decode checks, null-byte rejection, symlink resolution to a\n * real absolute path, and server-side logging of every operation.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { link, mkdir, open, readdir, realpath, rename, stat, unlink } from \"node:fs/promises\";\nimport { dirname, isAbsolute, join, normalize, resolve, sep } from \"node:path\";\nimport type { Writable } from \"node:stream\";\nimport type { DirListingDto, FileEntryDto } from \"../shared/protocol.js\";\n\nexport type FileOpLogger = (operation: string, path: string, detail?: string) => void;\n\nfunction assertValidChildName(name: string, label: string): void {\n\tif (!name || name.includes(\"/\") || name.includes(\"\\\\\") || name.includes(\"\\0\") || name === \".\" || name === \"..\") {\n\t\tthrow Object.assign(new Error(`Invalid ${label}: ${name}`), { status: 400 });\n\t}\n}\n\n/**\n * Canonicalize a client-supplied path. Throws (status 400) on anything\n * suspicious rather than guessing. Returns the resolved absolute path with\n * symlinks in the *parent* chain resolved (the leaf may not exist yet for\n * writes, so its parent is what gets realpath'd).\n */\nexport async function canonicalizePath(raw: string, opts: { mustExist: boolean }): Promise<string> {\n\tif (typeof raw !== \"string\" || raw.length === 0) {\n\t\tthrow Object.assign(new Error(\"Path is required\"), { status: 400 });\n\t}\n\tif (raw.includes(\"\\0\")) {\n\t\tthrow Object.assign(new Error(\"Path contains a null byte\"), { status: 400 });\n\t}\n\t// Reject lingering percent-encodings that survived normal URL decoding —\n\t// double-encoding is a classic canonicalization-confusion vector.\n\tif (/%2e|%2f|%5c|%00/i.test(raw)) {\n\t\tthrow Object.assign(new Error(\"Path contains percent-encoded traversal sequences\"), { status: 400 });\n\t}\n\tif (!isAbsolute(raw)) {\n\t\tthrow Object.assign(new Error(\"Path must be absolute\"), { status: 400 });\n\t}\n\tconst normalized = normalize(raw);\n\n\tif (opts.mustExist) {\n\t\ttry {\n\t\t\treturn await realpath(normalized);\n\t\t} catch (err) {\n\t\t\tthrow Object.assign(new Error(`Path does not exist or is unreadable: ${normalized}`), {\n\t\t\t\tstatus: 404,\n\t\t\t\tcause: err,\n\t\t\t});\n\t\t}\n\t}\n\t// For creation targets: resolve the parent, keep the leaf name literal.\n\tconst parent = dirname(normalized);\n\tlet realParent: string;\n\ttry {\n\t\trealParent = await realpath(parent);\n\t} catch (err) {\n\t\tthrow Object.assign(new Error(`Parent directory does not exist: ${parent}`), { status: 404, cause: err });\n\t}\n\tconst leaf = normalized.slice(parent === sep ? 1 : parent.length + 1);\n\tif (!leaf || leaf.includes(sep) || leaf === \".\" || leaf === \"..\") {\n\t\tthrow Object.assign(new Error(`Invalid file name: ${leaf || \"(empty)\"}`), { status: 400 });\n\t}\n\treturn join(realParent, leaf);\n}\n\nexport class FileApi {\n\tconstructor(private readonly log: FileOpLogger) {}\n\n\tasync list(rawPath: string): Promise<DirListingDto> {\n\t\tconst path = await canonicalizePath(rawPath, { mustExist: true });\n\t\tconst info = await stat(path);\n\t\tif (!info.isDirectory()) {\n\t\t\tthrow Object.assign(new Error(`Not a directory: ${path}`), { status: 400 });\n\t\t}\n\t\tconst names = await readdir(path, { withFileTypes: true });\n\t\tconst entries: FileEntryDto[] = [];\n\t\tfor (const dirent of names) {\n\t\t\tlet type: FileEntryDto[\"type\"] = \"other\";\n\t\t\tif (dirent.isDirectory()) type = \"dir\";\n\t\t\telse if (dirent.isFile()) type = \"file\";\n\t\t\telse if (dirent.isSymbolicLink()) type = \"symlink\";\n\t\t\tlet size = 0;\n\t\t\tlet modified = \"\";\n\t\t\ttry {\n\t\t\t\tconst s = await stat(join(path, dirent.name));\n\t\t\t\tsize = s.size;\n\t\t\t\tmodified = s.mtime.toISOString();\n\t\t\t\tif (type === \"symlink\") type = s.isDirectory() ? \"dir\" : \"file\";\n\t\t\t} catch {\n\t\t\t\t// Broken symlink or permission issue — keep the entry, mark it \"other\".\n\t\t\t\ttype = \"other\";\n\t\t\t}\n\t\t\tentries.push({ name: dirent.name, type, size, modified });\n\t\t}\n\t\tentries.sort((a, b) =>\n\t\t\t(a.type === \"dir\") === (b.type === \"dir\") ? a.name.localeCompare(b.name) : a.type === \"dir\" ? -1 : 1,\n\t\t);\n\t\tthis.log(\"list\", path);\n\t\treturn { path, entries };\n\t}\n\n\t/** Resolve a download target; returns the canonical path (must be a file). */\n\tasync resolveDownload(rawPath: string): Promise<{ path: string; size: number }> {\n\t\tconst path = await canonicalizePath(rawPath, { mustExist: true });\n\t\tconst info = await stat(path);\n\t\tif (!info.isFile()) {\n\t\t\tthrow Object.assign(new Error(`Not a file: ${path}`), { status: 400 });\n\t\t}\n\t\tthis.log(\"download\", path, `${info.size} bytes`);\n\t\treturn { path, size: info.size };\n\t}\n\n\t/**\n\t * Upload into a directory. Refuses to overwrite unless `overwrite` is set —\n\t * the collision surfaces as 409 so the client can prompt.\n\t */\n\tasync prepareUpload(\n\t\trawDir: string,\n\t\tfileName: string,\n\t\toverwrite: boolean,\n\t): Promise<{ path: string; stream: Writable; commit: () => Promise<void>; cleanup: () => Promise<void> }> {\n\t\tconst dir = await canonicalizePath(rawDir, { mustExist: true });\n\t\tconst dirInfo = await stat(dir);\n\t\tif (!dirInfo.isDirectory()) {\n\t\t\tthrow Object.assign(new Error(`Upload target is not a directory: ${dir}`), { status: 400 });\n\t\t}\n\t\tassertValidChildName(fileName, \"upload file name\");\n\t\tconst path = join(dir, fileName);\n\t\tlet exists = false;\n\t\ttry {\n\t\t\tawait stat(path);\n\t\t\texists = true;\n\t\t} catch {\n\t\t\texists = false;\n\t\t}\n\t\tif (exists && !overwrite) {\n\t\t\tthrow Object.assign(new Error(`File exists: ${path}`), { status: 409 });\n\t\t}\n\n\t\tconst tempPath = join(dir, `.dreb-upload-${process.pid}-${Date.now()}-${randomBytes(6).toString(\"hex\")}.tmp`);\n\t\tconst file = await open(tempPath, \"wx\");\n\t\tconst stream = file.createWriteStream({ autoClose: true });\n\t\tlet committed = false;\n\t\tconst cleanup = async () => {\n\t\t\tif (committed) return;\n\t\t\tawait unlink(tempPath).catch(() => {});\n\t\t};\n\t\tconst commit = async () => {\n\t\t\ttry {\n\t\t\t\tif (overwrite) {\n\t\t\t\t\tawait rename(tempPath, path);\n\t\t\t\t} else {\n\t\t\t\t\t// Atomic no-overwrite publish: hard-link into the final name and fail\n\t\t\t\t\t// with EEXIST if another writer won the race after the early stat().\n\t\t\t\t\tawait link(tempPath, path).catch((err: NodeJS.ErrnoException) => {\n\t\t\t\t\t\tif (err.code === \"EEXIST\") {\n\t\t\t\t\t\t\tthrow Object.assign(new Error(`File exists: ${path}`), { status: 409, cause: err });\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow err;\n\t\t\t\t\t});\n\t\t\t\t\tawait unlink(tempPath);\n\t\t\t\t}\n\t\t\t\tcommitted = true;\n\t\t\t\tthis.log(\"upload\", path, exists ? \"overwrite\" : \"create\");\n\t\t\t} catch (err) {\n\t\t\t\tawait cleanup();\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t};\n\t\treturn { path, stream, commit, cleanup };\n\t}\n\n\tasync mkdir(rawDir: string, name: string): Promise<string> {\n\t\tassertValidChildName(name, \"folder name\");\n\t\tconst parent = await canonicalizePath(rawDir, { mustExist: true });\n\t\tconst path = join(parent, name);\n\t\tawait mkdir(path);\n\t\tthis.log(\"mkdir\", path);\n\t\treturn path;\n\t}\n}\n\n/** Places shown as shortcuts in the files tab. */\nexport function defaultPlaces(homeDir: string, projectRoots: string[]): Array<{ label: string; path: string }> {\n\tconst places = [\n\t\t{ label: \"home\", path: homeDir },\n\t\t{ label: \"/tmp\", path: resolve(\"/tmp\") },\n\t];\n\tfor (const root of projectRoots) {\n\t\tplaces.push({ label: root.split(sep).filter(Boolean).pop() ?? root, path: root });\n\t}\n\treturn places;\n}\n"]}
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/server/files.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,uBAAuB,CAAC;AAEzE,MAAM,MAAM,YAAY,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;AAQtF;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCjG;AAED,qBAAa,OAAO;IACP,OAAO,CAAC,QAAQ,CAAC,GAAG;IAAhC,YAA6B,GAAG,EAAE,YAAY,EAAI;IAElD,kFAAkF;IAC5E,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOvD;IAEK,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,CA2BxE;IAED,8EAA8E;IACxE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAQ9E;IAED;;;OAGG;IACG,aAAa,CAClB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,OAAO,GAChB,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,QAAQ,CAAC;QAAC,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;QAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC,CAkDxG;IAEK,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOzD;CACD;AAED,kDAAkD;AAClD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAS7G","sourcesContent":["/**\n * Host-wide file API — browse, download, upload, mkdir.\n *\n * The dashboard is a trusted-operator surface: a paired device\n * already equals terminal access, so there is no project jail. Path handling\n * still canonicalizes and rejects confusion tricks so the *API* cannot be\n * abused: percent-decode checks, null-byte rejection, symlink resolution to a\n * real absolute path, and server-side logging of every operation.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { link, mkdir, open, readdir, realpath, rename, stat, unlink } from \"node:fs/promises\";\nimport { dirname, isAbsolute, join, normalize, resolve, sep } from \"node:path\";\nimport type { Writable } from \"node:stream\";\nimport type { DirListingDto, FileEntryDto } from \"../shared/protocol.js\";\n\nexport type FileOpLogger = (operation: string, path: string, detail?: string) => void;\n\nfunction assertValidChildName(name: string, label: string): void {\n\tif (!name || name.includes(\"/\") || name.includes(\"\\\\\") || name.includes(\"\\0\") || name === \".\" || name === \"..\") {\n\t\tthrow Object.assign(new Error(`Invalid ${label}: ${name}`), { status: 400 });\n\t}\n}\n\n/**\n * Canonicalize a client-supplied path. Throws (status 400) on anything\n * suspicious rather than guessing. Returns the resolved absolute path with\n * symlinks in the *parent* chain resolved (the leaf may not exist yet for\n * writes, so its parent is what gets realpath'd).\n */\nexport async function canonicalizePath(raw: string, opts: { mustExist: boolean }): Promise<string> {\n\tif (typeof raw !== \"string\" || raw.length === 0) {\n\t\tthrow Object.assign(new Error(\"Path is required\"), { status: 400 });\n\t}\n\tif (raw.includes(\"\\0\")) {\n\t\tthrow Object.assign(new Error(\"Path contains a null byte\"), { status: 400 });\n\t}\n\t// Reject lingering percent-encodings that survived normal URL decoding —\n\t// double-encoding is a classic canonicalization-confusion vector.\n\tif (/%2e|%2f|%5c|%00/i.test(raw)) {\n\t\tthrow Object.assign(new Error(\"Path contains percent-encoded traversal sequences\"), { status: 400 });\n\t}\n\tif (!isAbsolute(raw)) {\n\t\tthrow Object.assign(new Error(\"Path must be absolute\"), { status: 400 });\n\t}\n\tconst normalized = normalize(raw);\n\n\tif (opts.mustExist) {\n\t\ttry {\n\t\t\treturn await realpath(normalized);\n\t\t} catch (err) {\n\t\t\tthrow Object.assign(new Error(`Path does not exist or is unreadable: ${normalized}`), {\n\t\t\t\tstatus: 404,\n\t\t\t\tcause: err,\n\t\t\t});\n\t\t}\n\t}\n\t// For creation targets: resolve the parent, keep the leaf name literal.\n\tconst parent = dirname(normalized);\n\tlet realParent: string;\n\ttry {\n\t\trealParent = await realpath(parent);\n\t} catch (err) {\n\t\tthrow Object.assign(new Error(`Parent directory does not exist: ${parent}`), { status: 404, cause: err });\n\t}\n\tconst leaf = normalized.slice(parent === sep ? 1 : parent.length + 1);\n\tif (!leaf || leaf.includes(sep) || leaf === \".\" || leaf === \"..\") {\n\t\tthrow Object.assign(new Error(`Invalid file name: ${leaf || \"(empty)\"}`), { status: 400 });\n\t}\n\treturn join(realParent, leaf);\n}\n\nexport class FileApi {\n\tconstructor(private readonly log: FileOpLogger) {}\n\n\t/** Resolve an existing directory for listing and context-trust RPC operations. */\n\tasync resolveDirectory(rawPath: string): Promise<string> {\n\t\tconst path = await canonicalizePath(rawPath, { mustExist: true });\n\t\tconst info = await stat(path);\n\t\tif (!info.isDirectory()) {\n\t\t\tthrow Object.assign(new Error(`Not a directory: ${path}`), { status: 400 });\n\t\t}\n\t\treturn path;\n\t}\n\n\tasync list(rawPath: string): Promise<Omit<DirListingDto, \"contextTrust\">> {\n\t\tconst path = await this.resolveDirectory(rawPath);\n\t\tconst names = await readdir(path, { withFileTypes: true });\n\t\tconst entries: FileEntryDto[] = [];\n\t\tfor (const dirent of names) {\n\t\t\tlet type: FileEntryDto[\"type\"] = \"other\";\n\t\t\tif (dirent.isDirectory()) type = \"dir\";\n\t\t\telse if (dirent.isFile()) type = \"file\";\n\t\t\telse if (dirent.isSymbolicLink()) type = \"symlink\";\n\t\t\tlet size = 0;\n\t\t\tlet modified = \"\";\n\t\t\ttry {\n\t\t\t\tconst s = await stat(join(path, dirent.name));\n\t\t\t\tsize = s.size;\n\t\t\t\tmodified = s.mtime.toISOString();\n\t\t\t\tif (type === \"symlink\") type = s.isDirectory() ? \"dir\" : \"file\";\n\t\t\t} catch {\n\t\t\t\t// Broken symlink or permission issue — keep the entry, mark it \"other\".\n\t\t\t\ttype = \"other\";\n\t\t\t}\n\t\t\tentries.push({ name: dirent.name, type, size, modified });\n\t\t}\n\t\tentries.sort((a, b) =>\n\t\t\t(a.type === \"dir\") === (b.type === \"dir\") ? a.name.localeCompare(b.name) : a.type === \"dir\" ? -1 : 1,\n\t\t);\n\t\tthis.log(\"list\", path);\n\t\treturn { path, entries };\n\t}\n\n\t/** Resolve a download target; returns the canonical path (must be a file). */\n\tasync resolveDownload(rawPath: string): Promise<{ path: string; size: number }> {\n\t\tconst path = await canonicalizePath(rawPath, { mustExist: true });\n\t\tconst info = await stat(path);\n\t\tif (!info.isFile()) {\n\t\t\tthrow Object.assign(new Error(`Not a file: ${path}`), { status: 400 });\n\t\t}\n\t\tthis.log(\"download\", path, `${info.size} bytes`);\n\t\treturn { path, size: info.size };\n\t}\n\n\t/**\n\t * Upload into a directory. Refuses to overwrite unless `overwrite` is set —\n\t * the collision surfaces as 409 so the client can prompt.\n\t */\n\tasync prepareUpload(\n\t\trawDir: string,\n\t\tfileName: string,\n\t\toverwrite: boolean,\n\t): Promise<{ path: string; stream: Writable; commit: () => Promise<void>; cleanup: () => Promise<void> }> {\n\t\tconst dir = await canonicalizePath(rawDir, { mustExist: true });\n\t\tconst dirInfo = await stat(dir);\n\t\tif (!dirInfo.isDirectory()) {\n\t\t\tthrow Object.assign(new Error(`Upload target is not a directory: ${dir}`), { status: 400 });\n\t\t}\n\t\tassertValidChildName(fileName, \"upload file name\");\n\t\tconst path = join(dir, fileName);\n\t\tlet exists = false;\n\t\ttry {\n\t\t\tawait stat(path);\n\t\t\texists = true;\n\t\t} catch {\n\t\t\texists = false;\n\t\t}\n\t\tif (exists && !overwrite) {\n\t\t\tthrow Object.assign(new Error(`File exists: ${path}`), { status: 409 });\n\t\t}\n\n\t\tconst tempPath = join(dir, `.dreb-upload-${process.pid}-${Date.now()}-${randomBytes(6).toString(\"hex\")}.tmp`);\n\t\tconst file = await open(tempPath, \"wx\");\n\t\tconst stream = file.createWriteStream({ autoClose: true });\n\t\tlet committed = false;\n\t\tconst cleanup = async () => {\n\t\t\tif (committed) return;\n\t\t\tawait unlink(tempPath).catch(() => {});\n\t\t};\n\t\tconst commit = async () => {\n\t\t\ttry {\n\t\t\t\tif (overwrite) {\n\t\t\t\t\tawait rename(tempPath, path);\n\t\t\t\t} else {\n\t\t\t\t\t// Atomic no-overwrite publish: hard-link into the final name and fail\n\t\t\t\t\t// with EEXIST if another writer won the race after the early stat().\n\t\t\t\t\tawait link(tempPath, path).catch((err: NodeJS.ErrnoException) => {\n\t\t\t\t\t\tif (err.code === \"EEXIST\") {\n\t\t\t\t\t\t\tthrow Object.assign(new Error(`File exists: ${path}`), { status: 409, cause: err });\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow err;\n\t\t\t\t\t});\n\t\t\t\t\tawait unlink(tempPath);\n\t\t\t\t}\n\t\t\t\tcommitted = true;\n\t\t\t\tthis.log(\"upload\", path, exists ? \"overwrite\" : \"create\");\n\t\t\t} catch (err) {\n\t\t\t\tawait cleanup();\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t};\n\t\treturn { path, stream, commit, cleanup };\n\t}\n\n\tasync mkdir(rawDir: string, name: string): Promise<string> {\n\t\tassertValidChildName(name, \"folder name\");\n\t\tconst parent = await canonicalizePath(rawDir, { mustExist: true });\n\t\tconst path = join(parent, name);\n\t\tawait mkdir(path);\n\t\tthis.log(\"mkdir\", path);\n\t\treturn path;\n\t}\n}\n\n/** Places shown as shortcuts in the files tab. */\nexport function defaultPlaces(homeDir: string, projectRoots: string[]): Array<{ label: string; path: string }> {\n\tconst places = [\n\t\t{ label: \"home\", path: homeDir },\n\t\t{ label: \"/tmp\", path: resolve(\"/tmp\") },\n\t];\n\tfor (const root of projectRoots) {\n\t\tplaces.push({ label: root.split(sep).filter(Boolean).pop() ?? root, path: root });\n\t}\n\treturn places;\n}\n"]}
@@ -68,12 +68,17 @@ export class FileApi {
68
68
  constructor(log) {
69
69
  this.log = log;
70
70
  }
71
- async list(rawPath) {
71
+ /** Resolve an existing directory for listing and context-trust RPC operations. */
72
+ async resolveDirectory(rawPath) {
72
73
  const path = await canonicalizePath(rawPath, { mustExist: true });
73
74
  const info = await stat(path);
74
75
  if (!info.isDirectory()) {
75
76
  throw Object.assign(new Error(`Not a directory: ${path}`), { status: 400 });
76
77
  }
78
+ return path;
79
+ }
80
+ async list(rawPath) {
81
+ const path = await this.resolveDirectory(rawPath);
77
82
  const names = await readdir(path, { withFileTypes: true });
78
83
  const entries = [];
79
84
  for (const dirent of names) {