@hayasaka7/haya-pet 0.3.8 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ All notable changes to HAYA Pet are documented here. This project adheres to
7
7
  > 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
8
8
  > ships them.
9
9
 
10
+ ## [0.3.9]
11
+
12
+ ### Fixed
13
+ - **The cross-session contamination fix now covers Codex too.** Codex had the same
14
+ flaw fixed for Claude in 0.3.8: its transcript watcher chose the rollout by
15
+ newest mtime + cwd, and the guardian-review watcher derived the main thread id
16
+ from the newest main rollout — so two Codex sessions in the same folder could
17
+ cross-report each other's `turn_aborted` (interrupt) or tool activity, with the
18
+ idle session showing the busy one's state. Codex's command-hook payload also
19
+ carries `transcript_path`, so the `haya-pet state` reporter's per-session
20
+ `session → transcript` link (already written for every client) now pins the Codex
21
+ transcript watcher to its own rollout, and the guardian watcher binds the main
22
+ thread id from the linked rollout's `payload.id` (and only follows a trunk whose
23
+ `parent_thread_id` matches it). Both fall back to the previous mtime+cwd heuristic
24
+ when no link is available (e.g. `transcript_path` null early), so there is no
25
+ regression. No timer involved.
26
+
10
27
  ## [0.3.8]
11
28
 
12
29
  ### Fixed
@@ -19,8 +36,8 @@ All notable changes to HAYA Pet are documented here. This project adheres to
19
36
  session's watcher now pins to its own transcript via the `transcript_path` Claude
20
37
  includes in every hook payload (recorded as a per-session link by the `haya-pet
21
38
  state` reporter) instead of guessing; until that link exists it idles rather than
22
- locking onto another session's file. Codex shares the same discovery shape and is
23
- tracked as a known issue.
39
+ locking onto another session's file. (Codex had the same discovery shape fixed
40
+ in 0.3.9.)
24
41
  - **The pet no longer disappears when the display layout changes.** The overlay
25
42
  window's bounds were set once at creation to span one display's work area and
26
43
  never re-homed, so unplugging a monitor, changing resolution/DPI, docking or
@@ -434,12 +434,20 @@ async function runRunCommand(parsed, dependencies) {
434
434
  };
435
435
  cleanup = injected.cleanup;
436
436
 
437
+ // Pin both Codex watchers to THIS session's rollout via the
438
+ // session->transcript link the `haya-pet state` reporter records from the
439
+ // hook payload's transcript_path, instead of guessing newest-by-mtime (which
440
+ // leaks a concurrent same-cwd session's activity/interrupts).
441
+ const sessionDir = resolveSessionDir(dependencies, env);
442
+
437
443
  const activeToolCalls = new Set();
438
444
  const watcher = watchCodexTranscript({
439
445
  homeDir: dependencies.homeDir,
440
446
  sessionsRoot: dependencies.codexSessionsRoot,
441
447
  cwd,
442
448
  startedAt: now(),
449
+ sessionId,
450
+ sessionDir,
443
451
  onToolEvent: (event) => {
444
452
  hookDebugLog(env, now, {
445
453
  source: "codex_transcript",
@@ -522,6 +530,8 @@ async function runRunCommand(parsed, dependencies) {
522
530
  sessionsRoot: dependencies.codexSessionsRoot,
523
531
  cwd,
524
532
  startedAt: now(),
533
+ sessionId,
534
+ sessionDir,
525
535
  onReviewEvent: (event) => {
526
536
  hookDebugLog(env, now, {
527
537
  source: "codex_guardian",
@@ -550,6 +560,7 @@ async function runRunCommand(parsed, dependencies) {
550
560
  stopWatcher = () => {
551
561
  guardianWatcher.stop();
552
562
  stopWithoutGuardian();
563
+ removeSessionTranscriptLink({ sessionDir, sessionId });
553
564
  };
554
565
  }
555
566
  }
@@ -2,38 +2,34 @@
2
2
 
3
3
  Issues found in live use, with their current status.
4
4
 
5
- ## 🔲 Open: cross-session status contamination on Codex
6
-
7
- - **Symptom (same class as the resolved Claude entry below):** interrupting one
8
- Codex session can flip a **different, concurrent** Codex session's pet to
9
- *interrupted* (and more generally mirror another session's tool/working
10
- states). Most likely when two Codex sessions run in the **same folder** and one
11
- is busy while the other is idle. Not yet fixed.
12
- - **Root cause:** `discoverCodexTranscript` (`codex-transcript-watcher.js`) picks
13
- the rollout by **newest `.jsonl` by mtime**, filtered only by
14
- `session_meta.cwd` / freshness — it does **not** bind to a specific session, so
15
- an idle session's watcher can lock onto a busy session's rollout and then read
16
- that session's `turn_aborted` (Codex's interrupt signal) as its own. The rollout
17
- *does* identify itself (`session_meta.payload.id` = the Codex thread id, plus a
18
- unique per-session filename), but (1) we never learn **which** thread id belongs
19
- to the session our wrapper launched the Codex hooks pass only
20
- `HAYA_PET_SESSION_ID` via env and the reporter currently **discards Codex's hook
21
- stdin** — and (2) the watcher matches on mtime+cwd, not on that id. The
22
- `isFreshSession` branch even admits recently-started rollouts from **other
23
- cwds**, so the exposure is slightly *wider* than Claude's (which is scoped to one
24
- project dir). This is the residual same-session-folder case the earlier
25
- "Codex pet looked busy immediately after startup" fix narrowed but did not
26
- eliminate.
27
- - **Plan (handle later):** port the Claude binding to Codex. Verify against the
28
- live Codex CLI whether the hook **stdin payload** carries the rollout path or
29
- the thread id (`session_id`); if so, have the `haya-pet state` reporter record a
30
- per-session session→rollout link (reusing `session-transcript-link.js`) and pin
31
- the watcher to it or, failing that, match `session_meta.payload.id` once we
32
- can learn our session's id. Fall back to current behavior when no identifier is
33
- available. No timer, consistent with the rest of the status model.
34
- - **Status:** unfixed. The binding fix shipped this session is **Claude-only** and
35
- does not touch the Codex watcher or the guardian-review watcher (which shares the
36
- same discovery shape and should be checked alongside it).
5
+ ## Resolved: cross-session status contamination on Codex
6
+
7
+ - **Symptom (same class as the Claude entry below):** interrupting one Codex
8
+ session could flip a **different, concurrent** Codex session's pet to
9
+ *interrupted* (and more generally mirror another session's tool/working states).
10
+ Most likely when two Codex sessions ran in the **same folder** and one was busy
11
+ while the other was idle.
12
+ - **Root cause:** `discoverCodexTranscript` (`codex-transcript-watcher.js`) picked
13
+ the rollout by **newest `.jsonl` by mtime**, filtered only by `session_meta.cwd`
14
+ / freshness — it did **not** bind to a specific session, so an idle session's
15
+ watcher could lock onto a busy session's rollout and read that session's
16
+ `turn_aborted` (Codex's interrupt signal) as its own. The `isFreshSession` branch
17
+ even admitted recently-started rollouts from **other cwds**, so the exposure was
18
+ slightly *wider* than Claude's (scoped to one project dir). The guardian-review
19
+ watcher had the same flaw: it derived the main thread id from the newest main
20
+ rollout by mtime, so a concurrent session's review status could be misattributed.
21
+ - **Fix:** the same per-session binding used for Claude. Verified against the
22
+ OpenAI Codex docs that the command-hook stdin payload carries **`transcript_path`**
23
+ (and `session_id`, the conversation/rollout id which I also confirmed on disk
24
+ equals `session_meta.payload.id` and the rollout filename uuid). The
25
+ `haya-pet state` reporter already records a per-session `session→transcript` link
26
+ from that `transcript_path` (the capture is client-agnostic), so the Codex
27
+ transcript watcher now pins to its own rollout via the link
28
+ (`session-transcript-link.js`) instead of guessing newest-by-mtime, and the
29
+ guardian watcher derives the main thread id from the **linked** rollout's
30
+ `payload.id` (and only considers a trunk whose `parent_thread_id` matches it).
31
+ Both fall back to the old heuristic when no link is available (e.g. `transcript_path`
32
+ null early), so there is no regression. No timer involved.
37
33
 
38
34
  ## ✅ Resolved: Claude interrupt/denial leaked into a concurrent idle session
39
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -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;
@@ -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");