agent-horde 0.1.3 → 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.3
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
@@ -37,9 +37,10 @@ synthesize it.
37
37
  a fresh session takes its name, up to `PI_HORDE_RESET_CAP` times.
38
38
  3. **Repair.** After discovery ends normally, finished members get fresh, targeted sessions to
39
39
  answer open blockers, review requests, and responses, within the `PI_HORDE_REPAIR_*` budgets.
40
- The default totals follow the queue, so every queued member can afford a cold first turn; the
41
- phase start records the budget and `repair-end` records each attempt's turns and first-turn
42
- cost. A user stop skips this phase.
40
+ The default totals follow the queues' demand (one floor per queue plus one per further review
41
+ request or blocking challenge), each attempt's share is cut in proportion to its demand, and
42
+ every attempt gets at least a floor; the phase start records the budget, `repair-start` the
43
+ attempt's demand, and `repair-end` its turns and first-turn cost. A user stop skips this phase.
43
44
  4. **Finalize.** Acceptance is frozen (`ACCEPTANCE.*`, `snapshot/`), resource cleanup commands
44
45
  run, `SUMMARY.md` and `REPORT.md` are written, then a synthesis member writes `DELIVERABLE.md`
45
46
  and a read-only reviewer writes `POSTMORTEM.md`. The widget shows `synthesizing` until both
@@ -86,6 +87,24 @@ Every page has a JSON twin under `/api/<same path>`.
86
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 |
87
88
  | `/runs/<id>/graph.mmd` | the same run as a Mermaid flowchart with absolute dashboard links (text/plain) |
88
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.
89
108
 
90
109
  Only the active run has live member state. Historical runs are rebuilt from `member-N.log` and
91
110
  the session files, so they show end cause, turns, and usage but no activity.
@@ -181,6 +200,9 @@ from the same actor as an idempotent retry. The parent appends its own lifecycle
181
200
  request, and the requester then resolves it. A challenger can withdraw its own blocking
182
201
  challenge on an F# or A# before any response by recording a supporting review of the current
183
202
  revision and resolving with evidence on it; limits that review discloses remain acceptance debt.
203
+ The blocking challenge a `partially-supports` review raises is cleared when a different
204
+ non-owner member records a full-scope `supports` review of the same revision; the resolution
205
+ names both reviews. Acceptance names an open blocker as `blocked by Q#`.
184
206
  - **Inbox.** Targeted messages are inserted at turn boundaries, for at most
185
207
  `PI_HORDE_INBOX_TURNS` extra turns per session. A recipient answers with `respond`; `ack` is an
186
208
  optional first step that signals intent. Delivery is a context insertion, not
@@ -240,8 +262,8 @@ carries a one-line diagnosis and, when generated, the post-mortem text.
240
262
  | `PI_HORDE_SOLVED_STOP_MS` | 30000 | extra wind-down window for a solve landing at the deadline |
241
263
  | `PI_HORDE_ALLOW_UNVERIFIED` | unset | `1` accepts a validated answer with unverified findings |
242
264
  | `PI_HORDE_REPAIR_ROUNDS` | 4 | post-discovery repair rounds (max 6; `0` disables) |
243
- | `PI_HORDE_REPAIR_MS` | queue-sized | total repair time: one `PI_HORDE_REPAIR_MIN_MS` per queued member (max 4) plus two recheck shares; a number here is a flat total |
244
- | `PI_HORDE_REPAIR_TOKENS` | queue-sized | total repair tokens: one `PI_HORDE_REPAIR_MIN_TOKENS` per queued member (max 4) plus two recheck shares; a number here is a flat total |
265
+ | `PI_HORDE_REPAIR_MS` | demand-sized | total repair time: one `PI_HORDE_REPAIR_MIN_MS` per demand unit (max 6) plus two recheck shares; a number here is a flat total |
266
+ | `PI_HORDE_REPAIR_TOKENS` | demand-sized | total repair tokens: one `PI_HORDE_REPAIR_MIN_TOKENS` per demand unit (max 6) plus two recheck shares; a number here is a flat total |
245
267
  | `PI_HORDE_REPAIR_MIN_TOKENS` | 40000 | token floor per attempt; below it scheduling stops with a `repair-skipped` event |
246
268
  | `PI_HORDE_REPAIR_MIN_MS` | 60000 | time floor per attempt; the clock is split only among attempts the token budget can fund |
247
269
  | `PI_HORDE_REPAIR_TURNS` | 40 | turn cap per attempt; an attempt still reading its inbox at the cap is stopped with `turns` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-horde",
3
- "version": "0.1.3",
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",
package/src/citations.ts CHANGED
@@ -73,7 +73,10 @@ export function citedDataFiles(text: string): string[] {
73
73
  return [...out];
74
74
  }
75
75
 
76
- /** Bounded search for a file by name under a directory; artifact dirs are small. */
76
+ /** Trees a basename search never descends into: run data, dependencies and version control. */
77
+ const UNSEARCHED_DIRS = new Set([".horde", "node_modules", ".git"]);
78
+
79
+ /** Bounded search for a file by name under a directory; artifact dirs and source trees are shallow. */
77
80
  function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
78
81
  let entries: fs.Dirent[];
79
82
  try {
@@ -83,7 +86,7 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
83
86
  }
84
87
  for (const entry of entries) {
85
88
  if (entry.isFile() && entry.name === name) return true;
86
- if (entry.isDirectory() && depth > 0 && fileNamedUnder(path.join(dir, entry.name), name, depth - 1)) {
89
+ if (entry.isDirectory() && depth > 0 && !UNSEARCHED_DIRS.has(entry.name) && fileNamedUnder(path.join(dir, entry.name), name, depth - 1)) {
87
90
  return true;
88
91
  }
89
92
  }
@@ -94,7 +97,8 @@ function fileNamedUnder(dir: string, name: string, depth = 6): boolean {
94
97
  * True when a cited file cannot be resolved to an existing file on disk. Paths
95
98
  * are tried against the repo root, the run directory, its artifacts directory,
96
99
  * and the directory of the citing file. Because generated artifacts usually cite
97
- * retained files by a relative name, a basename match anywhere in this run's tree
100
+ * retained files by a relative name, and a deliverable cites the reviewed project's
101
+ * own files by bare name, a basename match in this run's tree or in the project tree
98
102
  * also counts, which keeps the warning precise instead of noisy.
99
103
  */
100
104
  function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean {
@@ -110,7 +114,7 @@ function citedFileMissing(run: HordeRun, ref: string, baseDir?: string): boolean
110
114
  }
111
115
  }
112
116
  const base = ref.replace(/^\.\//, "").split("/").pop() ?? ref;
113
- return !fileNamedUnder(run.runDir, base);
117
+ return !fileNamedUnder(run.runDir, base) && !fileNamedUnder(run.cwd, base);
114
118
  }
115
119
 
116
120
  export function resourceCitations(text: string): number[] {
@@ -229,13 +233,17 @@ function citedWithoutLabel(text: string, id: number): boolean {
229
233
  return citing.length > 0 && !citing.some((paragraph) => UNREVIEWED_LABEL.test(paragraph));
230
234
  }
231
235
 
236
+ /** A sentence ends at `.`, `!` or `?`, possibly inside closing emphasis or brackets, before a capitalised start. */
237
+ const SENTENCE_BREAK = /(?<=[.!?][*_)\]"']*)\s+(?=[A-Z"'([*_`])/;
238
+
232
239
  /**
233
- * The units a review citation is judged in: paragraphs, except that each list item and table row
240
+ * The units a review citation is judged in: sentences, except that each list item and table row
234
241
  * stands alone. A bullet that cites reviews as the source of a limitation does not claim them as
235
- * support for a finding named two bullets up.
242
+ * support for a finding named two bullets up, and a sentence that cites a document's review does
243
+ * not claim it for the finding named in the sentence before.
236
244
  */
237
245
  function citationUnits(text: string): string[] {
238
- return text.split(/\n\s*\n/).flatMap((paragraph) => (/^\s*(?:[-*]|\d+\.|\|)\s/m.test(paragraph) ? paragraph.split("\n") : [paragraph]));
246
+ return text.split(/\n\s*\n/).flatMap((paragraph) => (/^\s*(?:[-*]|\d+\.|\|)\s/m.test(paragraph) ? paragraph.split("\n") : paragraph.split(SENTENCE_BREAK)));
239
247
  }
240
248
 
241
249
  /**
@@ -51,6 +51,10 @@ function artifactProblem(hordeDir: string, runId: string, state: CollaborationSt
51
51
  const file = fs.realpathSync(path.join(hordeDir, "runs", runId, artifact.path));
52
52
  if (!file.startsWith(root + path.sep) || hashText(fs.readFileSync(file)) !== artifact.hash) return "file changed after publication";
53
53
  } catch { return "published file unavailable"; }
54
+ // A blocker is named as such: the acceptance text is read by members and the deliverable, and
55
+ // "needs a review" sent them after a review that already existed.
56
+ const blocker = blockingMessages(state).find((m) => m.target === artifact.id);
57
+ if (blocker) return `blocked by ${blocker.id} (${blocker.state} blocking ${blocker.kind} from ${blocker.sender})`;
54
58
  return artifactAccepted(state, artifact) ? undefined : artifact.kind === "document"
55
59
  ? "needs a full independent review against document criteria"
56
60
  : "needs a full independent tested/reproduced review";
@@ -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
  }