@amenophis1er/foreman 0.1.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 (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
package/src/deck.ts ADDED
@@ -0,0 +1,892 @@
1
+ /**
2
+ * Deck — what a mission changed, and what it produced.
3
+ *
4
+ * DESIGN.md §11 fixes the boundary: "The deck is diff and artifacts, not a
5
+ * file manager and not an editor. Its job is to show what this mission
6
+ * changed, which is something your editor cannot tell you and Foreman can."
7
+ * Everything here is read-only with respect to the user's project. The only
8
+ * thing this module ever writes is the baseline under `<folder>/.foreman/work/`,
9
+ * which is gitignored and already the mission's scratch area.
10
+ *
11
+ * How attribution works:
12
+ * - At run start the caller records a {@link Baseline}: for a git work tree,
13
+ * the HEAD sha plus the paths that were already dirty; for anything else,
14
+ * a hash snapshot of the folder. Without a baseline the deck cannot say
15
+ * what *this run* did versus what was already there, and says so instead
16
+ * of guessing.
17
+ * - {@link deckFor} compares the folder now against that baseline. In git
18
+ * mode git does the diffing; in snapshot mode we keep byte copies of small
19
+ * text files next to the baseline so a real unified diff is still possible
20
+ * (a hash alone can only say "changed").
21
+ * - Artifacts are the files a mission produces rather than edits:
22
+ * screenshots, logs under the work dir, and any report-like file whose
23
+ * mtime is newer than the baseline.
24
+ *
25
+ * Failure policy: {@link deckFor} never throws. A missing git binary, a folder
26
+ * that stopped being a repo, an unreadable baseline — all degrade to
27
+ * `baseline.kind = 'none'` with a `note` naming the failure class, and the
28
+ * artifact list is still returned because it does not depend on git.
29
+ */
30
+ import { execFile } from 'node:child_process';
31
+ import crypto from 'node:crypto';
32
+ import { createReadStream } from 'node:fs';
33
+ import {
34
+ mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile,
35
+ } from 'node:fs/promises';
36
+ import type { IncomingMessage, ServerResponse } from 'node:http';
37
+ import path from 'node:path';
38
+ import { WORK_DIR } from './policy.js';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Types
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export interface SnapshotEntry { size: number; mtimeMs: number; hash: string }
45
+
46
+ export type Baseline =
47
+ | { kind: 'git'; at: number; head: string | null; dirty: string[] }
48
+ | { kind: 'snapshot'; at: number; files: Record<string, SnapshotEntry>; truncated?: boolean };
49
+
50
+ export interface DeckFile {
51
+ path: string;
52
+ status: 'added' | 'modified' | 'deleted' | 'renamed';
53
+ additions: number;
54
+ deletions: number;
55
+ binary?: boolean;
56
+ diff?: string;
57
+ truncated?: boolean;
58
+ /** The path was already dirty when the run started; the run may have touched it too. */
59
+ preexisting?: boolean;
60
+ }
61
+
62
+ export interface DeckArtifact {
63
+ path: string;
64
+ kind: 'image' | 'text' | 'pdf' | 'other';
65
+ size: number;
66
+ mtimeMs: number;
67
+ }
68
+
69
+ export interface Deck {
70
+ runId: string;
71
+ baseline: { kind: 'git' | 'snapshot' | 'none'; at?: number; head?: string | null };
72
+ files: DeckFile[];
73
+ artifacts: DeckArtifact[];
74
+ totals: { files: number; additions: number; deletions: number };
75
+ note?: string;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Limits — every one of these exists because the folder is the user's real
80
+ // project and can be arbitrarily large. The deck is a summary, not a mirror.
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** Files larger than this are never hashed, diffed or listed as changes. */
84
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
85
+ /** Content-hash (and, in snapshot mode, keep a copy of) files up to this size. */
86
+ const HASH_BYTES = 512 * 1024;
87
+ /** Snapshot walk stops recording past this many files. */
88
+ const SNAPSHOT_CAP = 5_000;
89
+ /** Total bytes of text copies kept beside a snapshot baseline. */
90
+ const BLOB_BUDGET_BYTES = 32 * 1024 * 1024;
91
+ /** Changed files reported per deck. */
92
+ const FILE_CAP = 200;
93
+ /** Diff lines kept per file. */
94
+ const DIFF_LINE_CAP = 400;
95
+ /** Artifacts reported per deck. */
96
+ const ARTIFACT_CAP = 200;
97
+ /** Artifact walk gives up after this many directory entries. */
98
+ const ARTIFACT_WALK_CAP = 50_000;
99
+ /** Bytes sniffed for a NUL to decide text vs binary. */
100
+ const SNIFF_BYTES = 8 * 1024;
101
+ /** readArtifact refuses files above this. */
102
+ const MAX_ARTIFACT_BYTES = 25 * 1024 * 1024;
103
+ /** LCS table cells before the line diff falls back to whole-block replace. */
104
+ const LCS_CELL_CAP = 4_000_000;
105
+
106
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']);
107
+ const RUN_ID_RE = /^[A-Za-z0-9-]+$/;
108
+ const NO_BASELINE_NOTE =
109
+ 'No baseline was recorded when this run started, so changes cannot be attributed to it.';
110
+
111
+ const ARTIFACT_EXTS = new Set([
112
+ 'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'pdf', 'md', 'html', 'txt', 'log', 'json', 'csv',
113
+ ]);
114
+
115
+ /** Files a run typically leaves in its work dir that a person wants to read, not download. */
116
+ const CODE_EXTS = ['yml', 'yaml', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'css', 'py', 'sh', 'toml', 'xml', 'ini', 'sql', 'rb', 'go', 'rs', 'java', 'kt', 'swift', 'c', 'h', 'cpp', 'hpp', 'diff', 'patch', 'env', 'conf', 'cfg', 'lock', 'gitignore', 'txt'];
117
+
118
+ const MIME: Record<string, string> = {
119
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp',
120
+ gif: 'image/gif', svg: 'image/svg+xml', pdf: 'application/pdf',
121
+ md: 'text/markdown; charset=utf-8',
122
+ // HTML is served as plain text on purpose: an artifact produced by an agent
123
+ // must never execute script in the dashboard's origin.
124
+ html: 'text/plain; charset=utf-8',
125
+ txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8',
126
+ json: 'application/json; charset=utf-8', csv: 'text/csv; charset=utf-8',
127
+ // Code and config read as text too. Served as plain text, never as a
128
+ // script or stylesheet type: the deck shows work, it does not load it.
129
+ ...Object.fromEntries(CODE_EXTS.map((e) => [e, 'text/plain; charset=utf-8'])),
130
+ };
131
+
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Small helpers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ function baselinePath(folder: string, runId: string): string {
138
+ return path.join(folder, WORK_DIR, `baseline-${runId}.json`);
139
+ }
140
+ function blobDir(folder: string, runId: string): string {
141
+ return path.join(folder, WORK_DIR, `baseline-${runId}.blobs`);
142
+ }
143
+
144
+ function ext(p: string): string {
145
+ return path.extname(p).slice(1).toLowerCase();
146
+ }
147
+
148
+ function artifactKind(p: string): DeckArtifact['kind'] {
149
+ const e = ext(p);
150
+ if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg'].includes(e)) return 'image';
151
+ if (e === 'pdf') return 'pdf';
152
+ if (['md', 'html', 'txt', 'log', 'json', 'csv', ...CODE_EXTS].includes(e)) return 'text';
153
+ return 'other';
154
+ }
155
+
156
+ function isText(buf: Buffer): boolean {
157
+ return !buf.subarray(0, SNIFF_BYTES).includes(0);
158
+ }
159
+
160
+ /** Always forward slashes, so a deck reads the same and paths round-trip through URLs. */
161
+ function toPosix(p: string): string {
162
+ return p.split(path.sep).join('/');
163
+ }
164
+
165
+ function splitLines(text: string): string[] {
166
+ const lines = text.split('\n');
167
+ if (lines.length && lines[lines.length - 1] === '') lines.pop();
168
+ return lines;
169
+ }
170
+
171
+ async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
172
+ await mkdir(path.dirname(file), { recursive: true });
173
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
174
+ await writeFile(tmp, JSON.stringify(value));
175
+ await rename(tmp, file);
176
+ }
177
+
178
+ /**
179
+ * Run git with an argument vector — never a shell, because paths in the
180
+ * user's project can contain anything. Rejects with the git stderr so the
181
+ * caller can name the failure class in a note.
182
+ */
183
+ function git(args: string[], cwd: string): Promise<string> {
184
+ return new Promise((resolve, reject) => {
185
+ execFile('git', args, {
186
+ cwd, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8',
187
+ // Never let a repo hook or a pager surprise us; never prompt.
188
+ env: { ...process.env, GIT_PAGER: 'cat', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
189
+ }, (err, stdout, stderr) => {
190
+ if (err) reject(new Error(`git ${args[0]}: ${(stderr || err.message).trim()}`));
191
+ else resolve(stdout);
192
+ });
193
+ });
194
+ }
195
+
196
+ /**
197
+ * The repo's toplevel and the folder itself, both as real paths. Git reports
198
+ * paths relative to the toplevel and we rebase them onto the folder, which
199
+ * only works when neither side goes through a symlink (macOS's /var vs
200
+ * /private/var is the everyday case).
201
+ */
202
+ async function gitRoots(folder: string): Promise<{ top: string; folder: string } | null> {
203
+ try {
204
+ const top = (await git(['rev-parse', '--show-toplevel'], folder)).trim();
205
+ return top ? { top: await realpath(top), folder: await realpath(folder) } : null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ async function gitHead(cwd: string): Promise<string | null> {
212
+ try {
213
+ return (await git(['rev-parse', '--verify', 'HEAD'], cwd)).trim() || null;
214
+ } catch {
215
+ // An empty repository: rev-parse HEAD fails although the folder is a
216
+ // perfectly good work tree. Every tracked file is then "added".
217
+ return null;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * `git status --porcelain=v1 -z` entries scoped to `folder`, as
223
+ * `{ xy, path }` with paths relative to `folder`. Porcelain paths are always
224
+ * relative to the repo root (the format ignores status.relativePaths), hence
225
+ * the rebasing. Rename entries carry the original path as a second NUL
226
+ * record, which is skipped.
227
+ */
228
+ async function gitStatus(folder: string, top: string): Promise<{ xy: string; path: string }[]> {
229
+ const out = await git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', folder], top);
230
+ const parts = out.split('\0');
231
+ const entries: { xy: string; path: string }[] = [];
232
+ for (let i = 0; i < parts.length; i++) {
233
+ const rec = parts[i];
234
+ if (!rec) continue;
235
+ const xy = rec.slice(0, 2);
236
+ const rel = relToFolder(folder, top, rec.slice(3));
237
+ if (xy[0] === 'R' || xy[0] === 'C') i++;
238
+ if (rel !== null) entries.push({ xy, path: rel });
239
+ }
240
+ return entries;
241
+ }
242
+
243
+ function isScratch(rel: string): boolean {
244
+ return rel === WORK_DIR || rel.startsWith(`${WORK_DIR}/`);
245
+ }
246
+
247
+ function relToFolder(folder: string, top: string, repoRel: string): string | null {
248
+ const rel = path.relative(folder, path.join(top, repoRel));
249
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
250
+ return toPosix(rel);
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Folder walk (shared by the snapshot and the artifact scan)
255
+ // ---------------------------------------------------------------------------
256
+
257
+ interface WalkOpts {
258
+ /** Skip `.foreman/work` (the snapshot does; the artifact scan must not). */
259
+ skipWork: boolean;
260
+ /** Stop after this many files have been visited. */
261
+ limit: number;
262
+ }
263
+
264
+ interface WalkFile { rel: string; abs: string; size: number; mtimeMs: number }
265
+
266
+ /**
267
+ * Depth-first, sorted, symlink-free walk. Symlinks are skipped outright: they
268
+ * can loop, and they can point outside the folder, and neither is something
269
+ * a "what changed here" view should follow. Hidden directories are skipped
270
+ * except `.foreman`, which is ours.
271
+ */
272
+ async function walk(folder: string, opts: WalkOpts): Promise<{ files: WalkFile[]; truncated: boolean }> {
273
+ const files: WalkFile[] = [];
274
+ let truncated = false;
275
+ const visit = async (dirAbs: string, dirRel: string): Promise<void> => {
276
+ if (truncated) return;
277
+ let entries;
278
+ try {
279
+ entries = await readdir(dirAbs, { withFileTypes: true });
280
+ } catch {
281
+ return; // unreadable directory: not this module's problem to report
282
+ }
283
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
284
+ for (const ent of entries) {
285
+ if (truncated) return;
286
+ const rel = dirRel ? `${dirRel}/${ent.name}` : ent.name;
287
+ const abs = path.join(dirAbs, ent.name);
288
+ if (ent.isSymbolicLink()) continue;
289
+ if (ent.isDirectory()) {
290
+ if (SKIP_DIRS.has(ent.name)) continue;
291
+ if (ent.name.startsWith('.') && ent.name !== '.foreman') continue;
292
+ if (opts.skipWork && rel === WORK_DIR) continue;
293
+ await visit(abs, rel);
294
+ } else if (ent.isFile()) {
295
+ if (files.length >= opts.limit) { truncated = true; return; }
296
+ try {
297
+ const st = await stat(abs);
298
+ files.push({ rel, abs, size: st.size, mtimeMs: st.mtimeMs });
299
+ } catch { /* vanished between readdir and stat */ }
300
+ }
301
+ }
302
+ };
303
+ await visit(folder, '');
304
+ return { files, truncated };
305
+ }
306
+
307
+ /**
308
+ * Snapshot identity for a file. Small files are content-hashed so a touch
309
+ * without an edit is not a change; large ones fall back to size:mtime because
310
+ * hashing a 2 MB asset on every run start is not worth the precision.
311
+ * Returns the content too when it was read, so callers can keep a copy.
312
+ */
313
+ async function fingerprint(f: WalkFile): Promise<{ hash: string; content?: Buffer }> {
314
+ if (f.size > HASH_BYTES) return { hash: `${f.size}:${Math.round(f.mtimeMs)}` };
315
+ const content = await readFile(f.abs);
316
+ return { hash: crypto.createHash('sha1').update(content).digest('hex'), content };
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // Baseline
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /**
324
+ * Record what `folder` looks like right now so {@link deckFor} can later say
325
+ * what changed. Call once, when a run *starts* — never on resume, since a
326
+ * resumed run's earlier turns already changed the folder and re-baselining
327
+ * would erase them from the deck.
328
+ */
329
+ export async function captureBaseline(folder: string, runId: string): Promise<Baseline> {
330
+ const at = Date.now();
331
+ const roots = await gitRoots(folder);
332
+ let baseline: Baseline;
333
+ if (roots) {
334
+ const head = await gitHead(roots.top);
335
+ const dirty = (await gitStatus(roots.folder, roots.top)).map((e) => e.path);
336
+ baseline = { kind: 'git', at, head, dirty };
337
+ } else {
338
+ baseline = await snapshot(folder, runId, at);
339
+ }
340
+ await writeJsonAtomic(baselinePath(folder, runId), baseline);
341
+ return baseline;
342
+ }
343
+
344
+ async function snapshot(folder: string, runId: string, at: number): Promise<Baseline> {
345
+ const { files: walked, truncated } = await walk(folder, { skipWork: true, limit: SNAPSHOT_CAP });
346
+ const files: Record<string, SnapshotEntry> = {};
347
+ const blobs = blobDir(folder, runId);
348
+ // A fresh baseline never inherits blobs from an earlier run that reused
349
+ // the id (it cannot, ids are unique), but a crashed capture might have
350
+ // left a partial directory; start clean.
351
+ await rm(blobs, { recursive: true, force: true });
352
+ await mkdir(blobs, { recursive: true });
353
+ let budget = BLOB_BUDGET_BYTES;
354
+ for (const f of walked) {
355
+ if (f.size > MAX_FILE_BYTES) continue;
356
+ let fp;
357
+ try {
358
+ fp = await fingerprint(f);
359
+ } catch {
360
+ continue;
361
+ }
362
+ files[f.rel] = { size: f.size, mtimeMs: f.mtimeMs, hash: fp.hash };
363
+ // Keep a copy of small text files: it is the only way a non-git folder
364
+ // gets a real diff (and a real "deleted" body) later. Content-addressed,
365
+ // so identical files cost one copy.
366
+ if (fp.content && isText(fp.content) && budget >= fp.content.length) {
367
+ const dest = path.join(blobs, fp.hash);
368
+ try {
369
+ await stat(dest);
370
+ } catch {
371
+ await writeFile(dest, fp.content);
372
+ budget -= fp.content.length;
373
+ }
374
+ }
375
+ }
376
+ const out: Baseline = { kind: 'snapshot', at, files };
377
+ if (truncated) out.truncated = true;
378
+ return out;
379
+ }
380
+
381
+ /** The baseline recorded for a run, or null if none was (or it is unreadable). */
382
+ export async function loadBaseline(folder: string, runId: string): Promise<Baseline | null> {
383
+ try {
384
+ const raw = JSON.parse(await readFile(baselinePath(folder, runId), 'utf8')) as Baseline;
385
+ if (raw && (raw.kind === 'git' || raw.kind === 'snapshot') && typeof raw.at === 'number') return raw;
386
+ return null;
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Line diff — used when git is not there to do it. Prefix/suffix trimming
394
+ // then an LCS table on the middle; past LCS_CELL_CAP the middle is emitted as
395
+ // "all old lines removed, all new lines added", which is still a correct
396
+ // diff, merely a coarse one.
397
+ // ---------------------------------------------------------------------------
398
+
399
+ type Op = { t: ' ' | '-' | '+'; s: string };
400
+
401
+ function editScript(a: string[], b: string[]): Op[] {
402
+ let pre = 0;
403
+ while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
404
+ let suf = 0;
405
+ while (suf < a.length - pre && suf < b.length - pre
406
+ && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++;
407
+ const ops: Op[] = [];
408
+ for (let i = 0; i < pre; i++) ops.push({ t: ' ', s: a[i] });
409
+ for (const op of lcsOps(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf))) ops.push(op);
410
+ for (let i = a.length - suf; i < a.length; i++) ops.push({ t: ' ', s: a[i] });
411
+ return ops;
412
+ }
413
+
414
+ function lcsOps(a: string[], b: string[]): Op[] {
415
+ const n = a.length, m = b.length;
416
+ const ops: Op[] = [];
417
+ if (n === 0 || m === 0 || (n + 1) * (m + 1) > LCS_CELL_CAP) {
418
+ for (const s of a) ops.push({ t: '-', s });
419
+ for (const s of b) ops.push({ t: '+', s });
420
+ return ops;
421
+ }
422
+ const w = m + 1;
423
+ // L[i*w+j] = length of the LCS of a[i..] and b[j..]
424
+ const L = new Uint32Array((n + 1) * (m + 1));
425
+ for (let i = n - 1; i >= 0; i--) {
426
+ for (let j = m - 1; j >= 0; j--) {
427
+ L[i * w + j] = a[i] === b[j]
428
+ ? L[(i + 1) * w + j + 1] + 1
429
+ : Math.max(L[(i + 1) * w + j], L[i * w + j + 1]);
430
+ }
431
+ }
432
+ let i = 0, j = 0;
433
+ while (i < n && j < m) {
434
+ if (a[i] === b[j]) { ops.push({ t: ' ', s: a[i] }); i++; j++; }
435
+ else if (L[(i + 1) * w + j] >= L[i * w + j + 1]) { ops.push({ t: '-', s: a[i] }); i++; }
436
+ else { ops.push({ t: '+', s: b[j] }); j++; }
437
+ }
438
+ while (i < n) ops.push({ t: '-', s: a[i++] });
439
+ while (j < m) ops.push({ t: '+', s: b[j++] });
440
+ return ops;
441
+ }
442
+
443
+ /** Unified-diff text (3 lines of context) from an edit script. */
444
+ function unified(ops: Op[], oldName: string, newName: string, context = 3): string[] {
445
+ const out = [`--- ${oldName}`, `+++ ${newName}`];
446
+ // Old/new line counts before each op index, for hunk headers.
447
+ const oldBefore = new Int32Array(ops.length + 1);
448
+ const newBefore = new Int32Array(ops.length + 1);
449
+ for (let k = 0; k < ops.length; k++) {
450
+ oldBefore[k + 1] = oldBefore[k] + (ops[k].t === '+' ? 0 : 1);
451
+ newBefore[k + 1] = newBefore[k] + (ops[k].t === '-' ? 0 : 1);
452
+ }
453
+ let k = 0;
454
+ while (k < ops.length) {
455
+ if (ops[k].t === ' ') { k++; continue; }
456
+ const start = Math.max(0, k - context);
457
+ let last = k;
458
+ let end = k;
459
+ while (end < ops.length) {
460
+ if (ops[end].t !== ' ') last = end;
461
+ else if (end - last > 2 * context) break;
462
+ end++;
463
+ }
464
+ end = Math.min(ops.length, last + context + 1);
465
+ const oldLen = oldBefore[end] - oldBefore[start];
466
+ const newLen = newBefore[end] - newBefore[start];
467
+ const oldStart = oldLen === 0 ? oldBefore[start] : oldBefore[start] + 1;
468
+ const newStart = newLen === 0 ? newBefore[start] : newBefore[start] + 1;
469
+ out.push(`@@ -${oldStart},${oldLen} +${newStart},${newLen} @@`);
470
+ for (let x = start; x < end; x++) out.push(ops[x].t + ops[x].s);
471
+ k = end;
472
+ }
473
+ return out;
474
+ }
475
+
476
+ function capLines(lines: string[]): { diff: string; truncated?: boolean } {
477
+ if (lines.length <= DIFF_LINE_CAP) return { diff: lines.join('\n') };
478
+ return { diff: lines.slice(0, DIFF_LINE_CAP).join('\n'), truncated: true };
479
+ }
480
+
481
+ function countOps(ops: Op[]): { additions: number; deletions: number } {
482
+ let additions = 0, deletions = 0;
483
+ for (const op of ops) {
484
+ if (op.t === '+') additions++;
485
+ else if (op.t === '-') deletions++;
486
+ }
487
+ return { additions, deletions };
488
+ }
489
+
490
+ /** A DeckFile for a file whose whole content is new (untracked, or added since the snapshot). */
491
+ async function wholeFileAdded(abs: string, rel: string, preexisting?: boolean): Promise<DeckFile> {
492
+ const file: DeckFile = { path: rel, status: 'added', additions: 0, deletions: 0 };
493
+ if (preexisting) file.preexisting = true;
494
+ let content: Buffer;
495
+ try {
496
+ const st = await stat(abs);
497
+ if (st.size > MAX_FILE_BYTES) return file;
498
+ content = await readFile(abs);
499
+ } catch {
500
+ return file;
501
+ }
502
+ if (!isText(content)) { file.binary = true; return file; }
503
+ const ops = editScript([], splitLines(content.toString('utf8')));
504
+ file.additions = ops.length;
505
+ Object.assign(file, capLines(unified(ops, '/dev/null', `b/${rel}`)));
506
+ return file;
507
+ }
508
+
509
+ // ---------------------------------------------------------------------------
510
+ // Deck
511
+ // ---------------------------------------------------------------------------
512
+
513
+ /**
514
+ * What changed in `folder` since the run's baseline, plus what it produced.
515
+ * Never throws: git trouble degrades to `baseline.kind = 'none'` with a note,
516
+ * and the artifact list is computed independently.
517
+ */
518
+ export async function deckFor(folder: string, runId: string): Promise<Deck> {
519
+ const baseline = await loadBaseline(folder, runId);
520
+ let deck: Deck;
521
+ try {
522
+ if (!baseline) {
523
+ deck = emptyDeck(runId, NO_BASELINE_NOTE);
524
+ } else if (baseline.kind === 'git') {
525
+ deck = await gitDeck(folder, runId, baseline);
526
+ } else {
527
+ deck = await snapshotDeck(folder, runId, baseline);
528
+ }
529
+ } catch (err) {
530
+ const msg = err instanceof Error ? err.message : String(err);
531
+ deck = emptyDeck(runId, `Changes could not be computed (${failureClass(msg)}): ${msg}`);
532
+ }
533
+ try {
534
+ deck.artifacts = await artifactsFor(folder, runId, baseline?.at);
535
+ } catch {
536
+ deck.artifacts = [];
537
+ }
538
+ return deck;
539
+ }
540
+
541
+ function emptyDeck(runId: string, note: string): Deck {
542
+ return {
543
+ runId, baseline: { kind: 'none' }, files: [], artifacts: [],
544
+ totals: { files: 0, additions: 0, deletions: 0 }, note,
545
+ };
546
+ }
547
+
548
+ function failureClass(msg: string): string {
549
+ if (/ENOENT/.test(msg) && /git/.test(msg)) return 'git not installed';
550
+ if (/not a git repository/i.test(msg)) return 'not a git repository';
551
+ if (/^git /.test(msg)) return 'git failed';
552
+ if (/EACCES|EPERM/.test(msg)) return 'permission denied';
553
+ return 'unexpected error';
554
+ }
555
+
556
+ function finish(runId: string, baseline: Deck['baseline'], files: DeckFile[], totalChanged: number, notes: string[]): Deck {
557
+ const totals = { files: totalChanged, additions: 0, deletions: 0 };
558
+ for (const f of files) { totals.additions += f.additions; totals.deletions += f.deletions; }
559
+ if (totalChanged > files.length) {
560
+ notes.push(`Showing ${files.length} of ${totalChanged} changed files.`);
561
+ }
562
+ const deck: Deck = { runId, baseline, files, artifacts: [], totals };
563
+ if (notes.length) deck.note = notes.join(' ');
564
+ return deck;
565
+ }
566
+
567
+ async function gitDeck(folder: string, runId: string, baseline: Extract<Baseline, { kind: 'git' }>): Promise<Deck> {
568
+ const roots = await gitRoots(folder);
569
+ if (!roots) throw new Error('git rev-parse: not a git repository (the folder was one when the run started)');
570
+ const { top } = roots;
571
+ folder = roots.folder;
572
+ const dirty = new Set(baseline.dirty);
573
+ const notes: string[] = [];
574
+
575
+ // Tracked changes relative to the baseline commit. Statuses come from
576
+ // `--name-status -z`: `M\0path\0`, `R100\0old\0new\0`, and so on.
577
+ type Change = { status: DeckFile['status']; path: string; from?: string };
578
+ const changes: Change[] = [];
579
+ if (baseline.head) {
580
+ const out = await git(['diff', '--name-status', '-z', '-M', baseline.head, '--', folder], top);
581
+ const parts = out.split('\0');
582
+ for (let i = 0; i < parts.length; i++) {
583
+ const code = parts[i];
584
+ if (!code) continue;
585
+ const letter = code[0];
586
+ if (letter === 'R' || letter === 'C') {
587
+ const from = relToFolder(folder, top, parts[++i] ?? '');
588
+ const to = relToFolder(folder, top, parts[++i] ?? '');
589
+ if (to !== null && !isScratch(to)) changes.push({ status: letter === 'R' ? 'renamed' : 'added', path: to, from: from ?? undefined });
590
+ continue;
591
+ }
592
+ const rel = relToFolder(folder, top, parts[++i] ?? '');
593
+ if (rel === null || isScratch(rel)) continue;
594
+ changes.push({
595
+ status: letter === 'A' ? 'added' : letter === 'D' ? 'deleted' : 'modified',
596
+ path: rel,
597
+ });
598
+ }
599
+ } else {
600
+ // Empty repository at baseline: everything tracked now is new.
601
+ const out = await git(['ls-files', '-z', '--', folder], top);
602
+ for (const p of out.split('\0')) {
603
+ const rel = p ? relToFolder(folder, top, p) : null;
604
+ if (rel !== null && !isScratch(rel)) changes.push({ status: 'added', path: rel });
605
+ }
606
+ }
607
+ const seen = new Set(changes.map((c) => c.path));
608
+ const untracked: string[] = [];
609
+ for (const e of await gitStatus(folder, top)) {
610
+ // The work dir is the mission's scratch: it is listed as artifacts, never
611
+ // as changes, even when the folder has no .foreman/.gitignore yet.
612
+ if (e.xy === '??' && !seen.has(e.path) && !isScratch(e.path)) { untracked.push(e.path); seen.add(e.path); }
613
+ }
614
+
615
+ const total = changes.length + untracked.length;
616
+ const files: DeckFile[] = [];
617
+ for (const c of changes) {
618
+ if (files.length >= FILE_CAP) break;
619
+ // With no baseline commit there is nothing for git to diff against; the
620
+ // file's whole content is the change.
621
+ files.push(baseline.head
622
+ ? await gitFile(folder, top, baseline.head, c, dirty.has(c.path))
623
+ : await wholeFileAdded(path.join(folder, c.path), c.path, dirty.has(c.path)));
624
+ }
625
+ for (const rel of untracked) {
626
+ if (files.length >= FILE_CAP) break;
627
+ files.push(await wholeFileAdded(path.join(folder, rel), rel, dirty.has(rel)));
628
+ }
629
+ if (dirty.size) {
630
+ notes.push('Files marked preexisting were already modified before the run started; the diff shown may not all be the run\'s doing.');
631
+ }
632
+ return finish(runId, { kind: 'git', at: baseline.at, head: baseline.head }, files, total, notes);
633
+ }
634
+
635
+ async function gitFile(
636
+ folder: string, top: string, head: string,
637
+ c: { status: DeckFile['status']; path: string; from?: string }, preexisting: boolean,
638
+ ): Promise<DeckFile> {
639
+ const abs = path.join(folder, c.path);
640
+ // The pathspec must name both sides of a rename or git sees an add and a delete.
641
+ const spec = c.from ? [path.join(folder, c.from), abs] : [abs];
642
+ const file: DeckFile = { path: c.path, status: c.status, additions: 0, deletions: 0 };
643
+ if (preexisting) file.preexisting = true;
644
+ const numstat = (await git(['diff', '--numstat', '-M', head, '--', ...spec], top)).trim().split('\n')[0] ?? '';
645
+ const [add, del] = numstat.split('\t');
646
+ if (add === '-' && del === '-') { file.binary = true; return file; }
647
+ file.additions = Number(add) || 0;
648
+ file.deletions = Number(del) || 0;
649
+ // numstat treats an unreadable/large file as text; sniff the working copy
650
+ // too (a deleted file has none, and git already judged its blob).
651
+ if (c.status !== 'deleted') {
652
+ try {
653
+ const st = await stat(abs);
654
+ if (st.size > MAX_FILE_BYTES) return file;
655
+ const head8k = Buffer.alloc(Math.min(st.size, SNIFF_BYTES));
656
+ if (head8k.length) {
657
+ const fh = await open(abs, 'r');
658
+ try { await fh.read(head8k, 0, head8k.length, 0); } finally { await fh.close(); }
659
+ }
660
+ if (!isText(head8k)) { file.binary = true; return file; }
661
+ } catch { /* fall through to the diff, which is what we would show anyway */ }
662
+ }
663
+ const raw = await git(['diff', '-M', head, '--', ...spec], top);
664
+ Object.assign(file, capLines(splitLines(raw)));
665
+ return file;
666
+ }
667
+
668
+ async function snapshotDeck(folder: string, runId: string, baseline: Extract<Baseline, { kind: 'snapshot' }>): Promise<Deck> {
669
+ const { files: now } = await walk(folder, { skipWork: true, limit: SNAPSHOT_CAP });
670
+ const blobs = blobDir(folder, runId);
671
+ const notes: string[] = [];
672
+ if (baseline.truncated) {
673
+ notes.push(`The baseline stopped at ${SNAPSHOT_CAP} files, so additions beyond that point may be misreported.`);
674
+ }
675
+
676
+ type Change = { status: DeckFile['status']; rel: string; abs?: string; size?: number; oldHash?: string };
677
+ const changes: Change[] = [];
678
+ const seen = new Set<string>();
679
+ for (const f of now) {
680
+ if (f.size > MAX_FILE_BYTES) continue;
681
+ seen.add(f.rel);
682
+ const before = baseline.files[f.rel];
683
+ // Cheap pre-check: identical size and mtime means we never hashed it.
684
+ if (before && before.size === f.size && before.mtimeMs === f.mtimeMs) continue;
685
+ let hash: string;
686
+ try {
687
+ hash = (await fingerprint(f)).hash;
688
+ } catch {
689
+ continue;
690
+ }
691
+ if (!before) changes.push({ status: 'added', rel: f.rel, abs: f.abs, size: f.size });
692
+ else if (before.hash !== hash) changes.push({ status: 'modified', rel: f.rel, abs: f.abs, size: f.size, oldHash: before.hash });
693
+ }
694
+ for (const rel of Object.keys(baseline.files)) {
695
+ if (!seen.has(rel)) changes.push({ status: 'deleted', rel, oldHash: baseline.files[rel].hash });
696
+ }
697
+ changes.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
698
+
699
+ const files: DeckFile[] = [];
700
+ let deletedWithoutBody = 0;
701
+ for (const c of changes) {
702
+ if (files.length >= FILE_CAP) break;
703
+ const old = c.oldHash ? await readFile(path.join(blobs, c.oldHash)).catch(() => null) : null;
704
+ if (c.status === 'added') {
705
+ files.push(await wholeFileAdded(c.abs as string, c.rel));
706
+ continue;
707
+ }
708
+ const file: DeckFile = { path: c.rel, status: c.status, additions: 0, deletions: 0 };
709
+ if (c.status === 'deleted') {
710
+ if (old) {
711
+ const ops = editScript(splitLines(old.toString('utf8')), []);
712
+ file.deletions = ops.length;
713
+ Object.assign(file, capLines(unified(ops, `a/${c.rel}`, '/dev/null')));
714
+ } else {
715
+ deletedWithoutBody++;
716
+ }
717
+ files.push(file);
718
+ continue;
719
+ }
720
+ // modified
721
+ let content: Buffer | null = null;
722
+ try {
723
+ content = await readFile(c.abs as string);
724
+ } catch { /* unreadable now: report the change without a diff */ }
725
+ if (content && !isText(content)) { file.binary = true; files.push(file); continue; }
726
+ if (content && (c.size as number) <= HASH_BYTES && old) {
727
+ const ops = editScript(splitLines(old.toString('utf8')), splitLines(content.toString('utf8')));
728
+ Object.assign(file, countOps(ops));
729
+ Object.assign(file, capLines(unified(ops, `a/${c.rel}`, `b/${c.rel}`)));
730
+ }
731
+ files.push(file);
732
+ }
733
+ if (deletedWithoutBody) {
734
+ notes.push(`${deletedWithoutBody} deleted file(s) had no copy in the baseline (binary or over the size budget), so their line counts are unknown.`);
735
+ }
736
+ return finish(runId, { kind: 'snapshot', at: baseline.at }, files, changes.length, notes);
737
+ }
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // Artifacts
741
+ // ---------------------------------------------------------------------------
742
+
743
+ async function artifactsFor(folder: string, runId: string, since: number | undefined): Promise<DeckArtifact[]> {
744
+ const { files } = await walk(folder, { skipWork: false, limit: ARTIFACT_WALK_CAP });
745
+ const baselineJson = toPosix(path.relative(folder, baselinePath(folder, runId)));
746
+ const blobsPrefix = `${toPosix(path.relative(folder, blobDir(folder, runId)))}/`;
747
+ const out: DeckArtifact[] = [];
748
+ for (const f of files) {
749
+ if (f.rel === baselineJson || f.rel.startsWith(blobsPrefix)) continue;
750
+ if (f.rel.startsWith(`${WORK_DIR}/`) && path.basename(f.rel) === '.gitignore') continue;
751
+ const produced = f.rel.startsWith('screenshots/') || f.rel.startsWith(`${WORK_DIR}/`);
752
+ // Anything else counts only if it looks like output and is newer than
753
+ // the baseline; without a baseline there is no "newer", so only the two
754
+ // dedicated locations are listed.
755
+ const recent = since !== undefined && f.mtimeMs > since && ARTIFACT_EXTS.has(ext(f.rel));
756
+ if (produced || recent) out.push({ path: f.rel, kind: artifactKind(f.rel), size: f.size, mtimeMs: f.mtimeMs });
757
+ }
758
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.path < b.path ? -1 : 1));
759
+ return out.slice(0, ARTIFACT_CAP);
760
+ }
761
+
762
+ /**
763
+ * Resolve an artifact path for serving, or null if it is not something the
764
+ * deck may hand out. The jail is the realpath of `folder`: `..`, absolute
765
+ * paths and symlinks that resolve elsewhere all land outside it. Null (never
766
+ * a throw) so the route turns every refusal into a 404.
767
+ */
768
+ export async function readArtifact(
769
+ folder: string, relPath: string,
770
+ ): Promise<{ absPath: string; mime: string; size: number } | null> {
771
+ if (!relPath || path.isAbsolute(relPath) || relPath.includes('\0')) return null;
772
+ const normalized = path.normalize(relPath);
773
+ if (normalized === '..' || normalized.startsWith(`..${path.sep}`) || normalized.startsWith('../')) return null;
774
+ try {
775
+ const root = await realpath(folder);
776
+ const absPath = await realpath(path.join(root, normalized));
777
+ if (absPath !== root && !absPath.startsWith(root + path.sep)) return null;
778
+ const st = await stat(absPath);
779
+ if (!st.isFile() || st.size > MAX_ARTIFACT_BYTES) return null;
780
+ let mime = MIME[ext(absPath)];
781
+ if (!mime) {
782
+ // Unknown extension: look, don't guess. A file with no NUL in its first
783
+ // 8K is text a person can read in the viewer; anything else downloads.
784
+ const head = Buffer.alloc(SNIFF_BYTES);
785
+ const fh = await open(absPath, 'r');
786
+ let n = 0;
787
+ try { n = (await fh.read(head, 0, SNIFF_BYTES, 0)).bytesRead; } finally { await fh.close(); }
788
+ mime = n > 0 && isText(head.subarray(0, n)) ? 'text/plain; charset=utf-8' : 'application/octet-stream';
789
+ }
790
+ return { absPath, mime, size: st.size };
791
+ } catch {
792
+ return null;
793
+ }
794
+ }
795
+
796
+ // ---------------------------------------------------------------------------
797
+ // HTTP
798
+ // ---------------------------------------------------------------------------
799
+
800
+ function pipeFile(absPath: string, res: ServerResponse): Promise<void> {
801
+ return new Promise<void>((resolve) => {
802
+ const stream = createReadStream(absPath);
803
+ stream.on('error', () => { res.destroy(); resolve(); });
804
+ res.on('close', resolve);
805
+ stream.pipe(res);
806
+ });
807
+ }
808
+
809
+ /** Real types, for the preview route only — where the sandbox, not the type, is what keeps a page from running as Foreman. */
810
+ const PREVIEW_MIME: Record<string, string> = {
811
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
812
+ css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
813
+ json: 'application/json; charset=utf-8', svg: 'image/svg+xml',
814
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf',
815
+ };
816
+
817
+ function sendJson(res: ServerResponse, code: number, body: unknown): void {
818
+ res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' });
819
+ res.end(JSON.stringify(body));
820
+ }
821
+
822
+ /**
823
+ * `GET /runs/{id}/deck`, `GET /runs/{id}/artifact?path=<rel>` and
824
+ * `GET /runs/{id}/preview/<rel>` (sandboxed render, see below). Returns
825
+ * true when the URL was one of ours (whatever the outcome), false so the
826
+ * caller's router falls through. `lookup` maps a run id to its folder; null
827
+ * means unknown run, which is a 404 rather than an error.
828
+ */
829
+ export async function handleDeckRoute(
830
+ req: IncomingMessage, res: ServerResponse, url: URL,
831
+ lookup: (runId: string) => Promise<{ folder: string } | null>,
832
+ ): Promise<boolean> {
833
+ const m = url.pathname.match(/^\/runs\/([^/]+)\/(deck|artifact|preview)(?:\/(.*))?$/);
834
+ if (!m) return false;
835
+ const [, runId, what, previewRel] = m;
836
+ if ((what === 'preview') !== (previewRel !== undefined)) return false;
837
+ try {
838
+ if (req.method !== 'GET') { sendJson(res, 405, { error: 'method not allowed' }); return true; }
839
+ if (!RUN_ID_RE.test(runId)) { sendJson(res, 404, { error: 'not found' }); return true; }
840
+ const run = await lookup(runId);
841
+ if (!run) { sendJson(res, 404, { error: 'not found' }); return true; }
842
+
843
+ if (what === 'deck') {
844
+ sendJson(res, 200, await deckFor(run.folder, runId));
845
+ return true;
846
+ }
847
+
848
+ if (what === 'preview') {
849
+ // A rendered look at an HTML artifact. Files come out with their real
850
+ // types so the page's own CSS, scripts and JSON resolve by relative
851
+ // path — but every response carries a CSP `sandbox` (no same-origin),
852
+ // so the document runs in an opaque origin whether it is framed by the
853
+ // viewer or opened in a tab: no cookies, no storage, no dashboard DOM.
854
+ // Nothing an agent wrote is ever a same-origin page of Foreman's.
855
+ const art = await readArtifact(run.folder, decodeURIComponent(previewRel));
856
+ if (!art) { sendJson(res, 404, { error: 'not found' }); return true; }
857
+ res.writeHead(200, {
858
+ 'content-type': PREVIEW_MIME[ext(art.absPath)] ?? art.mime,
859
+ 'content-length': art.size,
860
+ 'cache-control': 'no-store',
861
+ 'content-security-policy': "sandbox allow-scripts; frame-ancestors 'self'",
862
+ // The sandboxed page has an opaque origin, so its own fetch() of a
863
+ // sibling JSON file is cross-origin. Read-only files, already jailed.
864
+ 'access-control-allow-origin': '*',
865
+ 'x-content-type-options': 'nosniff',
866
+ });
867
+ await pipeFile(art.absPath, res);
868
+ return true;
869
+ }
870
+
871
+ const rel = url.searchParams.get('path') ?? '';
872
+ const art = await readArtifact(run.folder, rel);
873
+ if (!art) { sendJson(res, 404, { error: 'not found' }); return true; }
874
+ const inline = /^(image\/|text\/|application\/pdf|application\/json)/.test(art.mime);
875
+ const filename = path.basename(art.absPath).replace(/["\r\n]/g, '_');
876
+ res.writeHead(200, {
877
+ 'content-type': art.mime,
878
+ 'content-length': art.size,
879
+ 'cache-control': 'no-store',
880
+ 'content-disposition': `${inline ? 'inline' : 'attachment'}; filename="${filename}"`,
881
+ // Belt and braces for the svg/html case: nothing served here may run.
882
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; img-src data:",
883
+ 'x-content-type-options': 'nosniff',
884
+ });
885
+ await pipeFile(art.absPath, res);
886
+ return true;
887
+ } catch (err) {
888
+ if (!res.headersSent) sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
889
+ else res.destroy();
890
+ return true;
891
+ }
892
+ }