@hayasaka7/haya-pet 0.2.0 → 0.2.1

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,97 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { parseCodexTranscriptLine, parseCodexTranscriptLines } from "../src/codex-transcript.js";
4
+
5
+ test("parseCodexTranscriptLine reports shell tool starts as running tools", () => {
6
+ const event = parseCodexTranscriptLine(JSON.stringify({
7
+ type: "response_item",
8
+ payload: {
9
+ type: "function_call",
10
+ name: "shell_command",
11
+ call_id: "call_shell"
12
+ }
13
+ }));
14
+
15
+ assert.deepEqual(event, {
16
+ type: "tool_started",
17
+ toolCallId: "call_shell",
18
+ toolName: "shell_command",
19
+ state: "running_tool"
20
+ });
21
+ });
22
+
23
+ test("parseCodexTranscriptLine reports apply_patch starts as file editing", () => {
24
+ const event = parseCodexTranscriptLine(JSON.stringify({
25
+ type: "response_item",
26
+ payload: {
27
+ type: "custom_tool_call",
28
+ name: "apply_patch",
29
+ call_id: "call_patch"
30
+ }
31
+ }));
32
+
33
+ assert.deepEqual(event, {
34
+ type: "tool_started",
35
+ toolCallId: "call_patch",
36
+ toolName: "apply_patch",
37
+ state: "editing_files"
38
+ });
39
+ });
40
+
41
+ test("parseCodexTranscriptLine reports tool output as tool finished", () => {
42
+ const event = parseCodexTranscriptLine(JSON.stringify({
43
+ type: "response_item",
44
+ payload: {
45
+ type: "function_call_output",
46
+ call_id: "call_shell",
47
+ output: "done"
48
+ }
49
+ }));
50
+
51
+ assert.deepEqual(event, {
52
+ type: "tool_finished",
53
+ toolCallId: "call_shell"
54
+ });
55
+ });
56
+
57
+ test("parseCodexTranscriptLines ignores malformed and unrelated lines", () => {
58
+ const lines = [
59
+ "",
60
+ "{not-json",
61
+ JSON.stringify({ type: "event_msg", payload: { type: "agent_message" } }),
62
+ JSON.stringify({ type: "response_item", payload: { type: "function_call", name: "shell_command", call_id: "call_1" } })
63
+ ];
64
+
65
+ assert.deepEqual(parseCodexTranscriptLines(lines), [
66
+ {
67
+ type: "tool_started",
68
+ toolCallId: "call_1",
69
+ toolName: "shell_command",
70
+ state: "running_tool"
71
+ }
72
+ ]);
73
+ });
74
+
75
+ test("parseCodexTranscriptLines can skip records older than the session start", () => {
76
+ const lines = [
77
+ JSON.stringify({
78
+ timestamp: "2026-06-08T10:59:59.000Z",
79
+ type: "response_item",
80
+ payload: { type: "function_call", name: "shell_command", call_id: "call_old" }
81
+ }),
82
+ JSON.stringify({
83
+ timestamp: "2026-06-08T11:00:01.000Z",
84
+ type: "response_item",
85
+ payload: { type: "function_call", name: "shell_command", call_id: "call_new" }
86
+ })
87
+ ];
88
+
89
+ assert.deepEqual(parseCodexTranscriptLines(lines, { minTimestampMs: Date.parse("2026-06-08T11:00:00.000Z") }), [
90
+ {
91
+ type: "tool_started",
92
+ toolCallId: "call_new",
93
+ toolName: "shell_command",
94
+ state: "running_tool"
95
+ }
96
+ ]);
97
+ });
@@ -13,23 +13,28 @@ export function createDefaultPositionState() {
13
13
  settings: {
14
14
  displayMode: "hybrid",
15
15
  attachBubblesToTerminals: true,
16
- claudeHooks: false
16
+ hooksEnabled: false
17
17
  }
18
18
  };
19
19
  }
20
20
 
21
- export function setClaudeHooksEnabled(state, enabled) {
21
+ // Global live-status hooks toggle — covers every hook-capable client (Claude Code,
22
+ // Codex). One switch keeps the user-facing model simple; per-run env vars still
23
+ // override it (see resolveHooksEnabled in the CLI).
24
+ export function setHooksEnabled(state, enabled) {
22
25
  return {
23
26
  ...state,
24
27
  settings: {
25
28
  ...state.settings,
26
- claudeHooks: Boolean(enabled)
29
+ hooksEnabled: Boolean(enabled)
27
30
  }
28
31
  };
29
32
  }
30
33
 
31
- export function getClaudeHooksEnabled(state) {
32
- return Boolean(state?.settings?.claudeHooks);
34
+ export function getHooksEnabled(state) {
35
+ // Back-compat: honor the legacy Claude-only `claudeHooks` key if a user enabled
36
+ // it before the toggle went global.
37
+ return Boolean(state?.settings?.hooksEnabled ?? state?.settings?.claudeHooks);
33
38
  }
34
39
 
35
40
  export function updateGlobalPetPosition(state, position) {
@@ -0,0 +1,49 @@
1
+ // Resolves stable paths, builds the Codex hook settings, serializes them to TOML,
2
+ // and writes them to a STABLE named-profile file inside CODEX_HOME. The wrapper
3
+ // then launches `codex -p <profileName>`, which layers these hooks ON TOP of the
4
+ // user's base config (auth/model/MCP untouched) and is inert for any codex run
5
+ // that doesn't pass `-p <profileName>`.
6
+ //
7
+ // Like the Claude injector, the file path and command strings are kept identical
8
+ // across sessions so Codex's hook-trust review only needs approving once. fnm hands
9
+ // out a per-shell symlink for process.execPath that dies when the launching shell
10
+ // exits, so we realpath it before baking it into the hook command.
11
+ import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { buildCodexHookSettings, serializeCodexHooksToml } from "../../adapters/src/codex-hooks.js";
16
+
17
+ const DEFAULT_CLI_PATH = fileURLToPath(new URL("../../../apps/cli/src/haya-pet.js", import.meta.url));
18
+ const PROFILE_NAME = "haya-pet";
19
+ const PROFILE_FILE = `${PROFILE_NAME}.config.toml`;
20
+
21
+ export function injectCodexHooks({ nodePath, cliPath, codexHome, env = process.env } = {}) {
22
+ const resolvedNode = nodePath ?? safeRealpath(process.execPath);
23
+ const resolvedCli = cliPath ?? safeRealpath(DEFAULT_CLI_PATH);
24
+ const home = codexHome ?? env.CODEX_HOME ?? join(homedir(), ".codex");
25
+
26
+ const settings = buildCodexHookSettings({ nodePath: resolvedNode, cliPath: resolvedCli });
27
+ const toml = serializeCodexHooksToml(settings, {
28
+ header: "haya-pet live-status hooks profile. Managed by haya-pet; safe to delete."
29
+ });
30
+
31
+ // A fixed path with session-independent content: concurrent sessions just
32
+ // rewrite identical bytes, and the hooks stay "trusted" across launches.
33
+ mkdirSync(home, { recursive: true });
34
+ const profilePath = join(home, PROFILE_FILE);
35
+ writeFileSync(profilePath, toml, "utf8");
36
+
37
+ // The profile file is stable and reusable on purpose — leaving it in place is
38
+ // what lets Codex remember the hooks are trusted. cleanup is a no-op kept for
39
+ // API symmetry with the caller's finally block.
40
+ return { profileName: PROFILE_NAME, profilePath, cleanup: () => {} };
41
+ }
42
+
43
+ function safeRealpath(target) {
44
+ try {
45
+ return realpathSync(target);
46
+ } catch {
47
+ return target;
48
+ }
49
+ }
@@ -0,0 +1,160 @@
1
+ // Tails Codex session JSONL and reports tool start/finish activity. Codex hooks
2
+ // cover turn lifecycle, but the transcript is the reliable source for tool use
3
+ // when PreToolUse is unavailable.
4
+ import {
5
+ closeSync,
6
+ existsSync,
7
+ openSync,
8
+ readdirSync,
9
+ readSync,
10
+ statSync
11
+ } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { parseCodexTranscriptLines } from "../../adapters/src/codex-transcript.js";
14
+
15
+ const DEFAULT_POLL_MS = 700;
16
+ const MTIME_SKEW_MS = 2000;
17
+
18
+ export function watchCodexTranscript(options = {}) {
19
+ const {
20
+ homeDir = process.env.USERPROFILE || process.env.HOME,
21
+ startedAt = 0,
22
+ onToolEvent = () => {},
23
+ pollIntervalMs = DEFAULT_POLL_MS,
24
+ sessionsRoot,
25
+ transcriptPath: fixedPath,
26
+ setInterval: setIntervalFn = setInterval,
27
+ clearInterval: clearIntervalFn = clearInterval
28
+ } = options;
29
+
30
+ const root = sessionsRoot ?? (homeDir ? join(homeDir, ".codex", "sessions") : undefined);
31
+ const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
32
+
33
+ let transcriptPath = fixedPath;
34
+ let offset = 0;
35
+ let carry = "";
36
+
37
+ const tick = () => {
38
+ try {
39
+ if (!transcriptPath) {
40
+ transcriptPath = discoverCodexTranscript(root, minMtime);
41
+ if (!transcriptPath) {
42
+ return;
43
+ }
44
+ // Replay from the start rather than skipping to the end: Codex may
45
+ // have written the session's first tool calls before our first poll,
46
+ // and skipping them would lose the initial running-tool status. The
47
+ // per-record timestamp filter below keeps an earlier session's records
48
+ // (in a resumed/rotated file) from replaying as live activity.
49
+ }
50
+
51
+ const size = safeSize(transcriptPath);
52
+ if (size <= offset) {
53
+ if (size < offset) {
54
+ offset = size;
55
+ carry = "";
56
+ }
57
+ return;
58
+ }
59
+
60
+ const chunk = readRange(transcriptPath, offset, size);
61
+ offset = size;
62
+
63
+ const lines = (carry + chunk).split("\n");
64
+ carry = lines.pop() ?? "";
65
+
66
+ for (const event of parseCodexTranscriptLines(lines, { minTimestampMs: startedAt })) {
67
+ onToolEvent(event);
68
+ }
69
+ } catch {
70
+ // best-effort: transcript surprises must never crash the wrapper
71
+ }
72
+ };
73
+
74
+ const timer = setIntervalFn(tick, pollIntervalMs);
75
+ if (timer && typeof timer.unref === "function") {
76
+ timer.unref();
77
+ }
78
+
79
+ return {
80
+ stop() {
81
+ clearIntervalFn(timer);
82
+ },
83
+ _tick: tick
84
+ };
85
+ }
86
+
87
+ export function discoverCodexTranscript(root, minMtime = 0) {
88
+ if (!root || !existsSync(root)) {
89
+ return undefined;
90
+ }
91
+
92
+ let newest;
93
+ for (const file of listJsonlFiles(root)) {
94
+ const mtime = safeMtime(file);
95
+ if (mtime < minMtime) {
96
+ continue;
97
+ }
98
+ if (!newest || mtime > newest.mtime) {
99
+ newest = { file, mtime };
100
+ }
101
+ }
102
+ return newest?.file;
103
+ }
104
+
105
+ function listJsonlFiles(root) {
106
+ const files = [];
107
+ const stack = [root];
108
+
109
+ while (stack.length > 0) {
110
+ const dir = stack.pop();
111
+ let entries;
112
+ try {
113
+ entries = readdirSync(dir, { withFileTypes: true });
114
+ } catch {
115
+ continue;
116
+ }
117
+
118
+ for (const entry of entries) {
119
+ const full = join(dir, entry.name);
120
+ if (entry.isDirectory()) {
121
+ stack.push(full);
122
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
123
+ files.push(full);
124
+ }
125
+ }
126
+ }
127
+
128
+ return files;
129
+ }
130
+
131
+ function safeSize(path) {
132
+ try {
133
+ return statSync(path).size;
134
+ } catch {
135
+ return 0;
136
+ }
137
+ }
138
+
139
+ function safeMtime(path) {
140
+ try {
141
+ return statSync(path).mtimeMs;
142
+ } catch {
143
+ return 0;
144
+ }
145
+ }
146
+
147
+ function readRange(path, start, end) {
148
+ const length = end - start;
149
+ if (length <= 0) {
150
+ return "";
151
+ }
152
+ const fd = openSync(path, "r");
153
+ try {
154
+ const buffer = Buffer.alloc(length);
155
+ const bytesRead = readSync(fd, buffer, 0, length, start);
156
+ return buffer.toString("utf8", 0, bytesRead);
157
+ } finally {
158
+ closeSync(fd);
159
+ }
160
+ }
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "../../../test/harness.mjs";
6
+ import { injectCodexHooks } from "../src/codex-hook-injection.js";
7
+
8
+ test("injectCodexHooks writes a stable profile into CODEX_HOME and returns its name", () => {
9
+ const home = mkdtempSync(join(tmpdir(), "haya-codex-home-"));
10
+ try {
11
+ const result = injectCodexHooks({
12
+ nodePath: "C:\\nodedir\\node.exe",
13
+ cliPath: "C:\\app\\haya-pet.js",
14
+ codexHome: home
15
+ });
16
+
17
+ assert.equal(result.profileName, "haya-pet");
18
+ assert.equal(result.profilePath, join(home, "haya-pet.config.toml"));
19
+
20
+ const toml = readFileSync(result.profilePath, "utf8");
21
+ assert.match(toml, /\[\[hooks\.UserPromptSubmit\]\]/);
22
+ assert.match(toml, /state thinking/);
23
+ // Program unquoted (cmd /c strips a leading quote on Windows).
24
+ const cmdLine = toml.split("\n").find((l) => l.startsWith("command ="));
25
+ assert.doesNotMatch(cmdLine, /^command = "\\"/);
26
+ } finally {
27
+ rmSync(home, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ test("injectCodexHooks honors CODEX_HOME from env and is stable across calls", () => {
32
+ const home = mkdtempSync(join(tmpdir(), "haya-codex-home-"));
33
+ try {
34
+ const opts = { nodePath: "n", cliPath: "c", env: { CODEX_HOME: home } };
35
+ const a = injectCodexHooks(opts);
36
+ const first = readFileSync(a.profilePath, "utf8");
37
+ const b = injectCodexHooks(opts);
38
+ const second = readFileSync(b.profilePath, "utf8");
39
+
40
+ assert.equal(a.profilePath, join(home, "haya-pet.config.toml"));
41
+ assert.equal(first, second, "stable content keeps Codex hook-trust cached");
42
+ } finally {
43
+ rmSync(home, { recursive: true, force: true });
44
+ }
45
+ });
@@ -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
+ }