@hayasaka7/haya-pet 0.3.7 → 0.3.9

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.
@@ -14,6 +14,7 @@ import {
14
14
  parseGuardianTranscriptLines
15
15
  } from "../../adapters/src/codex-guardian.js";
16
16
  import { listJsonlFiles, readFirstLine, readRange, safeMtime, safeSize } from "./codex-rollout-fs.js";
17
+ import { readSessionTranscriptLink } from "./session-transcript-link.js";
17
18
 
18
19
  const DEFAULT_POLL_MS = 700;
19
20
  const MTIME_SKEW_MS = 2000;
@@ -26,6 +27,8 @@ export function watchCodexGuardianReviews(options = {}) {
26
27
  onReviewEvent = () => {},
27
28
  pollIntervalMs = DEFAULT_POLL_MS,
28
29
  sessionsRoot,
30
+ sessionId,
31
+ sessionDir,
29
32
  setInterval: setIntervalFn = setInterval,
30
33
  clearInterval: clearIntervalFn = clearInterval
31
34
  } = options;
@@ -66,7 +69,33 @@ export function watchCodexGuardianReviews(options = {}) {
66
69
  return meta;
67
70
  };
68
71
 
72
+ // Authoritative main thread id from the session->transcript link (the linked
73
+ // rollout's session_meta.payload.id). Lets the guardian bind to OUR main thread
74
+ // even when another session's main rollout has a newer mtime.
75
+ const resolveLinkedMainThreadId = () => {
76
+ if (!sessionId || !sessionDir) {
77
+ return undefined;
78
+ }
79
+ const linked = readSessionTranscriptLink({ sessionDir, sessionId });
80
+ if (!linked) {
81
+ return undefined;
82
+ }
83
+ const firstLine = readFirstLine(linked);
84
+ if (firstLine === undefined) {
85
+ return undefined;
86
+ }
87
+ const meta = classifyCodexSessionMeta(firstLine);
88
+ return meta?.kind === "main" ? meta.threadId : undefined;
89
+ };
90
+
69
91
  const discoverTrunk = () => {
92
+ // Prefer the linked main thread id; fall back to the newest main rollout by
93
+ // mtime only when no link is available (older behavior, unsafe across
94
+ // concurrent same-cwd sessions).
95
+ if (!mainThreadId) {
96
+ mainThreadId = resolveLinkedMainThreadId();
97
+ }
98
+
70
99
  let newestMain;
71
100
  let newestTrunk;
72
101
 
@@ -83,8 +112,12 @@ export function watchCodexGuardianReviews(options = {}) {
83
112
  if (meta.kind === "main" && meta.threadId && (!newestMain || mtime > newestMain.mtime)) {
84
113
  newestMain = { threadId: meta.threadId, mtime };
85
114
  }
86
- if (meta.kind === "guardian" && (!newestTrunk || mtime > newestTrunk.mtime)) {
87
- newestTrunk = { file, parentThreadId: meta.parentThreadId, mtime };
115
+ if (meta.kind === "guardian" && meta.parentThreadId && (!newestTrunk || mtime > newestTrunk.mtime)) {
116
+ // Once our main thread id is known, only consider OUR trunk so a
117
+ // concurrent session's (or collab subagent's) newer trunk cannot win.
118
+ if (!mainThreadId || meta.parentThreadId === mainThreadId) {
119
+ newestTrunk = { file, parentThreadId: meta.parentThreadId, mtime };
120
+ }
88
121
  }
89
122
  }
90
123
 
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { parseCodexTranscriptLines } from "../../adapters/src/codex-transcript.js";
7
7
  import { listJsonlFiles, readFirstLine, readRange, safeMtime, safeSize } from "./codex-rollout-fs.js";
8
+ import { readSessionTranscriptLink } from "./session-transcript-link.js";
8
9
 
9
10
  const DEFAULT_POLL_MS = 700;
10
11
  const MTIME_SKEW_MS = 2000;
@@ -17,6 +18,8 @@ export function watchCodexTranscript(options = {}) {
17
18
  onToolEvent = () => {},
18
19
  pollIntervalMs = DEFAULT_POLL_MS,
19
20
  sessionsRoot,
21
+ sessionId,
22
+ sessionDir,
20
23
  transcriptPath: fixedPath,
21
24
  setInterval: setIntervalFn = setInterval,
22
25
  clearInterval: clearIntervalFn = clearInterval
@@ -25,6 +28,13 @@ export function watchCodexTranscript(options = {}) {
25
28
  const root = sessionsRoot ?? (homeDir ? join(homeDir, ".codex", "sessions") : undefined);
26
29
  const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
27
30
 
31
+ // Preferred resolution: pin to the exact rollout this session's hook reported
32
+ // (Codex puts `transcript_path` in every hook payload; the `haya-pet state`
33
+ // reporter records it as a session->transcript link). Without that link (e.g.
34
+ // the path was null early), fall back to the newest-by-mtime heuristic — unsafe
35
+ // with concurrent same-cwd sessions, which is the bug the link avoids.
36
+ const useLink = Boolean(sessionId && sessionDir);
37
+
28
38
  let transcriptPath = fixedPath;
29
39
  let offset = 0;
30
40
  let carry = "";
@@ -32,7 +42,9 @@ export function watchCodexTranscript(options = {}) {
32
42
  const tick = () => {
33
43
  try {
34
44
  if (!transcriptPath) {
35
- transcriptPath = discoverCodexTranscript(root, minMtime, { cwd });
45
+ transcriptPath = useLink
46
+ ? resolveLinkedRollout(sessionDir, sessionId)
47
+ : discoverCodexTranscript(root, minMtime, { cwd });
36
48
  if (!transcriptPath) {
37
49
  return;
38
50
  }
@@ -79,6 +91,18 @@ export function watchCodexTranscript(options = {}) {
79
91
  };
80
92
  }
81
93
 
94
+ // Resolve the rollout this session's hook bound itself to (via the
95
+ // session->transcript link). Returns undefined until the link exists and points
96
+ // at a real file, so before the first hook the watcher idles rather than guessing
97
+ // a rollout that may belong to another concurrent session.
98
+ function resolveLinkedRollout(sessionDir, sessionId) {
99
+ const linked = readSessionTranscriptLink({ sessionDir, sessionId });
100
+ if (!linked || !existsSync(linked)) {
101
+ return undefined;
102
+ }
103
+ return linked;
104
+ }
105
+
82
106
  export function discoverCodexTranscript(root, minMtime = 0, options = {}) {
83
107
  if (!root || !existsSync(root)) {
84
108
  return undefined;
@@ -6,6 +6,7 @@ import { createIpcClient as defaultCreateIpcClient } from "../../daemon-core/src
6
6
  import { getDefaultPaths } from "../../platform-core/src/paths.js";
7
7
  import { isAiClientState } from "../../protocol/src/messages.js";
8
8
  import { DEADLINE, raceDeadline } from "./deadline.js";
9
+ import { writeSessionTranscriptLink } from "./session-transcript-link.js";
9
10
 
10
11
  // Hard ceiling on the whole connect→send→close interaction. The reporter is a
11
12
  // child process of the wrapped AI client, and the client may wait for its hook
@@ -69,6 +70,13 @@ export async function runStateCommand(parsed, dependencies = {}) {
69
70
  return { command: "state", ok: false, reason: "invalid-state" };
70
71
  }
71
72
 
73
+ // Record this session's real transcript path (ground truth from the Claude hook
74
+ // payload, captured at the process entry and passed in via dependencies) so the
75
+ // wrapper's watcher tails THIS session's file rather than the newest in the
76
+ // project dir — which can bind it to a concurrent session and leak that
77
+ // session's interrupt/denial. Best-effort and synchronous; never blocks the hook.
78
+ recordTranscriptLink(sessionId, env, dependencies);
79
+
72
80
  const createIpcClient = dependencies.createIpcClient ?? defaultCreateIpcClient;
73
81
  const deadlineMs = dependencies.reportDeadlineMs ?? REPORT_DEADLINE_MS;
74
82
 
@@ -105,3 +113,105 @@ export async function runStateCommand(parsed, dependencies = {}) {
105
113
  return { command: "state", ok: false, reason: "no-daemon" };
106
114
  }
107
115
  }
116
+
117
+ // Persist the session -> transcript binding for the wrapper's watcher. The
118
+ // transcript path is supplied by the caller (read from the hook payload at the
119
+ // process entry); when it's absent we simply skip — there is nothing to bind and
120
+ // guessing is exactly the bug this avoids.
121
+ function recordTranscriptLink(sessionId, env, dependencies) {
122
+ const transcriptPath = dependencies.transcriptPath;
123
+ if (typeof transcriptPath !== "string" || transcriptPath === "") {
124
+ return;
125
+ }
126
+ try {
127
+ const sessionDir =
128
+ dependencies.sessionDir ??
129
+ getDefaultPaths({
130
+ platform: dependencies.platform,
131
+ env,
132
+ homeDir: dependencies.homeDir
133
+ }).sessionDir;
134
+ writeSessionTranscriptLink({ sessionDir, sessionId, transcriptPath }, dependencies);
135
+ } catch {
136
+ // never break a hook over a best-effort binding write
137
+ }
138
+ }
139
+
140
+ // Pull `transcript_path` out of a Claude hook payload (JSON on stdin). Pure and
141
+ // defensive: any non-JSON, missing-field, or wrong-type input yields undefined.
142
+ export function extractTranscriptPath(raw) {
143
+ if (typeof raw !== "string" || raw.trim() === "") {
144
+ return undefined;
145
+ }
146
+ try {
147
+ const parsed = JSON.parse(raw);
148
+ const value = parsed?.transcript_path;
149
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ // Read the Claude hook payload from stdin and return its transcript_path. Used by
156
+ // the real `haya-pet state` process (a Claude hook child) — NOT by internal
157
+ // callers, so tests and other commands never touch stdin. Bounded and best-effort:
158
+ // a TTY (manual invocation) or a slow/absent payload resolves to undefined rather
159
+ // than ever hanging the host client's hook.
160
+ export function readHookTranscriptPathFromStdin(options = {}) {
161
+ const stdin = options.stdin ?? process.stdin;
162
+ const timeoutMs = options.timeoutMs ?? 400;
163
+ const maxBytes = options.maxBytes ?? 1_000_000;
164
+
165
+ return new Promise((resolve) => {
166
+ if (!stdin || stdin.isTTY) {
167
+ resolve(undefined);
168
+ return;
169
+ }
170
+
171
+ let data = "";
172
+ let settled = false;
173
+ let timer;
174
+
175
+ const finish = (value) => {
176
+ if (settled) {
177
+ return;
178
+ }
179
+ settled = true;
180
+ if (timer) {
181
+ clearTimeout(timer);
182
+ }
183
+ try {
184
+ stdin.removeListener("data", onData);
185
+ stdin.removeListener("end", onEnd);
186
+ stdin.removeListener("error", onError);
187
+ stdin.pause();
188
+ } catch {
189
+ // detaching is best-effort
190
+ }
191
+ resolve(value);
192
+ };
193
+
194
+ const onData = (chunk) => {
195
+ data += chunk;
196
+ if (data.length > maxBytes) {
197
+ finish(extractTranscriptPath(data));
198
+ }
199
+ };
200
+ const onEnd = () => finish(extractTranscriptPath(data));
201
+ const onError = () => finish(undefined);
202
+
203
+ try {
204
+ stdin.setEncoding("utf8");
205
+ stdin.on("data", onData);
206
+ stdin.on("end", onEnd);
207
+ stdin.on("error", onError);
208
+ stdin.resume();
209
+ timer = setTimeout(() => finish(extractTranscriptPath(data)), timeoutMs);
210
+ if (timer && typeof timer.unref === "function") {
211
+ timer.unref();
212
+ }
213
+ } catch {
214
+ finish(undefined);
215
+ }
216
+ });
217
+ }
@@ -0,0 +1,72 @@
1
+ // Records which transcript file belongs to which haya-pet session, so a wrapper's
2
+ // transcript watcher can tail ITS OWN session's transcript instead of guessing
3
+ // "the newest .jsonl in the project dir".
4
+ //
5
+ // Why this exists: two Claude Code sessions running in the same folder share one
6
+ // project dir (~/.claude/projects/<sanitized-cwd>/), each with its own UUID file.
7
+ // Picking newest-by-mtime can bind a watcher to a DIFFERENT concurrent session's
8
+ // transcript — so an interrupt (or denial) in session A leaks onto idle session B.
9
+ // The only ground-truth source of a session's transcript path is the Claude hook
10
+ // payload (`transcript_path` on stdin), which the `haya-pet state` reporter sees.
11
+ // The reporter writes the path here (keyed by HAYA_PET_SESSION_ID); the wrapper's
12
+ // watcher reads it to pin to the exact file.
13
+ //
14
+ // Local-only and best-effort: every operation swallows errors so it can never
15
+ // break a hook or the wrapped command, and nothing here is ever sent off-device.
16
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ // Session ids are our own (`sess_<uuid>`), but sanitize defensively so the value
20
+ // can never escape the sessions dir or produce an invalid filename.
21
+ function sanitizeSessionId(sessionId) {
22
+ return String(sessionId).replace(/[^a-zA-Z0-9._-]/g, "_");
23
+ }
24
+
25
+ export function sessionLinkPath(sessionDir, sessionId) {
26
+ return join(sessionDir, `${sanitizeSessionId(sessionId)}.json`);
27
+ }
28
+
29
+ export function writeSessionTranscriptLink(options = {}, dependencies = {}) {
30
+ const { sessionDir, sessionId, transcriptPath } = options;
31
+ if (!sessionDir || !sessionId || typeof transcriptPath !== "string" || transcriptPath === "") {
32
+ return false;
33
+ }
34
+ const mkdir = dependencies.mkdirSync ?? mkdirSync;
35
+ const write = dependencies.writeFileSync ?? writeFileSync;
36
+ try {
37
+ mkdir(sessionDir, { recursive: true });
38
+ write(sessionLinkPath(sessionDir, sessionId), JSON.stringify({ transcriptPath }), "utf8");
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export function readSessionTranscriptLink(options = {}, dependencies = {}) {
46
+ const { sessionDir, sessionId } = options;
47
+ if (!sessionDir || !sessionId) {
48
+ return undefined;
49
+ }
50
+ const read = dependencies.readFileSync ?? readFileSync;
51
+ try {
52
+ const parsed = JSON.parse(read(sessionLinkPath(sessionDir, sessionId), "utf8"));
53
+ return typeof parsed?.transcriptPath === "string" && parsed.transcriptPath !== ""
54
+ ? parsed.transcriptPath
55
+ : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ export function removeSessionTranscriptLink(options = {}, dependencies = {}) {
62
+ const { sessionDir, sessionId } = options;
63
+ if (!sessionDir || !sessionId) {
64
+ return;
65
+ }
66
+ const rm = dependencies.rmSync ?? rmSync;
67
+ try {
68
+ rm(sessionLinkPath(sessionDir, sessionId), { force: true });
69
+ } catch {
70
+ // best-effort cleanup; a stale link is harmless (session ids are unique per run)
71
+ }
72
+ }
@@ -8,6 +8,7 @@ import {
8
8
  discoverTranscript,
9
9
  watchClaudeTranscript
10
10
  } from "../src/claude-transcript-watcher.js";
11
+ import { writeSessionTranscriptLink } from "../src/session-transcript-link.js";
11
12
 
12
13
  const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
13
14
 
@@ -128,6 +129,78 @@ test("watchClaudeTranscript handles a line split across two appends", () => {
128
129
  watcher.stop();
129
130
  });
130
131
 
132
+ test("watchClaudeTranscript pins to the session's linked transcript, not newest-by-mtime", () => {
133
+ // Two concurrent sessions share one project dir. Each has its own link.
134
+ const projectDir = mkdtempSync(join(tmpdir(), "proj-"));
135
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
136
+ const fileA = join(projectDir, "a.jsonl");
137
+ const fileB = join(projectDir, "b.jsonl");
138
+ writeFileSync(fileA, "");
139
+ writeFileSync(fileB, "");
140
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: fileA });
141
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_b", transcriptPath: fileB });
142
+
143
+ const interruptsA = [];
144
+ const interruptsB = [];
145
+ const watcherA = watchClaudeTranscript({
146
+ sessionId: "sess_a",
147
+ sessionDir,
148
+ onInterrupt: (e) => interruptsA.push(e),
149
+ ...noopTimers
150
+ });
151
+ const watcherB = watchClaudeTranscript({
152
+ sessionId: "sess_b",
153
+ sessionDir,
154
+ onInterrupt: (e) => interruptsB.push(e),
155
+ ...noopTimers
156
+ });
157
+
158
+ // Both pin to their own file, then skip to EOF.
159
+ watcherA._tick();
160
+ watcherB._tick();
161
+
162
+ // Interrupt happens in session A only.
163
+ appendFileSync(fileA, interrupt());
164
+ watcherA._tick();
165
+ watcherB._tick();
166
+
167
+ assert.deepEqual(interruptsA, [{ type: "interrupted" }], "session A sees its own interrupt");
168
+ assert.deepEqual(interruptsB, [], "session B (idle) is NOT contaminated by session A's interrupt");
169
+
170
+ watcherA.stop();
171
+ watcherB.stop();
172
+ });
173
+
174
+ test("watchClaudeTranscript with a session link idles until the link appears", () => {
175
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
176
+ const projectDir = mkdtempSync(join(tmpdir(), "proj-"));
177
+ const file = join(projectDir, "s.jsonl");
178
+ writeFileSync(file, "");
179
+
180
+ const interrupts = [];
181
+ const watcher = watchClaudeTranscript({
182
+ sessionId: "sess_late",
183
+ sessionDir,
184
+ onInterrupt: (e) => interrupts.push(e),
185
+ ...noopTimers
186
+ });
187
+
188
+ // No link yet → nothing tailed, even if a marker is already present.
189
+ appendFileSync(file, interrupt());
190
+ watcher._tick();
191
+ assert.deepEqual(interrupts, [], "no guessing before the hook reports the path");
192
+
193
+ // The hook fires and records the binding; the watcher now pins (skipping to EOF,
194
+ // so the pre-existing marker is not replayed) and only catches NEW events.
195
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_late", transcriptPath: file });
196
+ watcher._tick();
197
+ appendFileSync(file, interrupt());
198
+ watcher._tick();
199
+ assert.deepEqual(interrupts, [{ type: "interrupted" }]);
200
+
201
+ watcher.stop();
202
+ });
203
+
131
204
  test("watchClaudeTranscript reports an interrupt appended after it starts tailing", () => {
132
205
  const dir = mkdtempSync(join(tmpdir(), "transcript-"));
133
206
  const path = join(dir, "session.jsonl");
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "../../../test/harness.mjs";
6
6
  import { watchCodexGuardianReviews } from "../src/codex-guardian-watcher.js";
7
+ import { writeSessionTranscriptLink } from "../src/session-transcript-link.js";
7
8
 
8
9
  const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
9
10
 
@@ -262,6 +263,53 @@ test("watchCodexGuardianReviews ignores guardian trunks of other parents", () =>
262
263
  watcher.stop();
263
264
  });
264
265
 
266
+ test("watchCodexGuardianReviews binds to the LINKED main thread, not the newest main", () => {
267
+ const { root, dir } = makeSessionsRoot();
268
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
269
+
270
+ // Our main rollout, and a DIFFERENT concurrent session's main rollout written
271
+ // afterwards (so it has the newer mtime — what the old heuristic would pick).
272
+ const ourMain = join(dir, "rollout-main-ours.jsonl");
273
+ writeFileSync(ourMain, metaLine({ id: "main-ours", parent_thread_id: null, source: "cli", thread_source: "user" }));
274
+ writeFileSync(
275
+ join(dir, "rollout-main-other.jsonl"),
276
+ metaLine({ id: "main-other", parent_thread_id: null, source: "cli", thread_source: "user" })
277
+ );
278
+
279
+ // A guardian trunk for OUR main, plus a decoy trunk for the OTHER main that is
280
+ // newer and already has a review turn.
281
+ const ourTrunk = join(dir, "rollout-guardian-ours.jsonl");
282
+ writeFileSync(
283
+ ourTrunk,
284
+ metaLine({ id: "g-ours", parent_thread_id: "main-ours", source: { subagent: { other: "guardian" } } })
285
+ );
286
+ writeFileSync(
287
+ join(dir, "rollout-guardian-other.jsonl"),
288
+ metaLine({ id: "g-other", parent_thread_id: "main-other", source: { subagent: { other: "guardian" } } }) +
289
+ reviewStarted("decoy")
290
+ );
291
+
292
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: ourMain });
293
+
294
+ const events = [];
295
+ const watcher = watchCodexGuardianReviews({
296
+ sessionsRoot: root,
297
+ sessionId: "sess_a",
298
+ sessionDir,
299
+ onReviewEvent: (e) => events.push(e),
300
+ ...noopTimers
301
+ });
302
+
303
+ watcher._tick();
304
+ assert.deepEqual(events, [], "the newer decoy trunk (another session) is ignored");
305
+
306
+ appendFileSync(ourTrunk, reviewStarted());
307
+ watcher._tick();
308
+ assert.deepEqual(events, [{ type: "review_started" }], "our trunk is the one tailed");
309
+
310
+ watcher.stop();
311
+ });
312
+
265
313
  test("watchCodexGuardianReviews picks up a trunk created after watching began", () => {
266
314
  const { root, dir } = makeSessionsRoot();
267
315
  writeFileSync(
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "../../../test/harness.mjs";
6
6
  import { discoverCodexTranscript, watchCodexTranscript } from "../src/codex-transcript-watcher.js";
7
+ import { writeSessionTranscriptLink } from "../src/session-transcript-link.js";
7
8
 
8
9
  const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
9
10
 
@@ -60,6 +61,55 @@ test("discoverCodexTranscript skips files older than session start", () => {
60
61
  assert.equal(discoverCodexTranscript(root, Date.now() - 1000), undefined);
61
62
  });
62
63
 
64
+ test("watchCodexTranscript pins to the session's linked rollout, not newest-by-mtime", () => {
65
+ // Two concurrent Codex sessions, each with its own rollout and its own link.
66
+ const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
67
+ const dir = join(root, "2026", "06", "12");
68
+ mkdirSync(dir, { recursive: true });
69
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
70
+
71
+ const fileA = join(dir, "rollout-a.jsonl");
72
+ const fileB = join(dir, "rollout-b.jsonl");
73
+ writeFileSync(fileA, sessionMeta("2026-06-12T01:00:00.000Z", "thread-a"));
74
+ writeFileSync(fileB, sessionMeta("2026-06-12T01:00:00.000Z", "thread-b"));
75
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: fileA });
76
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_b", transcriptPath: fileB });
77
+
78
+ const eventsA = [];
79
+ const eventsB = [];
80
+ const watcherA = watchCodexTranscript({
81
+ sessionId: "sess_a",
82
+ sessionDir,
83
+ onToolEvent: (e) => eventsA.push(e),
84
+ ...noopTimers
85
+ });
86
+ const watcherB = watchCodexTranscript({
87
+ sessionId: "sess_b",
88
+ sessionDir,
89
+ onToolEvent: (e) => eventsB.push(e),
90
+ ...noopTimers
91
+ });
92
+
93
+ // Both pin to their own rollout and consume the session_meta line.
94
+ watcherA._tick();
95
+ watcherB._tick();
96
+
97
+ // Session A is interrupted.
98
+ appendFileSync(fileA, turnAborted());
99
+ watcherA._tick();
100
+ watcherB._tick();
101
+
102
+ assert.deepEqual(
103
+ eventsA,
104
+ [{ type: "turn_aborted", reason: "interrupted" }],
105
+ "session A sees its own interrupt"
106
+ );
107
+ assert.deepEqual(eventsB, [], "session B is NOT contaminated by session A's interrupt");
108
+
109
+ watcherA.stop();
110
+ watcherB.stop();
111
+ });
112
+
63
113
  test("watchCodexTranscript reports appended tool events", () => {
64
114
  const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
65
115
  const path = join(dir, "session.jsonl");
@@ -3,7 +3,8 @@ import { mkdtempSync, readFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "../../../test/harness.mjs";
6
- import { parseStateArgs, runStateCommand } from "../src/run-state.js";
6
+ import { extractTranscriptPath, parseStateArgs, runStateCommand } from "../src/run-state.js";
7
+ import { readSessionTranscriptLink } from "../src/session-transcript-link.js";
7
8
 
8
9
  test("runStateCommand appends a debug line when HAYA_PET_HOOK_DEBUG is set", async () => {
9
10
  const logPath = join(mkdtempSync(join(tmpdir(), "haya-dbg-")), "hooks.jsonl");
@@ -21,6 +22,51 @@ test("runStateCommand appends a debug line when HAYA_PET_HOOK_DEBUG is set", asy
21
22
  assert.deepEqual(line, { ts: 7, state: "waiting_approval", sessionId: "s1", summary: "approval" });
22
23
  });
23
24
 
25
+ test("extractTranscriptPath pulls transcript_path out of a Claude hook payload", () => {
26
+ assert.equal(
27
+ extractTranscriptPath(JSON.stringify({ session_id: "x", transcript_path: "/p/a.jsonl", cwd: "/p" })),
28
+ "/p/a.jsonl"
29
+ );
30
+ // Defensive: junk, missing field, wrong type, and empty all yield undefined.
31
+ assert.equal(extractTranscriptPath("{not json"), undefined);
32
+ assert.equal(extractTranscriptPath(JSON.stringify({ session_id: "x" })), undefined);
33
+ assert.equal(extractTranscriptPath(JSON.stringify({ transcript_path: 42 })), undefined);
34
+ assert.equal(extractTranscriptPath(""), undefined);
35
+ assert.equal(extractTranscriptPath(undefined), undefined);
36
+ });
37
+
38
+ test("runStateCommand records the session->transcript link when given a transcript path", async () => {
39
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
40
+ await runStateCommand(
41
+ { command: "state", state: "thinking", summary: undefined, session: "sess_link" },
42
+ {
43
+ now: () => 1,
44
+ sessionDir,
45
+ transcriptPath: "/p/.claude/projects/D--p/abc.jsonl",
46
+ createIpcClient: async () => ({ send: async () => {}, close: async () => {} })
47
+ }
48
+ );
49
+
50
+ assert.equal(
51
+ readSessionTranscriptLink({ sessionDir, sessionId: "sess_link" }),
52
+ "/p/.claude/projects/D--p/abc.jsonl"
53
+ );
54
+ });
55
+
56
+ test("runStateCommand writes no link when no transcript path is supplied", async () => {
57
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
58
+ await runStateCommand(
59
+ { command: "state", state: "thinking", summary: undefined, session: "sess_nolink" },
60
+ {
61
+ now: () => 1,
62
+ sessionDir,
63
+ createIpcClient: async () => ({ send: async () => {}, close: async () => {} })
64
+ }
65
+ );
66
+
67
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_nolink" }), undefined);
68
+ });
69
+
24
70
  test("parseStateArgs reads state, summary, and session", () => {
25
71
  assert.deepEqual(parseStateArgs(["thinking"]), {
26
72
  command: "state",
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "../../../test/harness.mjs";
6
+ import {
7
+ readSessionTranscriptLink,
8
+ removeSessionTranscriptLink,
9
+ sessionLinkPath,
10
+ writeSessionTranscriptLink
11
+ } from "../src/session-transcript-link.js";
12
+
13
+ test("write then read round-trips a session's transcript path", () => {
14
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
15
+ const transcriptPath = "D:\\proj\\.claude\\projects\\D--proj\\abc.jsonl";
16
+
17
+ const wrote = writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath });
18
+ assert.equal(wrote, true);
19
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }), transcriptPath);
20
+ });
21
+
22
+ test("two sessions in the same dir keep separate, independent links", () => {
23
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
24
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" });
25
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_b", transcriptPath: "/p/b.jsonl" });
26
+
27
+ // The core of the bug fix: each session resolves ONLY its own transcript.
28
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }), "/p/a.jsonl");
29
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "sess_b" }), "/p/b.jsonl");
30
+ });
31
+
32
+ test("reading a session with no link returns undefined (no guessing)", () => {
33
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
34
+ assert.equal(readSessionTranscriptLink({ sessionDir, sessionId: "missing" }), undefined);
35
+ });
36
+
37
+ test("write is a no-op without the required fields", () => {
38
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
39
+ assert.equal(writeSessionTranscriptLink({ sessionDir, sessionId: "s" }), false);
40
+ assert.equal(writeSessionTranscriptLink({ sessionDir, transcriptPath: "/p/a.jsonl" }), false);
41
+ assert.equal(writeSessionTranscriptLink({ sessionId: "s", transcriptPath: "/p/a.jsonl" }), false);
42
+ });
43
+
44
+ test("remove deletes the link and is safe when it is already gone", () => {
45
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
46
+ writeSessionTranscriptLink({ sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" });
47
+ const path = sessionLinkPath(sessionDir, "sess_a");
48
+ assert.equal(existsSync(path), true);
49
+
50
+ removeSessionTranscriptLink({ sessionDir, sessionId: "sess_a" });
51
+ assert.equal(existsSync(path), false);
52
+ // Second removal must not throw.
53
+ removeSessionTranscriptLink({ sessionDir, sessionId: "sess_a" });
54
+ });
55
+
56
+ test("a corrupt link file reads as undefined rather than throwing", () => {
57
+ const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
58
+ writeSessionTranscriptLink(
59
+ { sessionDir, sessionId: "sess_a", transcriptPath: "/p/a.jsonl" },
60
+ { writeFileSync: () => {} } // pretend write; nothing on disk
61
+ );
62
+ // Inject a reader returning junk to simulate a partially-written file.
63
+ assert.equal(
64
+ readSessionTranscriptLink({ sessionDir, sessionId: "sess_a" }, { readFileSync: () => "{not json" }),
65
+ undefined
66
+ );
67
+ });