@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.
- package/.github/workflows/ci.yml +75 -0
- package/CHANGELOG.md +112 -0
- package/README.md +31 -14
- package/apps/cli/src/haya-pet.js +110 -21
- package/apps/cli/test/haya-pet.test.mjs +111 -7
- package/apps/companion/src/main/index.js +40 -1
- package/apps/companion/src/renderer/task-talk-window.js +1 -1
- package/apps/companion/test/position-store.test.mjs +1 -1
- package/docs/architecture.md +33 -10
- package/docs/cross-os-qa.md +72 -0
- package/docs/known-issues.md +92 -9
- package/docs/troubleshooting.md +3 -1
- package/eslint.config.js +32 -0
- package/package.json +7 -1
- package/packages/adapters/src/codex-hooks.js +152 -0
- package/packages/adapters/src/codex-transcript.js +73 -0
- package/packages/adapters/test/codex-hooks.test.mjs +120 -0
- package/packages/adapters/test/codex-transcript.test.mjs +97 -0
- package/packages/app-state/src/state.js +10 -5
- package/packages/cli-core/src/codex-hook-injection.js +49 -0
- package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
- package/packages/cli-core/src/run-command.js +0 -1
- package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
- package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
- package/packages/daemon-core/src/approval-process-watcher.js +169 -0
- package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
- package/packages/platform-core/src/process-snapshot.js +88 -0
- package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
- package/packages/session-core/src/bubble-view.js +10 -7
- 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
|
+
}
|