@dreb/dashboard 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +146 -0
  2. package/dist/index.d.ts +30 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +251 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/server/auth.d.ts +164 -0
  7. package/dist/server/auth.d.ts.map +1 -0
  8. package/dist/server/auth.js +396 -0
  9. package/dist/server/auth.js.map +1 -0
  10. package/dist/server/event-hub.d.ts +31 -0
  11. package/dist/server/event-hub.d.ts.map +1 -0
  12. package/dist/server/event-hub.js +73 -0
  13. package/dist/server/event-hub.js.map +1 -0
  14. package/dist/server/files.d.ts +48 -0
  15. package/dist/server/files.d.ts.map +1 -0
  16. package/dist/server/files.js +194 -0
  17. package/dist/server/files.js.map +1 -0
  18. package/dist/server/pairing-storage.d.ts +18 -0
  19. package/dist/server/pairing-storage.d.ts.map +1 -0
  20. package/dist/server/pairing-storage.js +83 -0
  21. package/dist/server/pairing-storage.js.map +1 -0
  22. package/dist/server/runtime-pool.d.ts +89 -0
  23. package/dist/server/runtime-pool.d.ts.map +1 -0
  24. package/dist/server/runtime-pool.js +377 -0
  25. package/dist/server/runtime-pool.js.map +1 -0
  26. package/dist/server/server.d.ts +30 -0
  27. package/dist/server/server.d.ts.map +1 -0
  28. package/dist/server/server.js +557 -0
  29. package/dist/server/server.js.map +1 -0
  30. package/dist/server/subagent-log.d.ts +42 -0
  31. package/dist/server/subagent-log.d.ts.map +1 -0
  32. package/dist/server/subagent-log.js +155 -0
  33. package/dist/server/subagent-log.js.map +1 -0
  34. package/dist/shared/protocol.d.ts +261 -0
  35. package/dist/shared/protocol.d.ts.map +1 -0
  36. package/dist/shared/protocol.js +26 -0
  37. package/dist/shared/protocol.js.map +1 -0
  38. package/dist/static/assets/index-Cgwg2zRA.js +77 -0
  39. package/dist/static/assets/index-DnFrQoI9.css +1 -0
  40. package/dist/static/icons/apple-touch-icon.png +0 -0
  41. package/dist/static/icons/favicon-32.png +0 -0
  42. package/dist/static/icons/punk-1024.png +0 -0
  43. package/dist/static/icons/punk-192.png +0 -0
  44. package/dist/static/icons/punk-512.png +0 -0
  45. package/dist/static/index.html +32 -0
  46. package/dist/static/manifest.webmanifest +30 -0
  47. package/dist/static/sw.js +110 -0
  48. package/package.json +57 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-hub.js","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAQH,MAAM,mBAAmB,GAAG,IAAI,CAAC;AAEjC,MAAM,OAAO,QAAQ;IAKS,UAAU;IAJ/B,GAAG,GAAG,CAAC,CAAC;IACC,MAAM,GAAoB,EAAE,CAAC;IAC7B,OAAO,GAAG,IAAI,GAAG,EAAa,CAAC;IAEhD,YAA6B,UAAU,GAAG,mBAAmB,EAAE;0BAAlC,UAAU;IAAyB,CAAC;IAEjE,+EAA+E;IAC/E,OAAO,CAAC,GAAW,EAAE,KAA8B,EAAiB;QACnE,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC;QACd,MAAM,QAAQ,GAAkB,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC;QAC9D,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC3B,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,UAAU,EAAE,CAAC;YAC1C,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC;QAC7D,CAAC;QACD,MAAM,KAAK,GAAG,cAAc,CAAC,QAAQ,CAAC,CAAC;QACvC,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YACnC,IAAI,CAAC;gBACJ,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YACrB,CAAC;YAAC,MAAM,CAAC;gBACR,iEAAiE;gBACjE,0DAA0D;YAC3D,CAAC;QACF,CAAC;QACD,OAAO,QAAQ,CAAC;IAAA,CAChB;IAED;;;;;;;;OAQG;IACH,MAAM,CAAC,MAAiB,EAAE,WAAoB,EAAc;QAC3D,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;YAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC;YACnC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC;YACxD,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS,IAAI,WAAW,GAAG,MAAM,GAAG,CAAC,IAAI,WAAW,GAAG,MAAM,EAAE,CAAC;gBACtG,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC;gBACd,MAAM,CAAC,KAAK,CACX,cAAc,CAAC;oBACd,GAAG,EAAE,IAAI,CAAC,GAAG;oBACb,GAAG,EAAE,EAAE;oBACP,KAAK,EAAE,EAAE,IAAI,EAAE,kBAAkB,EAAE,MAAM,EAAE,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,YAAY,EAAE;iBACjG,CAAC,CACF,CAAC;YACH,CAAC;YACD,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;gBACpC,IAAI,QAAQ,CAAC,GAAG,GAAG,WAAW;oBAAE,MAAM,CAAC,KAAK,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC;YACxE,CAAC;QACF,CAAC;QACD,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACzB,OAAO,GAAG,EAAE,CAAC;YACZ,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAAA,CAC5B,CAAC;IAAA,CACF;IAED,IAAI,WAAW,GAAW;QACzB,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC;IAAA,CACzB;CACD;AAED,+EAA+E;AAC/E,MAAM,UAAU,cAAc,CAAC,QAAuB,EAAU;IAC/D,OAAO,OAAO,QAAQ,CAAC,GAAG,WAAW,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC;AAAA,CACpE","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"]}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Host-wide file API — browse, download, upload, mkdir.
3
+ *
4
+ * The dashboard is a trusted-operator surface: a paired device
5
+ * already equals terminal access, so there is no project jail. Path handling
6
+ * still canonicalizes and rejects confusion tricks so the *API* cannot be
7
+ * abused: percent-decode checks, null-byte rejection, symlink resolution to a
8
+ * real absolute path, and server-side logging of every operation.
9
+ */
10
+ import type { Writable } from "node:stream";
11
+ import type { DirListingDto } from "../shared/protocol.js";
12
+ export type FileOpLogger = (operation: string, path: string, detail?: string) => void;
13
+ /**
14
+ * Canonicalize a client-supplied path. Throws (status 400) on anything
15
+ * suspicious rather than guessing. Returns the resolved absolute path with
16
+ * symlinks in the *parent* chain resolved (the leaf may not exist yet for
17
+ * writes, so its parent is what gets realpath'd).
18
+ */
19
+ export declare function canonicalizePath(raw: string, opts: {
20
+ mustExist: boolean;
21
+ }): Promise<string>;
22
+ export declare class FileApi {
23
+ private readonly log;
24
+ constructor(log: FileOpLogger);
25
+ list(rawPath: string): Promise<DirListingDto>;
26
+ /** Resolve a download target; returns the canonical path (must be a file). */
27
+ resolveDownload(rawPath: string): Promise<{
28
+ path: string;
29
+ size: number;
30
+ }>;
31
+ /**
32
+ * Upload into a directory. Refuses to overwrite unless `overwrite` is set —
33
+ * the collision surfaces as 409 so the client can prompt.
34
+ */
35
+ prepareUpload(rawDir: string, fileName: string, overwrite: boolean): Promise<{
36
+ path: string;
37
+ stream: Writable;
38
+ commit: () => Promise<void>;
39
+ cleanup: () => Promise<void>;
40
+ }>;
41
+ mkdir(rawDir: string, name: string): Promise<string>;
42
+ }
43
+ /** Places shown as shortcuts in the files tab. */
44
+ export declare function defaultPlaces(homeDir: string, projectRoots: string[]): Array<{
45
+ label: string;
46
+ path: string;
47
+ }>;
48
+ //# sourceMappingURL=files.d.ts.map
@@ -0,0 +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"]}
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Host-wide file API — browse, download, upload, mkdir.
3
+ *
4
+ * The dashboard is a trusted-operator surface: a paired device
5
+ * already equals terminal access, so there is no project jail. Path handling
6
+ * still canonicalizes and rejects confusion tricks so the *API* cannot be
7
+ * abused: percent-decode checks, null-byte rejection, symlink resolution to a
8
+ * real absolute path, and server-side logging of every operation.
9
+ */
10
+ import { randomBytes } from "node:crypto";
11
+ import { link, mkdir, open, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
12
+ import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
13
+ function assertValidChildName(name, label) {
14
+ if (!name || name.includes("/") || name.includes("\\") || name.includes("\0") || name === "." || name === "..") {
15
+ throw Object.assign(new Error(`Invalid ${label}: ${name}`), { status: 400 });
16
+ }
17
+ }
18
+ /**
19
+ * Canonicalize a client-supplied path. Throws (status 400) on anything
20
+ * suspicious rather than guessing. Returns the resolved absolute path with
21
+ * symlinks in the *parent* chain resolved (the leaf may not exist yet for
22
+ * writes, so its parent is what gets realpath'd).
23
+ */
24
+ export async function canonicalizePath(raw, opts) {
25
+ if (typeof raw !== "string" || raw.length === 0) {
26
+ throw Object.assign(new Error("Path is required"), { status: 400 });
27
+ }
28
+ if (raw.includes("\0")) {
29
+ throw Object.assign(new Error("Path contains a null byte"), { status: 400 });
30
+ }
31
+ // Reject lingering percent-encodings that survived normal URL decoding —
32
+ // double-encoding is a classic canonicalization-confusion vector.
33
+ if (/%2e|%2f|%5c|%00/i.test(raw)) {
34
+ throw Object.assign(new Error("Path contains percent-encoded traversal sequences"), { status: 400 });
35
+ }
36
+ if (!isAbsolute(raw)) {
37
+ throw Object.assign(new Error("Path must be absolute"), { status: 400 });
38
+ }
39
+ const normalized = normalize(raw);
40
+ if (opts.mustExist) {
41
+ try {
42
+ return await realpath(normalized);
43
+ }
44
+ catch (err) {
45
+ throw Object.assign(new Error(`Path does not exist or is unreadable: ${normalized}`), {
46
+ status: 404,
47
+ cause: err,
48
+ });
49
+ }
50
+ }
51
+ // For creation targets: resolve the parent, keep the leaf name literal.
52
+ const parent = dirname(normalized);
53
+ let realParent;
54
+ try {
55
+ realParent = await realpath(parent);
56
+ }
57
+ catch (err) {
58
+ throw Object.assign(new Error(`Parent directory does not exist: ${parent}`), { status: 404, cause: err });
59
+ }
60
+ const leaf = normalized.slice(parent === sep ? 1 : parent.length + 1);
61
+ if (!leaf || leaf.includes(sep) || leaf === "." || leaf === "..") {
62
+ throw Object.assign(new Error(`Invalid file name: ${leaf || "(empty)"}`), { status: 400 });
63
+ }
64
+ return join(realParent, leaf);
65
+ }
66
+ export class FileApi {
67
+ log;
68
+ constructor(log) {
69
+ this.log = log;
70
+ }
71
+ async list(rawPath) {
72
+ const path = await canonicalizePath(rawPath, { mustExist: true });
73
+ const info = await stat(path);
74
+ if (!info.isDirectory()) {
75
+ throw Object.assign(new Error(`Not a directory: ${path}`), { status: 400 });
76
+ }
77
+ const names = await readdir(path, { withFileTypes: true });
78
+ const entries = [];
79
+ for (const dirent of names) {
80
+ let type = "other";
81
+ if (dirent.isDirectory())
82
+ type = "dir";
83
+ else if (dirent.isFile())
84
+ type = "file";
85
+ else if (dirent.isSymbolicLink())
86
+ type = "symlink";
87
+ let size = 0;
88
+ let modified = "";
89
+ try {
90
+ const s = await stat(join(path, dirent.name));
91
+ size = s.size;
92
+ modified = s.mtime.toISOString();
93
+ if (type === "symlink")
94
+ type = s.isDirectory() ? "dir" : "file";
95
+ }
96
+ catch {
97
+ // Broken symlink or permission issue — keep the entry, mark it "other".
98
+ type = "other";
99
+ }
100
+ entries.push({ name: dirent.name, type, size, modified });
101
+ }
102
+ entries.sort((a, b) => (a.type === "dir") === (b.type === "dir") ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1);
103
+ this.log("list", path);
104
+ return { path, entries };
105
+ }
106
+ /** Resolve a download target; returns the canonical path (must be a file). */
107
+ async resolveDownload(rawPath) {
108
+ const path = await canonicalizePath(rawPath, { mustExist: true });
109
+ const info = await stat(path);
110
+ if (!info.isFile()) {
111
+ throw Object.assign(new Error(`Not a file: ${path}`), { status: 400 });
112
+ }
113
+ this.log("download", path, `${info.size} bytes`);
114
+ return { path, size: info.size };
115
+ }
116
+ /**
117
+ * Upload into a directory. Refuses to overwrite unless `overwrite` is set —
118
+ * the collision surfaces as 409 so the client can prompt.
119
+ */
120
+ async prepareUpload(rawDir, fileName, overwrite) {
121
+ const dir = await canonicalizePath(rawDir, { mustExist: true });
122
+ const dirInfo = await stat(dir);
123
+ if (!dirInfo.isDirectory()) {
124
+ throw Object.assign(new Error(`Upload target is not a directory: ${dir}`), { status: 400 });
125
+ }
126
+ assertValidChildName(fileName, "upload file name");
127
+ const path = join(dir, fileName);
128
+ let exists = false;
129
+ try {
130
+ await stat(path);
131
+ exists = true;
132
+ }
133
+ catch {
134
+ exists = false;
135
+ }
136
+ if (exists && !overwrite) {
137
+ throw Object.assign(new Error(`File exists: ${path}`), { status: 409 });
138
+ }
139
+ const tempPath = join(dir, `.dreb-upload-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}.tmp`);
140
+ const file = await open(tempPath, "wx");
141
+ const stream = file.createWriteStream({ autoClose: true });
142
+ let committed = false;
143
+ const cleanup = async () => {
144
+ if (committed)
145
+ return;
146
+ await unlink(tempPath).catch(() => { });
147
+ };
148
+ const commit = async () => {
149
+ try {
150
+ if (overwrite) {
151
+ await rename(tempPath, path);
152
+ }
153
+ else {
154
+ // Atomic no-overwrite publish: hard-link into the final name and fail
155
+ // with EEXIST if another writer won the race after the early stat().
156
+ await link(tempPath, path).catch((err) => {
157
+ if (err.code === "EEXIST") {
158
+ throw Object.assign(new Error(`File exists: ${path}`), { status: 409, cause: err });
159
+ }
160
+ throw err;
161
+ });
162
+ await unlink(tempPath);
163
+ }
164
+ committed = true;
165
+ this.log("upload", path, exists ? "overwrite" : "create");
166
+ }
167
+ catch (err) {
168
+ await cleanup();
169
+ throw err;
170
+ }
171
+ };
172
+ return { path, stream, commit, cleanup };
173
+ }
174
+ async mkdir(rawDir, name) {
175
+ assertValidChildName(name, "folder name");
176
+ const parent = await canonicalizePath(rawDir, { mustExist: true });
177
+ const path = join(parent, name);
178
+ await mkdir(path);
179
+ this.log("mkdir", path);
180
+ return path;
181
+ }
182
+ }
183
+ /** Places shown as shortcuts in the files tab. */
184
+ export function defaultPlaces(homeDir, projectRoots) {
185
+ const places = [
186
+ { label: "home", path: homeDir },
187
+ { label: "/tmp", path: resolve("/tmp") },
188
+ ];
189
+ for (const root of projectRoots) {
190
+ places.push({ label: root.split(sep).filter(Boolean).pop() ?? root, path: root });
191
+ }
192
+ return places;
193
+ }
194
+ //# sourceMappingURL=files.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"files.js","sourceRoot":"","sources":["../../src/server/files.ts"],"names":[],"mappings":"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","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"]}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * File-backed pairing storage — persists paired devices under the dreb agent
3
+ * dir so pairings survive dashboard restarts. Written with mode 0600.
4
+ */
5
+ import type { PairingState } from "./auth.js";
6
+ /**
7
+ * Load or create the per-install dashboard auth secret. This secret keys both
8
+ * device-token HMACs and the rotating pairing code, so it must survive process
9
+ * restarts but must never be shared across installs/servers.
10
+ */
11
+ export declare function loadOrCreateDashboardSecret(path: string): Buffer;
12
+ export declare class FilePairingStorage {
13
+ private readonly path;
14
+ constructor(path: string);
15
+ load(): Promise<PairingState>;
16
+ save(state: PairingState): Promise<void>;
17
+ }
18
+ //# sourceMappingURL=pairing-storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-storage.d.ts","sourceRoot":"","sources":["../../src/server/pairing-storage.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAiB,MAAM,WAAW,CAAC;AAQ7D;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAoBhE;AAED,qBAAa,kBAAkB;IAClB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAAjC,YAA6B,IAAI,EAAE,MAAM,EAAI;IAEvC,IAAI,IAAI,OAAO,CAAC,YAAY,CAAC,CAwBlC;IAEK,IAAI,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAY7C;CACD","sourcesContent":["/**\n * File-backed pairing storage — persists paired devices under the dreb agent\n * dir so pairings survive dashboard restarts. Written with mode 0600.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { basename, dirname, join } from \"node:path\";\nimport type { PairingState, StoredPairing } from \"./auth.js\";\n\ninterface PairingFile {\n\tversion: 1;\n\tpairings: StoredPairing[];\n\tconsumedPairingWindows?: number[];\n}\n\n/**\n * Load or create the per-install dashboard auth secret. This secret keys both\n * device-token HMACs and the rotating pairing code, so it must survive process\n * restarts but must never be shared across installs/servers.\n */\nexport function loadOrCreateDashboardSecret(path: string): Buffer {\n\ttry {\n\t\tconst raw = readFileSync(path, \"utf8\").trim();\n\t\tif (!/^[0-9a-f]{64}$/i.test(raw)) throw new Error(`Invalid dashboard auth secret at ${path}`);\n\t\treturn Buffer.from(raw, \"hex\");\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code !== \"ENOENT\") throw err;\n\t}\n\n\tmkdirSync(dirname(path), { recursive: true });\n\tconst secret = randomBytes(32);\n\ttry {\n\t\twriteFileSync(path, `${secret.toString(\"hex\")}\\n`, { mode: 0o600, flag: \"wx\" });\n\t\treturn secret;\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n\t\tconst raw = readFileSync(path, \"utf8\").trim();\n\t\tif (!/^[0-9a-f]{64}$/i.test(raw)) throw new Error(`Invalid dashboard auth secret at ${path}`);\n\t\treturn Buffer.from(raw, \"hex\");\n\t}\n}\n\nexport class FilePairingStorage {\n\tconstructor(private readonly path: string) {}\n\n\tasync load(): Promise<PairingState> {\n\t\tlet raw: string;\n\t\ttry {\n\t\t\traw = readFileSync(this.path, \"utf8\");\n\t\t} catch (err) {\n\t\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n\t\t\t\treturn { pairings: [], consumedPairingWindows: [] };\n\t\t\t}\n\t\t\tthrow err;\n\t\t}\n\t\tconst parsed = JSON.parse(raw) as PairingFile;\n\t\tif (\n\t\t\tparsed.version !== 1 ||\n\t\t\t!Array.isArray(parsed.pairings) ||\n\t\t\t(parsed.consumedPairingWindows !== undefined &&\n\t\t\t\t(!Array.isArray(parsed.consumedPairingWindows) ||\n\t\t\t\t\t!parsed.consumedPairingWindows.every((window) => Number.isSafeInteger(window))))\n\t\t) {\n\t\t\tthrow new Error(`Unrecognized pairing file format at ${this.path}`);\n\t\t}\n\t\treturn {\n\t\t\tpairings: parsed.pairings,\n\t\t\tconsumedPairingWindows: parsed.consumedPairingWindows ?? [],\n\t\t};\n\t}\n\n\tasync save(state: PairingState): Promise<void> {\n\t\tconst dir = dirname(this.path);\n\t\tmkdirSync(dir, { recursive: true });\n\t\tconst file: PairingFile = { version: 1, ...state };\n\t\tconst tmp = join(dir, `.${basename(this.path)}.${process.pid}.${randomBytes(6).toString(\"hex\")}.tmp`);\n\t\ttry {\n\t\t\twriteFileSync(tmp, `${JSON.stringify(file, null, \"\\t\")}\\n`, { mode: 0o600, flag: \"wx\" });\n\t\t\trenameSync(tmp, this.path);\n\t\t} catch (err) {\n\t\t\trmSync(tmp, { force: true });\n\t\t\tthrow err;\n\t\t}\n\t}\n}\n"]}
@@ -0,0 +1,83 @@
1
+ /**
2
+ * File-backed pairing storage — persists paired devices under the dreb agent
3
+ * dir so pairings survive dashboard restarts. Written with mode 0600.
4
+ */
5
+ import { randomBytes } from "node:crypto";
6
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import { basename, dirname, join } from "node:path";
8
+ /**
9
+ * Load or create the per-install dashboard auth secret. This secret keys both
10
+ * device-token HMACs and the rotating pairing code, so it must survive process
11
+ * restarts but must never be shared across installs/servers.
12
+ */
13
+ export function loadOrCreateDashboardSecret(path) {
14
+ try {
15
+ const raw = readFileSync(path, "utf8").trim();
16
+ if (!/^[0-9a-f]{64}$/i.test(raw))
17
+ throw new Error(`Invalid dashboard auth secret at ${path}`);
18
+ return Buffer.from(raw, "hex");
19
+ }
20
+ catch (err) {
21
+ if (err.code !== "ENOENT")
22
+ throw err;
23
+ }
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ const secret = randomBytes(32);
26
+ try {
27
+ writeFileSync(path, `${secret.toString("hex")}\n`, { mode: 0o600, flag: "wx" });
28
+ return secret;
29
+ }
30
+ catch (err) {
31
+ if (err.code !== "EEXIST")
32
+ throw err;
33
+ const raw = readFileSync(path, "utf8").trim();
34
+ if (!/^[0-9a-f]{64}$/i.test(raw))
35
+ throw new Error(`Invalid dashboard auth secret at ${path}`);
36
+ return Buffer.from(raw, "hex");
37
+ }
38
+ }
39
+ export class FilePairingStorage {
40
+ path;
41
+ constructor(path) {
42
+ this.path = path;
43
+ }
44
+ async load() {
45
+ let raw;
46
+ try {
47
+ raw = readFileSync(this.path, "utf8");
48
+ }
49
+ catch (err) {
50
+ if (err.code === "ENOENT") {
51
+ return { pairings: [], consumedPairingWindows: [] };
52
+ }
53
+ throw err;
54
+ }
55
+ const parsed = JSON.parse(raw);
56
+ if (parsed.version !== 1 ||
57
+ !Array.isArray(parsed.pairings) ||
58
+ (parsed.consumedPairingWindows !== undefined &&
59
+ (!Array.isArray(parsed.consumedPairingWindows) ||
60
+ !parsed.consumedPairingWindows.every((window) => Number.isSafeInteger(window))))) {
61
+ throw new Error(`Unrecognized pairing file format at ${this.path}`);
62
+ }
63
+ return {
64
+ pairings: parsed.pairings,
65
+ consumedPairingWindows: parsed.consumedPairingWindows ?? [],
66
+ };
67
+ }
68
+ async save(state) {
69
+ const dir = dirname(this.path);
70
+ mkdirSync(dir, { recursive: true });
71
+ const file = { version: 1, ...state };
72
+ const tmp = join(dir, `.${basename(this.path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
73
+ try {
74
+ writeFileSync(tmp, `${JSON.stringify(file, null, "\t")}\n`, { mode: 0o600, flag: "wx" });
75
+ renameSync(tmp, this.path);
76
+ }
77
+ catch (err) {
78
+ rmSync(tmp, { force: true });
79
+ throw err;
80
+ }
81
+ }
82
+ }
83
+ //# sourceMappingURL=pairing-storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-storage.js","sourceRoot":"","sources":["../../src/server/pairing-storage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * File-backed pairing storage — persists paired devices under the dreb agent\n * dir so pairings survive dashboard restarts. Written with mode 0600.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"node:fs\";\nimport { basename, dirname, join } from \"node:path\";\nimport type { PairingState, StoredPairing } from \"./auth.js\";\n\ninterface PairingFile {\n\tversion: 1;\n\tpairings: StoredPairing[];\n\tconsumedPairingWindows?: number[];\n}\n\n/**\n * Load or create the per-install dashboard auth secret. This secret keys both\n * device-token HMACs and the rotating pairing code, so it must survive process\n * restarts but must never be shared across installs/servers.\n */\nexport function loadOrCreateDashboardSecret(path: string): Buffer {\n\ttry {\n\t\tconst raw = readFileSync(path, \"utf8\").trim();\n\t\tif (!/^[0-9a-f]{64}$/i.test(raw)) throw new Error(`Invalid dashboard auth secret at ${path}`);\n\t\treturn Buffer.from(raw, \"hex\");\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code !== \"ENOENT\") throw err;\n\t}\n\n\tmkdirSync(dirname(path), { recursive: true });\n\tconst secret = randomBytes(32);\n\ttry {\n\t\twriteFileSync(path, `${secret.toString(\"hex\")}\\n`, { mode: 0o600, flag: \"wx\" });\n\t\treturn secret;\n\t} catch (err) {\n\t\tif ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n\t\tconst raw = readFileSync(path, \"utf8\").trim();\n\t\tif (!/^[0-9a-f]{64}$/i.test(raw)) throw new Error(`Invalid dashboard auth secret at ${path}`);\n\t\treturn Buffer.from(raw, \"hex\");\n\t}\n}\n\nexport class FilePairingStorage {\n\tconstructor(private readonly path: string) {}\n\n\tasync load(): Promise<PairingState> {\n\t\tlet raw: string;\n\t\ttry {\n\t\t\traw = readFileSync(this.path, \"utf8\");\n\t\t} catch (err) {\n\t\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n\t\t\t\treturn { pairings: [], consumedPairingWindows: [] };\n\t\t\t}\n\t\t\tthrow err;\n\t\t}\n\t\tconst parsed = JSON.parse(raw) as PairingFile;\n\t\tif (\n\t\t\tparsed.version !== 1 ||\n\t\t\t!Array.isArray(parsed.pairings) ||\n\t\t\t(parsed.consumedPairingWindows !== undefined &&\n\t\t\t\t(!Array.isArray(parsed.consumedPairingWindows) ||\n\t\t\t\t\t!parsed.consumedPairingWindows.every((window) => Number.isSafeInteger(window))))\n\t\t) {\n\t\t\tthrow new Error(`Unrecognized pairing file format at ${this.path}`);\n\t\t}\n\t\treturn {\n\t\t\tpairings: parsed.pairings,\n\t\t\tconsumedPairingWindows: parsed.consumedPairingWindows ?? [],\n\t\t};\n\t}\n\n\tasync save(state: PairingState): Promise<void> {\n\t\tconst dir = dirname(this.path);\n\t\tmkdirSync(dir, { recursive: true });\n\t\tconst file: PairingFile = { version: 1, ...state };\n\t\tconst tmp = join(dir, `.${basename(this.path)}.${process.pid}.${randomBytes(6).toString(\"hex\")}.tmp`);\n\t\ttry {\n\t\t\twriteFileSync(tmp, `${JSON.stringify(file, null, \"\\t\")}\\n`, { mode: 0o600, flag: \"wx\" });\n\t\t\trenameSync(tmp, this.path);\n\t\t} catch (err) {\n\t\t\trmSync(tmp, { force: true });\n\t\t\tthrow err;\n\t\t}\n\t}\n}\n"]}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * RPC runtime pool — one `dreb --mode rpc` child process per live session.
3
+ *
4
+ * dreb's RPC mode is strictly one-session-per-process (switch_session repoints
5
+ * the same process; it never multiplexes), so the pool spawns N children keyed
6
+ * by an opaque runtime key. The telegram bridge is the in-repo precedent.
7
+ */
8
+ import { RpcClient } from "@dreb/coding-agent/rpc";
9
+ import { type BackgroundAgentDto, MAX_COMPLETED_BACKGROUND_AGENTS, type RuntimeInfoDto, type SessionStateDto } from "../shared/protocol.js";
10
+ /** Resolve the absolute path to the dreb CLI (RpcClient defaults to a cwd-relative path). */
11
+ export declare function resolveDrebCliPath(): string;
12
+ export type RuntimeEventListener = (key: string, event: Record<string, unknown>) => void;
13
+ export { MAX_COMPLETED_BACKGROUND_AGENTS };
14
+ export interface RuntimeHandle {
15
+ key: string;
16
+ cwd: string;
17
+ client: RpcClient;
18
+ /** Session start time (ms epoch) — stable tiebreak for deterministic fleet ordering. */
19
+ createdAt: number;
20
+ lastActivity: number;
21
+ /** Needs-attention sources, keyed so they can be cleared independently. */
22
+ attention: Map<string, string>;
23
+ /** Last runtime-level error, persisted server-side so fleet refreshes stay honest. */
24
+ error?: string;
25
+ /** Last known state, used to keep failed runtime cards renderable. */
26
+ lastState?: SessionStateDto;
27
+ /** Background agents seen via events (agentId → latest info). */
28
+ backgroundAgents: Map<string, BackgroundAgentDto>;
29
+ }
30
+ export interface RuntimePoolOptions {
31
+ cliPath?: string;
32
+ /** Extra args for every runtime (e.g. --provider). */
33
+ baseArgs?: string[];
34
+ /** RpcClient factory override for tests. */
35
+ clientFactory?: (options: {
36
+ cliPath: string;
37
+ cwd: string;
38
+ args: string[];
39
+ }) => RpcClient;
40
+ logger?: (line: string) => void;
41
+ }
42
+ export declare class RuntimePool {
43
+ private readonly runtimes;
44
+ private readonly listeners;
45
+ private readonly cliPath;
46
+ private readonly baseArgs;
47
+ private readonly clientFactory;
48
+ private readonly logger;
49
+ /**
50
+ * A single lazily-spawned utility runtime used to service settings/model/
51
+ * agent-type endpoints when no user session is live. Kept out of `runtimes`
52
+ * (and therefore out of the fleet) so it never shows as a session card.
53
+ */
54
+ private readonly utilities;
55
+ private readonly utilityPromises;
56
+ private readonly starting;
57
+ private readonly startupPromises;
58
+ private readonly exitedHandles;
59
+ private closing;
60
+ constructor(options?: RuntimePoolOptions);
61
+ /** Subscribe to events from every runtime, tagged with the runtime key. */
62
+ onEvent(listener: RuntimeEventListener): () => void;
63
+ list(): RuntimeHandle[];
64
+ get(key: string): RuntimeHandle | undefined;
65
+ /** Spawn a new runtime in `cwd`, optionally opening an existing session file. */
66
+ create(cwd: string, sessionPath?: string): Promise<RuntimeHandle>;
67
+ private startSessionRuntime;
68
+ /** Stop a runtime and remove it from the pool. */
69
+ stop(key: string): Promise<boolean>;
70
+ /**
71
+ * Return any live runtime suitable for process-global settings work, spawning
72
+ * a hidden utility runtime (in the home directory) if no user session exists.
73
+ * This is what lets the settings page — models, agent types, defaults — work
74
+ * with zero sessions open, instead of 503-ing.
75
+ */
76
+ ensureUtilityRuntime(cwd?: string): Promise<RuntimeHandle>;
77
+ private startUtilityRuntime;
78
+ stopAll(): Promise<void>;
79
+ private handleRuntimeExit;
80
+ private isLiveHandle;
81
+ private handleEvent;
82
+ private recordRuntimeError;
83
+ private pruneCompletedBackgroundAgents;
84
+ private fallbackState;
85
+ private seedBackgroundAgents;
86
+ /** Snapshot a runtime for the fleet endpoint. */
87
+ describe(handle: RuntimeHandle): Promise<RuntimeInfoDto>;
88
+ }
89
+ //# sourceMappingURL=runtime-pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime-pool.d.ts","sourceRoot":"","sources":["../../src/server/runtime-pool.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * RPC runtime pool — one `dreb --mode rpc` child process per live session.\n *\n * dreb's RPC mode is strictly one-session-per-process (switch_session repoints\n * the same process; it never multiplexes), so the pool spawns N children keyed\n * by an opaque runtime key. The telegram bridge is the in-repo precedent.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { RpcClient, type RpcExitInfo } from \"@dreb/coding-agent/rpc\";\nimport {\n\ttype BackgroundAgentDto,\n\tMAX_COMPLETED_BACKGROUND_AGENTS,\n\ttype RuntimeInfoDto,\n\ttype RuntimeStatsSummaryDto,\n\ttype SessionStateDto,\n} from \"../shared/protocol.js\";\n\n/** Resolve the absolute path to the dreb CLI (RpcClient defaults to a cwd-relative path). */\nexport function resolveDrebCliPath(): string {\n\tconst resolved = import.meta.resolve(\"@dreb/coding-agent\");\n\treturn join(dirname(fileURLToPath(resolved)), \"cli.js\");\n}\n\nfunction formatRpcExit(info: RpcExitInfo): string {\n\tif (info.error) return `RPC process failed: ${info.error.message}`;\n\treturn `RPC process exited (code ${info.code}, signal ${info.signal})`;\n}\n\nexport type RuntimeEventListener = (key: string, event: Record<string, unknown>) => void;\n\nexport { MAX_COMPLETED_BACKGROUND_AGENTS };\n\nexport interface RuntimeHandle {\n\tkey: string;\n\tcwd: string;\n\tclient: RpcClient;\n\t/** Session start time (ms epoch) — stable tiebreak for deterministic fleet ordering. */\n\tcreatedAt: number;\n\tlastActivity: number;\n\t/** Needs-attention sources, keyed so they can be cleared independently. */\n\tattention: Map<string, string>;\n\t/** Last runtime-level error, persisted server-side so fleet refreshes stay honest. */\n\terror?: string;\n\t/** Last known state, used to keep failed runtime cards renderable. */\n\tlastState?: SessionStateDto;\n\t/** Background agents seen via events (agentId → latest info). */\n\tbackgroundAgents: Map<string, BackgroundAgentDto>;\n}\n\nexport interface RuntimePoolOptions {\n\tcliPath?: string;\n\t/** Extra args for every runtime (e.g. --provider). */\n\tbaseArgs?: string[];\n\t/** RpcClient factory override for tests. */\n\tclientFactory?: (options: { cliPath: string; cwd: string; args: string[] }) => RpcClient;\n\tlogger?: (line: string) => void;\n}\n\nexport class RuntimePool {\n\tprivate readonly runtimes = new Map<string, RuntimeHandle>();\n\tprivate readonly listeners: RuntimeEventListener[] = [];\n\tprivate readonly cliPath: string;\n\tprivate readonly baseArgs: string[];\n\tprivate readonly clientFactory: (options: { cliPath: string; cwd: string; args: string[] }) => RpcClient;\n\tprivate readonly logger: (line: string) => void;\n\t/**\n\t * A single lazily-spawned utility runtime used to service settings/model/\n\t * agent-type endpoints when no user session is live. Kept out of `runtimes`\n\t * (and therefore out of the fleet) so it never shows as a session card.\n\t */\n\tprivate readonly utilities = new Map<string, RuntimeHandle>();\n\tprivate readonly utilityPromises = new Map<string, Promise<RuntimeHandle>>();\n\tprivate readonly starting = new Set<RuntimeHandle>();\n\tprivate readonly startupPromises = new Set<Promise<unknown>>();\n\tprivate readonly exitedHandles = new WeakSet<RuntimeHandle>();\n\tprivate closing = false;\n\n\tconstructor(options: RuntimePoolOptions = {}) {\n\t\tthis.cliPath = options.cliPath ?? resolveDrebCliPath();\n\t\tthis.baseArgs = options.baseArgs ?? [];\n\t\tthis.clientFactory =\n\t\t\toptions.clientFactory ?? ((o) => new RpcClient({ cliPath: o.cliPath, cwd: o.cwd, args: o.args }));\n\t\tthis.logger = options.logger ?? ((line) => console.warn(`[dashboard] ${line}`));\n\t}\n\n\t/** Subscribe to events from every runtime, tagged with the runtime key. */\n\tonEvent(listener: RuntimeEventListener): () => void {\n\t\tthis.listeners.push(listener);\n\t\treturn () => {\n\t\t\tconst i = this.listeners.indexOf(listener);\n\t\t\tif (i !== -1) this.listeners.splice(i, 1);\n\t\t};\n\t}\n\n\tlist(): RuntimeHandle[] {\n\t\treturn [...this.runtimes.values()];\n\t}\n\n\tget(key: string): RuntimeHandle | undefined {\n\t\treturn this.runtimes.get(key);\n\t}\n\n\t/** Spawn a new runtime in `cwd`, optionally opening an existing session file. */\n\tasync create(cwd: string, sessionPath?: string): Promise<RuntimeHandle> {\n\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\tconst key = randomBytes(6).toString(\"hex\");\n\t\tconst args = [\"--ui\", \"dashboard\", ...this.baseArgs];\n\t\tif (sessionPath) args.push(\"--session\", sessionPath);\n\t\tconst client = this.clientFactory({ cliPath: this.cliPath, cwd, args });\n\t\tconst handle: RuntimeHandle = {\n\t\t\tkey,\n\t\t\tcwd,\n\t\t\tclient,\n\t\t\tcreatedAt: Date.now(),\n\t\t\tlastActivity: Date.now(),\n\t\t\tattention: new Map(),\n\t\t\tbackgroundAgents: new Map(),\n\t\t};\n\t\tclient.onEvent((event) => this.handleEvent(handle, event as unknown as Record<string, unknown>));\n\t\tclient.onExit((info) => this.handleRuntimeExit(handle, info));\n\n\t\tconst startup = this.startSessionRuntime(handle);\n\t\tthis.startupPromises.add(startup);\n\t\ttry {\n\t\t\treturn await startup;\n\t\t} finally {\n\t\t\tthis.startupPromises.delete(startup);\n\t\t}\n\t}\n\n\tprivate async startSessionRuntime(handle: RuntimeHandle): Promise<RuntimeHandle> {\n\t\tthis.starting.add(handle);\n\t\ttry {\n\t\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\t\tawait handle.client.start();\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tawait this.seedBackgroundAgents(handle);\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tthis.runtimes.set(handle.key, handle);\n\t\t\treturn handle;\n\t\t} finally {\n\t\t\tthis.starting.delete(handle);\n\t\t}\n\t}\n\n\t/** Stop a runtime and remove it from the pool. */\n\tasync stop(key: string): Promise<boolean> {\n\t\tconst handle = this.runtimes.get(key);\n\t\tif (!handle) return false;\n\t\tthis.handleEvent(handle, { type: \"runtime_removed\" });\n\t\tthis.runtimes.delete(key);\n\t\tawait handle.client.stop();\n\t\treturn true;\n\t}\n\n\t/**\n\t * Return any live runtime suitable for process-global settings work, spawning\n\t * a hidden utility runtime (in the home directory) if no user session exists.\n\t * This is what lets the settings page — models, agent types, defaults — work\n\t * with zero sessions open, instead of 503-ing.\n\t */\n\tasync ensureUtilityRuntime(cwd = homedir()): Promise<RuntimeHandle> {\n\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\tconst existing = this.utilities.get(cwd);\n\t\tif (existing) return existing;\n\t\tlet promise = this.utilityPromises.get(cwd);\n\t\tif (!promise) {\n\t\t\tconst args = [\"--ui\", \"dashboard\", ...this.baseArgs];\n\t\t\tconst client = this.clientFactory({ cliPath: this.cliPath, cwd, args });\n\t\t\tconst handle: RuntimeHandle = {\n\t\t\t\tkey: `utility:${cwd}`,\n\t\t\t\tcwd,\n\t\t\t\tclient,\n\t\t\t\tcreatedAt: Date.now(),\n\t\t\t\tlastActivity: Date.now(),\n\t\t\t\tattention: new Map(),\n\t\t\t\tbackgroundAgents: new Map(),\n\t\t\t};\n\t\t\tconst startup = this.startUtilityRuntime(cwd, handle);\n\t\t\tthis.startupPromises.add(startup);\n\t\t\tpromise = startup\n\t\t\t\t.catch((err) => {\n\t\t\t\t\t// Allow a retry on the next request instead of caching the failure.\n\t\t\t\t\tthis.utilityPromises.delete(cwd);\n\t\t\t\t\tthrow err;\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tthis.startupPromises.delete(startup);\n\t\t\t\t});\n\t\t\tthis.utilityPromises.set(cwd, promise);\n\t\t}\n\t\treturn promise;\n\t}\n\n\tprivate async startUtilityRuntime(cwd: string, handle: RuntimeHandle): Promise<RuntimeHandle> {\n\t\tthis.starting.add(handle);\n\t\ttry {\n\t\t\tif (this.closing) throw new Error(\"Runtime pool is closing\");\n\t\t\tawait handle.client.start();\n\t\t\tif (this.closing) {\n\t\t\t\tawait handle.client.stop();\n\t\t\t\tthrow new Error(\"Runtime pool is closing\");\n\t\t\t}\n\t\t\tthis.utilities.set(cwd, handle);\n\t\t\treturn handle;\n\t\t} finally {\n\t\t\tthis.starting.delete(handle);\n\t\t}\n\t}\n\n\tasync stopAll(): Promise<void> {\n\t\tthis.closing = true;\n\t\tconst handles = new Set<RuntimeHandle>([\n\t\t\t...this.runtimes.values(),\n\t\t\t...this.utilities.values(),\n\t\t\t...this.starting.values(),\n\t\t]);\n\t\tconst startupPromises = new Set<Promise<unknown>>([...this.startupPromises, ...this.utilityPromises.values()]);\n\t\tthis.runtimes.clear();\n\t\tthis.utilities.clear();\n\t\tthis.utilityPromises.clear();\n\t\tawait Promise.allSettled([...handles].map((handle) => handle.client.stop()));\n\t\tawait Promise.allSettled([...startupPromises]);\n\t}\n\n\tprivate handleRuntimeExit(handle: RuntimeHandle, info: RpcExitInfo): void {\n\t\tif (this.closing || this.exitedHandles.has(handle) || !this.isLiveHandle(handle)) return;\n\t\tthis.exitedHandles.add(handle);\n\t\tconst message = formatRpcExit(info);\n\t\tthis.recordRuntimeError(handle, message);\n\t\tthis.logger(`runtime ${handle.key} ${message}`);\n\t\tthis.handleEvent(handle, { type: \"agent_end\", messages: [], aborted: true, errorMessage: message });\n\t}\n\n\tprivate isLiveHandle(handle: RuntimeHandle): boolean {\n\t\treturn (\n\t\t\tthis.runtimes.get(handle.key) === handle ||\n\t\t\tthis.utilities.get(handle.cwd) === handle ||\n\t\t\tthis.starting.has(handle)\n\t\t);\n\t}\n\n\tprivate handleEvent(handle: RuntimeHandle, event: Record<string, unknown>): void {\n\t\thandle.lastActivity = Date.now();\n\t\tconst type = event.type as string;\n\n\t\t// Track needs-attention sources: extension UI requests, parent\n\t\t// paused, error states.\n\t\tif (type === \"extension_ui_request\") {\n\t\t\tconst method = event.method as string;\n\t\t\tif (method === \"select\" || method === \"confirm\" || method === \"input\" || method === \"editor\") {\n\t\t\t\thandle.attention.set(`ui:${event.id}`, `extension ${method} awaiting response`);\n\t\t\t}\n\t\t}\n\t\tif (type === \"extension_ui_response_handled\") {\n\t\t\thandle.attention.delete(`ui:${event.id}`);\n\t\t}\n\t\tif (type === \"agent_start\") {\n\t\t\t// A new turn clears prior UI-request attention (requests were resolved or timed out).\n\t\t\tfor (const k of [...handle.attention.keys()]) {\n\t\t\t\tif (k.startsWith(\"ui:\")) handle.attention.delete(k);\n\t\t\t}\n\t\t\thandle.attention.delete(\"paused\");\n\t\t\thandle.attention.delete(\"suggest\");\n\t\t\thandle.attention.delete(\"error\");\n\t\t\thandle.error = undefined;\n\t\t}\n\t\tif (type === \"suggest_next\") {\n\t\t\t// suggest_next as the ending action = \"your move\": mark needs-attention\n\t\t\t// so the fleet card doesn't read idle. Cleared on the next agent_start.\n\t\t\thandle.attention.set(\"suggest\", \"suggested command awaiting\");\n\t\t}\n\t\tif (type === \"parent_paused_for_background_agents\") {\n\t\t\thandle.attention.set(\"paused\", `paused — ${event.runningAgentCount} background agents running`);\n\t\t}\n\t\tif (type === \"agent_end\") {\n\t\t\thandle.attention.delete(\"paused\");\n\t\t}\n\t\tif (type === \"auto_retry_end\" && event.success === false && event.finalError) {\n\t\t\tthis.recordRuntimeError(handle, String(event.finalError));\n\t\t}\n\t\tif (type === \"auto_compaction_end\" && event.errorMessage) {\n\t\t\tthis.recordRuntimeError(handle, String(event.errorMessage));\n\t\t}\n\n\t\t// Track background agents from lifecycle events.\n\t\tif (type === \"background_agent_start\") {\n\t\t\thandle.backgroundAgents.set(event.agentId as string, {\n\t\t\t\tagentId: event.agentId as string,\n\t\t\t\tagentType: event.agentType as string,\n\t\t\t\ttaskSummary: event.taskSummary as string,\n\t\t\t\tstartedAt: new Date().toISOString(),\n\t\t\t\tstatus: \"running\",\n\t\t\t\tsessionDir: event.sessionDir as string | undefined,\n\t\t\t});\n\t\t}\n\t\tif (type === \"background_agent_end\") {\n\t\t\tconst existing = handle.backgroundAgents.get(event.agentId as string);\n\t\t\tif (existing) {\n\t\t\t\texisting.status = event.success ? \"completed\" : \"failed\";\n\t\t\t\texisting.sessionFile = (event.sessionFile as string | undefined) ?? existing.sessionFile;\n\t\t\t\tthis.pruneCompletedBackgroundAgents(handle);\n\t\t\t}\n\t\t}\n\n\t\tfor (const listener of this.listeners) {\n\t\t\ttry {\n\t\t\t\tlistener(handle.key, event);\n\t\t\t} catch {\n\t\t\t\t// A broken SSE subscriber must not break event distribution.\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate recordRuntimeError(handle: RuntimeHandle, message: string): void {\n\t\thandle.error = message;\n\t\thandle.attention.set(\"error\", message);\n\t}\n\n\tprivate pruneCompletedBackgroundAgents(handle: RuntimeHandle): void {\n\t\tconst evictable = [...handle.backgroundAgents.values()]\n\t\t\t.map((agent, index) => {\n\t\t\t\tconst startedAtMs = Date.parse(agent.startedAt);\n\t\t\t\treturn { agent, index, startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : 0 };\n\t\t\t})\n\t\t\t.filter(({ agent }) => agent.status !== \"running\")\n\t\t\t.sort((a, b) => a.startedAtMs - b.startedAtMs || a.index - b.index);\n\t\tconst excess = evictable.length - MAX_COMPLETED_BACKGROUND_AGENTS;\n\t\tif (excess <= 0) return;\n\t\tfor (const { agent } of evictable.slice(0, excess)) {\n\t\t\thandle.backgroundAgents.delete(agent.agentId);\n\t\t}\n\t}\n\n\tprivate fallbackState(handle: RuntimeHandle): SessionStateDto {\n\t\treturn {\n\t\t\tsessionId: handle.lastState?.sessionId ?? handle.key,\n\t\t\tsessionName: handle.lastState?.sessionName,\n\t\t\tthinkingLevel: handle.lastState?.thinkingLevel ?? \"off\",\n\t\t\tisStreaming: false,\n\t\t\tisCompacting: false,\n\t\t\tsteeringMode: handle.lastState?.steeringMode ?? \"all\",\n\t\t\tfollowUpMode: handle.lastState?.followUpMode ?? \"all\",\n\t\t\tsessionFile: handle.lastState?.sessionFile,\n\t\t\tautoCompactionEnabled: handle.lastState?.autoCompactionEnabled ?? false,\n\t\t\tmessageCount: handle.lastState?.messageCount ?? 0,\n\t\t\tpendingMessageCount: handle.lastState?.pendingMessageCount ?? 0,\n\t\t\tcontextUsage: handle.lastState?.contextUsage,\n\t\t\tmodel: handle.lastState?.model,\n\t\t\tmodelFallbackMessage: handle.lastState?.modelFallbackMessage,\n\t\t};\n\t}\n\n\tprivate async seedBackgroundAgents(handle: RuntimeHandle): Promise<void> {\n\t\ttry {\n\t\t\tconst agents = (await handle.client.listBackgroundAgents()) as unknown as BackgroundAgentDto[];\n\t\t\tfor (const agent of agents) {\n\t\t\t\thandle.backgroundAgents.set(agent.agentId, agent);\n\t\t\t}\n\t\t\tthis.pruneCompletedBackgroundAgents(handle);\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} background-agent registry unavailable: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t/** Snapshot a runtime for the fleet endpoint. */\n\tasync describe(handle: RuntimeHandle): Promise<RuntimeInfoDto> {\n\t\tlet state: SessionStateDto;\n\t\ttry {\n\t\t\tstate = (await handle.client.getState()) as unknown as SessionStateDto;\n\t\t\thandle.lastState = state;\n\t\t\tif (handle.error?.startsWith(\"RPC process\")) {\n\t\t\t\thandle.error = undefined;\n\t\t\t\thandle.attention.delete(\"error\");\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tconst message = err instanceof Error ? err.message : String(err);\n\t\t\tthis.recordRuntimeError(handle, message);\n\t\t\tthis.logger(`runtime ${handle.key} state unavailable for fleet card: ${message}`);\n\t\t\tstate = this.fallbackState(handle);\n\t\t}\n\t\tlet stats: RuntimeStatsSummaryDto | undefined;\n\t\ttry {\n\t\t\tconst sessionStats = await handle.client.getSessionStats();\n\t\t\tstats = { tokensTotal: sessionStats.tokens.total, cost: sessionStats.cost };\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} stats unavailable for fleet card: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t\tlet lastAssistantText: string | undefined;\n\t\ttry {\n\t\t\tconst text = await handle.client.getLastAssistantText();\n\t\t\tlastAssistantText = text ? text.slice(0, 200) : undefined;\n\t\t} catch (err) {\n\t\t\tthis.logger(\n\t\t\t\t`runtime ${handle.key} last assistant text unavailable for fleet card: ${err instanceof Error ? err.message : String(err)}`,\n\t\t\t);\n\t\t}\n\t\treturn {\n\t\t\tkey: handle.key,\n\t\t\tcwd: handle.cwd,\n\t\t\tstate,\n\t\t\tstats,\n\t\t\tbackgroundAgents: [...handle.backgroundAgents.values()],\n\t\t\tneedsAttention: handle.attention.size > 0,\n\t\t\terror: handle.error,\n\t\t\tlastAssistantText,\n\t\t\tcreatedAt: new Date(handle.createdAt).toISOString(),\n\t\t\tlastActivity: new Date(handle.lastActivity).toISOString(),\n\t\t};\n\t}\n}\n"]}