@hayasaka7/haya-pet 0.3.5 → 0.3.6

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,7 +7,7 @@ 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.5]
10
+ ## [0.3.6]
11
11
 
12
12
  ### Fixed
13
13
  - **The pet no longer gets stuck on "compacting" in Claude Code.** `PreCompact`
@@ -19,21 +19,31 @@ All notable changes to HAYA Pet are documented here. This project adheres to
19
19
  an **auto** compaction (context filled mid-turn) resumes to *thinking* and the
20
20
  next real event refines from there. Mirrors Codex, which already handled
21
21
  `PostCompact`.
22
+ - **Codex interrupts no longer get clobbered by stale working states.** The Codex
23
+ transcript watcher already detected `turn_aborted` and emitted
24
+ *interrupted*, but the daemon registry applied state by IPC arrival order. A
25
+ slower hook reporter could therefore deliver an older *thinking* / *running*
26
+ state after the interrupt and overwrite it. The registry now keeps a separate
27
+ per-session state timestamp and ignores state messages older than the latest
28
+ accepted state, while heartbeats still update liveness independently.
29
+ - **Codex immediate interrupts in resumed sessions are detected.** In a resumed
30
+ Codex session, `session_meta.timestamp` stays at the original session start.
31
+ The prompt-start hook could still set the pet to *thinking*, but the transcript
32
+ watcher rejected the old rollout before it could see the immediately appended
33
+ `turn_aborted`. The watcher now also follows a fresh rollout from the wrapped
34
+ cwd, so resumed sessions can report interrupts while unrelated old sessions
35
+ remain filtered.
36
+ - **Codex auto-review status works in resumed sessions too.** The guardian-review
37
+ watcher had the same old-`session_meta.timestamp` filter as the transcript
38
+ watcher, so a resumed main rollout could be rejected before the guardian trunk
39
+ was matched to it. The guardian watcher now uses the same fresh-mtime + wrapped
40
+ cwd rule for resumed main sessions before following the guardian review trunk.
22
41
 
23
42
  ### Added
24
43
  - **`HAYA_PET_DAEMON_DEBUG` diagnostic.** When set to a file path, the companion
25
44
  appends one JSONL line per incoming non-heartbeat message in daemon **arrival
26
45
  order** (with `updatedAt`), making out-of-order state delivery observable. Added
27
- to investigate the Codex interrupt issue below.
28
-
29
- ### Known issues
30
- - **Codex interrupt can still leave the pet "working".** On some interrupts the
31
- pet keeps a working state instead of *interrupted*. The transcript watcher does
32
- record `turn_aborted` (the "a late tool result resets it" theory was ruled out
33
- across 257 real aborts), so the suspect is the daemon applying state by IPC
34
- **arrival order**, letting a stale "working" message land after *interrupted*.
35
- Instrumented via `HAYA_PET_DAEMON_DEBUG`; **fix to follow shortly.** See
36
- `docs/known-issues.md`.
46
+ to investigate state-order races such as the Codex interrupt issue.
37
47
 
38
48
  ## [0.3.4]
39
49
 
@@ -426,6 +426,7 @@ async function runRunCommand(parsed, dependencies) {
426
426
  const watcher = watchCodexTranscript({
427
427
  homeDir: dependencies.homeDir,
428
428
  sessionsRoot: dependencies.codexSessionsRoot,
429
+ cwd,
429
430
  startedAt: now(),
430
431
  onToolEvent: (event) => {
431
432
  hookDebugLog(env, now, {
@@ -507,6 +508,7 @@ async function runRunCommand(parsed, dependencies) {
507
508
  const guardianWatcher = watchCodexGuardianReviews({
508
509
  homeDir: dependencies.homeDir,
509
510
  sessionsRoot: dependencies.codexSessionsRoot,
511
+ cwd,
510
512
  startedAt: now(),
511
513
  onReviewEvent: (event) => {
512
514
  hookDebugLog(env, now, {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Issues found in live use, with their current status.
4
4
 
5
- ## In progress: Codex interrupt sometimes leaves the pet "working"
5
+ ## Resolved: Codex interrupt sometimes left the pet "working"
6
6
 
7
7
  - **Symptom:** Pressing Esc to interrupt a Codex turn occasionally does **not**
8
8
  flip the pet to *interrupted* — it keeps showing a working state (*thinking* /
@@ -12,18 +12,32 @@ Issues found in live use, with their current status.
12
12
  live `~/.codex/sessions`, **zero** had a tool result after the abort. Codex
13
13
  fires no hook on an abort, and the L3 transcript watcher does record
14
14
  `turn_aborted` and emit `interrupted`.
15
- - **Leading suspect:** The daemon registry applies session state **last-writer-
16
- wins by IPC arrival order** (`registry.js` `applyState` ignores `updatedAt` and
17
- `confidence`). Hooks (separate `haya-pet state` subprocesses) and the interrupt
18
- watcher use different IPC connections, so a stale "working" message can arrive
19
- *after* `interrupted` and clobber it. Discovery edges (a resumed session whose
20
- `session_meta.timestamp` predates the wrapper launch is filtered out; concurrent
21
- Codex sessions can attach the watcher to the wrong rollout) are secondary
22
- candidates.
23
- - **Status:** Instrumented with a daemon-side arrival trace (`HAYA_PET_DAEMON_DEBUG`,
24
- in `apps/companion/src/main/index.js`) to confirm whether `interrupted` is never
25
- emitted (watcher/discovery) vs. emitted-then-clobbered (arrival-order race).
26
- **Fix to follow shortly** likely an `updatedAt`-monotonic guard in `applyState`.
15
+ - **Root cause:** The daemon registry applied session state **last-writer-wins by
16
+ IPC arrival order** (`registry.js` `applyState` ignored state ordering). Hooks
17
+ (separate `haya-pet state` subprocesses) and the interrupt watcher use different
18
+ IPC connections, so a stale "working" message could arrive *after*
19
+ `interrupted` and clobber it.
20
+ - **Fix:** The registry now keeps a separate per-session state timestamp and ignores
21
+ state messages older than the latest accepted state. Heartbeats still update
22
+ liveness independently, so a newer heartbeat cannot block a legitimate
23
+ later-delivered state message.
24
+ - **Follow-up root cause:** Immediate Esc after prompt submit still failed in
25
+ **resumed** Codex sessions. `UserPromptSubmit` fired and set *thinking*, and
26
+ Codex wrote a normal `turn_aborted` record, but the watcher rejected the rollout
27
+ because `session_meta.timestamp` was from the original session start, before the
28
+ HAYA wrapper launch. The transcript watcher now allows a fresh rollout from the
29
+ wrapped cwd, preserving the old guard against unrelated stale sessions while
30
+ covering resumed sessions.
31
+ - **Other affected case checked:** The Codex guardian-review watcher had the same
32
+ resumed-session shape. It matched guardian trunks by the main thread id, but the
33
+ old resumed main rollout could be rejected before that id was accepted. It now
34
+ uses the same fresh-mtime + wrapped-cwd rule for resumed main sessions, so
35
+ "Approve for me" review status is not lost after a Codex resume.
36
+ - **How to diagnose if it recurs:** Set `HAYA_PET_DAEMON_DEBUG=<path>` before
37
+ launching the companion. The companion writes daemon-arrival JSONL for
38
+ non-heartbeat messages. If `interrupted` never appears, the issue is in
39
+ transcript discovery/watching; if `interrupted` appears before a stale
40
+ hook-sourced working state, it is an ordering regression.
27
41
 
28
42
  ## ✅ Resolved: Claude pet stuck on "compacting" after a compaction
29
43
 
@@ -112,9 +126,11 @@ Issues found in live use, with their current status.
112
126
  Another already-running Codex session could keep writing fresh records after
113
127
  HAYA Pet started, making its rollout look like the wrapped session even though
114
128
  it began earlier.
115
- - **Fix:** Both watchers now inspect the first `session_meta` line and require
116
- its timestamp to belong to this wrapper launch. Old-but-active Codex sessions
117
- are ignored even if their files continue to receive fresh writes.
129
+ - **Fix:** Both watchers inspect the first `session_meta` line and require either
130
+ a timestamp that belongs to this wrapper launch, or a fresh rollout whose cwd
131
+ matches the wrapped Codex cwd for resumed sessions. Old-but-active Codex
132
+ sessions from unrelated projects are ignored even if their files continue to
133
+ receive fresh writes.
118
134
 
119
135
  ## ✅ Resolved: Codex `/quit` hung on its goodbye (and the pet kept showing "working")
120
136
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -43,8 +43,11 @@
43
43
  // while manual/unknown reviewer config reports waiting_approval. The guardian
44
44
  // fires NO hooks itself (SubAgentSource::Other is excluded from Subagent
45
45
  // hooks), so the wrapper also tails the guardian rollout directly.
46
- // - UNTESTED: PreCompact / SubagentStart|Stop (no compaction / subagent
47
- // occurred in the probe).
46
+ // - PreCompact / PostCompact trigger split follows the same manual/auto matcher
47
+ // shape as Claude. A manual `/compact` returns to a prompt, while auto compact
48
+ // happens mid-turn and should resume the pet's working state.
49
+ // - UNTESTED: PreCompact / SubagentStart|Stop live firing (no compaction /
50
+ // subagent occurred in the probe).
48
51
  //
49
52
  // OPEN QUESTION (injection): unlike `claude --settings <file>`, Codex has no
50
53
  // per-invocation settings-file flag. Candidate non-mutating paths, best first:
@@ -70,8 +73,9 @@ const EDIT_TOOLS_MATCHER = EDIT_TOOLS.join("|");
70
73
  const COMMAND_TOOLS_MATCHER = "shell_command";
71
74
 
72
75
  // The hook table. Each entry → one Codex hook that reports a fixed pet state.
73
- // `matcher` (when present) filters PreToolUse by tool name. `summary` is an
74
- // optional short label shown in the bubble and in HAYA_PET_HOOK_DEBUG logs.
76
+ // `matcher` (when present) filters PreToolUse by tool name or PostCompact by
77
+ // compaction trigger. `summary` is an optional short label shown in the bubble and
78
+ // in HAYA_PET_HOOK_DEBUG logs.
75
79
  const HOOK_TABLE = Object.freeze([
76
80
  { event: "UserPromptSubmit", state: "thinking" },
77
81
  { event: "PreToolUse", matcher: EDIT_TOOLS_MATCHER, state: "editing_files" },
@@ -79,7 +83,8 @@ const HOOK_TABLE = Object.freeze([
79
83
  { event: "PostToolUse", state: "thinking" },
80
84
  { event: "PermissionRequest", command: "codex-permission-request" },
81
85
  { event: "PreCompact", state: "compacting" },
82
- { event: "PostCompact", state: "thinking", summary: "compacted" },
86
+ { event: "PostCompact", matcher: "manual", state: "idle", summary: "compacted" },
87
+ { event: "PostCompact", matcher: "auto", state: "thinking", summary: "compacted" },
83
88
  // A subagent finishing is mid-turn — the main agent keeps working, so this is
84
89
  // NOT idle. Turn-end is the dedicated `Stop` event below.
85
90
  { event: "SubagentStart", state: "running_tool", summary: "subagent" },
@@ -87,11 +92,15 @@ const HOOK_TABLE = Object.freeze([
87
92
  { event: "Stop", state: "idle" }
88
93
  ]);
89
94
 
90
- // Resolve the pet state for a Codex event. `toolName` is the tool for PreToolUse.
91
- // Exposed for testing and to keep the mapping in one place.
92
- export function mapCodexEventToState(event, toolName) {
95
+ // Resolve the pet state for a Codex event. `detail` is the tool for PreToolUse or
96
+ // the compaction trigger for PostCompact. Exposed for testing and to keep the
97
+ // mapping in one place.
98
+ export function mapCodexEventToState(event, detail) {
93
99
  if (event === "PreToolUse") {
94
- return EDIT_TOOLS.includes(toolName) ? "editing_files" : "running_tool";
100
+ return EDIT_TOOLS.includes(detail) ? "editing_files" : "running_tool";
101
+ }
102
+ if (event === "PostCompact") {
103
+ return detail === "manual" ? "idle" : "thinking";
95
104
  }
96
105
  const entry = HOOK_TABLE.find((row) => row.event === event && row.matcher === undefined);
97
106
  return entry?.state;
@@ -25,6 +25,12 @@ test("mapCodexEventToState branches PreToolUse on tool name (apply_patch vs comm
25
25
  assert.equal(mapCodexEventToState("PreToolUse", "read_file"), "running_tool");
26
26
  });
27
27
 
28
+ test("mapCodexEventToState branches PostCompact on compaction trigger", () => {
29
+ assert.equal(mapCodexEventToState("PostCompact", "manual"), "idle");
30
+ assert.equal(mapCodexEventToState("PostCompact", "auto"), "thinking");
31
+ assert.equal(mapCodexEventToState("PostCompact"), "thinking");
32
+ });
33
+
28
34
  test("Stop is the only idle signal — SubagentStop stays working", () => {
29
35
  // Regression guard for the key Codex-vs-Claude difference: a subagent finishing
30
36
  // mid-turn must NOT flip the pet to idle.
@@ -64,6 +70,15 @@ test("buildCodexHookSettings splits PreToolUse into edit + command matchers", ()
64
70
  assert.equal(other.matcher, "shell_command");
65
71
  });
66
72
 
73
+ test("buildCodexHookSettings splits PostCompact into manual + auto triggers", () => {
74
+ const post = buildCodexHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PostCompact;
75
+ assert.equal(post.length, 2);
76
+ const manual = post.find((e) => e.matcher === "manual");
77
+ const auto = post.find((e) => e.matcher === "auto");
78
+ assert.match(manual.hooks[0].command, /state idle --summary compacted$/);
79
+ assert.match(auto.hooks[0].command, /state thinking --summary compacted$/);
80
+ });
81
+
67
82
  test("buildCodexHookSettings routes PermissionRequest through the Codex reporter", () => {
68
83
  const permission = buildCodexHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PermissionRequest;
69
84
  assert.equal(permission.length, 1);
@@ -21,6 +21,7 @@ const MTIME_SKEW_MS = 2000;
21
21
  export function watchCodexGuardianReviews(options = {}) {
22
22
  const {
23
23
  homeDir = process.env.USERPROFILE || process.env.HOME,
24
+ cwd,
24
25
  startedAt = 0,
25
26
  onReviewEvent = () => {},
26
27
  pollIntervalMs = DEFAULT_POLL_MS,
@@ -31,6 +32,7 @@ export function watchCodexGuardianReviews(options = {}) {
31
32
 
32
33
  const root = sessionsRoot ?? (homeDir ? join(homeDir, ".codex", "sessions") : undefined);
33
34
  const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
35
+ const expectedCwd = normalizePathForCompare(cwd);
34
36
 
35
37
  // session_meta classifications are immutable once written, so cache them by
36
38
  // path. A file with no complete first line yet is NOT cached — it is retried
@@ -50,8 +52,13 @@ export function watchCodexGuardianReviews(options = {}) {
50
52
  return undefined;
51
53
  }
52
54
  const meta = classifyCodexSessionMeta(firstLine) ?? null;
53
- const sessionStartedAt = readSessionMetaTimestamp(firstLine);
54
- if (meta && minMtime > 0 && (!Number.isFinite(sessionStartedAt) || sessionStartedAt < minMtime)) {
55
+ const sessionMeta = readSessionMeta(firstLine);
56
+ const isFreshSession = sessionMeta && sessionMeta.startedAt >= minMtime;
57
+ const isFreshResume =
58
+ meta?.kind === "main" &&
59
+ expectedCwd !== undefined &&
60
+ normalizePathForCompare(sessionMeta?.cwd) === expectedCwd;
61
+ if (meta && minMtime > 0 && !isFreshSession && !isFreshResume) {
55
62
  metaByPath.set(file, null);
56
63
  return null;
57
64
  }
@@ -140,7 +147,7 @@ export function watchCodexGuardianReviews(options = {}) {
140
147
  };
141
148
  }
142
149
 
143
- function readSessionMetaTimestamp(line) {
150
+ function readSessionMeta(line) {
144
151
  let entry;
145
152
  try {
146
153
  entry = JSON.parse(line);
@@ -153,5 +160,19 @@ function readSessionMetaTimestamp(line) {
153
160
  }
154
161
 
155
162
  const timestampMs = Date.parse(entry.timestamp);
156
- return Number.isFinite(timestampMs) ? timestampMs : undefined;
163
+ if (!Number.isFinite(timestampMs)) {
164
+ return undefined;
165
+ }
166
+
167
+ return {
168
+ startedAt: timestampMs,
169
+ cwd: typeof entry.payload?.cwd === "string" ? entry.payload.cwd : undefined
170
+ };
171
+ }
172
+
173
+ function normalizePathForCompare(value) {
174
+ if (typeof value !== "string" || value.trim() === "") {
175
+ return undefined;
176
+ }
177
+ return value.trim().replace(/\\/g, "/").replace(/\/+$/g, "").toLowerCase();
157
178
  }
@@ -12,6 +12,7 @@ const MTIME_SKEW_MS = 2000;
12
12
  export function watchCodexTranscript(options = {}) {
13
13
  const {
14
14
  homeDir = process.env.USERPROFILE || process.env.HOME,
15
+ cwd,
15
16
  startedAt = 0,
16
17
  onToolEvent = () => {},
17
18
  pollIntervalMs = DEFAULT_POLL_MS,
@@ -31,7 +32,7 @@ export function watchCodexTranscript(options = {}) {
31
32
  const tick = () => {
32
33
  try {
33
34
  if (!transcriptPath) {
34
- transcriptPath = discoverCodexTranscript(root, minMtime);
35
+ transcriptPath = discoverCodexTranscript(root, minMtime, { cwd });
35
36
  if (!transcriptPath) {
36
37
  return;
37
38
  }
@@ -78,21 +79,29 @@ export function watchCodexTranscript(options = {}) {
78
79
  };
79
80
  }
80
81
 
81
- export function discoverCodexTranscript(root, minMtime = 0) {
82
+ export function discoverCodexTranscript(root, minMtime = 0, options = {}) {
82
83
  if (!root || !existsSync(root)) {
83
84
  return undefined;
84
85
  }
85
86
 
87
+ const expectedCwd = normalizePathForCompare(options.cwd);
86
88
  let newest;
87
89
  for (const file of listJsonlFiles(root)) {
88
90
  const mtime = safeMtime(file);
89
91
  if (mtime < minMtime) {
90
92
  continue;
91
93
  }
92
- const sessionStartedAt = readCodexSessionStartedAt(file);
93
- if (!Number.isFinite(sessionStartedAt) || sessionStartedAt < minMtime) {
94
+ const meta = readCodexSessionMeta(file);
95
+ if (!meta) {
94
96
  continue;
95
97
  }
98
+
99
+ const isFreshSession = meta.startedAt >= minMtime;
100
+ const isFreshResume = expectedCwd !== undefined && normalizePathForCompare(meta.cwd) === expectedCwd;
101
+ if (!isFreshSession && !isFreshResume) {
102
+ continue;
103
+ }
104
+
96
105
  if (!newest || mtime > newest.mtime) {
97
106
  newest = { file, mtime };
98
107
  }
@@ -100,7 +109,7 @@ export function discoverCodexTranscript(root, minMtime = 0) {
100
109
  return newest?.file;
101
110
  }
102
111
 
103
- function readCodexSessionStartedAt(file) {
112
+ function readCodexSessionMeta(file) {
104
113
  const line = readFirstLine(file);
105
114
  if (line === undefined) {
106
115
  return undefined;
@@ -118,5 +127,19 @@ function readCodexSessionStartedAt(file) {
118
127
  }
119
128
 
120
129
  const timestampMs = Date.parse(entry.timestamp);
121
- return Number.isFinite(timestampMs) ? timestampMs : undefined;
130
+ if (!Number.isFinite(timestampMs)) {
131
+ return undefined;
132
+ }
133
+
134
+ return {
135
+ startedAt: timestampMs,
136
+ cwd: typeof entry.payload?.cwd === "string" ? entry.payload.cwd : undefined
137
+ };
138
+ }
139
+
140
+ function normalizePathForCompare(value) {
141
+ if (typeof value !== "string" || value.trim() === "") {
142
+ return undefined;
143
+ }
144
+ return value.trim().replace(/\\/g, "/").replace(/\/+$/g, "").toLowerCase();
122
145
  }
@@ -175,6 +175,43 @@ test("watchCodexGuardianReviews ignores guardian trunks for sessions that starte
175
175
  watcher.stop();
176
176
  });
177
177
 
178
+ test("watchCodexGuardianReviews follows a resumed main session in the same cwd", () => {
179
+ const { root, dir } = makeSessionsRoot();
180
+ writeFileSync(
181
+ join(dir, "rollout-main-resumed.jsonl"),
182
+ metaLineAt("2026-06-12T00:00:00.000Z", {
183
+ id: "main-1",
184
+ parent_thread_id: null,
185
+ source: "cli",
186
+ thread_source: "user",
187
+ cwd: "D:\\Work\\project"
188
+ })
189
+ );
190
+ writeFileSync(
191
+ join(dir, "rollout-guardian.jsonl"),
192
+ metaLineAt("2026-06-12T01:01:00.000Z", {
193
+ id: "guardian-1",
194
+ parent_thread_id: "main-1",
195
+ source: { subagent: { other: "guardian" } }
196
+ }) + reviewStarted("turn-new", "2026-06-12T01:02:00.000Z")
197
+ );
198
+
199
+ const events = [];
200
+ const watcher = watchCodexGuardianReviews({
201
+ sessionsRoot: root,
202
+ cwd: "D:\\Work\\project",
203
+ startedAt: Date.parse("2026-06-12T01:00:00.000Z"),
204
+ onReviewEvent: (event) => events.push(event),
205
+ ...noopTimers
206
+ });
207
+
208
+ watcher._tick();
209
+
210
+ assert.deepEqual(events, [{ type: "review_started" }]);
211
+
212
+ watcher.stop();
213
+ });
214
+
178
215
  test("watchCodexGuardianReviews emits nothing without a classifiable main session", () => {
179
216
  const { root, dir } = makeSessionsRoot();
180
217
  // Guardian trunk exists but there is no main rollout to bind its parent to.
@@ -7,11 +7,11 @@ import { discoverCodexTranscript, watchCodexTranscript } from "../src/codex-tran
7
7
 
8
8
  const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
9
9
 
10
- function sessionMeta(timestamp, id = "thread-1") {
10
+ function sessionMeta(timestamp, id = "thread-1", cwd) {
11
11
  return `${JSON.stringify({
12
12
  timestamp,
13
13
  type: "session_meta",
14
- payload: { id, parent_thread_id: null, source: "cli", thread_source: "user" }
14
+ payload: { id, parent_thread_id: null, source: "cli", thread_source: "user", ...(cwd ? { cwd } : {}) }
15
15
  })}\n`;
16
16
  }
17
17
 
@@ -152,6 +152,37 @@ test("watchCodexTranscript ignores fresh writes to sessions that started before
152
152
  watcher.stop();
153
153
  });
154
154
 
155
+ test("watchCodexTranscript follows a fresh resumed session in the same cwd", () => {
156
+ const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
157
+ const dir = join(root, "2026", "06", "08");
158
+ mkdirSync(dir, { recursive: true });
159
+ const path = join(dir, "rollout-resumed.jsonl");
160
+ writeFileSync(
161
+ path,
162
+ [
163
+ sessionMeta("2026-06-08T10:00:00.000Z", "resumed-thread", "D:\\Work\\project"),
164
+ turnAborted("2026-06-08T11:00:01.000Z")
165
+ ].join("")
166
+ );
167
+ const fresh = new Date("2026-06-08T11:00:01.500Z");
168
+ utimesSync(path, fresh, fresh);
169
+
170
+ const events = [];
171
+ const watcher = watchCodexTranscript({
172
+ sessionsRoot: root,
173
+ cwd: "D:\\Work\\project",
174
+ startedAt: Date.parse("2026-06-08T11:00:00.000Z"),
175
+ onToolEvent: (event) => events.push(event),
176
+ ...noopTimers
177
+ });
178
+
179
+ watcher._tick();
180
+
181
+ assert.deepEqual(events, [{ type: "turn_aborted", reason: "interrupted" }]);
182
+
183
+ watcher.stop();
184
+ });
185
+
155
186
  test("watchCodexTranscript forwards a turn_aborted interrupt event", () => {
156
187
  const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
157
188
  const path = join(dir, "session.jsonl");
@@ -11,6 +11,7 @@ export function createSessionRegistry(options = {}) {
11
11
  class SessionRegistry {
12
12
  constructor(options) {
13
13
  this.sessions = new Map();
14
+ this.lastStateUpdatedAt = new Map();
14
15
  this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
15
16
  this.dropAfterMs = options.dropAfterMs ?? DEFAULT_DROP_AFTER_MS;
16
17
  }
@@ -58,6 +59,7 @@ class SessionRegistry {
58
59
  // real update — marking stale must NOT bump updatedAt, or it never elapses.
59
60
  if (now - session.updatedAt > this.dropAfterMs) {
60
61
  this.sessions.delete(sessionId);
62
+ this.lastStateUpdatedAt.delete(sessionId);
61
63
  continue;
62
64
  }
63
65
 
@@ -70,6 +72,7 @@ class SessionRegistry {
70
72
  session.source = "wrapper";
71
73
  session.confidence = 0.3;
72
74
  session.summary = "heartbeat stale";
75
+ this.lastStateUpdatedAt.set(sessionId, now);
73
76
  staleSessions.push(snapshotSession(session));
74
77
  }
75
78
  }
@@ -93,6 +96,7 @@ class SessionRegistry {
93
96
  };
94
97
 
95
98
  this.sessions.set(message.sessionId, session);
99
+ this.lastStateUpdatedAt.set(message.sessionId, message.startedAt);
96
100
  return snapshotSession(session);
97
101
  }
98
102
 
@@ -104,10 +108,17 @@ class SessionRegistry {
104
108
 
105
109
  applyState(message) {
106
110
  const session = this.requireSession(message.sessionId);
111
+ const lastStateUpdatedAt = this.lastStateUpdatedAt.get(message.sessionId) ?? session.startedAt;
112
+
113
+ if (message.updatedAt < lastStateUpdatedAt) {
114
+ return snapshotSession(session);
115
+ }
116
+
117
+ this.lastStateUpdatedAt.set(message.sessionId, message.updatedAt);
107
118
  session.state = message.state;
108
119
  session.confidence = message.confidence;
109
120
  session.source = message.source;
110
- session.updatedAt = message.updatedAt;
121
+ session.updatedAt = Math.max(session.updatedAt, message.updatedAt);
111
122
 
112
123
  if (Object.prototype.hasOwnProperty.call(message, "summary")) {
113
124
  session.summary = message.summary;
@@ -126,6 +137,7 @@ class SessionRegistry {
126
137
  session.exitCode = message.exitCode;
127
138
  session.finishedAt = message.finishedAt;
128
139
  session.updatedAt = message.finishedAt;
140
+ this.lastStateUpdatedAt.set(message.sessionId, message.finishedAt);
129
141
  return snapshotSession(session);
130
142
  }
131
143
 
@@ -65,6 +65,60 @@ test("applies state and heartbeat messages without losing session metadata", ()
65
65
  assert.equal(session.projectName, "project");
66
66
  });
67
67
 
68
+ test("ignores late state messages older than the latest accepted state", () => {
69
+ const registry = createSessionRegistry();
70
+
71
+ registry.applyMessage(registerMessage("sess_a"));
72
+ registry.applyMessage({
73
+ type: "state",
74
+ sessionId: "sess_a",
75
+ state: "interrupted",
76
+ summary: "interrupted",
77
+ confidence: 0.9,
78
+ source: "client_log",
79
+ updatedAt: 2000
80
+ });
81
+ registry.applyMessage({
82
+ type: "state",
83
+ sessionId: "sess_a",
84
+ state: "thinking",
85
+ confidence: 0.9,
86
+ source: "official_plugin",
87
+ updatedAt: 1500
88
+ });
89
+
90
+ const session = registry.getSession("sess_a");
91
+ assert.equal(session.state, "interrupted");
92
+ assert.equal(session.summary, "interrupted");
93
+ assert.equal(session.source, "client_log");
94
+ assert.equal(session.updatedAt, 2000);
95
+ });
96
+
97
+ test("heartbeats do not block later-delivered state messages", () => {
98
+ const registry = createSessionRegistry();
99
+
100
+ registry.applyMessage(registerMessage("sess_a"));
101
+ registry.applyMessage({
102
+ type: "heartbeat",
103
+ sessionId: "sess_a",
104
+ updatedAt: 3000
105
+ });
106
+ registry.applyMessage({
107
+ type: "state",
108
+ sessionId: "sess_a",
109
+ state: "running_tool",
110
+ summary: "shell_command",
111
+ confidence: 0.85,
112
+ source: "client_log",
113
+ updatedAt: 2000
114
+ });
115
+
116
+ const session = registry.getSession("sess_a");
117
+ assert.equal(session.state, "running_tool");
118
+ assert.equal(session.summary, "shell_command");
119
+ assert.equal(session.updatedAt, 3000);
120
+ });
121
+
68
122
  test("unregister marks sessions as exited and preserves exit details", () => {
69
123
  const registry = createSessionRegistry();
70
124