@hayasaka7/haya-pet 0.2.0 → 0.2.2

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.
Files changed (30) hide show
  1. package/.github/workflows/ci.yml +75 -0
  2. package/CHANGELOG.md +112 -0
  3. package/README.md +31 -14
  4. package/apps/cli/src/haya-pet.js +110 -21
  5. package/apps/cli/test/haya-pet.test.mjs +111 -7
  6. package/apps/companion/src/main/index.js +40 -1
  7. package/apps/companion/src/renderer/task-talk-window.js +1 -1
  8. package/apps/companion/test/position-store.test.mjs +1 -1
  9. package/docs/architecture.md +33 -10
  10. package/docs/cross-os-qa.md +72 -0
  11. package/docs/known-issues.md +92 -9
  12. package/docs/troubleshooting.md +3 -1
  13. package/eslint.config.js +32 -0
  14. package/package.json +7 -1
  15. package/packages/adapters/src/codex-hooks.js +152 -0
  16. package/packages/adapters/src/codex-transcript.js +73 -0
  17. package/packages/adapters/test/codex-hooks.test.mjs +120 -0
  18. package/packages/adapters/test/codex-transcript.test.mjs +97 -0
  19. package/packages/app-state/src/state.js +10 -5
  20. package/packages/cli-core/src/codex-hook-injection.js +49 -0
  21. package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
  22. package/packages/cli-core/src/run-command.js +0 -1
  23. package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
  24. package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
  25. package/packages/daemon-core/src/approval-process-watcher.js +169 -0
  26. package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
  27. package/packages/platform-core/src/process-snapshot.js +88 -0
  28. package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
  29. package/packages/session-core/src/bubble-view.js +10 -7
  30. package/packages/session-core/test/bubble-view.test.mjs +30 -5
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import { appendFileSync, mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "../../../test/harness.mjs";
6
+ import { discoverCodexTranscript, watchCodexTranscript } from "../src/codex-transcript-watcher.js";
7
+
8
+ const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
9
+
10
+ function toolStart(toolName = "shell_command", callId = "call_1", timestamp) {
11
+ return `${JSON.stringify({
12
+ ...(timestamp ? { timestamp } : {}),
13
+ type: "response_item",
14
+ payload: { type: "function_call", name: toolName, call_id: callId }
15
+ })}\n`;
16
+ }
17
+
18
+ test("discoverCodexTranscript finds the newest session jsonl under date folders", () => {
19
+ const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
20
+ const oldDir = join(root, "2026", "06", "07");
21
+ const newDir = join(root, "2026", "06", "08");
22
+ mkdirSync(oldDir, { recursive: true });
23
+ mkdirSync(newDir, { recursive: true });
24
+
25
+ const oldFile = join(oldDir, "rollout-old.jsonl");
26
+ const newFile = join(newDir, "rollout-new.jsonl");
27
+ writeFileSync(oldFile, "{}\n");
28
+ writeFileSync(newFile, "{}\n");
29
+ appendFileSync(newFile, "{}\n");
30
+
31
+ assert.equal(discoverCodexTranscript(root), newFile);
32
+ });
33
+
34
+ test("discoverCodexTranscript skips files older than session start", () => {
35
+ const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
36
+ const dir = join(root, "2026", "06", "08");
37
+ mkdirSync(dir, { recursive: true });
38
+
39
+ const oldFile = join(dir, "rollout-old.jsonl");
40
+ writeFileSync(oldFile, "{}\n");
41
+ const past = new Date(Date.now() - 3_600_000);
42
+ utimesSync(oldFile, past, past);
43
+
44
+ assert.equal(discoverCodexTranscript(root, Date.now() - 1000), undefined);
45
+ });
46
+
47
+ test("watchCodexTranscript reports appended tool events", () => {
48
+ const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
49
+ const path = join(dir, "session.jsonl");
50
+ writeFileSync(path, "");
51
+
52
+ const events = [];
53
+ const watcher = watchCodexTranscript({
54
+ transcriptPath: path,
55
+ onToolEvent: (event) => events.push(event),
56
+ ...noopTimers
57
+ });
58
+
59
+ watcher._tick();
60
+ appendFileSync(path, toolStart("shell_command", "call_shell"));
61
+ watcher._tick();
62
+
63
+ assert.deepEqual(events, [
64
+ {
65
+ type: "tool_started",
66
+ toolCallId: "call_shell",
67
+ toolName: "shell_command",
68
+ state: "running_tool"
69
+ }
70
+ ]);
71
+
72
+ watcher.stop();
73
+ });
74
+
75
+ test("watchCodexTranscript replays current-session records when a transcript is first discovered", () => {
76
+ const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
77
+ const dir = join(root, "2026", "06", "08");
78
+ mkdirSync(dir, { recursive: true });
79
+ const path = join(dir, "rollout-new.jsonl");
80
+ writeFileSync(
81
+ path,
82
+ [
83
+ toolStart("shell_command", "call_old", "2026-06-08T10:59:59.000Z"),
84
+ toolStart("shell_command", "call_new", "2026-06-08T11:00:01.000Z")
85
+ ].join("")
86
+ );
87
+
88
+ const events = [];
89
+ const watcher = watchCodexTranscript({
90
+ sessionsRoot: root,
91
+ startedAt: Date.parse("2026-06-08T11:00:00.000Z"),
92
+ onToolEvent: (event) => events.push(event),
93
+ ...noopTimers
94
+ });
95
+
96
+ watcher._tick();
97
+
98
+ assert.deepEqual(events, [
99
+ {
100
+ type: "tool_started",
101
+ toolCallId: "call_new",
102
+ toolName: "shell_command",
103
+ state: "running_tool"
104
+ }
105
+ ]);
106
+
107
+ watcher.stop();
108
+ });
@@ -0,0 +1,169 @@
1
+ // Detects that the user ACCEPTED a client's permission prompt by observing the
2
+ // client's process tree. Claude Code (and similar CLIs) emit no hook and write
3
+ // no transcript record at the moment of a manual approval — the only real-world
4
+ // signal is that the approved command actually starts running as a new process
5
+ // under the client. This watcher turns that into an event.
6
+ //
7
+ // Safety contract (the project rule this exists to honor): a pending-approval
8
+ // warning must NEVER be cleared by a guess or a timer. The watcher only reports
9
+ // "approved" when a NEW descendant process appears after the prompt and is still
10
+ // alive on the next poll. Short-lived blips (our own hook reporter, the user's
11
+ // hooks) die between polls and are filtered out. A missed detection is always
12
+ // safe — the pet just keeps showing "waiting" until the tool finishes.
13
+
14
+ const DEFAULT_POLL_INTERVAL_MS = 1500;
15
+
16
+ // Polls a full-process-table lister and fires `onApproved` ONCE when a new
17
+ // descendant of `rootPid` persists across two consecutive snapshots.
18
+ export function watchForApprovedProcess(options = {}) {
19
+ const {
20
+ rootPid,
21
+ listProcesses,
22
+ onApproved = () => {},
23
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
24
+ immediate = true,
25
+ setInterval: setIntervalFn = setInterval,
26
+ clearInterval: clearIntervalFn = clearInterval
27
+ } = options;
28
+
29
+ let baseline;
30
+ let candidates = new Set();
31
+ let stopped = false;
32
+ let ticking = false;
33
+
34
+ const tick = async () => {
35
+ if (stopped || ticking) {
36
+ return;
37
+ }
38
+ ticking = true;
39
+ try {
40
+ const table = await listProcesses();
41
+ const descendants = collectDescendants(table, rootPid);
42
+
43
+ if (!baseline) {
44
+ // First successful snapshot: everything already running (the client
45
+ // itself, its shell, the hook that reported the prompt) is baseline
46
+ // and can never count as the approved command.
47
+ baseline = descendants;
48
+ return;
49
+ }
50
+
51
+ const fresh = new Set();
52
+ for (const pid of descendants) {
53
+ if (baseline.has(pid)) {
54
+ continue;
55
+ }
56
+ if (candidates.has(pid)) {
57
+ // Seen in two consecutive snapshots — a real, persistent process.
58
+ stop();
59
+ onApproved({ pid });
60
+ return;
61
+ }
62
+ fresh.add(pid);
63
+ }
64
+ candidates = fresh;
65
+ } catch {
66
+ // Snapshots are best-effort; a failed poll must never break the daemon.
67
+ } finally {
68
+ ticking = false;
69
+ }
70
+ };
71
+
72
+ const timer = setIntervalFn(tick, pollIntervalMs);
73
+ if (timer && typeof timer.unref === "function") {
74
+ timer.unref();
75
+ }
76
+
77
+ function stop() {
78
+ if (stopped) {
79
+ return;
80
+ }
81
+ stopped = true;
82
+ clearIntervalFn(timer);
83
+ }
84
+
85
+ if (immediate) {
86
+ // Take the baseline right away rather than a full poll interval later, so a
87
+ // quickly-approved command is less likely to slip into the baseline.
88
+ void tick();
89
+ }
90
+
91
+ return { stop, _tick: tick };
92
+ }
93
+
94
+ // Walks the process table for all (transitive) descendants of rootPid. The
95
+ // client may sit behind shim layers (cmd.exe -> claude.exe -> command), so the
96
+ // whole subtree counts, not just direct children.
97
+ export function collectDescendants(table, rootPid) {
98
+ const childrenByParent = new Map();
99
+ for (const entry of table) {
100
+ if (!Number.isInteger(entry?.pid) || !Number.isInteger(entry?.ppid)) {
101
+ continue;
102
+ }
103
+ const siblings = childrenByParent.get(entry.ppid);
104
+ if (siblings) {
105
+ siblings.push(entry.pid);
106
+ } else {
107
+ childrenByParent.set(entry.ppid, [entry.pid]);
108
+ }
109
+ }
110
+
111
+ const descendants = new Set();
112
+ const queue = [rootPid];
113
+ while (queue.length > 0) {
114
+ const parent = queue.pop();
115
+ for (const child of childrenByParent.get(parent) ?? []) {
116
+ if (!descendants.has(child)) {
117
+ descendants.add(child);
118
+ queue.push(child);
119
+ }
120
+ }
121
+ }
122
+ return descendants;
123
+ }
124
+
125
+ // Bridges session state changes to per-session watchers: watch a session's
126
+ // process tree only while it sits in waiting_approval (and has a known pid),
127
+ // stop the moment any other state arrives (finish, denial, exit), and report
128
+ // detections with the owning sessionId.
129
+ export function createApprovalWatchCoordinator(options = {}) {
130
+ const { createWatcher, onApproved = () => {} } = options;
131
+ const watchers = new Map();
132
+
133
+ return {
134
+ onSessionChanged(session) {
135
+ if (!session || typeof session.sessionId !== "string") {
136
+ return;
137
+ }
138
+
139
+ const { sessionId, pid, state } = session;
140
+
141
+ if (state === "waiting_approval" && Number.isInteger(pid)) {
142
+ if (!watchers.has(sessionId)) {
143
+ const watcher = createWatcher({
144
+ rootPid: pid,
145
+ onApproved: (event) => {
146
+ watchers.delete(sessionId);
147
+ onApproved(sessionId, event);
148
+ }
149
+ });
150
+ watchers.set(sessionId, watcher);
151
+ }
152
+ return;
153
+ }
154
+
155
+ const watcher = watchers.get(sessionId);
156
+ if (watcher) {
157
+ watchers.delete(sessionId);
158
+ watcher.stop();
159
+ }
160
+ },
161
+
162
+ stopAll() {
163
+ for (const watcher of watchers.values()) {
164
+ watcher.stop();
165
+ }
166
+ watchers.clear();
167
+ }
168
+ };
169
+ }
@@ -0,0 +1,295 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ watchForApprovedProcess,
5
+ createApprovalWatchCoordinator
6
+ } from "../src/approval-process-watcher.js";
7
+
8
+ // --- watchForApprovedProcess -------------------------------------------------
9
+ // The watcher's contract is event-based, never time-based: it may only report
10
+ // "approved" because a real NEW process appeared under the client and stayed
11
+ // alive across two consecutive polls. A missed detection is always safe (the
12
+ // pet just keeps showing "waiting"); a false detection would hide a pending
13
+ // approval warning, so the persistence filter errs toward not firing.
14
+
15
+ function makeWatcher(snapshots, overrides = {}) {
16
+ // `snapshots` is a queue of process tables the fake lister returns per call.
17
+ const calls = { cleared: 0 };
18
+ const watcher = watchForApprovedProcess({
19
+ rootPid: 100,
20
+ listProcesses: async () => {
21
+ if (snapshots.length === 0) {
22
+ throw new Error("test ran out of snapshots");
23
+ }
24
+ const next = snapshots.shift();
25
+ if (next instanceof Error) {
26
+ throw next;
27
+ }
28
+ return next;
29
+ },
30
+ immediate: false,
31
+ setInterval: () => ({ unref() {} }),
32
+ clearInterval: () => {
33
+ calls.cleared += 1;
34
+ },
35
+ ...overrides
36
+ });
37
+ return { watcher, calls };
38
+ }
39
+
40
+ const ROOT = { pid: 100, ppid: 1 };
41
+ const BASE_CHILD = { pid: 110, ppid: 100 };
42
+
43
+ test("approval watcher ignores descendants that existed at baseline", async () => {
44
+ const approved = [];
45
+ const { watcher } = makeWatcher(
46
+ [
47
+ [ROOT, BASE_CHILD],
48
+ [ROOT, BASE_CHILD],
49
+ [ROOT, BASE_CHILD]
50
+ ],
51
+ { onApproved: (event) => approved.push(event) }
52
+ );
53
+
54
+ await watcher._tick(); // baseline
55
+ await watcher._tick();
56
+ await watcher._tick();
57
+
58
+ assert.deepEqual(approved, []);
59
+ });
60
+
61
+ test("approval watcher fires once when a new descendant persists across two polls", async () => {
62
+ const approved = [];
63
+ const newChild = { pid: 200, ppid: 100 };
64
+ const { watcher, calls } = makeWatcher(
65
+ [
66
+ [ROOT, BASE_CHILD],
67
+ [ROOT, BASE_CHILD, newChild],
68
+ [ROOT, BASE_CHILD, newChild],
69
+ [ROOT, BASE_CHILD, newChild, { pid: 300, ppid: 100 }]
70
+ ],
71
+ { onApproved: (event) => approved.push(event) }
72
+ );
73
+
74
+ await watcher._tick(); // baseline
75
+ await watcher._tick(); // candidate seen once — not yet
76
+ assert.deepEqual(approved, []);
77
+ await watcher._tick(); // candidate persisted — fire
78
+ assert.deepEqual(approved, [{ pid: 200 }]);
79
+
80
+ // Fires once and stops itself; later ticks are no-ops.
81
+ await watcher._tick();
82
+ assert.equal(approved.length, 1);
83
+ assert.equal(calls.cleared, 1);
84
+ });
85
+
86
+ test("approval watcher ignores a short-lived blip (hook spawn)", async () => {
87
+ const approved = [];
88
+ const blip = { pid: 200, ppid: 100 };
89
+ const { watcher } = makeWatcher(
90
+ [
91
+ [ROOT, BASE_CHILD],
92
+ [ROOT, BASE_CHILD, blip],
93
+ [ROOT, BASE_CHILD],
94
+ [ROOT, BASE_CHILD]
95
+ ],
96
+ { onApproved: (event) => approved.push(event) }
97
+ );
98
+
99
+ await watcher._tick(); // baseline
100
+ await watcher._tick(); // blip appears
101
+ await watcher._tick(); // blip gone — must not fire
102
+ await watcher._tick();
103
+
104
+ assert.deepEqual(approved, []);
105
+ });
106
+
107
+ test("approval watcher detects new grandchildren, not just direct children", async () => {
108
+ const approved = [];
109
+ // New process hangs off the pre-existing shell child (root -> shell -> command).
110
+ const grandchild = { pid: 200, ppid: 110 };
111
+ const { watcher } = makeWatcher(
112
+ [
113
+ [ROOT, BASE_CHILD],
114
+ [ROOT, BASE_CHILD, grandchild],
115
+ [ROOT, BASE_CHILD, grandchild]
116
+ ],
117
+ { onApproved: (event) => approved.push(event) }
118
+ );
119
+
120
+ await watcher._tick();
121
+ await watcher._tick();
122
+ await watcher._tick();
123
+
124
+ assert.deepEqual(approved, [{ pid: 200 }]);
125
+ });
126
+
127
+ test("approval watcher does not detect unrelated processes", async () => {
128
+ const approved = [];
129
+ const unrelated = { pid: 200, ppid: 999 };
130
+ const { watcher } = makeWatcher(
131
+ [
132
+ [ROOT, BASE_CHILD],
133
+ [ROOT, BASE_CHILD, unrelated],
134
+ [ROOT, BASE_CHILD, unrelated]
135
+ ],
136
+ { onApproved: (event) => approved.push(event) }
137
+ );
138
+
139
+ await watcher._tick();
140
+ await watcher._tick();
141
+ await watcher._tick();
142
+
143
+ assert.deepEqual(approved, []);
144
+ });
145
+
146
+ test("approval watcher survives lister errors and keeps working", async () => {
147
+ const approved = [];
148
+ const newChild = { pid: 200, ppid: 100 };
149
+ const { watcher } = makeWatcher(
150
+ [
151
+ [ROOT, BASE_CHILD],
152
+ new Error("snapshot failed"),
153
+ [ROOT, BASE_CHILD, newChild],
154
+ [ROOT, BASE_CHILD, newChild]
155
+ ],
156
+ { onApproved: (event) => approved.push(event) }
157
+ );
158
+
159
+ await watcher._tick(); // baseline
160
+ await watcher._tick(); // error — swallowed
161
+ await watcher._tick(); // candidate
162
+ await watcher._tick(); // persisted — fire
163
+
164
+ assert.deepEqual(approved, [{ pid: 200 }]);
165
+ });
166
+
167
+ test("approval watcher stop() prevents any further detection", async () => {
168
+ const approved = [];
169
+ const newChild = { pid: 200, ppid: 100 };
170
+ const { watcher, calls } = makeWatcher(
171
+ [
172
+ [ROOT, BASE_CHILD],
173
+ [ROOT, BASE_CHILD, newChild],
174
+ [ROOT, BASE_CHILD, newChild]
175
+ ],
176
+ { onApproved: (event) => approved.push(event) }
177
+ );
178
+
179
+ await watcher._tick();
180
+ watcher.stop();
181
+ await watcher._tick();
182
+ await watcher._tick();
183
+
184
+ assert.deepEqual(approved, []);
185
+ assert.equal(calls.cleared, 1);
186
+ });
187
+
188
+ // --- createApprovalWatchCoordinator -------------------------------------------
189
+ // Bridges session state changes to per-session watchers: watch only while a
190
+ // session with a known pid sits in waiting_approval; stop on any other state.
191
+
192
+ function makeCoordinator(overrides = {}) {
193
+ const events = { started: [], stopped: [], approved: [] };
194
+ const watchers = new Map();
195
+ const coordinator = createApprovalWatchCoordinator({
196
+ createWatcher: ({ rootPid, onApproved }) => {
197
+ events.started.push(rootPid);
198
+ const watcher = {
199
+ onApproved,
200
+ stop: () => events.stopped.push(rootPid)
201
+ };
202
+ watchers.set(rootPid, watcher);
203
+ return watcher;
204
+ },
205
+ onApproved: (sessionId, event) => events.approved.push({ sessionId, ...event }),
206
+ ...overrides
207
+ });
208
+ return { coordinator, events, watchers };
209
+ }
210
+
211
+ function session(state, overrides = {}) {
212
+ return { sessionId: "sess_a", pid: 100, state, ...overrides };
213
+ }
214
+
215
+ test("coordinator starts a watcher when a session enters waiting_approval", () => {
216
+ const { coordinator, events } = makeCoordinator();
217
+
218
+ coordinator.onSessionChanged(session("thinking"));
219
+ assert.deepEqual(events.started, []);
220
+
221
+ coordinator.onSessionChanged(session("waiting_approval"));
222
+ assert.deepEqual(events.started, [100]);
223
+ });
224
+
225
+ test("coordinator does not start a duplicate watcher for the same session", () => {
226
+ const { coordinator, events } = makeCoordinator();
227
+
228
+ coordinator.onSessionChanged(session("waiting_approval"));
229
+ coordinator.onSessionChanged(session("waiting_approval"));
230
+
231
+ assert.deepEqual(events.started, [100]);
232
+ });
233
+
234
+ test("coordinator stops the watcher when the session leaves waiting_approval", () => {
235
+ const { coordinator, events } = makeCoordinator();
236
+
237
+ coordinator.onSessionChanged(session("waiting_approval"));
238
+ coordinator.onSessionChanged(session("thinking"));
239
+
240
+ assert.deepEqual(events.stopped, [100]);
241
+
242
+ // Re-entering waiting_approval starts a fresh watcher.
243
+ coordinator.onSessionChanged(session("waiting_approval"));
244
+ assert.deepEqual(events.started, [100, 100]);
245
+ });
246
+
247
+ test("coordinator ignores sessions without a pid", () => {
248
+ const { coordinator, events } = makeCoordinator();
249
+
250
+ coordinator.onSessionChanged(session("waiting_approval", { pid: undefined }));
251
+
252
+ assert.deepEqual(events.started, []);
253
+ });
254
+
255
+ test("coordinator ignores undefined session changes", () => {
256
+ const { coordinator, events } = makeCoordinator();
257
+
258
+ coordinator.onSessionChanged(undefined);
259
+
260
+ assert.deepEqual(events.started, []);
261
+ });
262
+
263
+ test("coordinator forwards approval with the sessionId and stops tracking", () => {
264
+ const { coordinator, events, watchers } = makeCoordinator();
265
+
266
+ coordinator.onSessionChanged(session("waiting_approval"));
267
+ watchers.get(100).onApproved({ pid: 200 });
268
+
269
+ assert.deepEqual(events.approved, [{ sessionId: "sess_a", pid: 200 }]);
270
+
271
+ // The fired watcher is forgotten — a later waiting_approval starts a new one.
272
+ coordinator.onSessionChanged(session("waiting_approval"));
273
+ assert.deepEqual(events.started, [100, 100]);
274
+ });
275
+
276
+ test("coordinator tracks multiple sessions independently", () => {
277
+ const { coordinator, events } = makeCoordinator();
278
+
279
+ coordinator.onSessionChanged(session("waiting_approval"));
280
+ coordinator.onSessionChanged(session("waiting_approval", { sessionId: "sess_b", pid: 300 }));
281
+ coordinator.onSessionChanged(session("idle"));
282
+
283
+ assert.deepEqual(events.started, [100, 300]);
284
+ assert.deepEqual(events.stopped, [100]);
285
+ });
286
+
287
+ test("coordinator stopAll stops every active watcher", () => {
288
+ const { coordinator, events } = makeCoordinator();
289
+
290
+ coordinator.onSessionChanged(session("waiting_approval"));
291
+ coordinator.onSessionChanged(session("waiting_approval", { sessionId: "sess_b", pid: 300 }));
292
+ coordinator.stopAll();
293
+
294
+ assert.deepEqual(events.stopped.sort(), [100, 300]);
295
+ });
@@ -0,0 +1,88 @@
1
+ // Cross-OS process-table snapshots: every lister resolves to the full process
2
+ // list as [{ pid, ppid }], so consumers (the approval watcher) never branch on
3
+ // platform. Returns undefined on platforms we don't support — callers treat
4
+ // that as "feature unavailable", never as an error.
5
+ import { execFile as execFileCb } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { readdir as readdirFs, readFile as readFileFs } from "node:fs/promises";
8
+
9
+ const execFileAsync = promisify(execFileCb);
10
+
11
+ // One pid+ppid pair per line, whitespace-separated — both `ps` and our
12
+ // PowerShell query are shaped to emit exactly this.
13
+ function parsePidTable(stdout) {
14
+ const table = [];
15
+ for (const line of String(stdout).split("\n")) {
16
+ const parts = line.trim().split(/\s+/);
17
+ if (parts.length !== 2) {
18
+ continue;
19
+ }
20
+ const pid = Number.parseInt(parts[0], 10);
21
+ const ppid = Number.parseInt(parts[1], 10);
22
+ if (Number.isInteger(pid) && Number.isInteger(ppid)) {
23
+ table.push({ pid, ppid });
24
+ }
25
+ }
26
+ return table;
27
+ }
28
+
29
+ export function createProcessSnapshotLister(options = {}) {
30
+ const {
31
+ platform = process.platform,
32
+ execFile = execFileAsync,
33
+ readdir = readdirFs,
34
+ readFile = readFileFs
35
+ } = options;
36
+
37
+ if (platform === "win32") {
38
+ // CIM query via PowerShell (wmic is removed on current Windows 11). Output
39
+ // is forced to bare "pid ppid" lines to keep parsing trivial.
40
+ const script =
41
+ "Get-CimInstance -ClassName Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.ParentProcessId)\" }";
42
+ return async () => {
43
+ const { stdout } = await execFile(
44
+ "powershell.exe",
45
+ ["-NoProfile", "-NonInteractive", "-Command", script],
46
+ { windowsHide: true }
47
+ );
48
+ return parsePidTable(stdout);
49
+ };
50
+ }
51
+
52
+ if (platform === "darwin") {
53
+ return async () => {
54
+ const { stdout } = await execFile("ps", ["-axo", "pid=,ppid="]);
55
+ return parsePidTable(stdout);
56
+ };
57
+ }
58
+
59
+ if (platform === "linux") {
60
+ // Read /proc directly — no subprocess at all. stat field 4 is ppid, but the
61
+ // comm field (2) is parenthesised and may contain spaces/parens, so parse
62
+ // from AFTER the last ')'.
63
+ return async () => {
64
+ const entries = await readdir("/proc");
65
+ const table = [];
66
+ for (const entry of entries) {
67
+ if (!/^\d+$/.test(entry)) {
68
+ continue;
69
+ }
70
+ let stat;
71
+ try {
72
+ stat = String(await readFile(`/proc/${entry}/stat`, "utf8"));
73
+ } catch {
74
+ continue; // process exited mid-scan
75
+ }
76
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/);
77
+ const ppid = Number.parseInt(afterComm[1], 10);
78
+ const pid = Number.parseInt(entry, 10);
79
+ if (Number.isInteger(pid) && Number.isInteger(ppid)) {
80
+ table.push({ pid, ppid });
81
+ }
82
+ }
83
+ return table;
84
+ };
85
+ }
86
+
87
+ return undefined;
88
+ }