pi-claude-supervisor 0.5.3 → 0.5.4

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,218 @@
1
+ import { readdir, readFile, readlink } from "node:fs/promises";
2
+
3
+ export interface ProcessTreeEntry {
4
+ pid: number;
5
+ ppid: number;
6
+ startTime: string;
7
+ command: string;
8
+ executable: string;
9
+ argv0: string;
10
+ commandLine: string;
11
+ /** Raw argv when read from Linux /proc; test fixtures may omit it. */
12
+ argv?: string[];
13
+ }
14
+
15
+ export async function linuxCgroupProcesses(cgroupPath: string): Promise<ProcessTreeEntry[]> {
16
+ if (process.platform !== "linux") return [];
17
+ const contents = await readFile(`${cgroupPath}/cgroup.procs`, "utf8");
18
+ const pids = [...new Set(contents.split(/\s+/u).filter(Boolean).map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0))];
19
+ const entries = await Promise.all(pids.map((pid) => readProcess(pid)));
20
+ if (entries.some((entry) => !entry)) throw new Error("cgroup process identity could not be inspected");
21
+ return entries as ProcessTreeEntry[];
22
+ }
23
+
24
+ export async function linuxProcessTree(rootPid: number): Promise<ProcessTreeEntry[]> {
25
+ if (process.platform !== "linux" || !Number.isInteger(rootPid) || rootPid <= 0) return [];
26
+ let names: string[];
27
+ try { names = await readdir("/proc"); }
28
+ catch (error) { throw new Error(`Linux process inspection is unavailable: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); }
29
+ const entries = (await Promise.all(names.filter((name) => /^\d+$/u.test(name)).map((name) => readProcess(Number(name))))).filter((entry): entry is ProcessTreeEntry => Boolean(entry));
30
+ if (!entries.some((entry) => entry.pid === rootPid)) throw new Error(`Linux worker process ${rootPid} could not be inspected`);
31
+ const byPid = new Map(entries.map((entry) => [entry.pid, entry]));
32
+ return entries.filter((entry) => entry.pid === rootPid || isDescendant(entry, rootPid, byPid));
33
+ }
34
+
35
+ export function isClaudeProcess(entry: ProcessTreeEntry, expectedCommand?: string): boolean {
36
+ if (expectedCommand && expectedCommandInvocation(entry, expectedCommand)) return true;
37
+ const executableName = basename(entry.executable);
38
+ const commandName = basename(entry.command);
39
+ const isClaudeNamed = executableName === "claude" || executableName === "claude.exe" || commandName === "claude" || commandName === "claude.exe";
40
+ if (!isClaudeNamed) return false;
41
+ const argv0 = basename(entry.argv0);
42
+ return argv0 === "claude" || argv0 === "claude.exe" || basename(entry.commandLine.split(/\s+/u)[0] ?? "") === "claude" || basename(entry.commandLine.split(/\s+/u)[0] ?? "") === "claude.exe";
43
+ }
44
+
45
+ export function isClaudeLauncherProcess(entry: ProcessTreeEntry): boolean {
46
+ if (isClaudeProcess(entry)) return true;
47
+ return commandInvocationCandidates(entry.commandLine, entry.argv).some((value) => {
48
+ const name = basename(value);
49
+ return name === "claude" || name === "claude.exe";
50
+ });
51
+ }
52
+
53
+ export function unexpectedReviewerProcess(entries: readonly ProcessTreeEntry[]): ProcessTreeEntry | undefined {
54
+ const pattern = /(?:^|[\\/_ .-])(?:review(?:er)?|code[-_ ]?review|read[-_ ]?only[-_ ]?review|independent[-_ ]?review|codex|cursor(?:-agent)?|aider|opencode|gemini)(?:$|[\\/_. -])/iu;
55
+ return entries.find((entry) => {
56
+ const tokens = entry.commandLine.split(/\s+/u).map((token) => token.replace(/^['"]|['"]$/gu, ""));
57
+ const candidates: Array<string | undefined> = [entry.argv0, entry.executable, entry.command, tokens[0], tokens[1]];
58
+ if (tokens[1] === "-c" || tokens[1] === "--command") candidates.push(tokens[2]);
59
+ if (basename(tokens[0] ?? "") === "env") candidates.push(tokens.find((token) => !token.startsWith("-") && !token.includes("=") && token !== tokens[0]));
60
+ return candidates.some((value) => typeof value === "string" && pattern.test(value));
61
+ });
62
+ }
63
+
64
+ export function sameProcessIdentity(expected: ProcessTreeEntry, current: ProcessTreeEntry): boolean {
65
+ return expected.pid === current.pid
66
+ && expected.startTime === current.startTime
67
+ && expected.executable === current.executable
68
+ && expected.argv0 === current.argv0;
69
+ }
70
+
71
+ export function unexpectedClaudeProcess(entries: readonly ProcessTreeEntry[], trusted: ReadonlyMap<number, ProcessTreeEntry>, expectedCommand?: string): ProcessTreeEntry | undefined {
72
+ for (const entry of entries) {
73
+ if (!isClaudeProcess(entry, expectedCommand)) continue;
74
+ const original = trusted.get(entry.pid);
75
+ if (!original || !sameProcessIdentity(original, entry)) return entry;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ export async function readProcess(pid: number): Promise<ProcessTreeEntry | undefined> {
81
+ try {
82
+ const statText = await readFile(`/proc/${pid}/stat`, "utf8");
83
+ const closeParen = statText.lastIndexOf(")");
84
+ if (closeParen < 0) return undefined;
85
+ const fields = statText.slice(closeParen + 2).trim().split(/\s+/u);
86
+ const ppid = Number(fields[1]);
87
+ const startTime = fields[19];
88
+ if (!Number.isSafeInteger(ppid) || !startTime) return undefined;
89
+ const command = (await readFile(`/proc/${pid}/comm`, "utf8")).trim();
90
+ const rawCommandLine = await readFile(`/proc/${pid}/cmdline`, "utf8");
91
+ const argv = rawCommandLine.split("\u0000").filter((argument) => argument.length > 0);
92
+ const argv0 = argv[0] ?? "";
93
+ const commandLine = argv.join(" ").trim();
94
+ let executable = command;
95
+ try { executable = await readlink(`/proc/${pid}/exe`); }
96
+ catch { /* exited or permission-restricted processes fall back to comm */ }
97
+ return { pid, ppid, startTime, command, executable, argv0, commandLine, argv };
98
+ } catch {
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ function basename(value: string): string {
104
+ return value.split(/[\\/]/u).at(-1)?.toLowerCase() ?? "";
105
+ }
106
+
107
+ function expectedCommandInvocation(entry: ProcessTreeEntry, expectedCommand: string): boolean {
108
+ const expected = expectedCommand.trim();
109
+ if (!expected) return false;
110
+ const expectedBase = basename(expected);
111
+ const expectedAbsolute = expected.includes("/") || expected.includes("\\");
112
+ const argv0 = entry.argv0.trim();
113
+ if (argv0 === expected || (!expectedAbsolute && basename(argv0) === expectedBase)) return true;
114
+ return commandInvocationCandidates(entry.commandLine, entry.argv).some((value) => expectedAbsolute ? value === expected : basename(value) === expectedBase);
115
+ }
116
+
117
+ function commandInvocationCandidates(commandLine: string, argv?: readonly string[]): string[] {
118
+ const tokens = (argv && argv.length > 0 ? [...argv] : commandLine.split(/\s+/u)).map((token) => token.replace(/^['"]|['"]$/gu, ""));
119
+ if (tokens.length === 0) return [];
120
+ const candidates = [tokens[0]!];
121
+ let index = 0;
122
+ let launcher = basename(tokens[0]!);
123
+ if (launcher === "env") {
124
+ index = envCommandIndex(tokens, 1);
125
+ if (index >= tokens.length) return candidates;
126
+ launcher = basename(tokens[index]!);
127
+ candidates.push(tokens[index]!);
128
+ }
129
+ const shells = new Set(["sh", "bash", "dash", "zsh", "fish", "ksh", "mksh", "csh", "tcsh"]);
130
+ const wrappers = new Set(["node", "nodejs", "deno", "bun", ...shells]);
131
+ if (!wrappers.has(launcher)) return candidates;
132
+ let argument = index + 1;
133
+ if (shells.has(launcher)) {
134
+ argument = shellCommandIndex(tokens, argument);
135
+ } else {
136
+ argument = nodeScriptIndex(tokens, argument);
137
+ }
138
+ if (argument < tokens.length) {
139
+ candidates.push(tokens[argument]!);
140
+ const commandWords = tokens[argument]!.trim().split(/\s+/u);
141
+ if (commandWords.length > 1 && (commandWords[0] === "exec" || commandWords[0] === "command")) candidates.push(commandWords[1]!);
142
+ }
143
+ return candidates;
144
+ }
145
+
146
+ function envCommandIndex(tokens: readonly string[], start: number): number {
147
+ let index = start;
148
+ while (index < tokens.length) {
149
+ const token = tokens[index]!;
150
+ if (token === "--") return index + 1;
151
+ if (token === "-u" || token === "--unset" || token === "-C" || token === "--chdir" || token === "-S" || token === "--split-string") {
152
+ index += 2;
153
+ continue;
154
+ }
155
+ if (token.startsWith("--unset=") || token.startsWith("--chdir=")) {
156
+ index += 1;
157
+ continue;
158
+ }
159
+ if (token.startsWith("-") || token.includes("=")) {
160
+ index += 1;
161
+ continue;
162
+ }
163
+ return index;
164
+ }
165
+ return index;
166
+ }
167
+
168
+ function shellCommandIndex(tokens: readonly string[], start: number): number {
169
+ let index = start;
170
+ while (index < tokens.length) {
171
+ const token = tokens[index]!;
172
+ if (token === "-c" || token === "--command") return index + 1;
173
+ if (token === "-O" || token === "--rcfile" || token === "--init-file") {
174
+ index += 2;
175
+ continue;
176
+ }
177
+ if (token.startsWith("-")) {
178
+ index += 1;
179
+ continue;
180
+ }
181
+ return index;
182
+ }
183
+ return index;
184
+ }
185
+
186
+ function nodeScriptIndex(tokens: readonly string[], start: number): number {
187
+ let index = start;
188
+ while (index < tokens.length) {
189
+ const token = tokens[index]!;
190
+ if (token === "--") return index + 1;
191
+ if (token === "-e" || token === "--eval" || token === "-p" || token === "--print") return tokens.length;
192
+ if (token === "-r" || token === "--require" || token === "--loader" || token === "--experimental-loader" || token === "--import" || token === "--conditions" || token === "--title" || token === "--watch-path" || token === "--test-name-pattern" || token === "--test-reporter" || token === "--test-reporter-destination" || token === "--env-file") {
193
+ index += 2;
194
+ continue;
195
+ }
196
+ if (token.startsWith("--require=") || token.startsWith("--loader=") || token.startsWith("--experimental-loader=") || token.startsWith("--import=") || token.startsWith("--conditions=") || token.startsWith("--title=") || token.startsWith("--watch-path=") || token.startsWith("--test-name-pattern=") || token.startsWith("--test-reporter=") || token.startsWith("--test-reporter-destination=") || token.startsWith("--env-file=")) {
197
+ index += 1;
198
+ continue;
199
+ }
200
+ if (token.startsWith("-")) {
201
+ index += 1;
202
+ continue;
203
+ }
204
+ return index;
205
+ }
206
+ return index;
207
+ }
208
+
209
+ function isDescendant(entry: ProcessTreeEntry, rootPid: number, byPid: ReadonlyMap<number, ProcessTreeEntry>): boolean {
210
+ let parent = entry.ppid;
211
+ for (let depth = 0; depth < 64 && parent > 0; depth += 1) {
212
+ if (parent === rootPid) return true;
213
+ const ancestor = byPid.get(parent);
214
+ if (!ancestor || ancestor.ppid === parent) return false;
215
+ parent = ancestor.ppid;
216
+ }
217
+ return false;
218
+ }