@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 +4 -2
- package/package.json +10 -2
- package/skills/open-pr/SKILL.md +2 -1
- package/skills/reset/SKILL.md +2 -0
- package/skills/ship-pr/SKILL.md +11 -1
- package/src/cli.ts +23 -0
- package/src/commands/run.ts +11 -2
- package/src/config.ts +4 -0
- package/src/images/build.ts +2 -45
- package/src/images/png.ts +10 -0
- package/src/permissions/catalog.ts +4 -3
- package/src/processes/commands.ts +283 -0
- package/src/processes/ledger.ts +123 -0
- package/src/processes/table.ts +111 -0
- package/src/screenshots/build.ts +271 -0
- package/src/screenshots/config.ts +94 -0
- package/src/screenshots/gallery.css +528 -0
- package/src/screenshots/gallery.ts +713 -0
- package/src/screenshots/manifest.ts +176 -0
- package/src/skills/bundled.ts +3 -3
- package/src/system/files.ts +46 -0
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,
|
|
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.
|
|
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": {
|
package/skills/open-pr/SKILL.md
CHANGED
|
@@ -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.
|
package/skills/reset/SKILL.md
CHANGED
|
@@ -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.
|
package/skills/ship-pr/SKILL.md
CHANGED
|
@@ -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
|
|
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);
|
package/src/commands/run.ts
CHANGED
|
@@ -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
|
-
|
|
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}'.`); };
|
package/src/images/build.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
+
}
|