@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.
package/README.md CHANGED
@@ -4,8 +4,8 @@ agent-tool is a command-line tool and a set of portable skills for repositories
4
4
  where Claude Code, Codex, or OpenCode make the changes. It provides shared Git,
5
5
  GitHub, review, validation, and release workflows: independent agent reviews of
6
6
  a committed branch, guarded pushes and squash merges of the exact reviewed
7
- commit, package versioning, brand image rendering, and compact output for noisy
8
- commands. Repository policy stays in `agent-tool.json`; the portable skills
7
+ commit, package versioning, brand image rendering, screenshot staging with a
8
+ framework-free gallery, and compact output for noisy commands. Repository policy stays in `agent-tool.json`; the portable skills
9
9
  coordinate independently reviewed changes.
10
10
 
11
11
  ## Quick start
@@ -85,6 +85,8 @@ validation suite, and release procedures.
85
85
  - [Development](docs/development.md): setup, source layout, validation, and release
86
86
  procedures for this package.
87
87
  - [Images](docs/images.md): declarative brand sources and generated outputs.
88
+ - [Screenshots](docs/screenshots.md): staging captured screenshots into a
89
+ website, and the gallery and carousel web components that show them.
88
90
  - [Permissions](docs/permissions.md): which commands run without approval in
89
91
  Claude Code and Codex, and the dependency commands that enforce it.
90
92
  - [Dependency maintenance](docs/dependency-upgrades.md): active compatibility
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "packageManager": "bun@1.4.3",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -15,6 +15,12 @@
15
15
  "bin": {
16
16
  "agent-tool": "src/index.ts"
17
17
  },
18
+ "exports": {
19
+ "./package.json": "./package.json",
20
+ "./screenshots/gallery": "./src/screenshots/gallery.ts",
21
+ "./screenshots/gallery.css": "./src/screenshots/gallery.css",
22
+ "./screenshots/manifest": "./src/screenshots/manifest.ts"
23
+ },
18
24
  "files": [
19
25
  "src",
20
26
  "skills",
@@ -29,12 +35,14 @@
29
35
  "agent:run": "bun src/index.ts run",
30
36
  "agent:logs": "bun src/index.ts logs",
31
37
  "test": "bun test --parallel src scripts",
32
- "typecheck": "tsc --noEmit",
38
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.browser.json",
33
39
  "generate": "bun scripts/build/bundle-skills.ts",
34
40
  "build": "bun run generate && bun build --compile --minify src/index.ts --outfile dist/agent-tool"
35
41
  },
36
42
  "devDependencies": {
43
+ "@happy-dom/global-registrator": "21.0.4",
37
44
  "@types/bun": "1.4.3",
45
+ "happy-dom": "21.0.4",
38
46
  "typescript": "7.0.2"
39
47
  },
40
48
  "dependencies": {
@@ -33,4 +33,5 @@ open PRs and requires the branch to be pushed at its local HEAD. It does not
33
33
  push for you. Resolve a fork's head/base identities explicitly if the repository
34
34
  uses fork PRs; the initial open helper supports same-repository branches.
35
35
 
36
- Report the created PR link and any remaining validation or review work.
36
+ Report the created PR link and any remaining validation or review work. Run
37
+ `agent-tool teardown` first, stop what the work started, and report what remains.
@@ -39,6 +39,8 @@ switch, and update by fast-forward only. Never discard or automatically stash
39
39
  edits, force a diverged default branch, or infer shipping from Git ancestry after
40
40
  a squash merge.
41
41
 
42
+ Run `agent-tool teardown` and stop what the finished work left running
43
+ (`teardown --apply` for recorded processes); report anything that remains.
42
44
  Run the repository's documented dependency/hook setup if required. When copied
43
45
  hooks are configured, use `agent-tool hooks check` and `hooks install --apply`;
44
46
  inspect unmanaged hooks before an explicit `--adopt`, which backs them up.
@@ -23,6 +23,9 @@ machine-readable receipts and review findings visible by running `versions
23
23
  prepare`, `review`, and other commands whose output drives a decision directly.
24
24
  Prefer documented compact test reporters when they preserve required evidence;
25
25
  do not reduce test scope, skip checks, silence warnings, or mask failures.
26
+ Start servers, watchers, and other long-running processes with
27
+ `agent-tool proc start --label <name> -- <command>`, never a backgrounded shell
28
+ command, so they can be found and stopped.
26
29
 
27
30
  1. Finish the requested change on a feature branch. Use
28
31
  `agent-tool branch create <branch> --base <base-branch> --remote <remote>`
@@ -73,8 +76,15 @@ do not reduce test scope, skip checks, silence warnings, or mask failures.
73
76
  live checks. Report deployment failures
74
77
  separately from a successful merge; follow project policy for repairs.
75
78
 
79
+ Before the final report, run `agent-tool teardown` in each repository this work
80
+ touched. Stop what the work started (`teardown --apply`, `proc stop`), stop any
81
+ untracked process it started, and remove worktrees it added. Report what
82
+ remains and why; the work is not done while teardown fails for anything it
83
+ started.
84
+
76
85
  Never infer a clean review from process success alone or carry a review across
77
86
  content changes. Do not weaken configured CI policy to get a merge through.
78
87
  If a step fails, preserve useful intermediate work and report the concrete
79
88
  remaining issue. Report PR link, merged commit, review verdict, validation, final
80
- checkout state, and whether the shipped local branch was pruned or preserved.
89
+ checkout state, the teardown result, and whether the shipped local branch was
90
+ pruned or preserved.
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@ import { doctor } from "./doctor";
6
6
  import { checkTerraformPlan } from "./dependencies/terraformPlan";
7
7
  import { dependencyDiff } from "./dependencies/diff";
8
8
  import { checkGraph } from "./graph/check";
9
+ import { buildScreenshots, describeScreenshots } from "./screenshots/build";
9
10
  import { review } from "./harnesses";
10
11
  import { runAgentToolAction } from "./runAgentToolAction";
11
12
  import { BUNDLED_SKILLS } from "./skills/bundled";
@@ -27,6 +28,7 @@ import { hooksCommand } from "./workflows/hooks";
27
28
  import { repoCommand, policyCommand, toolchainCommand } from "./workflows/repository";
28
29
  import { prWorkflowCommand } from "./workflows/pr";
29
30
  import { releaseCommand } from "./release/commands";
31
+ import { procCommand, teardownCommand } from "./processes/commands";
30
32
 
31
33
  export const HELP = `agent-tool ${metadata.version}
32
34
 
@@ -34,6 +36,9 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
34
36
 
35
37
  run [--label text] [--max-bytes N] [--tail-lines N] -- <command> [args...]
36
38
  run-group <plan.json> Sequential checks with durable source-bound receipts
39
+ proc start [--label text] [--keep] -- <command> [args...] Recorded long-running process
40
+ proc list | stop <id...>|--all [--grace-ms N]
41
+ teardown [--apply] What this checkout still runs; --apply stops recorded processes
37
42
  runs list [--head oid] [--branch name] [--since ISO-date]
38
43
  branch check [name...|--pre-push] | status
39
44
  branch create <name> [--base branch] [--remote origin]
@@ -73,6 +78,7 @@ Usage: agent-tool [--repo <directory>] [--config <file>] <command>
73
78
  permissions check [--harness <all|claude|codex>]
74
79
  images list [target...] JSON files each image target writes
75
80
  images build [target...] [--out <dir>] Render assets/brand/*.svg per agent-tool.json
81
+ screenshots build [target...] [--out <dir>] [--json] Stage guarded captures and a gallery manifest
76
82
  graph check Import cycles and layer violations per agent-tool.json
77
83
  init Create agent-tool.json without overwriting
78
84
  config show
@@ -111,6 +117,8 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
111
117
  if (command === "run") return runCommand(args);
112
118
  if (command === "ci") { usage(args.shift() === "gate", "Usage: ci gate <needs.json|-> [--policy policy.json]"); return gateCommand(args); }
113
119
  if (command === "release") return releaseCommand(args);
120
+ if (command === "proc") return procCommand(args);
121
+ if (command === "teardown") return teardownCommand(args);
114
122
  if (command === "logs") return inspectLog(args);
115
123
  if (command === "dependencies" && args[0] === "diff") {
116
124
  usage(args.length === 3 && args[1] === "--base" && !!args[2] && !args[2].startsWith("-"), "Usage: agent-tool dependencies diff --base <ref>");
@@ -151,6 +159,21 @@ export function main(argv = process.argv.slice(2)): number | Promise<number> {
151
159
  output(action === "list" ? listImages(images, names) : await buildImages(process.cwd(), images, names, out)); return 0;
152
160
  });
153
161
  }
162
+ if (command === "screenshots") {
163
+ const action = args.shift();
164
+ usage(action === "build", "Usage: agent-tool screenshots build [target...] [--out <dir>] [--json]");
165
+ const names: string[] = [];
166
+ let out: string | undefined, json = false;
167
+ while (args.length) {
168
+ const value = args.shift()!;
169
+ if (value === "--out") { usage(out === undefined && !!args[0] && !args[0].startsWith("--"), "--out requires one directory."); out = args.shift(); }
170
+ else if (value === "--json") { usage(!json, "Duplicate --json."); json = true; }
171
+ else { usage(!value.startsWith("-"), `Unknown screenshots argument '${value}'.`); names.push(value); }
172
+ }
173
+ const receipt = buildScreenshots(process.cwd(), loadConfig().screenshots, names, out);
174
+ if (json) output(receipt); else writeSync(1, describeScreenshots(receipt));
175
+ return 0;
176
+ }
154
177
  if (command === "graph") {
155
178
  usage(args.length === 1 && args[0] === "check", "Usage: agent-tool graph check");
156
179
  const report = checkGraph(process.cwd(), loadConfig().graph);
@@ -6,7 +6,7 @@ import { createLog } from "./logFile";
6
6
 
7
7
  const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
8
8
 
9
- export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string }
9
+ export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string; leftoverProcessGroup?: number }
10
10
 
11
11
  /** Run directly into a private file: child output never accumulates in memory. */
12
12
  export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean } = {}): Promise<number> {
@@ -79,9 +79,18 @@ export async function runCommand(args: string[], record?: (receipt: RunReceipt)
79
79
  forward("SIGKILL");
80
80
  }
81
81
  const exitCode = signal ? 128 + (constants.signals[signal] ?? 0) : status ?? 1;
82
- const receipt: RunReceipt = { status: exitCode === 0 ? "passed" : "failed", ...(label ? { label } : {}), exitCode, signal, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log };
82
+ // A finite command that left processes behind in its group, such as `server &`, is reported:
83
+ // nothing else would notice them before the work is called done.
84
+ let leftover: number | undefined;
85
+ if (grouped && !interrupted && child.pid) {
86
+ // EPERM still means the group exists, now holding a process this user cannot signal.
87
+ try { process.kill(-child.pid, 0); leftover = child.pid; }
88
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "EPERM") leftover = child.pid; }
89
+ }
90
+ const receipt: RunReceipt = { status: exitCode === 0 ? "passed" : "failed", ...(label ? { label } : {}), exitCode, signal, durationMs: Math.round(performance.now() - started), outputBytes: fstatSync(fd).size, log, ...(leftover ? { leftoverProcessGroup: leftover } : {}) };
83
91
  record?.(receipt);
84
92
  if (!options.quiet) writeSync(1, `${JSON.stringify(receipt)}\n`);
93
+ if (leftover) writeSync(2, `Processes this command started are still running in its process group ${leftover} (\`kill -TERM -${leftover}\`). Stop them, or start long-running processes with \`agent-tool proc start\`.\n`);
85
94
  if (exitCode !== 0) printLogTail(log, limits, 2);
86
95
  } finally {
87
96
  if (timer) clearTimeout(timer);
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { GRAPH_DEFAULTS, parseGraph, type GraphConfig } from "./graph/config";
4
4
  import { parseImages, type ImagesConfig } from "./images/config";
5
5
  import { CLASSES, type PermissionClass } from "./permissions/catalog";
6
+ import { parseScreenshots, SCREENSHOTS_DEFAULTS, type ScreenshotsConfig } from "./screenshots/config";
6
7
  import { WORKFLOW_DEFAULTS, validateWorkflowConfig, type WorkflowConfig } from "./workflows/config";
7
8
 
8
9
  export interface RequiredCheck {
@@ -18,6 +19,7 @@ export interface AgentToolConfig extends WorkflowConfig {
18
19
  versions: { packages: string[] | null; bundles: string[]; lockfile: "bun.lock" | null; validate: string[][]; commitMessage: string };
19
20
  pr: { rejectClaudeBranding: boolean };
20
21
  images: ImagesConfig;
22
+ screenshots: ScreenshotsConfig;
21
23
  graph: GraphConfig;
22
24
  permissions: { executables: Record<string, PermissionClass> };
23
25
  }
@@ -38,6 +40,7 @@ export const DEFAULT_CONFIG: AgentToolConfig = {
38
40
  versions: { packages: null, bundles: [], lockfile: "bun.lock", validate: [], commitMessage: "chore: bump package versions" },
39
41
  pr: { rejectClaudeBranding: false },
40
42
  images: { targets: {} },
43
+ screenshots: structuredClone(SCREENSHOTS_DEFAULTS),
41
44
  graph: structuredClone(GRAPH_DEFAULTS),
42
45
  permissions: { executables: {} },
43
46
  };
@@ -72,6 +75,7 @@ export function parseConfig(source: string): AgentToolConfig {
72
75
  Object.assign(result[section], settings);
73
76
  }
74
77
  if (input.images !== undefined) result.images = parseImages(input.images);
78
+ if (input.screenshots !== undefined) result.screenshots = parseScreenshots(input.screenshots);
75
79
  if (input.graph !== undefined) result.graph = parseGraph(input.graph);
76
80
  if (input.branch !== undefined && !Object.hasOwn(input.branch as object, "enforce")) result.branch.enforce = true;
77
81
  const invalid = (setting: string): never => { throw new Error(`Invalid config setting '${setting}'.`); };
@@ -1,12 +1,12 @@
1
1
  import { initWasm, Resvg } from "@resvg/resvg-wasm";
2
2
  import wasm from "@resvg/resvg-wasm/index_bg.wasm" with { type: "file" };
3
- import { randomUUID } from "node:crypto";
4
- import { closeSync, constants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
5
4
  import path from "node:path";
6
5
  import { composeSvg, type Canvas } from "./compose";
7
6
  import { expand, format, SOURCE_DIRECTORY, verbatim, type ImageOutput, type ImagesConfig, type ImageTarget } from "./config";
8
7
  import { encodeIco, encodePng, fromPremultiplied, type Pixels } from "./png";
9
8
  import { parseSvg, type SvgSource } from "./svg";
9
+ import { assertNoLinks, replaceFile } from "../system/files";
10
10
 
11
11
  let ready: Promise<void> | undefined;
12
12
 
@@ -85,49 +85,6 @@ function select(config: ImagesConfig, names: string[], writable: boolean): [stri
85
85
  return selected.map(name => [name, config.targets[name]!]);
86
86
  }
87
87
 
88
- /** Refuses symbolic links below the base, so a committed link cannot redirect a write outside it. */
89
- function assertNoLinks(base: string, destination: string): void {
90
- let current = base;
91
- for (const part of path.relative(base, destination).split(path.sep)) {
92
- current = path.join(current, part);
93
- let link: boolean;
94
- try { link = lstatSync(current).isSymbolicLink(); } catch { return; }
95
- if (link) throw new Error(`Refusing to write through symbolic link ${path.relative(base, current)}.`);
96
- }
97
- }
98
-
99
- /**
100
- * Replaces a file atomically through an unpredictable, exclusively created
101
- * temporary name, so no committed link can stand in for it. Sandboxed build
102
- * phases, such as Xcode's, may write only their declared outputs; there the
103
- * file is rewritten in place, refusing to follow a link.
104
- */
105
- export function replaceFile(destination: string, bytes: Uint8Array): void {
106
- const temporary = `${destination}.${randomUUID()}.tmp`;
107
- let created = false;
108
- try {
109
- writeFileSync(temporary, bytes, { flag: "wx" });
110
- created = true;
111
- renameSync(temporary, destination);
112
- } catch (error) {
113
- if (created || !["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) {
114
- // Best effort: the original error matters more than a temporary file left behind.
115
- try { rmSync(temporary, { force: true }); } catch {}
116
- throw error;
117
- }
118
- // Nothing was created, and a sandbox may refuse even an attempt to remove an undeclared path.
119
- const descriptor = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW);
120
- try {
121
- // A hard link would share the file with a path outside the output, so only a sole link is rewritten.
122
- const stats = fstatSync(descriptor);
123
- if (!stats.isFile() || stats.nlink !== 1) throw new Error(`Refusing to rewrite ${destination}, which is not a file with a single link.`);
124
- ftruncateSync(descriptor, 0);
125
- // writeFileSync on a descriptor repeats short writes until every byte is written.
126
- writeFileSync(descriptor, bytes);
127
- } finally { closeSync(descriptor); }
128
- }
129
- }
130
-
131
88
  function directory(name: string, target: ImageTarget, out: string | undefined): string {
132
89
  const dir = out ?? target.dir;
133
90
  if (dir === null) throw new Error(`Image target '${name}' has no dir; pass --out <directory>.`);
package/src/images/png.ts CHANGED
@@ -88,6 +88,16 @@ export function encodePng({ width, height, channels, data }: Pixels): Uint8Array
88
88
  ]);
89
89
  }
90
90
 
91
+ const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
92
+
93
+ /** Pixel size from a PNG's header: the signature, then IHDR's big-endian width and height. */
94
+ export function pngSize(bytes: Uint8Array): { width: number; height: number } | null {
95
+ if (bytes.length < 24 || SIGNATURE.some((byte, index) => bytes[index] !== byte) || new TextDecoder().decode(bytes.subarray(12, 16)) !== "IHDR") return null;
96
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
97
+ const width = view.getUint32(16), height = view.getUint32(20);
98
+ return width > 0 && height > 0 ? { width, height } : null;
99
+ }
100
+
91
101
  /** A Windows icon that stores one PNG per size, which every current browser reads. */
92
102
  export function encodeIco(images: readonly Pixels[]): Uint8Array {
93
103
  const pngs = images.map(encodePng);
@@ -19,12 +19,13 @@ export type Decision = "allow" | "ask";
19
19
  export interface CatalogEntry { command: string; class: PermissionClass; applyClass?: PermissionClass }
20
20
 
21
21
  export const CATALOG: CatalogEntry[] = [
22
- ...["--help", "--version", "logs", "runs list", "doctor", "config show", "branch check", "branch status", "commits check", "checks binaries", "repo context", "policy check", "toolchain check", "ci gate", "release plan", "release verify", "pr context", "pr checks", "pr logs", "pr preflight", "pr threads list", "versions plan", "versions check", "dependencies check-terraform-plan", "dependencies diff", "skills list", "skills check", "hooks check", "images list", "graph check", "permissions show", "permissions check"]
22
+ ...["--help", "--version", "logs", "runs list", "doctor", "config show", "branch check", "branch status", "commits check", "checks binaries", "repo context", "policy check", "toolchain check", "ci gate", "release plan", "release verify", "pr context", "pr checks", "pr logs", "pr preflight", "pr threads list", "versions plan", "versions check", "dependencies check-terraform-plan", "dependencies diff", "skills list", "skills check", "hooks check", "images list", "graph check", "permissions show", "permissions check", "proc list"]
23
23
  .map(command => ({ command, class: "read" as const })),
24
- ...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "review", "init"]
24
+ ...["bun install", "bun update", "bun remove", "bun test", "branch create", "versions bump", "versions prepare", "versions resolve-conflicts", "images build", "screenshots build", "review", "init", "proc stop"]
25
25
  .map(command => ({ command, class: "local" as const })),
26
26
  { command: "skills install", class: "read", applyClass: "local" },
27
27
  { command: "hooks install", class: "read", applyClass: "local" },
28
+ { command: "teardown", class: "read", applyClass: "local" },
28
29
  { command: "permissions install", class: "read", applyClass: "policy" },
29
30
  ...["branch push", "pr open", "pr threads reply", "pr threads resolve"].map(command => ({ command, class: "share" as const })),
30
31
  { command: "pr merge", class: "release" },
@@ -36,7 +37,7 @@ export const CATALOG: CatalogEntry[] = [
36
37
  { command: "openPr", class: "share" },
37
38
  { command: "squashMerge", class: "release" },
38
39
  ];
39
- // `run` and `run-group` execute arbitrary commands, so they stay unclassified and keep the harness default.
40
+ // `run`, `run-group` and `proc start` execute arbitrary commands, so they stay unclassified and keep the harness default.
40
41
  // `bun x` runs only executables that `permissions.executables` classifies; project.ts adds those.
41
42
  // Package scripts are not classified, so `bun run` keeps the harness default.
42
43
 
@@ -0,0 +1,283 @@
1
+ // `proc start|list|stop` and `teardown`: long-running processes started through agent-tool are
2
+ // recorded so they can be found and stopped, and teardown reports everything a piece of work
3
+ // may have left running in the repository before an agent says it is done.
4
+ import { spawn } from "node:child_process";
5
+ import { closeSync, realpathSync } from "node:fs";
6
+ import { constants } from "node:os";
7
+ import path from "node:path";
8
+ import { createLog } from "../commands/logFile";
9
+ import { plainText } from "../commands/output";
10
+ import { assert, git, output } from "../workflows/core";
11
+ import { claimedBy, ledgerDirectory, membersOf, newId, observe, readRecords, removeRecord, saveRecord, type ProcessRecord, type ProcessState } from "./ledger";
12
+ import { ancestors, carrying, listeningPorts, processTable, startOf, workingDirectories, type ProcessRow } from "./table";
13
+
14
+ const STOP_GRACE_MS = 5000;
15
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
16
+ // A bound on another wait that does not itself keep the process alive once that wait is over.
17
+ const bound = (ms: number) => new Promise<undefined>(resolve => { (setTimeout(resolve, ms) as unknown as NodeJS.Timeout).unref(); });
18
+
19
+ function within(root: string, candidate: string): boolean {
20
+ const relative = path.relative(root, candidate);
21
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
22
+ }
23
+
24
+ function signal(pid: number, value: NodeJS.Signals, group: boolean): void {
25
+ try { process.kill(group ? -pid : pid, value); }
26
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
27
+ }
28
+
29
+ interface Described { id: string; label: string | null; state: ProcessState; keep: boolean; pid: number; command: string[]; cwd: string; log: string; startedAt: string; members: { pid: number; command: string }[]; ports: string[] }
30
+
31
+ // Read-only: `proc list` and report-only `teardown` never write records.
32
+ function describe(record: ProcessRecord, table: Map<number, ProcessRow>, ports: Map<number, string[]>, others: ProcessRecord[]): Described {
33
+ const { state, members } = membersOf(record, table, others);
34
+ return {
35
+ id: record.id, label: record.label, state, keep: record.keep, pid: record.pid, command: record.command, cwd: record.cwd, log: record.log, startedAt: record.startedAt,
36
+ members: members.map(row => ({ pid: row.pid, command: row.command })),
37
+ ports: [...new Set(members.flatMap(row => ports.get(row.pid) ?? []))].sort(),
38
+ };
39
+ }
40
+
41
+ const alive = (state: ProcessState) => state === "running" || state === "orphaned";
42
+
43
+ /**
44
+ * Stops one record's processes: SIGTERM to its group and to members outside it, then SIGKILL
45
+ * after the grace period. Every member seen is remembered with its start time, so a descendant
46
+ * that left the group and outlived its parent is still signalled, and only while it is the same
47
+ * process; the group is signalled only while its leader, or a member of it, is ours.
48
+ */
49
+ async function stopRecord(directory: string, record: ProcessRecord, grace = STOP_GRACE_MS): Promise<{ id: string; stopped: boolean; remaining: number[] }> {
50
+ const known = new Map<number, string>();
51
+ const look = () => {
52
+ const table = processTable(), others = readRecords(directory).records, { state, members } = membersOf(record, table, others);
53
+ observe(directory, record, state, members);
54
+ for (const member of members) known.set(member.pid, member.started);
55
+ const claimed = claimedBy(record, table, others);
56
+ const live = [...known].filter(([pid, started]) => table.get(pid)?.started === started && !claimed.has(pid)).map(([pid]) => table.get(pid)!);
57
+ // The group id is ours while our leader runs, or while a member we know still holds it;
58
+ // otherwise it may name someone else's group, and members are signalled one by one.
59
+ return { group: state === "running" || live.some(member => member.pgid === record.pid), live };
60
+ };
61
+ const send = (value: NodeJS.Signals) => {
62
+ const { group, live } = look();
63
+ if (group) signal(record.pid, value, true);
64
+ for (const member of live) if (!group || member.pgid !== record.pid) signal(member.pid, value, false);
65
+ };
66
+ const running = () => { const { group, live } = look(); return group || live.length > 0; };
67
+ send("SIGTERM");
68
+ const deadline = performance.now() + grace;
69
+ while (performance.now() < deadline && running()) await sleep(50);
70
+ send("SIGKILL");
71
+ for (let tries = 0; tries < 20 && running(); tries += 1) await sleep(50);
72
+ const { group, live } = look();
73
+ const stopped = !group && live.length === 0;
74
+ // observe() may have rewritten the record while stopping; remove it only after the last look.
75
+ if (stopped) removeRecord(directory, record.id);
76
+ return { id: record.id, stopped, remaining: stopped ? [] : live.map(row => row.pid) };
77
+ }
78
+
79
+ /**
80
+ * The members of a launcher's group that are provably its own once the launcher has exited and
81
+ * the group id could name another group: those carrying the record's id, or started while the
82
+ * launcher ran, to the second `ps` reports with a second's margin either side.
83
+ */
84
+ export function adoptable(candidates: ProcessRow[], tagged: Set<number>, spawnedAt: number, exitedAt: number): ProcessRow[] {
85
+ const from = Math.floor(spawnedAt / 1000) * 1000 - 1000, until = exitedAt + 1000;
86
+ return candidates.filter(row => {
87
+ const at = Date.parse(`${row.started} UTC`);
88
+ return tagged.has(row.pid) || (at >= from && at <= until);
89
+ });
90
+ }
91
+
92
+ async function start(args: string[]): Promise<number> {
93
+ let label: string | null = null, keep = false;
94
+ while (args.length && args[0] !== "--") {
95
+ const flag = args.shift();
96
+ if (flag === "--label") {
97
+ const value = args.shift();
98
+ assert(value && value.length <= 120, "--label requires 1 to 120 characters.");
99
+ label = plainText(value);
100
+ } else if (flag === "--keep") keep = true;
101
+ else throw new Error(`Unknown proc start argument '${flag}'.`);
102
+ }
103
+ assert(args.shift() === "--" && args[0], "Usage: agent-tool proc start [--label text] [--keep] -- <command> [args...]");
104
+ const directory = ledgerDirectory();
105
+ // Without a working ps nothing started could be identified, recorded or stopped later.
106
+ try { processTable(); }
107
+ catch (error) { throw new Error(`Cannot inspect processes with ps, so nothing was started: ${(error as Error).message}`); }
108
+ const id = newId(), { log, fd } = createLog("agent-tool-proc-");
109
+ let record: ProcessRecord | undefined;
110
+ try {
111
+ // Its own process group, so stopping it reaches everything it starts that stays in the group.
112
+ const spawnedAt = Date.now();
113
+ let exitedAt: number | undefined;
114
+ const child = spawn(args[0]!, args.slice(1), { cwd: process.cwd(), detached: true, stdio: ["ignore", fd, fd], env: { ...process.env, AGENT_TOOL_PROCESS: id } });
115
+ const exited = new Promise<{ code: number | null; signal?: NodeJS.Signals | null; error?: string }>(resolve => {
116
+ child.once("error", error => resolve({ code: (error as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 126, error: error.message }));
117
+ child.once("exit", (code, signal) => { exitedAt = Date.now(); resolve({ code, signal }); });
118
+ });
119
+ if (child.pid) {
120
+ const started = startOf(child.pid);
121
+ if (started) {
122
+ record = { schemaVersion: 1, id, label, pid: child.pid, started, command: [...args], cwd: process.cwd(), log, startedAt: new Date().toISOString(), keep };
123
+ try { saveRecord(directory, record); }
124
+ catch (error) {
125
+ // An unrecorded process could never be found and stopped by teardown.
126
+ signal(child.pid, "SIGKILL", true);
127
+ await Promise.race([exited, bound(2000)]);
128
+ throw new Error(`Could not record the process, so it was stopped: ${(error as Error).message}`);
129
+ }
130
+ } else if (!(await Promise.race([exited, bound(300)]))) {
131
+ // Running, but its identity could not be read, so it could never be stopped safely later.
132
+ signal(child.pid, "SIGKILL", true);
133
+ await Promise.race([exited, bound(2000)]);
134
+ throw new Error("Could not read the started process's identity with ps; stopped it. Nothing was recorded.");
135
+ }
136
+ }
137
+ // A command that fails at once is reported here rather than found later.
138
+ const early = await Promise.race([exited, bound(300)]);
139
+ child.unref();
140
+ if (child.pid) {
141
+ const table = processTable();
142
+ if (table.get(child.pid)?.started === record?.started && record) {
143
+ const { state, members } = membersOf(record, table, readRecords(directory).records);
144
+ observe(directory, record, state, members);
145
+ } else if (!table.has(child.pid)) {
146
+ // The launcher exited, perhaps before its identity could be read, and its group id may
147
+ // already name another group. A member is ours when it carries this record's id, or
148
+ // started while the launcher ran (to the second `ps` reports, with a second's margin).
149
+ const candidates = [...table.values()].filter(row => row.pgid === child.pid);
150
+ const survivors = adoptable(candidates, carrying(candidates.map(row => row.pid), "AGENT_TOOL_PROCESS", id), spawnedAt, exitedAt ?? Date.now());
151
+ if (survivors.length) {
152
+ record ??= { schemaVersion: 1, id, label, pid: child.pid, started: "", command: [...args], cwd: process.cwd(), log, startedAt: new Date().toISOString(), keep };
153
+ record.members = survivors.map(row => ({ pid: row.pid, started: row.started }));
154
+ try { saveRecord(directory, record); }
155
+ catch (error) {
156
+ for (const row of survivors) signal(row.pid, "SIGKILL", false);
157
+ throw new Error(`Could not record the processes the command left running, so they were stopped: ${(error as Error).message}`);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ const receipt = { id, ...(label ? { label } : {}), pid: child.pid ?? null, keep, log, ...(early ? { exited: early.code, ...(early.signal ? { signal: early.signal } : {}), ...(early.error ? { error: early.error } : {}) } : {}) };
163
+ if (early && record && !alive(membersOf(record, processTable()).state)) removeRecord(directory, id);
164
+ output(receipt);
165
+ if (early?.signal) return 128 + (constants.signals[early.signal] ?? 0);
166
+ return early && early.code !== 0 ? (early.code ?? 1) : 0;
167
+ } finally { closeSync(fd); }
168
+ }
169
+
170
+ function parseStopTargets(args: string[]): { all: boolean; ids: string[]; grace: number } {
171
+ let all = false, grace = STOP_GRACE_MS;
172
+ const ids: string[] = [];
173
+ while (args.length) {
174
+ const value = args.shift()!;
175
+ if (value === "--all") all = true;
176
+ else if (value === "--grace-ms") {
177
+ grace = Number(args.shift());
178
+ assert(Number.isInteger(grace) && grace >= 0 && grace <= 60_000, "--grace-ms requires 0 to 60000.");
179
+ } else { assert(/^[a-f0-9]{8}$/.test(value), `Unknown process id '${value}'.`); ids.push(value); }
180
+ }
181
+ assert(all !== ids.length > 0, "Usage: agent-tool proc stop <id...>|--all [--grace-ms N]");
182
+ return { all, ids, grace };
183
+ }
184
+
185
+ export async function procCommand(args: string[]): Promise<number> {
186
+ const action = args.shift();
187
+ if (action === "start") return start(args);
188
+ const directory = ledgerDirectory();
189
+ if (action === "list") {
190
+ assert(args.length === 0, "Usage: agent-tool proc list");
191
+ const { records, skipped } = readRecords(directory), table = processTable(), { ports } = listeningPorts();
192
+ output({ processes: records.map(record => describe(record, table, ports, records)), skipped });
193
+ return 0;
194
+ }
195
+ assert(action === "stop", "Usage: agent-tool proc <start|list|stop>");
196
+ const { all, ids, grace } = parseStopTargets(args);
197
+ const { records, skipped } = readRecords(directory);
198
+ for (const id of ids) assert(records.some(record => record.id === id), `No recorded process '${id}'.`);
199
+ const results = [];
200
+ for (const record of records.filter(record => all || ids.includes(record.id))) results.push(await stopRecord(directory, record, grace));
201
+ output({ stopped: results, skipped });
202
+ // An unreadable record may name processes --all could not stop.
203
+ return results.every(result => result.stopped) && (!all || skipped.length === 0) ? 0 : 1;
204
+ }
205
+
206
+ export interface TeardownReport {
207
+ ok: boolean;
208
+ applied: boolean;
209
+ root: string;
210
+ /** Recorded processes still running: they fail teardown unless started with --keep. */
211
+ leftover: Described[];
212
+ kept: Described[];
213
+ /** Records whose processes have ended; --apply removes them. */
214
+ finished: { id: string; label: string | null; state: ProcessState }[];
215
+ stopped: { id: string; stopped: boolean; remaining: number[] }[];
216
+ /** Processes started some other way whose working directory is in the repository. Listeners fail teardown. */
217
+ untracked: { pid: number; command: string; cwd: string; ports: string[] }[];
218
+ worktrees: { path: string; branch: string | null }[];
219
+ unavailable: string[];
220
+ skipped: { file: string; reason: string }[];
221
+ }
222
+
223
+ function survey(root: string, directory: string): Omit<TeardownReport, "ok" | "applied" | "stopped"> {
224
+ const table = processTable(), { records, skipped } = readRecords(directory);
225
+ const listening = listeningPorts(), cwd = workingDirectories(table.keys());
226
+ const described = records.map(record => describe(record, table, listening.ports, records));
227
+ const owned = new Set(described.flatMap(item => alive(item.state) ? item.members.map(member => member.pid) : []));
228
+ const excluded = ancestors(table);
229
+ const untracked = [...cwd.cwd].flatMap(([pid, directoryOf]) => {
230
+ const row = table.get(pid);
231
+ if (!row || excluded.has(pid) || owned.has(pid) || !within(root, directoryOf)) return [];
232
+ // Processes this command started to look (ps, lsof) are its children.
233
+ if (row.ppid === process.pid) return [];
234
+ const ports = listening.ports.get(pid) ?? [];
235
+ // A pipeline's other commands (`agent-tool teardown | jq`) share its shell and group; one that
236
+ // listens is still reported, since a server backgrounded from the same shell looks alike.
237
+ const self = table.get(process.pid);
238
+ if (self && row.pgid === self.pgid && row.ppid === self.ppid && ports.length === 0) return [];
239
+ return [{ pid, command: row.command, cwd: directoryOf, ports }];
240
+ }).sort((a, b) => a.pid - b.pid);
241
+ const worktrees: { path: string; branch: string | null }[] = [];
242
+ let current: { path: string; branch: string | null } | undefined;
243
+ for (const line of git(["worktree", "list", "--porcelain"]).split("\n")) {
244
+ if (line.startsWith("worktree ")) worktrees.push(current = { path: line.slice(9), branch: null });
245
+ else if (line.startsWith("branch ") && current) current.branch = line.slice(7).replace(/^refs\/heads\//, "");
246
+ }
247
+ return {
248
+ root,
249
+ leftover: described.filter(item => alive(item.state) && !item.keep),
250
+ kept: described.filter(item => alive(item.state) && item.keep),
251
+ finished: described.filter(item => !alive(item.state)).map(item => ({ id: item.id, label: item.label, state: item.state })),
252
+ untracked,
253
+ // The first entry is the main worktree; the rest were added and may need removing.
254
+ worktrees: worktrees.slice(1),
255
+ unavailable: [...(listening.available ? [] : ["listening ports (lsof)"]), ...(cwd.available ? [] : ["working directories (lsof)"])],
256
+ skipped,
257
+ };
258
+ }
259
+
260
+ export async function teardownCommand(args: string[]): Promise<number> {
261
+ let apply = false;
262
+ for (const value of args) {
263
+ assert(value === "--apply" && !apply, "Usage: agent-tool teardown [--apply]");
264
+ apply = true;
265
+ }
266
+ const root = realpathSync(git(["rev-parse", "--show-toplevel"])), directory = ledgerDirectory();
267
+ const stopped: TeardownReport["stopped"] = [];
268
+ if (apply) {
269
+ const { records } = readRecords(directory), table = processTable();
270
+ for (const record of records) {
271
+ const { state, members } = membersOf(record, table, records);
272
+ observe(directory, record, state, members);
273
+ if (!alive(state)) removeRecord(directory, record.id);
274
+ else if (!record.keep) stopped.push(await stopRecord(directory, record));
275
+ }
276
+ }
277
+ const found = survey(root, directory);
278
+ // A clean report needs every inspection: without lsof a listening server would go unseen.
279
+ // Unreadable records may name running processes, so they fail teardown too.
280
+ const ok = found.leftover.length === 0 && found.untracked.every(item => item.ports.length === 0) && found.unavailable.length === 0 && found.skipped.length === 0;
281
+ output({ ok, applied: apply, ...found, stopped } satisfies TeardownReport);
282
+ return ok ? 0 : 1;
283
+ }