@a2f0/agent-tool 0.1.29 → 0.1.31

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,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
+ }
@@ -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
+ }
@@ -1,32 +1,8 @@
1
- import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
- import path from "node:path";
1
+ import { outsideExecutable } from "../system/executable";
3
2
  import type { ReviewerEnv } from "./runReview";
4
3
 
5
- function within(root: string, candidate: string): boolean {
6
- const relative = path.relative(root, candidate);
7
- return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
8
- }
9
-
10
- /**
11
- * Find a PATH executable whose entry and target both lie outside the
12
- * repository. `candidate` keeps the PATH entry, which multi-call binaries such
13
- * as `bunx` need; `resolved` is its real file.
14
- */
15
- export function outsideExecutable(name: string, env: ReviewerEnv, repositoryRoot = process.cwd()): { candidate: string; resolved: string } | undefined {
16
- const root = realpathSync(repositoryRoot);
17
- for (const directory of (env.PATH ?? "").split(path.delimiter)) {
18
- if (!path.isAbsolute(directory)) continue;
19
- const candidate = path.join(directory, name);
20
- try {
21
- accessSync(candidate, constants.X_OK);
22
- const resolved = realpathSync(candidate);
23
- if (!statSync(resolved).isFile() || within(root, candidate) || within(root, resolved)) continue;
24
- return { candidate, resolved };
25
- } catch { /* Continue searching outside the repository. */ }
26
- }
27
- // Never fall back to a predictable path or PATH resolution after rejection.
28
- return undefined;
29
- }
4
+ // The generic PATH lookup lives in the primitives layer; re-exported for existing callers.
5
+ export { outsideExecutable };
30
6
 
31
7
  /** Never launch a reviewer binary supplied by the repository being reviewed. */
32
8
  export function reviewExecutable(name: string, env: ReviewerEnv, repositoryRoot = process.cwd()): string | undefined {