agent-horde 0.1.4 → 0.1.5

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
@@ -88,7 +88,7 @@ pi install npm:agent-horde
88
88
  Or from the repository, pinned to a tag:
89
89
 
90
90
  ```
91
- pi install git:github.com/eliyastein/agent-horde@v0.1.4
91
+ pi install git:github.com/eliyastein/agent-horde@v0.1.5
92
92
  ```
93
93
 
94
94
  To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
@@ -192,7 +192,8 @@ ledgers, the collaboration threads with their review scope and acceptance state,
192
192
  audit, and a graph of the run. The graph is a swimlane, one lane per member, with every event
193
193
  linked to the page that shows it, so a run reads left to right: who claimed what, which findings
194
194
  landed under which ideas, who reviewed whom, and where actions were rejected. It is server-rendered
195
- SVG/HTML with no scripts.
195
+ SVG/HTML with no scripts. The download icon on every page exports the whole dashboard as a zip of
196
+ static pages that opens from disk, after a warning that it may carry sensitive data from the machine.
196
197
 
197
198
  ## Benchmark: FinanceBench
198
199
 
package/SECURITY.md CHANGED
@@ -32,6 +32,14 @@ writes, rejects requests whose `Host` header is not localhost, sends a `default-
32
32
  security policy, and renders no scripts. It still exposes everything under `.horde/` to any process
33
33
  or browser tab on the machine, member transcripts included. Do not forward or proxy the port.
34
34
 
35
+ `/export.zip` packs every dashboard page, transcripts and logs included, into one archive meant
36
+ to be shared. The export control warns before the download, but the archive carries whatever the
37
+ members read or produced: file contents, command output, paths, and any credentials they met.
38
+ Review it before passing it on. Building it costs seconds of work, so the dashboard runs one
39
+ export at a time and refuses to start one for a request the browser marks as cross-site; a page
40
+ on another origin cannot read the dashboard, but it can otherwise make a browser request its
41
+ localhost port.
42
+
35
43
  ## Supply chain
36
44
 
37
45
  The package has no runtime dependencies beyond the packages pi itself provides to extensions.
package/docs/REFERENCE.md CHANGED
@@ -87,6 +87,24 @@ Every page has a JSON twin under `/api/<same path>`.
87
87
  | `/runs/<id>/graph` | the run as a swimlane: one lane per member plus the parent, phase bands, every event a link to its ledger entry (`?w=<px>`, `?from=<s>&to=<s>`); the axis is real time until the last member exits, then the synthesis phases share a compressed tail |
88
88
  | `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
89
89
  | `/ideas`, `/board`, `/findings`, `/claims`, `/facts`, `/resources` | the shared ledgers; `?run=<id>\|all`, `?view=clusters`, `?owner=<m>` filter them; `/resources` also lists who cited each resource in the active run, as REPORT.md counts reuse |
90
+ | `/export` | confirms the export and starts the download of `/export.zip` (`?back=<path>` names the page to return to); no JSON twin |
91
+ | `/export.zip` | the whole dashboard as static HTML (`application/zip`, streamed, no JSON twin); see below |
92
+
93
+ The download icon in the top-right corner of every page opens a warning that the export may
94
+ leak sensitive data from this machine. Its download link goes to `/export`, which closes the
95
+ warning, explains the wait, and starts the download of `/export.zip`. The zip holds one
96
+ folder, `horde-dashboard-<timestamp>/`, whose `index.html` opens the dashboard from disk: every
97
+ run, member, transcript page and entry, log, run document, collaboration page, graph (each width,
98
+ plus the Mermaid file), and the ledgers under each run filter, with links rewritten relative to
99
+ each other. Exported pages do not refresh, expandable entries use the browser's own disclosure,
100
+ finding bodies are shown inline, transcripts are paged fifty entries at a time, and the export
101
+ control itself is absent. Pages that fail to load are exported as their error page, so no link
102
+ dangles. The export is built when requested and has no cache. It is streamed one page per
103
+ event-loop turn, so the download appears in the browser at once and grows while the pages are
104
+ packed, and the run is not held up; a dashboard with many long transcripts takes a few seconds
105
+ and the archive can run to hundreds of megabytes. One export runs at a time (a second request
106
+ gets 503), and requests a browser marks as coming from another site (`Sec-Fetch-Site:
107
+ cross-site`) get 403.
90
108
 
91
109
  Only the active run has live member state. Historical runs are rebuilt from `member-N.log` and
92
110
  the session files, so they show end cause, turns, and usage but no activity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Run a horde of pi agents on one task: shared ledgers, peer review, an auditable event log, and a live dashboard",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,192 @@
1
+ import * as path from "node:path";
2
+ import * as zlib from "node:zlib";
3
+
4
+ /** One rendered page; `text` pages (the Mermaid export) carry no links to rewrite. */
5
+ export interface ExportPage {
6
+ body: string;
7
+ text?: boolean | undefined;
8
+ }
9
+
10
+ /** Renders the dashboard page at a URL; must return an error page rather than throw. */
11
+ export type ExportRenderer = (url: URL) => ExportPage;
12
+
13
+ export interface ZipEntry {
14
+ path: string;
15
+ data: Buffer;
16
+ }
17
+
18
+ const BASE = "http://127.0.0.1";
19
+ const MAX_PAGES = 100_000;
20
+ const ZIP_MAX_ENTRIES = 0xffff;
21
+ const ZIP_MAX_BYTES = 0xffff_ffff;
22
+
23
+ /** One name component: no separators, and never a dot-only segment, so the zip cannot escape its folder. */
24
+ function safeName(text: string): string {
25
+ const safe = text.replace(/[^A-Za-z0-9._=~-]/g, "_");
26
+ return /^\.+$/.test(safe) ? safe.replace(/\./g, "_") : safe;
27
+ }
28
+
29
+ /** The file a dashboard URL becomes: `/` is index.html, query parameters are sorted into the name. */
30
+ export function exportFile(url: URL): string {
31
+ const segments = url.pathname.split("/").filter(Boolean).map((segment) => safeName(decodeURIComponent(segment)));
32
+ const stem = segments.length === 0 ? "index" : segments.join("/");
33
+ const params = [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => safeName(`${k}=${v}`));
34
+ const name = `${stem}${params.map((pair) => `~${pair}`).join("")}`;
35
+ return url.pathname.endsWith(".mmd") ? name : `${name}.html`;
36
+ }
37
+
38
+ function canonical(href: string): URL {
39
+ const url = new URL(href, BASE);
40
+ url.hash = "";
41
+ const sorted = [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
42
+ url.search = "";
43
+ for (const [k, v] of sorted) url.searchParams.append(k, v);
44
+ return url;
45
+ }
46
+
47
+ function unescapeHtml(text: string): string {
48
+ return text.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
49
+ }
50
+
51
+ function escapeHtml(text: string): string {
52
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
53
+ }
54
+
55
+ /** Rewrites every dashboard-absolute href in a page to a path relative to the page's own file. */
56
+ function rewriteLinks(body: string, file: string, visit: (url: URL) => string): string {
57
+ const dir = path.posix.dirname(file);
58
+ return body.replace(/href="([^"]*)"/g, (match, raw: string) => {
59
+ const href = unescapeHtml(raw);
60
+ if (!href.startsWith("/")) return match;
61
+ const hashAt = href.indexOf("#");
62
+ const hash = hashAt >= 0 ? href.slice(hashAt) : "";
63
+ const target = visit(canonical(hashAt >= 0 ? href.slice(0, hashAt) : href));
64
+ return `href="${escapeHtml(path.posix.relative(dir, target) + hash)}"`;
65
+ });
66
+ }
67
+
68
+ const yieldToLoop = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
69
+
70
+ /** Renders every page reachable from `/`, as files whose links point at each other, one page per event-loop turn. */
71
+ export async function* crawlDashboard(render: ExportRenderer): AsyncGenerator<ZipEntry> {
72
+ const files = new Map<string, string>();
73
+ const queue: URL[] = [];
74
+ const visit = (url: URL): string => {
75
+ const key = url.pathname + url.search;
76
+ const known = files.get(key);
77
+ if (known !== undefined) return known;
78
+ if (files.size >= MAX_PAGES) throw new Error(`dashboard export exceeds ${MAX_PAGES} pages`);
79
+ const file = exportFile(url);
80
+ files.set(key, file);
81
+ queue.push(url);
82
+ return file;
83
+ };
84
+ visit(new URL("/", BASE));
85
+ for (let i = 0; i < queue.length; i++) {
86
+ const url = queue[i] as URL;
87
+ const file = files.get(url.pathname + url.search) as string;
88
+ const page = render(url);
89
+ const body = page.text ? page.body : rewriteLinks(page.body, file, visit);
90
+ yield { path: file, data: Buffer.from(body, "utf8") };
91
+ await yieldToLoop();
92
+ }
93
+ }
94
+
95
+ function dosDateTime(date: Date): { time: number; date: number } {
96
+ const year = Math.min(Math.max(date.getUTCFullYear(), 1980), 2107);
97
+ return {
98
+ time: (date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | (date.getUTCSeconds() >> 1),
99
+ date: ((year - 1980) << 9) | ((date.getUTCMonth() + 1) << 5) | date.getUTCDate(),
100
+ };
101
+ }
102
+
103
+ interface ZipRecord {
104
+ name: Buffer;
105
+ method: number;
106
+ crc: number;
107
+ compressedSize: number;
108
+ size: number;
109
+ offset: number;
110
+ }
111
+
112
+ function zipHeader(signature: number, record: ZipRecord, stamp: { time: number; date: number }): Buffer {
113
+ const header = Buffer.alloc(26);
114
+ header.writeUInt16LE(20, 0);
115
+ header.writeUInt16LE(0x0800, 2);
116
+ header.writeUInt16LE(record.method, 4);
117
+ header.writeUInt16LE(stamp.time, 6);
118
+ header.writeUInt16LE(stamp.date, 8);
119
+ header.writeUInt32LE(record.crc, 10);
120
+ header.writeUInt32LE(record.compressedSize, 14);
121
+ header.writeUInt32LE(record.size, 18);
122
+ header.writeUInt16LE(record.name.length, 22);
123
+ header.writeUInt16LE(0, 24);
124
+ const sig = Buffer.alloc(4);
125
+ sig.writeUInt32LE(signature, 0);
126
+ return Buffer.concat([sig, header]);
127
+ }
128
+
129
+ function centralEntry(record: ZipRecord, stamp: { time: number; date: number }): Buffer {
130
+ const madeBy = Buffer.alloc(2);
131
+ madeBy.writeUInt16LE(20, 0);
132
+ const tail = Buffer.alloc(14);
133
+ tail.writeUInt16LE(0, 0);
134
+ tail.writeUInt16LE(0, 2);
135
+ tail.writeUInt16LE(0, 4);
136
+ tail.writeUInt32LE(0, 6);
137
+ tail.writeUInt32LE(record.offset, 10);
138
+ const header = zipHeader(0x02014b50, record, stamp);
139
+ return Buffer.concat([header.subarray(0, 4), madeBy, header.subarray(4), tail, record.name]);
140
+ }
141
+
142
+ /** A zip archive streamed one entry at a time: deflated where smaller, UTF-8 names, the given modification time. */
143
+ export async function* zipArchive(entries: AsyncIterable<ZipEntry> | Iterable<ZipEntry>, mtime = new Date()): AsyncGenerator<Buffer> {
144
+ const stamp = dosDateTime(mtime);
145
+ const records: ZipRecord[] = [];
146
+ let offset = 0;
147
+ for await (const entry of entries) {
148
+ if (records.length >= ZIP_MAX_ENTRIES) throw new Error(`dashboard export exceeds ${ZIP_MAX_ENTRIES} files; zip64 is not supported`);
149
+ const deflated = zlib.deflateRawSync(entry.data);
150
+ const compressed = deflated.length < entry.data.length ? deflated : entry.data;
151
+ const record: ZipRecord = {
152
+ name: Buffer.from(entry.path, "utf8"),
153
+ method: compressed === deflated ? 8 : 0,
154
+ crc: zlib.crc32(entry.data),
155
+ compressedSize: compressed.length,
156
+ size: entry.data.length,
157
+ offset,
158
+ };
159
+ const local = Buffer.concat([zipHeader(0x04034b50, record, stamp), record.name, compressed]);
160
+ records.push(record);
161
+ offset += local.length;
162
+ if (offset > ZIP_MAX_BYTES) throw new Error("dashboard export exceeds 4 GiB; zip64 is not supported");
163
+ yield local;
164
+ }
165
+ const central = Buffer.concat(records.map((record) => centralEntry(record, stamp)));
166
+ const end = Buffer.alloc(22);
167
+ end.writeUInt32LE(0x06054b50, 0);
168
+ end.writeUInt16LE(0, 4);
169
+ end.writeUInt16LE(0, 6);
170
+ end.writeUInt16LE(records.length, 8);
171
+ end.writeUInt16LE(records.length, 10);
172
+ end.writeUInt32LE(central.length, 12);
173
+ end.writeUInt32LE(offset, 16);
174
+ end.writeUInt16LE(0, 20);
175
+ yield Buffer.concat([central, end]);
176
+ }
177
+
178
+ async function* underFolder(entries: AsyncIterable<ZipEntry>, folder: string): AsyncGenerator<ZipEntry> {
179
+ for await (const entry of entries) yield { ...entry, path: `${folder}/${entry.path}` };
180
+ }
181
+
182
+ /** Crawls the dashboard into a zip, streamed as it is built, whose top-level folder holds index.html. */
183
+ export function exportDashboard(render: ExportRenderer, folder: string, now = new Date()): AsyncGenerator<Buffer> {
184
+ return zipArchive(underFolder(crawlDashboard(render), folder), now);
185
+ }
186
+
187
+ /** The whole stream in one buffer, for callers that want the finished archive. */
188
+ export async function collect(chunks: AsyncIterable<Buffer>): Promise<Buffer> {
189
+ const parts: Buffer[] = [];
190
+ for await (const chunk of chunks) parts.push(chunk);
191
+ return Buffer.concat(parts);
192
+ }
@@ -39,6 +39,8 @@ export interface RenderContext {
39
39
  origin?: string | undefined;
40
40
  /** The run the collaboration and graph links target: this page's run, else the newest. */
41
41
  navRun?: string | undefined;
42
+ /** Rendering for the static export: no refresh, no export control, native disclosure. */
43
+ export?: boolean | undefined;
42
44
  }
43
45
 
44
46
  interface Crumb {
@@ -53,8 +55,25 @@ const CSS =
53
55
  "th{background:#eee}pre{white-space:pre-wrap;word-break:break-word;margin:0}" +
54
56
  "nav a{margin-right:8px}.r{text-align:right}h1,h2{font-size:13px;margin:8px 0 4px}" +
55
57
  "details>summary{cursor:pointer}a{color:#000}" +
58
+ ".export{position:absolute;top:8px;right:8px;line-height:0}" +
59
+ ".modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.7)}" +
60
+ ".modal:target{display:flex;align-items:center;justify-content:center}" +
61
+ ".modal-box{background:#fff;border:3px solid #000;padding:32px;width:min(90vw,760px);font-size:18px;line-height:1.5}" +
62
+ ".modal-box h2{font-size:24px;margin:0 0 16px}" +
63
+ ".modal-box .btn{display:inline-block;border:2px solid #000;padding:8px 16px;margin-right:12px}" +
56
64
  GRAPH_CSS;
57
65
 
66
+ const EXPORT_ICON =
67
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">' +
68
+ '<path d="M12 3v12m0 0l-5-5m5 5l5-5M4 17v3h16v-3" fill="none" stroke="currentColor" stroke-width="2" ' +
69
+ 'stroke-linecap="round" stroke-linejoin="round"/></svg>';
70
+
71
+ const EXPORT_WARNING =
72
+ "The zip holds every page of this dashboard as static HTML: every run, every member transcript and log, " +
73
+ "the run documents, the shared ledgers and the event audit. Transcripts include everything members read " +
74
+ "and produced on this machine: file contents, command output, paths, and any secrets or credentials they " +
75
+ "encountered. Review the export before sharing it with anyone who should not see all of that.";
76
+
58
77
  const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
59
78
 
60
79
  function escapeHtml(text: string): string {
@@ -182,12 +201,41 @@ function persistentDetails(body: string, href = "/"): string {
182
201
  });
183
202
  }
184
203
 
185
- export function page(title: string, crumbs: Crumb[], body: string, ctx: RenderContext): string {
186
- body = persistentDetails(body, ctx.href);
187
- const meta = ctx.refresh?.on ? `<meta http-equiv="refresh" content="${ctx.refresh.seconds}">` : "";
204
+ /** The export opens the modal on the paused page, so a refresh cannot close it; cancel restores the page. */
205
+ function exportControl(ctx: RenderContext): string {
206
+ if (ctx.export) return "";
207
+ const current = ctx.href ?? "/";
208
+ const paused = ctx.refresh?.on ? ctx.refresh.toggleHref : current;
209
+ return (
210
+ `<a class="export" href="${e(`${paused}#export`)}" title="export dashboard" aria-label="export dashboard">${EXPORT_ICON}</a>` +
211
+ `<div id="export" class="modal"><div class="modal-box"><h2>Export this dashboard?</h2><p>${EXPORT_WARNING}</p>` +
212
+ `<p><a class="btn" href="${e(`/export${query({ back: current })}`)}">download zip</a><a class="btn" href="${e(current)}">cancel</a></p></div></div>`
213
+ );
214
+ }
215
+
216
+ /** Shown once the download is confirmed; it starts the download itself and says what the wait is. */
217
+ export function renderExportStart(zipHref: string, backHref: string, ctx: RenderContext): string {
218
+ const body =
219
+ "<p>The archive is being packed one page at a time and streamed to your browser, so it appears in the " +
220
+ "browser's download list right away and grows until it is complete. A dashboard with long transcripts takes " +
221
+ "a while; the dashboard stays usable meanwhile.</p>" +
222
+ `<p>If no download appears, ${link(zipHref, "download it directly")}.</p>` +
223
+ `<p>${link(backHref, "back to the dashboard")}</p>`;
224
+ const start = `<meta http-equiv="refresh" content="0;url=${e(zipHref)}">`;
225
+ return page("exporting dashboard", [{ text: "runs", href: "/" }, { text: "export" }], body, { ...ctx, refresh: undefined }, start);
226
+ }
227
+
228
+ /** The static export has no server, so disclosure falls back to the browser's own details element. */
229
+ function nativeDetails(body: string): string {
230
+ return body.replace(/<details data-expand="[a-f0-9]+">/g, "<details>");
231
+ }
232
+
233
+ export function page(title: string, crumbs: Crumb[], body: string, ctx: RenderContext, head = ""): string {
234
+ body = ctx.export ? nativeDetails(body) : persistentDetails(body, ctx.href);
235
+ const meta = ctx.refresh?.on ? `<meta http-equiv="refresh" content="${ctx.refresh.seconds}">` : head;
188
236
  return (
189
237
  `<!doctype html><html><head><meta charset="utf-8"><title>${e(title)}</title>${meta}` +
190
- `<style>${CSS}</style></head><body>${nav(ctx)}${crumbLine(crumbs)}${refreshLine(ctx)}` +
238
+ `<style>${CSS}</style></head><body>${exportControl(ctx)}${nav(ctx)}${crumbLine(crumbs)}${refreshLine(ctx)}` +
191
239
  `<h1>${e(title)}</h1>${body}</body></html>`
192
240
  );
193
241
  }
@@ -331,7 +379,7 @@ export function renderMemberDetail(vm: MemberDetailVm, ctx: RenderContext): stri
331
379
  ["session file", vm.sessionFile ? e(vm.sessionFile) : "none mapped; see " + link(`${runHref(vm.runId)}/sessions`, "session files")],
332
380
  ];
333
381
  const transcript = vm.sessionFile
334
- ? `<p>${link(`${base}/transcript?from=last&n=40`, "transcript (tail)")} | ${link(`${base}/transcript?from=0&n=50`, "transcript (start)")}</p>`
382
+ ? `<p>${link(`${base}/transcript?from=last&n=${TRANSCRIPT_PAGE}`, "transcript (tail)")} | ${link(`${base}/transcript?from=0&n=${TRANSCRIPT_PAGE}`, "transcript (start)")}</p>`
335
383
  : "";
336
384
  const last = vm.lastText ? `<h2>last text</h2>${pre(vm.lastText)}` : "";
337
385
  const final = vm.finalText
@@ -359,6 +407,8 @@ export function renderMemberLog(vm: MemberLogVm, ctx: RenderContext): string {
359
407
  // Transcript
360
408
  // ---------------------------------------------------------------------------
361
409
 
410
+ const TRANSCRIPT_PAGE = 50;
411
+
362
412
  function transcriptBase(vm: { runId: string; member?: string | undefined; file: string }): string {
363
413
  return vm.member
364
414
  ? `${memberHref(vm.runId, vm.member)}/transcript`
@@ -409,18 +459,27 @@ function entryRow(entry: TranscriptEntry, base: string, full: boolean, file: str
409
459
  return [link(href, `#${entry.index}`), clock(entry.timestamp), entryRole(entry), content, entryUsage(entry)];
410
460
  }
411
461
 
412
- function pagingBar(vm: TranscriptVm, base: string): string {
462
+ /** The export crawls one page size, so it offers no size links. */
463
+ function pagingBar(vm: TranscriptVm, base: string, ctx: RenderContext): string {
413
464
  const to = Math.min(vm.total, vm.from + vm.n);
414
465
  const go = (from: number | "last", n = vm.n): string => `${base}${query({ from: String(from), n: String(n) })}`;
415
466
  const prev = vm.from > 0 ? link(go(Math.max(0, vm.from - vm.n)), "prev") : "prev";
416
467
  const next = vm.hasMore ? link(go(vm.from + vm.n), "next") : "next";
417
- const sizes = [20, 50, 100].map((n) => (n === vm.n ? `n=${n}` : link(go(vm.from, n), `n=${n}`))).join(" ");
468
+ const sizes = ctx.export
469
+ ? ""
470
+ : ` | ${[20, 50, 100].map((n) => (n === vm.n ? `n=${n}` : link(go(vm.from, n), `n=${n}`))).join(" ")}`;
418
471
  return (
419
472
  `<p>entries ${vm.from}..${to} of ${vm.total} | ${link(go(0), "first")} ${prev} ${next} ` +
420
- `${link(go("last"), "last")} | ${sizes}</p>`
473
+ `${link(go("last"), "last")}${sizes}</p>`
421
474
  );
422
475
  }
423
476
 
477
+ /** Live pages centre the entry in a short window; the export returns to the crawled page holding it. */
478
+ function transcriptPageHref(base: string, index: number, ctx: RenderContext): string {
479
+ if (ctx.export) return `${base}?from=${Math.floor(index / TRANSCRIPT_PAGE) * TRANSCRIPT_PAGE}&n=${TRANSCRIPT_PAGE}`;
480
+ return `${base}?from=${Math.max(0, index - 10)}&n=20`;
481
+ }
482
+
424
483
  function transcriptCrumbs(vm: { runId: string; member?: string | undefined; file: string }, leaf: string): Crumb[] {
425
484
  const crumbs: Crumb[] = [{ text: "runs", href: "/" }, { text: vm.runId, href: runHref(vm.runId) }];
426
485
  if (vm.member) crumbs.push({ text: vm.member, href: memberHref(vm.runId, vm.member) });
@@ -433,18 +492,17 @@ export function renderTranscript(vm: TranscriptVm, ctx: RenderContext): string {
433
492
  const base = transcriptBase(vm);
434
493
  const body =
435
494
  `<p>file ${e(vm.file)}${vm.sessionId ? `, session ${e(vm.sessionId)}` : ""}${vm.cwd ? `, cwd ${e(vm.cwd)}` : ""}</p>` +
436
- pagingBar(vm, base) +
495
+ pagingBar(vm, base, ctx) +
437
496
  table(["#", "time", "role", "content", "in/out"], vm.entries.map((entry) => entryRow(entry, base, false, vm.file)), "no entries") +
438
- pagingBar(vm, base);
497
+ pagingBar(vm, base, ctx);
439
498
  const title = `${vm.member ?? vm.file} transcript`;
440
499
  return page(title, transcriptCrumbs(vm, "transcript"), body, ctx);
441
500
  }
442
501
 
443
502
  export function renderTranscriptEntry(vm: TranscriptEntryVm, ctx: RenderContext): string {
444
503
  const base = transcriptBase(vm);
445
- const from = Math.max(0, vm.entry.index - 10);
446
504
  const body =
447
- `<p>${link(`${base}?from=${from}&n=20`, "back to transcript")}</p>` +
505
+ `<p>${link(transcriptPageHref(base, vm.entry.index, ctx), "back to transcript")}</p>` +
448
506
  table(["#", "time", "role", "content", "in/out"], [entryRow(vm.entry, base, true, vm.file)]) +
449
507
  details("raw json", pre(vm.raw), `${base}:${vm.file}:${vm.entry.index}:raw`);
450
508
  return page(`entry #${vm.entry.index}`, transcriptCrumbs(vm, `#${vm.entry.index}`), body, ctx);
@@ -529,16 +587,17 @@ function findingsHref(vm: FindingsVm, open: number[]): string {
529
587
  return `/findings${query({ run: vm.run, view: vm.view, open: open.length > 0 ? open.join(",") : undefined })}`;
530
588
  }
531
589
 
532
- /** The body is expanded when its id is in the URL, so the state survives auto-refresh. */
533
- function findingBody(f: FindingEntry, vm: FindingsVm): string {
590
+ /** The body is expanded when its id is in the URL, so the state survives auto-refresh; the export shows it inline. */
591
+ function findingBody(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string {
534
592
  if (!f.body) return "";
593
+ if (ctx.export) return pre(f.body);
535
594
  if (vm.open.includes(f.id)) {
536
595
  return pre(f.body) + link(findingsHref(vm, vm.open.filter((id) => id !== f.id)), "[hide body]");
537
596
  }
538
597
  return ` ${link(findingsHref(vm, [...vm.open, f.id]), "[show body]")}`;
539
598
  }
540
599
 
541
- function findingRow(f: FindingEntry, vm: FindingsVm): string[] {
600
+ function findingRow(f: FindingEntry, vm: FindingsVm, ctx: RenderContext): string[] {
542
601
  const idea = f.ideaId === undefined ? "" : link(`/ideas#idea-${f.ideaId}`, `#${f.ideaId}`);
543
602
  const authors = [f.author, ...f.corroborators].map((a) => authorLink(a, f.runId)).join(", ");
544
603
  const title = (f.supersededBy !== undefined ? "<b>SUPERSEDED</b> " : "") + e(f.title);
@@ -555,7 +614,7 @@ function findingRow(f: FindingEntry, vm: FindingsVm): string[] {
555
614
  f.supersedes === undefined ? "" : link(`#F${f.supersedes}`, `F${f.supersedes}`),
556
615
  f.supersededBy === undefined ? "" : link(`#F${f.supersededBy}`, `F${f.supersededBy}`),
557
616
  e(vm.reviewStates?.[f.id] ?? (f.verifiedBy ? `legacy (${f.verifiedBy})` : "unreviewed")),
558
- title + findingBody(f, vm),
617
+ title + findingBody(f, vm, ctx),
559
618
  ];
560
619
  }
561
620
 
@@ -577,13 +636,13 @@ export function renderFindings(vm: FindingsVm, ctx: RenderContext): string {
577
636
  const deltas = cluster.deltaMembers.length > 0 ? `; deltas ${cluster.deltaMembers.join(", ")}` : "";
578
637
  return (
579
638
  `<h2>cluster ${i + 1}: F${cluster.representative}, members ${e(members)}${e(deltas)}</h2>` +
580
- table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm)))
639
+ table(FINDING_HEADERS, cluster.findings.map((f) => findingRow(f, vm, ctx)))
581
640
  );
582
641
  })
583
642
  .join("");
584
643
  if (vm.clusters.length === 0) body += "<p>no findings</p>";
585
644
  } else {
586
- body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm)), "no findings");
645
+ body += table(FINDING_HEADERS, vm.findings.map((f) => findingRow(f, vm, ctx)), "no findings");
587
646
  }
588
647
  return page("findings", [{ text: "findings" }], body, ctx);
589
648
  }
package/src/dashboard.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  renderClaims,
30
30
  renderCollaboration,
31
31
  renderError,
32
+ renderExportStart,
32
33
  renderFacts,
33
34
  renderFile,
34
35
  renderFindings,
@@ -45,8 +46,12 @@ import {
45
46
  renderTranscriptEntry,
46
47
  } from "./dashboard-html.js";
47
48
  import type { RenderContext } from "./dashboard-html.js";
49
+ import { collect, exportDashboard } from "./dashboard-export.js";
50
+ import type { ExportPage } from "./dashboard-export.js";
48
51
 
49
52
  const DEFAULT_PORT = 7433;
53
+ const EXPORT_PAGE = "/export";
54
+ const EXPORT_PATH = "/export.zip";
50
55
  const REFRESH_SECONDS = 3;
51
56
  const MAX_URL_LENGTH = 4096;
52
57
  const CLOSE_GUARD_MS = 2_000;
@@ -57,6 +62,8 @@ interface DashboardResponse {
57
62
  contentType: string;
58
63
  body: string;
59
64
  headers: Record<string, string>;
65
+ /** Written after the headers in place of `body`, as it is produced. */
66
+ stream?: AsyncIterable<Buffer> | undefined;
60
67
  }
61
68
 
62
69
  interface Route {
@@ -282,6 +289,12 @@ function respond(status: number, contentType: string, body: string, extra: Recor
282
289
  return { status, contentType, body, headers: { ...baseHeaders(), ...extra } };
283
290
  }
284
291
 
292
+ /** A same-origin dashboard path, else `/`; the export page returns here. */
293
+ function backPath(query: URLSearchParams): string {
294
+ const back = query.get("back") ?? "/";
295
+ return /^\/(?![/\\])[^\s]{0,2000}$/.test(back) ? back : "/";
296
+ }
297
+
285
298
  function parentPath(pathname: string): string {
286
299
  const trimmed = pathname.replace(/\/+$/, "");
287
300
  const parent = trimmed.slice(0, trimmed.lastIndexOf("/"));
@@ -316,24 +329,33 @@ function routeMatch(pathname: string): { route: Route; params: string[] } | unde
316
329
  return undefined;
317
330
  }
318
331
 
319
- function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderContext): DashboardResponse {
332
+ /** The run this page belongs to, and the run the nav's collaboration and graph links target. */
333
+ function runContext(src: DashboardSource, url: URL, params: string[]): Pick<RenderContext, "runId" | "navRun"> {
334
+ const runId = RUN_ID_RE.test(params[0] ?? "") ? params[0] : undefined;
335
+ const filterRun = url.searchParams.get("run");
336
+ const navRun = runId ?? (filterRun && RUN_ID_RE.test(filterRun) ? filterRun : undefined) ?? latestRunId(src);
337
+ return { runId, navRun };
338
+ }
339
+
340
+ /** The export costs seconds of work per request, so pages from other sites may not start one. */
341
+ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderContext, crossSite: boolean): DashboardResponse {
320
342
  const pathname = json ? url.pathname.slice("/api".length) || "/" : url.pathname;
343
+ if (!json && (pathname === EXPORT_PATH || pathname === EXPORT_PAGE)) {
344
+ if (crossSite) return errorResponse(403, "the export can only be started from the dashboard itself", url, json, ctx);
345
+ if (pathname === EXPORT_PATH) return exportResponse(src, url, ctx);
346
+ return respond(200, "text/html; charset=utf-8", renderExportStart(EXPORT_PATH, backPath(url.searchParams), ctx));
347
+ }
321
348
  const matched = routeMatch(pathname);
322
349
  if (!matched) throw new DashboardError(404, `no such page: ${pathname}`);
323
350
  const { route, params } = matched;
324
351
  const refreshParam = queryEnum(url.searchParams, "refresh", ["0", String(REFRESH_SECONDS)] as const, String(REFRESH_SECONDS));
325
352
  const vm = route.load(src, params, url.searchParams);
326
353
  if (json) return respond(200, "application/json; charset=utf-8", JSON.stringify(vm));
327
- const runId = RUN_ID_RE.test(params[0] ?? "") ? params[0] : undefined;
328
- const filterRun = url.searchParams.get("run");
329
- const navRun = runId ?? (filterRun && RUN_ID_RE.test(filterRun) ? filterRun : undefined) ?? latestRunId(src);
330
354
  const live = route.live(vm, src, params, url.searchParams);
331
355
  const on = live && refreshParam !== "0";
332
356
  const pageCtx: RenderContext = {
333
357
  ...ctx,
334
- runId,
335
- navRun,
336
- href: url.pathname + url.search,
358
+ ...runContext(src, url, params),
337
359
  refresh: live ? { on, toggleHref: refreshToggle(url, on), seconds: REFRESH_SECONDS } : undefined,
338
360
  };
339
361
  const body = route.render(vm as never, pageCtx);
@@ -341,17 +363,68 @@ function handleRoute(src: DashboardSource, url: URL, json: boolean, ctx: RenderC
341
363
  return respond(200, "text/html; charset=utf-8", body);
342
364
  }
343
365
 
344
- /** Pure request handler: validates method and host, dispatches, maps errors to responses. */
345
- export function routeRequest(src: DashboardSource, method: string, url: URL, host: string | undefined): DashboardResponse {
366
+ /** One page of the static export; errors become pages so no exported link dangles. */
367
+ function exportPage(src: DashboardSource, url: URL): ExportPage {
368
+ const ctx: RenderContext = { activeId: src.getActive()?.id, href: url.pathname + url.search, export: true };
369
+ try {
370
+ const matched = routeMatch(url.pathname);
371
+ if (!matched) throw new DashboardError(404, `no such page: ${url.pathname}`);
372
+ const { route, params } = matched;
373
+ const vm = route.load(src, params, url.searchParams);
374
+ return { body: route.render(vm as never, { ...ctx, ...runContext(src, url, params) }), text: route.text };
375
+ } catch (error) {
376
+ const status = error instanceof DashboardError ? error.status : 500;
377
+ const message = error instanceof DashboardError ? error.message : `dashboard error: ${error instanceof Error ? error.message : String(error)}`;
378
+ return { body: renderError(status, message, parentPath(url.pathname), ctx) };
379
+ }
380
+ }
381
+
382
+ /** The whole dashboard as a zip of static pages, streamed as it is built, named for the moment it was taken. */
383
+ export function exportDashboardArchive(src: DashboardSource, now = new Date()): { filename: string; zip: AsyncGenerator<Buffer> } {
384
+ const folder = `horde-dashboard-${now.toISOString().slice(0, 19).replace(/[T:]/g, "-")}`;
385
+ return { filename: `${folder}.zip`, zip: exportDashboard((url) => exportPage(src, url), folder, now) };
386
+ }
387
+
388
+ /** The finished archive in one buffer. */
389
+ export async function exportDashboardBuffer(src: DashboardSource, now = new Date()): Promise<{ filename: string; zip: Buffer }> {
390
+ const { filename, zip } = exportDashboardArchive(src, now);
391
+ return { filename, zip: await collect(zip) };
392
+ }
393
+
394
+ let exporting = false;
395
+
396
+ /** Marks the export busy from its first chunk until the stream ends or its reader goes away. */
397
+ async function* exclusive(stream: AsyncIterable<Buffer>): AsyncGenerator<Buffer> {
398
+ exporting = true;
399
+ try {
400
+ yield* stream;
401
+ } finally {
402
+ exporting = false;
403
+ }
404
+ }
405
+
406
+ /** Headers go out at once so the browser shows the download while the pages are still being packed; one at a time. */
407
+ function exportResponse(src: DashboardSource, url: URL, ctx: RenderContext): DashboardResponse {
408
+ if (exporting) return errorResponse(503, "an export is already in progress; wait for it to finish", url, false, ctx);
409
+ const { filename, zip } = exportDashboardArchive(src);
410
+ return { ...respond(200, "application/zip", "", { "Content-Disposition": `attachment; filename="${filename}"` }), stream: exclusive(zip) };
411
+ }
412
+
413
+ /** Pure request handler: validates method and host, dispatches, maps errors to responses. `fetchSite` is the Sec-Fetch-Site header. */
414
+ export function routeRequest(src: DashboardSource, method: string, url: URL, host: string | undefined, fetchSite?: string): DashboardResponse {
346
415
  const json = url.pathname === "/api" || url.pathname.startsWith("/api/");
347
- const ctx: RenderContext = { activeId: src.getActive()?.id, origin: host ? `http://${host}` : undefined };
416
+ const ctx: RenderContext = {
417
+ activeId: src.getActive()?.id,
418
+ origin: host ? `http://${host}` : undefined,
419
+ href: url.pathname + url.search,
420
+ };
348
421
  try {
349
422
  if (method !== "GET" && method !== "HEAD") {
350
423
  return { ...errorResponse(405, `method ${method} not allowed`, url, json, ctx), headers: { ...baseHeaders(), Allow: "GET, HEAD" } };
351
424
  }
352
425
  if (!host || !HOST_RE.test(host)) return errorResponse(403, "host must be 127.0.0.1 or localhost", url, json, ctx);
353
426
  if (url.href.length > MAX_URL_LENGTH) return errorResponse(414, "url too long", url, json, ctx);
354
- return handleRoute(src, url, json, ctx);
427
+ return handleRoute(src, url, json, ctx, fetchSite === "cross-site");
355
428
  } catch (error) {
356
429
  if (error instanceof DashboardError) return errorResponse(error.status, error.message, url, json, ctx);
357
430
  const message = error instanceof Error ? error.message : String(error);
@@ -383,12 +456,41 @@ function listenError(error: NodeJS.ErrnoException, port: number): Error {
383
456
  return new Error(`dashboard could not listen on ${port}: ${error.message}`);
384
457
  }
385
458
 
459
+ const drained = (res: http.ServerResponse): Promise<void> =>
460
+ new Promise((resolve) => {
461
+ const done = (): void => {
462
+ res.off("drain", done);
463
+ res.off("close", done);
464
+ resolve();
465
+ };
466
+ res.once("drain", done);
467
+ res.once("close", done);
468
+ });
469
+
470
+ /** Writes the chunks as they come, pausing for the client; a failure mid-stream cuts the connection. */
471
+ async function streamBody(res: http.ServerResponse, stream: AsyncIterable<Buffer>): Promise<void> {
472
+ try {
473
+ for await (const chunk of stream) {
474
+ if (res.destroyed) return;
475
+ if (!res.write(chunk)) await drained(res);
476
+ }
477
+ res.end();
478
+ } catch (error) {
479
+ res.destroy(error instanceof Error ? error : new Error(String(error)));
480
+ }
481
+ }
482
+
386
483
  function serve(src: DashboardSource, req: http.IncomingMessage, res: http.ServerResponse): void {
387
484
  req.resume();
388
485
  res.on("error", () => {});
389
486
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
390
- const response = routeRequest(src, req.method ?? "GET", url, req.headers.host);
487
+ const fetchSite = req.headers["sec-fetch-site"];
488
+ const response = routeRequest(src, req.method ?? "GET", url, req.headers.host, Array.isArray(fetchSite) ? fetchSite[0] : fetchSite);
391
489
  res.writeHead(response.status, { ...response.headers, "Content-Type": response.contentType });
490
+ if (response.stream && req.method !== "HEAD") {
491
+ void streamBody(res, response.stream);
492
+ return;
493
+ }
392
494
  res.end(response.body);
393
495
  }
394
496