@a2f0/agent-tool 0.1.30 → 0.1.32

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.
@@ -0,0 +1,123 @@
1
+ // Long-running processes agent-tool started for a repository, recorded one file each under the
2
+ // Git common directory, so every worktree and clone-local session sees the same list and it
3
+ // survives branch switches. A record names the process group agent-tool created; whether that
4
+ // group still runs is decided from the live process table, never from the record alone.
5
+ import { randomUUID } from "node:crypto";
6
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { git } from "../workflows/core";
9
+ import { carrying, type ProcessRow } from "./table";
10
+
11
+ export interface ProcessRecord {
12
+ schemaVersion: 1;
13
+ id: string;
14
+ label: string | null;
15
+ /** The group leader agent-tool spawned; its pid is the group id. */
16
+ pid: number;
17
+ /** The leader's start time as `ps` prints it, which tells a reused pid apart. */
18
+ started: string;
19
+ command: string[];
20
+ cwd: string;
21
+ log: string;
22
+ startedAt: string;
23
+ /** Started to outlive the work, such as a dev server the user asked to keep: teardown reports it without failing. */
24
+ keep: boolean;
25
+ /** Members seen while the leader ran, by start time, so they are still known once it has gone. */
26
+ members?: { pid: number; started: string }[];
27
+ }
28
+
29
+ export type ProcessState = "running" | "orphaned" | "exited" | "replaced";
30
+
31
+ export function ledgerDirectory(cwd = process.cwd()): string {
32
+ return path.join(git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd), "agent-tool", "processes");
33
+ }
34
+
35
+ export function newId(): string { return randomUUID().slice(0, 8); }
36
+
37
+ export function saveRecord(directory: string, record: ProcessRecord): void {
38
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
39
+ const file = path.join(directory, `${record.id}.json`), temporary = `${file}.${randomUUID()}.tmp`;
40
+ writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: "wx" });
41
+ renameSync(temporary, file);
42
+ }
43
+
44
+ export function removeRecord(directory: string, id: string): void { rmSync(path.join(directory, `${id}.json`), { force: true }); }
45
+
46
+ const ID = /^[a-f0-9]{8}$/;
47
+ // Generous enough for any argv the OS accepts plus the member list, which keeps only live members.
48
+ const MAX_RECORD_BYTES = 4 * 1024 * 1024;
49
+ const MAX_MEMBERS = 1000;
50
+
51
+ function valid(value: unknown): value is ProcessRecord {
52
+ const record = value as ProcessRecord;
53
+ return !!record && record.schemaVersion === 1 && typeof record.id === "string" && ID.test(record.id) && Number.isInteger(record.pid) && record.pid > 1
54
+ && typeof record.started === "string" && Array.isArray(record.command) && record.command.every(arg => typeof arg === "string")
55
+ && typeof record.cwd === "string" && typeof record.log === "string" && typeof record.startedAt === "string"
56
+ && (record.label === null || typeof record.label === "string") && typeof record.keep === "boolean"
57
+ && (record.members === undefined || (Array.isArray(record.members) && record.members.every(member => Number.isInteger(member?.pid) && typeof member.started === "string")));
58
+ }
59
+
60
+ /** Every record, and the files that could not be read as one. */
61
+ export function readRecords(directory: string): { records: ProcessRecord[]; skipped: { file: string; reason: string }[] } {
62
+ const records: ProcessRecord[] = [], skipped: { file: string; reason: string }[] = [];
63
+ for (const file of existsSync(directory) ? readdirSync(directory).filter(name => name.endsWith(".json")).sort() : []) {
64
+ try {
65
+ const full = path.join(directory, file), stat = lstatSync(full);
66
+ if (!stat.isFile() || stat.size > MAX_RECORD_BYTES) throw new Error("Not a process record file.");
67
+ const record = JSON.parse(readFileSync(full, "utf8")) as unknown;
68
+ if (!valid(record) || `${record.id}.json` !== file) throw new Error("Invalid process record.");
69
+ records.push(record);
70
+ } catch (error) { skipped.push({ file, reason: (error as Error).message }); }
71
+ }
72
+ return { records, skipped };
73
+ }
74
+
75
+ /**
76
+ * The processes that belong to a record. While its leader runs, that is its group and the live
77
+ * descendants of the group. Once the leader has gone its pid, and so the group id, can be reused,
78
+ * so a leaderless member counts only when it was seen while the leader ran (same pid and start
79
+ * time) or carries the record's AGENT_TOOL_PROCESS id; descendants of counted members count too.
80
+ */
81
+ /** The live leaders of records other than `record`, which own their own process trees. */
82
+ export function claimedBy(record: ProcessRecord, table: Map<number, ProcessRow>, others: ProcessRecord[]): Set<number> {
83
+ return new Set(others.filter(other => other.id !== record.id && table.get(other.pid)?.started === other.started).map(other => other.pid));
84
+ }
85
+
86
+ export function membersOf(record: ProcessRecord, table: Map<number, ProcessRow>, others: ProcessRecord[] = []): { state: ProcessState; members: ProcessRow[] } {
87
+ // Another record's live leader, and everything under it, belongs to that record, even when it
88
+ // was started from inside this one (a nested `proc start --keep`).
89
+ const claimed = claimedBy(record, table, others);
90
+ const leader = table.get(record.pid);
91
+ const ours = !!leader && leader.started === record.started;
92
+ const seen = new Set((record.members ?? []).map(member => `${member.pid} ${member.started}`));
93
+ const known = (row: ProcessRow) => seen.has(`${row.pid} ${row.started}`);
94
+ let group: ProcessRow[] = [];
95
+ // Another process now holding the leader's pid makes the group id unreliable too: only
96
+ // remembered members still count.
97
+ if (ours) group = [...table.values()].filter(row => row.pgid === record.pid);
98
+ else if (!leader) {
99
+ const candidates = [...table.values()].filter(row => row.pgid === record.pid);
100
+ const tagged = carrying(candidates.filter(row => !known(row)).map(row => row.pid), "AGENT_TOOL_PROCESS", record.id);
101
+ group = candidates.filter(row => known(row) || tagged.has(row.pid));
102
+ }
103
+ // Members seen before, wherever they are now: a descendant that left the group keeps its
104
+ // identity after its parent exits and it is no longer anyone's descendant.
105
+ const members = new Map([...group, ...[...table.values()].filter(known)].filter(row => !claimed.has(row.pid)).map(row => [row.pid, row]));
106
+ // Children that left the group (setsid, a new process group) are still reached through their parents.
107
+ for (let grew = true; grew;) {
108
+ grew = false;
109
+ for (const row of table.values()) if (!members.has(row.pid) && !claimed.has(row.pid) && members.has(row.ppid)) { members.set(row.pid, row); grew = true; }
110
+ }
111
+ const list = [...members.values()].sort((a, b) => a.pid - b.pid);
112
+ return { state: ours ? "running" : list.length ? "orphaned" : leader ? "replaced" : "exited", members: list };
113
+ }
114
+
115
+ /** Remembers the live members seen while the leader runs, for when it has gone; dead ones are dropped. */
116
+ export function observe(directory: string, record: ProcessRecord, state: ProcessState, members: ProcessRow[]): void {
117
+ if (state !== "running") return;
118
+ const next = members.slice(0, MAX_MEMBERS).map(row => ({ pid: row.pid, started: row.started }));
119
+ const key = (list: { pid: number; started: string }[]) => list.map(member => `${member.pid} ${member.started}`).sort().join("\n");
120
+ if (key(next) === key(record.members ?? [])) return;
121
+ record.members = next;
122
+ try { saveRecord(directory, record); } catch { /* Best effort: an unrecorded member is only found while the leader runs. */ }
123
+ }
@@ -0,0 +1,111 @@
1
+ // The machine's processes as agent-tool sees them: identity (pid, parent, group and start time),
2
+ // working directory, and listening TCP ports. Read through `ps`, `lsof` and, on Linux, `/proc`,
3
+ // so it needs nothing beyond the operating system.
4
+ import { execFileSync } from "node:child_process";
5
+ import { readFileSync, readlinkSync } from "node:fs";
6
+
7
+ export interface ProcessRow { pid: number; ppid: number; pgid: number; started: string; command: string }
8
+
9
+ // One locale and time zone, so start times read the same in every shell that compares them.
10
+ const C_LOCALE = { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" };
11
+ // `stat` is one token; `lstart` prints a fixed-width date of five fields, such as `Fri Oct 30 21:43:01 2026`.
12
+ const ROW = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s(.*)$/;
13
+
14
+ /**
15
+ * Rows of `ps -o pid=,ppid=,pgid=,stat=,lstart=,command=`. Zombies are left out: they have
16
+ * exited, cannot be signalled, and may never be reaped where PID 1 does not reap orphans.
17
+ */
18
+ export function parseRows(text: string): ProcessRow[] {
19
+ return text.split("\n").flatMap(line => {
20
+ const match = ROW.exec(line);
21
+ if (!match || match[4]!.startsWith("Z")) return [];
22
+ return [{ pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), started: match[5]!.replace(/\s+/g, " "), command: match[6]!.trim() }];
23
+ });
24
+ }
25
+
26
+ /** Every live process, by pid. */
27
+ export function processTable(): Map<number, ProcessRow> {
28
+ const text = execFileSync("ps", ["-A", "-o", "pid=,ppid=,pgid=,stat=,lstart=,command="], { encoding: "utf8", env: C_LOCALE, maxBuffer: 64 * 1024 * 1024 });
29
+ return new Map(parseRows(text).map(row => [row.pid, row]));
30
+ }
31
+
32
+ /** When a running process started, as `ps` prints it, or undefined once it has gone. */
33
+ export function startOf(pid: number): string | undefined {
34
+ try {
35
+ const text = execFileSync("ps", ["-o", "stat=,lstart=", "-p", String(pid)], { encoding: "utf8", env: C_LOCALE, stdio: ["ignore", "pipe", "ignore"] }).trim();
36
+ const match = /^(\S+)\s+(.+)$/.exec(text);
37
+ return match && !match[1]!.startsWith("Z") ? match[2]!.replace(/\s+/g, " ") : undefined;
38
+ } catch { return undefined; }
39
+ }
40
+
41
+ /** lsof's output, or undefined when it is missing or failed rather than finding nothing. */
42
+ function lsof(args: string[]): string | undefined {
43
+ try {
44
+ return execFileSync("lsof", args, { encoding: "utf8", env: C_LOCALE, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
45
+ } catch (error) {
46
+ // lsof exits 1 when nothing matches, perhaps with warnings. With another diagnostic, any other status, a
47
+ // missing binary or a signal, the inspection failed and may have missed what it looked for.
48
+ const result = error as { status?: number | null; stdout?: string; stderr?: string };
49
+ const failures = (result.stderr ?? "").split("\n").filter(line => line.trim() && !line.startsWith("lsof: WARNING"));
50
+ return result.status === 1 && failures.length === 0 ? result.stdout ?? "" : undefined;
51
+ }
52
+ }
53
+
54
+ /** Parses `lsof -F pn` output into the names each pid holds. */
55
+ export function parseFields(text: string): Map<number, string[]> {
56
+ const found = new Map<number, string[]>();
57
+ let pid = 0;
58
+ for (const line of text.split("\n")) {
59
+ if (line.startsWith("p")) pid = Number(line.slice(1));
60
+ else if (line.startsWith("n") && pid) found.set(pid, [...found.get(pid) ?? [], line.slice(1)]);
61
+ }
62
+ return found;
63
+ }
64
+
65
+ /** Each process's working directory; `available` is false when neither lsof nor /proc can say. */
66
+ export function workingDirectories(pids: Iterable<number>): { available: boolean; cwd: Map<number, string> } {
67
+ const text = lsof(["-a", "-d", "cwd", "-F", "pn"]);
68
+ if (text !== undefined) return { available: true, cwd: new Map([...parseFields(text)].map(([pid, names]) => [pid, names[0]!])) };
69
+ if (process.platform !== "linux") return { available: false, cwd: new Map() };
70
+ const cwd = new Map<number, string>();
71
+ for (const pid of pids) { try { cwd.set(pid, readlinkSync(`/proc/${pid}/cwd`)); } catch { /* Gone or not ours. */ } }
72
+ return { available: true, cwd };
73
+ }
74
+
75
+ /** The TCP addresses each process listens on; `available` is false without lsof. */
76
+ export function listeningPorts(): { available: boolean; ports: Map<number, string[]> } {
77
+ const text = lsof(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pn"]);
78
+ if (text === undefined) return { available: false, ports: new Map() };
79
+ return { available: true, ports: new Map([...parseFields(text)].map(([pid, names]) => [pid, [...new Set(names)]])) };
80
+ }
81
+
82
+ /** This process and every process above it, which teardown never reports or stops. */
83
+ export function ancestors(table: Map<number, ProcessRow>, pid = process.pid): Set<number> {
84
+ const found = new Set<number>();
85
+ for (let current: number | undefined = pid; current && !found.has(current); current = table.get(current)?.ppid) found.add(current);
86
+ return found;
87
+ }
88
+
89
+ /**
90
+ * The processes among `pids` whose environment carries `NAME=value`, as `proc start` sets for
91
+ * everything it launches. macOS hides the environment of protected system binaries, which then
92
+ * never match.
93
+ */
94
+ export function carrying(pids: number[], name: string, value: string): Set<number> {
95
+ const found = new Set<number>(), entry = `${name}=${value}`;
96
+ if (pids.length === 0) return found;
97
+ if (process.platform === "linux") {
98
+ for (const pid of pids) {
99
+ try { if (readFileSync(`/proc/${pid}/environ`, "latin1").split("\0").includes(entry)) found.add(pid); } catch { /* Gone or not ours. */ }
100
+ }
101
+ return found;
102
+ }
103
+ try {
104
+ const text = execFileSync("ps", ["-E", "-ww", "-o", "pid=,command=", "-p", pids.join(",")], { encoding: "utf8", env: C_LOCALE, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
105
+ for (const line of text.split("\n")) {
106
+ const match = /^\s*(\d+)\s(.*)$/.exec(line);
107
+ if (match && ` ${match[2]} `.includes(` ${entry} `)) found.add(Number(match[1]));
108
+ }
109
+ } catch { /* None readable. */ }
110
+ return found;
111
+ }
@@ -0,0 +1,271 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { pngSize } from "../images/png";
5
+ import { assertNoLinks, replaceFile } from "../system/files";
6
+ import type { ScreenshotsConfig, ScreenshotTarget } from "./config";
7
+ import type { ScreenshotCapture, ScreenshotEntry, ScreenshotManifest } from "./manifest";
8
+
9
+ /** The record the take-screenshots skill keeps beside a shared capture. */
10
+ export const CAPTURE_RECORD = "capture.json";
11
+ const MANIFEST = "manifest.json";
12
+ const IMAGES = "img";
13
+
14
+ interface CaptureRecord { status?: unknown; commit?: unknown; dirty?: unknown; finishedAt?: unknown; args?: unknown }
15
+ export interface CaptureSummary extends ScreenshotCapture { partial: string[] }
16
+
17
+ function readRecord(source: string): string | undefined {
18
+ try { return readFileSync(path.join(source, CAPTURE_RECORD), "utf8"); } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return undefined;
20
+ throw error;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * The shared-capture guard. Every capture writes a `running` record before it
26
+ * touches an image, so a `complete` record that is unchanged before and after
27
+ * copying proves no capture overlapped the copy. A plain directory without a
28
+ * record (CI, an unlinked clone) copies as is; a linked, non-empty store
29
+ * without one is refused, since its images name no commit.
30
+ */
31
+ export function checkCapture(root: string, source: string, raw: string | undefined): CaptureSummary | null {
32
+ if (raw === undefined) {
33
+ // Linked anywhere below the repository root, as `.screenshots` or through a parent directory.
34
+ const linked = resolved(source) !== path.join(resolved(root), path.relative(root, source));
35
+ let contents: string[] = [];
36
+ try { contents = readdirSync(source); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
37
+ if (linked && contents.length > 0) throw new Error("The shared screenshots have no capture record; capture once through the take-screenshots skill.");
38
+ return null;
39
+ }
40
+ let record: CaptureRecord;
41
+ try { record = JSON.parse(raw) as CaptureRecord; } catch { throw new Error(`The shared screenshot capture record ${CAPTURE_RECORD} is not JSON; recapture.`); }
42
+ if (!record || typeof record !== "object") throw new Error(`The shared screenshot capture record ${CAPTURE_RECORD} is not an object; recapture.`);
43
+ if (record.status !== "complete") throw new Error(`The shared screenshot capture is ${String(record.status)}; wait for it or recapture.`);
44
+ if (typeof record.commit !== "string" || !/^[0-9a-f]{40}$/.test(record.commit)) throw new Error("The shared screenshot capture record names no commit; recapture.");
45
+ if (record.dirty !== false) throw new Error("The shared screenshots were captured from uncommitted changes; recapture from a commit.");
46
+ const partial = Array.isArray(record.args) ? record.args.map(String) : [];
47
+ return { commit: record.commit, finishedAt: typeof record.finishedAt === "string" ? record.finishedAt : null, partial };
48
+ }
49
+
50
+ function select(config: ScreenshotsConfig, names: string[], out: string | undefined): [string, ScreenshotTarget][] {
51
+ const available = Object.keys(config.targets);
52
+ if (!available.length) throw new Error("agent-tool.json defines no screenshots.targets.");
53
+ for (const name of names) if (!config.targets[name]) throw new Error(`Unknown screenshots target '${name}'; expected one of: ${available.join(", ")}.`);
54
+ if (out !== undefined && new Set(names).size !== 1) throw new Error("--out needs exactly one screenshots target.");
55
+ const selected = names.length ? [...new Set(names)] : available.filter(name => config.targets[name]!.dir !== null);
56
+ if (!selected.length) throw new Error("No screenshots target has a dir; name one and pass --out <directory>.");
57
+ return selected.map(name => [name, config.targets[name]!]);
58
+ }
59
+
60
+ /** The path with every existing ancestor's links resolved, so overlap checks see where writes really land. */
61
+ function resolved(file: string): string {
62
+ const parts: string[] = [];
63
+ let current = path.resolve(file);
64
+ for (;;) {
65
+ try { return path.join(realpathSync(current), ...parts.reverse()); } catch (error) {
66
+ const code = (error as NodeJS.ErrnoException).code;
67
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
68
+ }
69
+ const parent = path.dirname(current);
70
+ if (parent === current) return path.join(current, ...parts.reverse());
71
+ parts.push(path.basename(current));
72
+ current = parent;
73
+ }
74
+ }
75
+ /** Whether either path is the other or contains it. */
76
+ function overlaps(first: string, second: string): boolean {
77
+ const a = first.toLowerCase(), b = second.toLowerCase();
78
+ return a === b || a.startsWith(`${b}${path.sep}`) || b.startsWith(`${a}${path.sep}`);
79
+ }
80
+
81
+ /** The first link anywhere in the capture tree whose target overlaps an output, following linked directories once. */
82
+ function linkedInto(source: string, outputs: string[]): string | undefined {
83
+ const visited = new Set<string>();
84
+ const walk = (dir: string): string | undefined => {
85
+ let real: string;
86
+ try { real = realpathSync(dir); } catch { return undefined; }
87
+ if (visited.has(real)) return undefined;
88
+ visited.add(real);
89
+ let names: string[];
90
+ try { names = readdirSync(dir); } catch { return undefined; }
91
+ for (const name of names) {
92
+ const entry = path.join(dir, name), stats = lstatSync(entry, { throwIfNoEntry: false });
93
+ if (stats?.isSymbolicLink() && outputs.some(output => overlaps(resolved(entry), output))) return entry;
94
+ if (stats?.isDirectory() || (stats?.isSymbolicLink() && lstatSync(resolved(entry), { throwIfNoEntry: false })?.isDirectory())) {
95
+ const found = walk(entry);
96
+ if (found) return found;
97
+ }
98
+ }
99
+ return undefined;
100
+ };
101
+ return walk(source);
102
+ }
103
+
104
+ function sameBytes(file: string, bytes: Uint8Array): boolean {
105
+ const stats = lstatSync(file, { throwIfNoEntry: false });
106
+ return !!stats?.isFile() && stats.size === bytes.length && Buffer.compare(readFileSync(file), bytes) === 0;
107
+ }
108
+
109
+ /** Files below `dir`, relative to it, without following links; links count as files to remove. */
110
+ function ownedFiles(dir: string): string[] {
111
+ const stats = lstatSync(dir, { throwIfNoEntry: false });
112
+ if (!stats) return [];
113
+ if (!stats.isDirectory()) return [""];
114
+ return readdirSync(dir).sort().flatMap(name => ownedFiles(path.join(dir, name)).map(file => file ? path.join(name, file) : name));
115
+ }
116
+
117
+ function removeEmptyDirectories(dir: string, keep: boolean): void {
118
+ const stats = lstatSync(dir, { throwIfNoEntry: false });
119
+ if (!stats?.isDirectory()) return;
120
+ for (const name of readdirSync(dir)) removeEmptyDirectories(path.join(dir, name), false);
121
+ if (!keep && readdirSync(dir).length === 0) rmdirSync(dir);
122
+ }
123
+
124
+ /** Removes everything the target owns: the manifest and the image tree. Other files in the directory stay. */
125
+ function removeOutputs(dir: string): void {
126
+ rmSync(path.join(dir, MANIFEST), { force: true });
127
+ rmSync(path.join(dir, IMAGES), { force: true, recursive: true });
128
+ }
129
+
130
+ interface Staged {
131
+ name: string;
132
+ dir: string;
133
+ absolute: string;
134
+ manifest: ScreenshotManifest;
135
+ written: string[];
136
+ unchanged: string[];
137
+ keep: Set<string>;
138
+ }
139
+
140
+ function stageTarget(root: string, source: string, name: string, target: ScreenshotTarget, dir: string, base: string, capture: CaptureSummary | null): Staged {
141
+ const absolute = path.resolve(root, dir);
142
+ const staged: Staged = { name, dir, absolute, manifest: { schemaVersion: 1, projects: [], themes: [], screens: [], entries: [], capture: null }, written: [], unchanged: [], keep: new Set() };
143
+ const entries: ScreenshotEntry[] = [];
144
+ for (const project of target.projects) {
145
+ for (const theme of target.themes) {
146
+ for (const screen of target.screens) {
147
+ const file = path.join(source, project.dir, theme, `${screen.name}.png`);
148
+ let bytes: Uint8Array;
149
+ try { bytes = readFileSync(file); } catch (error) {
150
+ if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) continue;
151
+ throw error;
152
+ }
153
+ const size = pngSize(bytes);
154
+ if (!size) throw new Error(`${path.relative(root, file)} is not a PNG image.`);
155
+ const relative = path.join(IMAGES, project.name, theme, `${screen.name}.png`);
156
+ const destination = path.join(absolute, relative);
157
+ assertNoLinks(base, destination);
158
+ if (sameBytes(destination, bytes)) staged.unchanged.push(path.join(dir, relative));
159
+ else {
160
+ mkdirSync(path.dirname(destination), { recursive: true });
161
+ replaceFile(destination, bytes);
162
+ staged.written.push(path.join(dir, relative));
163
+ }
164
+ staged.keep.add(relative);
165
+ const version = createHash("sha256").update(bytes).digest("hex").slice(0, 8);
166
+ entries.push({ project: project.name, theme, name: screen.name, src: `${target.urlPrefix}${IMAGES}/${project.name}/${theme}/${screen.name}.png?v=${version}`, ...size });
167
+ }
168
+ }
169
+ }
170
+ const has = (key: keyof ScreenshotEntry, value: string) => entries.some(entry => entry[key] === value);
171
+ staged.manifest = {
172
+ schemaVersion: 1,
173
+ projects: target.projects.filter(project => has("project", project.name)).map(({ name, label, frame }) => frame ? { name, label, frame } : { name, label }),
174
+ themes: target.themes.filter(theme => has("theme", theme)).map(theme => ({ name: theme, label: theme.charAt(0).toUpperCase() + theme.slice(1).replaceAll("-", " ") })),
175
+ screens: target.screens.filter(screen => has("name", screen.name)).map(({ name, label, alt }) => alt ? { name, label, alt } : { name, label }),
176
+ entries,
177
+ capture: capture && { commit: capture.commit, finishedAt: capture.finishedAt },
178
+ };
179
+ return staged;
180
+ }
181
+
182
+ export interface ScreenshotsTargetReceipt {
183
+ name: string;
184
+ dir: string;
185
+ manifest: string;
186
+ entries: number;
187
+ projects: string[];
188
+ written: string[];
189
+ unchanged: number;
190
+ removed: string[];
191
+ }
192
+ export interface ScreenshotsReceipt { source: string; capture: CaptureSummary | null; targets: ScreenshotsTargetReceipt[] }
193
+
194
+ /**
195
+ * Stages the selected targets from the capture source. Images are copied only
196
+ * when their bytes differ, files the target owns but no longer lists are
197
+ * removed, and the manifest is written last, atomically, once the capture
198
+ * record is confirmed unchanged. Any failure removes the targets' outputs, so
199
+ * a site never builds from a partial or unverified gallery.
200
+ */
201
+ export function buildScreenshots(root: string, config: ScreenshotsConfig, names: string[], out?: string, hooks: { afterCopy?: () => void } = {}): ScreenshotsReceipt {
202
+ const selected = select(config, names, out);
203
+ const source = path.resolve(root, config.source);
204
+ // The source is usually a link to the shared store; compare where both really are.
205
+ const sources = [source, resolved(source)];
206
+ const claimed: { name: string; dirs: string[] }[] = [];
207
+ const plans = selected.map(([name, target]) => {
208
+ const dir = out ?? target.dir;
209
+ if (dir === null) throw new Error(`Screenshots target '${name}' has no dir; pass --out <directory>.`);
210
+ const absolute = path.resolve(root, dir);
211
+ const dirs = [absolute, resolved(absolute)];
212
+ // Pruning or cleanup in an overlapping directory would delete captures or another target's images.
213
+ if (dirs.some(item => sources.some(other => overlaps(item, other)))) throw new Error(`Screenshots target '${name}' overlaps the capture source.`);
214
+ const owner = claimed.find(other => other.dirs.some(item => dirs.some(mine => overlaps(item, mine))));
215
+ if (owner) throw new Error(`Screenshots targets '${owner.name}' and '${name}' write overlapping directories.`);
216
+ claimed.push({ name, dirs });
217
+ // An explicit --out is the caller's choice; configured directories are checked from the repository root.
218
+ const base = out === undefined ? root : absolute;
219
+ assertNoLinks(base, path.join(absolute, IMAGES));
220
+ assertNoLinks(base, path.join(absolute, MANIFEST));
221
+ return { name, target, dir, absolute, base };
222
+ });
223
+ // A capture linked into an output would be pruned or cleaned up with it.
224
+ const outputs = plans.flatMap(plan => [plan.absolute, resolved(plan.absolute)]);
225
+ const linked = linkedInto(source, outputs);
226
+ if (linked) throw new Error(`${path.relative(root, linked)} links into a screenshots output directory.`);
227
+ let staged: Staged[] = [];
228
+ try {
229
+ const before = readRecord(source);
230
+ const capture = checkCapture(root, source, before);
231
+ staged = plans.map(plan => stageTarget(root, source, plan.name, plan.target, plan.dir, plan.base, capture));
232
+ hooks.afterCopy?.();
233
+ if (readRecord(source) !== before) throw new Error("A screenshot capture started while copying; build again once it finishes.");
234
+ const targets = staged.map(item => {
235
+ const removed = ownedFiles(path.join(item.absolute, IMAGES)).filter(file => !item.keep.has(path.join(IMAGES, file)));
236
+ for (const file of removed) rmSync(path.join(item.absolute, IMAGES, file), { force: true });
237
+ removeEmptyDirectories(path.join(item.absolute, IMAGES), item.keep.size > 0);
238
+ if (!item.keep.size) rmSync(path.join(item.absolute, IMAGES), { force: true, recursive: true });
239
+ const manifestFile = path.join(item.absolute, MANIFEST);
240
+ const bytes = Buffer.from(`${JSON.stringify(item.manifest, null, 2)}\n`);
241
+ mkdirSync(item.absolute, { recursive: true });
242
+ if (sameBytes(manifestFile, bytes)) item.unchanged.push(path.join(item.dir, MANIFEST));
243
+ else { replaceFile(manifestFile, bytes); item.written.push(path.join(item.dir, MANIFEST)); }
244
+ return {
245
+ name: item.name, dir: item.dir, manifest: path.join(item.dir, MANIFEST), entries: item.manifest.entries.length,
246
+ projects: item.manifest.projects.map(project => project.name), written: item.written, unchanged: item.unchanged.length,
247
+ removed: removed.map(file => path.join(item.dir, IMAGES, file)),
248
+ };
249
+ });
250
+ return { source: config.source, capture, targets };
251
+ } catch (error) {
252
+ for (const plan of plans) if (existsSync(plan.absolute)) removeOutputs(plan.absolute);
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ /** One line per target for people; `--json` prints the receipt instead. */
258
+ export function describeScreenshots(receipt: ScreenshotsReceipt): string {
259
+ const lines: string[] = [];
260
+ if (receipt.capture) {
261
+ const partial = receipt.capture.partial.length ? ` (partial: ${receipt.capture.partial.join(" ")})` : "";
262
+ lines.push(`Screenshots from ${receipt.capture.commit.slice(0, 10)}, captured ${receipt.capture.finishedAt ?? "at an unrecorded time"}${partial}.`);
263
+ }
264
+ for (const target of receipt.targets) {
265
+ const counts = `${target.written.length} written, ${target.unchanged} unchanged, ${target.removed.length} removed`;
266
+ lines.push(target.entries
267
+ ? `${target.name}: staged ${target.entries} screenshot(s) across ${target.projects.join(", ")} into ${target.dir} (${counts}).`
268
+ : `${target.name}: no screenshots in ${receipt.source}; wrote an empty manifest to ${target.manifest} (${counts}).`);
269
+ }
270
+ return `${lines.join("\n")}\n`;
271
+ }
@@ -0,0 +1,94 @@
1
+ /** The data-only `screenshots` section of agent-tool.json: captured screens staged into a site. */
2
+
3
+ export type Frame = "phone" | "tablet" | "none";
4
+ export interface ScreenshotProject {
5
+ name: string;
6
+ label: string;
7
+ /** The capture directory under the source, when it differs from the name. */
8
+ dir: string;
9
+ frame: Frame | null;
10
+ }
11
+ export interface ScreenshotScreen { name: string; label: string; alt: string | null }
12
+ export interface ScreenshotTarget {
13
+ dir: string | null;
14
+ urlPrefix: string;
15
+ projects: ScreenshotProject[];
16
+ themes: string[];
17
+ screens: ScreenshotScreen[];
18
+ }
19
+ export interface ScreenshotsConfig { source: string; targets: Record<string, ScreenshotTarget> }
20
+
21
+ export const SCREENSHOTS_DEFAULTS: ScreenshotsConfig = { source: ".screenshots", targets: {} };
22
+ const NAME = /^[a-z0-9][a-z0-9-]*$/;
23
+ const TARGET = /^[a-z][a-z0-9-]*$/;
24
+ const FRAMES: Frame[] = ["phone", "tablet", "none"];
25
+
26
+ type Json = Record<string, unknown>;
27
+ function fail(setting: string): never { throw new Error(`Invalid config setting '${setting}'.`); }
28
+ function object(value: unknown, setting: string, allowed?: string[]): Json {
29
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail(setting);
30
+ if (allowed) for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new Error(`Unknown ${setting} setting '${key}'.`);
31
+ return value as Json;
32
+ }
33
+ function text(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0 && !/[\r\n]/.test(value); }
34
+ /** Repository-relative and inside it: no absolute paths, backslashes, or dot segments. */
35
+ function relativePath(value: unknown): value is string {
36
+ return typeof value === "string" && !value.startsWith("/") && !value.includes("\\") && value.split("/").every(part => part && part !== "." && part !== "..");
37
+ }
38
+ export function titleCase(name: string): string {
39
+ return name.split("-").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
40
+ }
41
+
42
+ function parseProjects(value: unknown, setting: string): ScreenshotProject[] {
43
+ const entries = Object.entries(object(value, setting));
44
+ if (!entries.length) fail(setting);
45
+ return entries.map(([name, entry]) => {
46
+ if (!NAME.test(name)) fail(`${setting}.${name}`);
47
+ if (text(entry)) return { name, label: entry, dir: name, frame: null };
48
+ const project = object(entry, `${setting}.${name}`, ["label", "dir", "frame"]);
49
+ if (project.label !== undefined && !text(project.label)) fail(`${setting}.${name}.label`);
50
+ if (project.dir !== undefined && (typeof project.dir !== "string" || !NAME.test(project.dir))) fail(`${setting}.${name}.dir`);
51
+ if (project.frame !== undefined && !FRAMES.includes(project.frame as Frame)) fail(`${setting}.${name}.frame`);
52
+ return { name, label: (project.label as string | undefined) ?? titleCase(name), dir: (project.dir as string | undefined) ?? name, frame: (project.frame as Frame | undefined) ?? null };
53
+ });
54
+ }
55
+
56
+ function parseScreens(value: unknown, setting: string): ScreenshotScreen[] {
57
+ if (!Array.isArray(value) || !value.length) fail(setting);
58
+ const screens = value.map((entry, index): ScreenshotScreen => {
59
+ if (typeof entry === "string") {
60
+ if (!NAME.test(entry)) fail(`${setting}[${index}]`);
61
+ return { name: entry, label: titleCase(entry), alt: null };
62
+ }
63
+ const screen = object(entry, `${setting}[${index}]`, ["name", "label", "alt"]);
64
+ if (typeof screen.name !== "string" || !NAME.test(screen.name)) fail(`${setting}[${index}].name`);
65
+ if (screen.label !== undefined && !text(screen.label)) fail(`${setting}[${index}].label`);
66
+ if (screen.alt !== undefined && !text(screen.alt)) fail(`${setting}[${index}].alt`);
67
+ return { name: screen.name, label: (screen.label as string | undefined) ?? titleCase(screen.name), alt: (screen.alt as string | undefined) ?? null };
68
+ });
69
+ const repeated = screens.find((screen, index) => screens.findIndex(other => other.name === screen.name) !== index);
70
+ if (repeated) throw new Error(`${setting} lists '${repeated.name}' more than once.`);
71
+ return screens;
72
+ }
73
+
74
+ export function parseScreenshots(value: unknown): ScreenshotsConfig {
75
+ const input = object(value, "screenshots", ["source", "targets"]);
76
+ if (input.source !== undefined && !relativePath(input.source)) fail("screenshots.source");
77
+ const result: ScreenshotsConfig = { source: (input.source as string | undefined) ?? SCREENSHOTS_DEFAULTS.source, targets: {} };
78
+ for (const [name, entry] of Object.entries(object(input.targets ?? {}, "screenshots.targets"))) {
79
+ const setting = `screenshots.targets.${name}`;
80
+ if (!TARGET.test(name)) fail(setting);
81
+ const target = object(entry, setting, ["dir", "urlPrefix", "projects", "themes", "screens"]);
82
+ if (target.dir !== undefined && !relativePath(target.dir)) fail(`${setting}.dir`);
83
+ // A URL path or absolute URL that image names are appended to; no characters that need escaping in HTML or CSS.
84
+ if (typeof target.urlPrefix !== "string" || !/^(?:\/|https:\/\/[^/\s]+\/)[^\s"'<>\\()]*$/.test(target.urlPrefix) || !target.urlPrefix.endsWith("/")) fail(`${setting}.urlPrefix`);
85
+ const projects = parseProjects(target.projects, `${setting}.projects`);
86
+ const dirs = projects.map(project => project.dir);
87
+ const shared = dirs.find((dir, index) => dirs.indexOf(dir) !== index);
88
+ if (shared) throw new Error(`${setting}.projects read capture directory '${shared}' more than once.`);
89
+ const themes = target.themes ?? ["light", "dark"];
90
+ if (!Array.isArray(themes) || !themes.length || themes.some(theme => typeof theme !== "string" || !NAME.test(theme)) || new Set(themes).size !== themes.length) fail(`${setting}.themes`);
91
+ result.targets[name] = { dir: (target.dir as string | undefined) ?? null, urlPrefix: target.urlPrefix, projects, themes: themes as string[], screens: parseScreens(target.screens, `${setting}.screens`) };
92
+ }
93
+ return result;
94
+ }