@hayasaka7/haya-pet 0.3.3 → 0.3.5

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,50 @@ 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]
11
+
12
+ ### Fixed
13
+ - **The pet no longer gets stuck on "compacting" in Claude Code.** `PreCompact`
14
+ set the status to *compacting*, but nothing ever cleared it — Claude's `Stop`
15
+ does not fire for a `/compact`, so the pet sat on *compacting* until the next
16
+ prompt or the 30 s stale sweep. The Claude hook table now also subscribes
17
+ **`PostCompact`**, split by the documented `manual`/`auto` trigger matcher: a
18
+ **manual** `/compact` returns to *idle* (control is back at the prompt), while
19
+ an **auto** compaction (context filled mid-turn) resumes to *thinking* and the
20
+ next real event refines from there. Mirrors Codex, which already handled
21
+ `PostCompact`.
22
+
23
+ ### Added
24
+ - **`HAYA_PET_DAEMON_DEBUG` diagnostic.** When set to a file path, the companion
25
+ appends one JSONL line per incoming non-heartbeat message in daemon **arrival
26
+ 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`.
37
+
38
+ ## [0.3.4]
39
+
40
+ ### Fixed
41
+ - **Codex "Approve for me" status no longer depends on a timer.** The
42
+ `PermissionRequest` hook now calls a Codex-specific reporter instead of
43
+ emitting delayed `waiting_approval`. The wrapper resolves
44
+ `approvals_reviewer` for the session: `auto_review` / legacy
45
+ `guardian_subagent` reports *reviewing*, while manual review still reports
46
+ *waiting for approval*. The daemon no longer has a deferred-state protocol or
47
+ timer-based approval fallback.
48
+ - **Fresh Codex sessions no longer inherit status from an older active Codex
49
+ session.** The transcript and guardian watchers now require the rollout's
50
+ first `session_meta.timestamp` to belong to this wrapper launch, so a
51
+ different Codex session writing `shell_command` / `thinking` after startup
52
+ cannot make an idle pet look busy.
53
+
10
54
  ## [0.3.3]
11
55
 
12
56
  ### Fixed
package/README.md CHANGED
@@ -187,11 +187,12 @@ Why opt in? Both clients show a one-time trust prompt when hooks are added. HAYA
187
187
  Pet lets you decide when to approve that instead of surprising you in the middle
188
188
  of work.
189
189
 
190
- Codex live status combines three sources: hooks report `thinking`/`idle` and
191
- approval requests, a transcript watcher reports tool/file activity, and a
192
- guardian-review watcher tracks Codex's **"Approve for me"** auto-reviewer — the
193
- pet shows *reviewing* while the guardian assesses a request and only shows
194
- *waiting for approval* when Codex actually asks you ("Ask for approval" mode).
190
+ Codex live status combines three sources: hooks report `thinking`/`idle`, a
191
+ Codex-specific permission reporter maps approval requests from the session's
192
+ resolved `approvals_reviewer` setting, and transcript watchers report tool/file
193
+ activity plus guardian-review outcomes. With **"Approve for me"** the pet shows
194
+ *reviewing* immediately; *waiting for approval* is reserved for Codex's manual
195
+ "Ask for approval" mode.
195
196
  Per-tool `PreToolUse` hooks still depend on an upstream Codex gap
196
197
  ([openai/codex#16732](https://github.com/openai/codex/issues/16732)); the
197
198
  transcript watcher covers that in the meantime.
@@ -58,6 +58,10 @@ export function parseAiPetArgs(argv) {
58
58
  return parseStateArgs(rest);
59
59
  }
60
60
 
61
+ if (command === "codex-permission-request") {
62
+ return { command: "codex-permission-request" };
63
+ }
64
+
61
65
  if (command === "hooks") {
62
66
  return parseHooksArgs(rest);
63
67
  }
@@ -84,6 +88,10 @@ export async function runAiPet(argv, dependencies = {}) {
84
88
  return runStateCommand(parsed, dependencies);
85
89
  }
86
90
 
91
+ if (parsed.command === "codex-permission-request") {
92
+ return runCodexPermissionRequestCommand(parsed, dependencies);
93
+ }
94
+
87
95
  if (parsed.command === "hooks") {
88
96
  return runHooksCommand(parsed, dependencies);
89
97
  }
@@ -172,6 +180,21 @@ async function reportUpdateNotice(updateCheck, print) {
172
180
  }
173
181
  }
174
182
 
183
+ function runCodexPermissionRequestCommand(_parsed, dependencies = {}) {
184
+ const env = dependencies.env ?? process.env;
185
+ const reviewer = normalizeCodexApprovalsReviewer(env.HAYA_PET_CODEX_APPROVAL_REVIEWER);
186
+ const autoReview = isCodexAutoReviewer(reviewer);
187
+ return runStateCommand(
188
+ {
189
+ command: "state",
190
+ state: autoReview ? "reviewing" : "waiting_approval",
191
+ summary: autoReview ? "agent reviewing approval" : "approval",
192
+ session: undefined
193
+ },
194
+ dependencies
195
+ );
196
+ }
197
+
175
198
  function readOwnVersion() {
176
199
  try {
177
200
  const packagePath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "package.json");
@@ -181,6 +204,116 @@ function readOwnVersion() {
181
204
  }
182
205
  }
183
206
 
207
+ function resolveCodexApprovalsReviewer(options = {}) {
208
+ const env = options.env ?? process.env;
209
+ const explicit = normalizeCodexApprovalsReviewer(env.HAYA_PET_CODEX_APPROVAL_REVIEWER);
210
+ if (explicit) {
211
+ return explicit;
212
+ }
213
+
214
+ const fromArgs = findCodexApprovalsReviewerInArgs(options.childArgs ?? []);
215
+ if (fromArgs) {
216
+ return fromArgs;
217
+ }
218
+
219
+ const home = options.codexHome ?? env.CODEX_HOME ?? resolveHomeCodexDir(options.homeDir, env);
220
+ const readFile = options.readFile ?? readFileSync;
221
+ const fromConfig = readCodexApprovalsReviewerFromConfig(home, readFile);
222
+ return fromConfig ?? "user";
223
+ }
224
+
225
+ function resolveHomeCodexDir(homeDir, env) {
226
+ const home = homeDir ?? env.USERPROFILE ?? env.HOME;
227
+ return home ? join(home, ".codex") : undefined;
228
+ }
229
+
230
+ function findCodexApprovalsReviewerInArgs(args) {
231
+ let reviewer;
232
+ for (let index = 0; index < args.length; index += 1) {
233
+ const arg = args[index];
234
+ let configValue;
235
+ if (arg === "-c" || arg === "--config") {
236
+ configValue = args[index + 1];
237
+ index += 1;
238
+ } else if (arg.startsWith("--config=")) {
239
+ configValue = arg.slice("--config=".length);
240
+ } else if (arg.startsWith("-c") && arg.length > 2) {
241
+ configValue = arg.slice(2);
242
+ }
243
+
244
+ const parsed = parseApprovalsReviewerAssignment(configValue);
245
+ if (parsed) {
246
+ reviewer = parsed;
247
+ }
248
+ }
249
+ return reviewer;
250
+ }
251
+
252
+ function readCodexApprovalsReviewerFromConfig(codexHome, readFile) {
253
+ if (!codexHome) {
254
+ return undefined;
255
+ }
256
+ try {
257
+ return parseTopLevelApprovalsReviewer(readFile(join(codexHome, "config.toml"), "utf8"));
258
+ } catch {
259
+ return undefined;
260
+ }
261
+ }
262
+
263
+ function parseTopLevelApprovalsReviewer(toml) {
264
+ let inTopLevel = true;
265
+ for (const line of String(toml).split(/\r?\n/)) {
266
+ const trimmed = line.trim();
267
+ if (!trimmed || trimmed.startsWith("#")) {
268
+ continue;
269
+ }
270
+ if (trimmed.startsWith("[")) {
271
+ inTopLevel = false;
272
+ continue;
273
+ }
274
+ if (!inTopLevel) {
275
+ continue;
276
+ }
277
+ const reviewer = parseApprovalsReviewerAssignment(trimmed);
278
+ if (reviewer) {
279
+ return reviewer;
280
+ }
281
+ }
282
+ return undefined;
283
+ }
284
+
285
+ function parseApprovalsReviewerAssignment(value) {
286
+ if (typeof value !== "string") {
287
+ return undefined;
288
+ }
289
+ const match = /^\s*approvals_reviewer\s*=\s*(.+?)\s*(?:#.*)?$/.exec(value);
290
+ if (!match) {
291
+ return undefined;
292
+ }
293
+ return normalizeCodexApprovalsReviewer(stripTomlString(match[1]));
294
+ }
295
+
296
+ function stripTomlString(value) {
297
+ const trimmed = String(value).trim();
298
+ const quote = trimmed[0];
299
+ if ((quote === "\"" || quote === "'") && trimmed.endsWith(quote)) {
300
+ return trimmed.slice(1, -1);
301
+ }
302
+ return trimmed;
303
+ }
304
+
305
+ function normalizeCodexApprovalsReviewer(value) {
306
+ if (typeof value !== "string") {
307
+ return undefined;
308
+ }
309
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
310
+ return normalized || undefined;
311
+ }
312
+
313
+ function isCodexAutoReviewer(value) {
314
+ return value === "auto_review" || value === "guardian_subagent";
315
+ }
316
+
184
317
  async function runRunCommand(parsed, dependencies) {
185
318
  const runGenericCommand = dependencies.runGenericCommand ?? defaultRunGenericCommand;
186
319
  const injectClaudeHooks = dependencies.injectClaudeHooks ?? defaultInjectClaudeHooks;
@@ -276,7 +409,17 @@ async function runRunCommand(parsed, dependencies) {
276
409
  } else {
277
410
  const injected = injectCodexHooks();
278
411
  childArgs = ["-p", injected.profileName, ...parsed.childArgs];
279
- childEnv = { ...env, HAYA_PET_SESSION_ID: sessionId };
412
+ childEnv = {
413
+ ...env,
414
+ HAYA_PET_SESSION_ID: sessionId,
415
+ HAYA_PET_CODEX_APPROVAL_REVIEWER: resolveCodexApprovalsReviewer({
416
+ childArgs: parsed.childArgs,
417
+ env,
418
+ homeDir: dependencies.homeDir,
419
+ codexHome: dependencies.codexHome,
420
+ readFile: dependencies.readFile
421
+ })
422
+ };
280
423
  cleanup = injected.cleanup;
281
424
 
282
425
  const activeToolCalls = new Set();
@@ -353,8 +496,9 @@ async function runRunCommand(parsed, dependencies) {
353
496
  // With "Approve for me" (approvals_reviewer=auto_review, legacy alias
354
497
  // guardian_subagent), Codex routes approval requests to a guardian
355
498
  // subagent and never shows the human approval UI — yet the
356
- // PermissionRequest hook still fires at request creation, which used to
357
- // pin the pet on a false "waiting for approval" for the whole review.
499
+ // PermissionRequest hook still fires at request creation. The hook's
500
+ // reporter uses the resolved approvals reviewer config: auto-review
501
+ // reports reviewing immediately, while manual review reports waiting.
358
502
  // The guardian's own rollout is the only observable record of the
359
503
  // review, so tail it: a review turn starting proves the agent is
360
504
  // reviewing; an "allow" verdict proves the action proceeds; a "deny"
@@ -399,10 +399,56 @@ test("parses the state command", () => {
399
399
  command: "state",
400
400
  state: "thinking",
401
401
  summary: undefined,
402
- session: "sess_q"
402
+ session: "sess_q",
403
403
  });
404
404
  });
405
405
 
406
+ test("parses the Codex permission request reporter command", () => {
407
+ assert.deepEqual(parseAiPetArgs(["codex-permission-request"]), {
408
+ command: "codex-permission-request"
409
+ });
410
+ });
411
+
412
+ test("Codex permission request reporter shows reviewing for auto-review", async () => {
413
+ const messages = [];
414
+ await runAiPet(["codex-permission-request"], {
415
+ env: {
416
+ HAYA_PET_SESSION_ID: "sess_review",
417
+ HAYA_PET_CODEX_APPROVAL_REVIEWER: "auto_review"
418
+ },
419
+ now: () => 123,
420
+ ipcEndpoint: "test-endpoint",
421
+ createIpcClient: async () => ({
422
+ send: async (message) => messages.push(message),
423
+ close: async () => {}
424
+ })
425
+ });
426
+
427
+ assert.equal(messages.length, 1);
428
+ assert.equal(messages[0].state, "reviewing");
429
+ assert.equal(messages[0].summary, "agent reviewing approval");
430
+ });
431
+
432
+ test("Codex permission request reporter shows waiting for manual reviewer", async () => {
433
+ const messages = [];
434
+ await runAiPet(["codex-permission-request"], {
435
+ env: {
436
+ HAYA_PET_SESSION_ID: "sess_manual",
437
+ HAYA_PET_CODEX_APPROVAL_REVIEWER: "user"
438
+ },
439
+ now: () => 123,
440
+ ipcEndpoint: "test-endpoint",
441
+ createIpcClient: async () => ({
442
+ send: async (message) => messages.push(message),
443
+ close: async () => {}
444
+ })
445
+ });
446
+
447
+ assert.equal(messages.length, 1);
448
+ assert.equal(messages[0].state, "waiting_approval");
449
+ assert.equal(messages[0].summary, "approval");
450
+ });
451
+
406
452
  const hooksStateFile = (hooksEnabled) => () => ({
407
453
  load: async () => ({ settings: { hooksEnabled } }),
408
454
  save: async (state) => state
@@ -509,6 +555,26 @@ test("persisted `hooks on` injects a Codex profile via -p at the front of args",
509
555
 
510
556
  assert.equal(injected, 1, "config preference enables Codex hooks");
511
557
  assert.deepEqual(calls[0].args, ["-p", "haya-pet"], "profile flag goes at the front");
558
+ assert.equal(calls[0].env.HAYA_PET_CODEX_APPROVAL_REVIEWER, "user");
559
+ });
560
+
561
+ test("codex hooks pass auto-review config to the PermissionRequest reporter", async () => {
562
+ const calls = [];
563
+ await runAiPet(["run", "--client", "codex", "--", "codex"], {
564
+ cwd: process.cwd(),
565
+ env: { USERPROFILE: "C:\\Users\\A" },
566
+ heartbeatIntervalMs: 10,
567
+ send: async () => {},
568
+ createStateFile: hooksStateFile(true),
569
+ injectCodexHooks: () => ({ profileName: "haya-pet", cleanup: () => {} }),
570
+ readFile: () => 'approvals_reviewer = "auto_review"\n',
571
+ runGenericCommand: async (options) => {
572
+ calls.push(options);
573
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
574
+ }
575
+ });
576
+
577
+ assert.equal(calls[0].env.HAYA_PET_CODEX_APPROVAL_REVIEWER, "auto_review");
512
578
  });
513
579
 
514
580
  test("codex hooks also start a transcript watcher for tool activity", async () => {
@@ -1,6 +1,6 @@
1
1
  import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron";
2
2
  import { fileURLToPath } from "node:url";
3
- import { readFileSync } from "node:fs";
3
+ import { appendFileSync, readFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { createDaemonRuntime } from "../../../../packages/daemon-core/src/daemon-runtime.js";
6
6
  import { createIpcServer } from "../../../../packages/daemon-core/src/ipc-server.js";
@@ -30,6 +30,35 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
30
30
  const TRAY_ICON_DATA_URL =
31
31
  "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAANElEQVR4nGNgoAXIW/HvPzZMtkaiDCJWM05DKDKAVM0YhowaQAUDBj4dUCUpE2MQQY3kAACyf/g8DHVl5wAAAABJRU5ErkJggg==";
32
32
 
33
+ // Best-effort daemon-side diagnostic, mirroring the wrapper's HAYA_PET_HOOK_DEBUG.
34
+ // When HAYA_PET_DAEMON_DEBUG points at a file, append one JSONL line per incoming
35
+ // non-heartbeat message in DAEMON ARRIVAL ORDER, with its updatedAt. This is the
36
+ // only place the true apply order is visible: the registry is last-writer-wins by
37
+ // arrival and ignores updatedAt, so a stale "working" message that arrives after
38
+ // "interrupted" would surface here as the clobber. Never throws.
39
+ function debugLogDaemonMessage(message) {
40
+ const target = process.env.HAYA_PET_DAEMON_DEBUG;
41
+ if (!target || !message || message.type === "heartbeat") {
42
+ return;
43
+ }
44
+ try {
45
+ appendFileSync(
46
+ target,
47
+ `${JSON.stringify({
48
+ ts: Date.now(),
49
+ type: message.type,
50
+ sessionId: message.sessionId,
51
+ state: message.state,
52
+ source: message.source,
53
+ updatedAt: message.updatedAt,
54
+ summary: message.summary
55
+ })}\n`
56
+ );
57
+ } catch {
58
+ // diagnostics must never break the daemon
59
+ }
60
+ }
61
+
33
62
  const paths = getDefaultPaths();
34
63
  const capabilities = getPlatformCapabilities();
35
64
  const stateFile = createStateFile({ statePath: paths.statePath });
@@ -113,6 +142,7 @@ async function bootstrap() {
113
142
  app.quit();
114
143
  return;
115
144
  }
145
+ debugLogDaemonMessage(message);
116
146
  return runtime.handleMessage(message);
117
147
  },
118
148
  onProtocolError: (error) => console.error("protocol error:", error.message)
@@ -74,10 +74,13 @@ notice. Codex's hook command must be unquoted at the program position (it runs v
74
74
  transcript watcher tailing the session rollout. `PermissionRequest` fires, but
75
75
  once at approval-request creation — before Codex routes the request to either
76
76
  the user or its guardian auto-reviewer ("Approve for me"), which never prompts
77
- the user at all. An L3 **guardian-trunk watcher** tails the guardian's own
77
+ the user at all. The hook therefore calls a Codex-specific permission reporter:
78
+ when the resolved Codex config says `approvals_reviewer = "auto_review"` (or the
79
+ legacy `guardian_subagent` alias), it reports `reviewing`; otherwise it reports
80
+ `waiting_approval`. An L3 **guardian-trunk watcher** tails the guardian's own
78
81
  rollout (`source: {subagent:{other:"guardian"}}`, parented to the main thread)
79
- and refines the state: review running → `reviewing`, verdict allow →
80
- `running_tool`, verdict deny → `thinking`.
82
+ and refines the state from real review events: review running → `reviewing`,
83
+ verdict allow → `running_tool`, verdict deny → `thinking`.
81
84
 
82
85
  Hooks alone can't see one moment: clients emit **no event when the user accepts a
83
86
  permission prompt** (denial and completion are observable; the accept click is
@@ -2,6 +2,43 @@
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"
6
+
7
+ - **Symptom:** Pressing Esc to interrupt a Codex turn occasionally does **not**
8
+ flip the pet to *interrupted* — it keeps showing a working state (*thinking* /
9
+ *running*) even though the turn was cut short. Intermittent ("some occasions").
10
+ - **Investigation so far:** The "a late `function_call_output` resets the state to
11
+ *thinking*" theory was **ruled out** — across 257 real `turn_aborted` events in
12
+ live `~/.codex/sessions`, **zero** had a tool result after the abort. Codex
13
+ fires no hook on an abort, and the L3 transcript watcher does record
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`.
27
+
28
+ ## ✅ Resolved: Claude pet stuck on "compacting" after a compaction
29
+
30
+ - **Symptom:** With Claude Code hooks enabled, the pet entered *compacting* on
31
+ `PreCompact` and never left — it sat there until the 30 s stale sweep or the
32
+ next prompt. Most visible after a manual `/compact`.
33
+ - **Root cause:** The Claude hook table subscribed `PreCompact` → *compacting* but
34
+ had **no completion event**. Claude's `Stop` does not fire for a compaction, so
35
+ nothing cleared the state. (Codex already subscribed `PostCompact`.)
36
+ - **Fix:** The Claude hook table now also subscribes **`PostCompact`**, split by
37
+ the documented `manual`/`auto` trigger matcher: a **manual** `/compact` returns
38
+ to *idle* (control is back at the prompt), while an **auto** compaction resumes
39
+ to *thinking* (the agent continues the turn) and the next real event refines it.
40
+ Verified live on the manual path; auto uses the identical matcher mechanism.
41
+
5
42
  ## ✅ Resolved: Claude Code subagent completion changed the main session status
6
43
 
7
44
  - **Symptom:** In Claude Code multi-agent runs, the main agent could already be
@@ -32,12 +69,17 @@ Issues found in live use, with their current status.
32
69
  guardian `deny` returns the rationale to the **model** as a rejected tool call
33
70
  ("This action was rejected due to unacceptable risk. …"), so no human decision
34
71
  is ever pending. Our Codex hook table mapped `PermissionRequest` →
35
- `waiting_approval` unconditionally. No better hook exists: nothing fires on
36
- guardian start/finish (the guardian session is `SubAgentSource::Other`, which
37
- is excluded from Subagent hooks), and `GuardianAssessment` events are
38
- explicitly not persisted to the main rollout (`rollout/src/policy.rs`).
39
- - **Fix:** an **L3 guardian-trunk watcher** (`codex-guardian-watcher.js` +
40
- `adapters/codex-guardian.js`). The guardian runs as its own Codex session that
72
+ `waiting_approval` unconditionally. Nothing fires on guardian start/finish
73
+ (the guardian session is `SubAgentSource::Other`, which is excluded from
74
+ Subagent hooks), and `GuardianAssessment` events are explicitly not persisted
75
+ to the main rollout (`rollout/src/policy.rs`).
76
+ - **Fix:** a Codex-specific `PermissionRequest` reporter plus an **L3
77
+ guardian-trunk watcher** (`codex-guardian-watcher.js` +
78
+ `adapters/codex-guardian.js`). The reporter checks the wrapped session's
79
+ resolved Codex `approvals_reviewer` config: `auto_review` / legacy
80
+ `guardian_subagent` reports **reviewing** immediately, while manual/unknown
81
+ reviewer config reports **waiting for approval**. This is config/event-backed,
82
+ not a timer. The guardian runs as its own Codex session that
41
83
  writes its own rollout under `~/.codex/sessions` — session_meta has
42
84
  `source: {subagent: {other: "guardian"}}` and `parent_thread_id` = the main
43
85
  thread; each review is one turn (`task_started` → `task_complete` with the
@@ -51,17 +93,29 @@ Issues found in live use, with their current status.
51
93
  there is no trunk and behavior is unchanged: `PermissionRequest` →
52
94
  *waiting for approval* until the user decides (process-tree/denial detection
53
95
  resolve it, as before).
54
- - **Known limitations (accepted):** (1) A ~1 s *waiting for approval* flicker
55
- can precede *reviewing* (the hook fires immediately; the trunk poll is 700 ms).
56
- (2) Reviews of a **collab subagent's** actions (multi-agent runs) have their
96
+ - **Known limitations (accepted):** (1) Reviews of a **collab subagent's** actions (multi-agent runs) have their
57
97
  own trunks keyed to the subagent's thread and are not watched; a subagent's
58
- `PermissionRequest` can still briefly show *waiting for approval* until the
59
- next main-session event. (3) After a guardian deny the pet shows *thinking*,
98
+ `PermissionRequest` still follows the wrapped session's resolved reviewer
99
+ config; if that subagent is using different approval settings, the parent
100
+ session may not be able to distinguish it. (2) After a guardian deny the pet shows *thinking*,
60
101
  not *waiting for approval* — by design: Codex resolves the request itself and
61
102
  the model decides what to do next (it may ask the user in chat, which then
62
103
  surfaces as turn-end *idle*). The TUI's passive `/approve` denial-override
63
104
  picker is not a blocking prompt.
64
105
 
106
+ ## ✅ Resolved: Codex pet looked busy immediately after startup
107
+
108
+ - **Symptom:** Starting a wrapped Codex session and doing nothing could still make
109
+ the pet show `shell_command` or `thinking` instead of `idle`.
110
+ - **Root cause:** The Codex transcript and guardian watchers originally chose the
111
+ newest rollout by file mtime, then filtered individual records by timestamp.
112
+ Another already-running Codex session could keep writing fresh records after
113
+ HAYA Pet started, making its rollout look like the wrapped session even though
114
+ 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.
118
+
65
119
  ## ✅ Resolved: Codex `/quit` hung on its goodbye (and the pet kept showing "working")
66
120
 
67
121
  - **Symptom:** Exiting Codex with `/quit` printed the token-usage goodbye and the
@@ -242,8 +296,10 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
242
296
  lifecycle status). Live in-session status is **opt-in** via `HAYA_PET_HOOKS=1`,
243
297
  which injects a settings file (`claude --settings <stable-file>`, no change to
244
298
  your global config) wiring Claude's `UserPromptSubmit`/`PreToolUse`/`PostToolUse`/
245
- `Notification`/`PreCompact`/`Stop` events to `haya-pet state <state>`, reported
246
- to the daemon over the IPC pipe. `SubagentStop` is intentionally ignored because
299
+ `Notification`/`PreCompact`/`PostCompact`/`Stop` events to `haya-pet state <state>`,
300
+ reported to the daemon over the IPC pipe. `PostCompact` is split by its
301
+ `manual`/`auto` trigger matcher (manual `/compact` → *idle*, auto compaction →
302
+ *thinking*) so the pet never sticks on *compacting*. `SubagentStop` is intentionally ignored because
247
303
  it is not a main-turn idle signal. `PreToolUse` distinguishes
248
304
  file-editing tools (`Edit`/`Write`/`MultiEdit`/`NotebookEdit` → *editing files*)
249
305
  from other tools (→ *running tools*) via the hook `matcher`. **Why opt-in:**
@@ -298,14 +354,15 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
298
354
  reports `editing_files`, and HAYA Pet returns to `thinking` after active tool
299
355
  calls drain.
300
356
  - **`PermissionRequest` fires** (confirmed live on 0.139.0), but **once, at
301
- approval-request creation, before routing** under "Approve for me"
302
- (`approvals_reviewer = auto_review` / legacy `guardian_subagent`) the user is
303
- never actually prompted, so the hook alone over-reports *waiting for
304
- approval*. An L3 **guardian-trunk watcher** tails the guardian reviewer's own
305
- rollout (`source: {subagent:{other:"guardian"}}`, `parent_thread_id` = main
306
- thread) and refines the state: review running → *reviewing*, verdict `allow`
307
- *running_tool*, verdict `deny` *thinking*. See the resolved
308
- false-waiting-for-approval entry above.
357
+ approval-request creation, before routing**. The hook calls
358
+ `haya-pet codex-permission-request`, which uses the wrapped session's
359
+ resolved `approvals_reviewer` config: `auto_review` / legacy
360
+ `guardian_subagent` reports *reviewing*, while manual review reports
361
+ *waiting for approval*. An L3 **guardian-trunk watcher** tails the guardian
362
+ reviewer's own rollout (`source: {subagent:{other:"guardian"}}`,
363
+ `parent_thread_id` = main thread) and refines the state: review running →
364
+ *reviewing*, verdict `allow` → *running_tool*, verdict `deny` → *thinking*.
365
+ See the resolved false-waiting-for-approval entry above.
309
366
  - **Antigravity (`agy`)** — **not yet implemented** (no hook injection). Uses
310
367
  `--observe` or L1 lifecycle. A Gemini-schema hook adapter is a planned follow-up.
311
368
  - **Generic / unknown** — no hooks; PTY observation (`--observe`) or L1 lifecycle.
@@ -18,7 +18,8 @@ deferred problems with known root causes.
18
18
  | Typing doesn't work / **Claude Code** TUI frozen under `haya-pet run` | You have hooks enabled and Claude is showing its *review hooks* trust prompt (approve it once), or your Claude is too old for `--settings`. Run `haya-pet hooks off` (or set `HAYA_PET_NO_HOOKS=1`) for native passthrough with lifecycle-only status — typing and Shift+Tab work normally. |
19
19
  | Pet changes status after a **Claude Code** subagent finishes, even though the main agent already stopped | Fixed — Claude `SubagentStop` is ignored because it is not a reliable main-turn state. Update to the latest version and restart the wrapped Claude session so the new hook settings are used. |
20
20
  | Pet shows only **idle/lifecycle** while **Codex** works | Live status is opt-in: run `haya-pet hooks on` once (persisted, global), then `haya-pet run --client codex -- codex`; approve Codex's one-time *review hooks* prompt. `thinking`/`idle` come from hooks, `running_tool`/`editing_files` from a transcript watcher, and approval states from the `PermissionRequest` hook plus a guardian-review watcher. |
21
- | Pet showed **waiting for approval** while **Codex** auto-reviewed the request ("Approve for me") | Fixed — with `approvals_reviewer = auto_review` (legacy `guardian_subagent`) Codex's guardian decides without asking you; the pet now shows **reviewing** during the assessment, then **working** on an allow verdict or **thinking** on a deny. *Waiting for approval* still shows when Codex actually asks you (`approvals_reviewer = "user"`). |
21
+ | Pet showed **waiting for approval** while **Codex** auto-reviewed the request ("Approve for me") | Fixed — with `approvals_reviewer = auto_review` (legacy `guardian_subagent`) Codex's guardian decides without asking you; the pet now reports **reviewing** from the permission hook itself, then **working** on an allow verdict or **thinking** on a deny. *Waiting for approval* still shows when Codex actually asks you (`approvals_reviewer = "user"`). Restart the wrapped Codex session after updating so Codex reloads the changed hook command. |
22
+ | Pet shows **shell_command** or **thinking** right after starting Codex, before you prompt it | Fixed — the Codex transcript and guardian watchers now ignore rollouts whose `session_meta.timestamp` predates the current wrapper launch, so another active Codex session cannot drive this pet's status. Restart the wrapped Codex session after updating. |
22
23
  | **Codex** live status didn't turn on / you pass your own `-p`/`--profile` | Codex allows only one profile, so haya-pet skips hook injection when you supply your own and prints a notice. Drop your `-p` for that run to get live status, or accept lifecycle-only. |
23
24
  | Pet shows only **idle/lifecycle** while **Antigravity** (`agy`) works | Antigravity has no hook adapter yet. Add `--observe` for coarse PTY activity, or accept lifecycle-only status. |
24
25
  | Claude hooks fail with **"hook exited with code 1"** | The hook command must not bake an **fnm**/node-manager *per-shell* node path (`…\fnm_multishells\<pid>_…\node.exe`) that dies when the shell exits. haya-pet bakes the stable `realpath`-resolved node path into the temp settings instead. Update to the latest version. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -33,6 +33,14 @@ const HOOK_TABLE = Object.freeze([
33
33
  { event: "Notification", matcher: "idle_prompt", state: "idle", summary: "idle" },
34
34
  { event: "PermissionDenied", state: "idle", summary: "denied" },
35
35
  { event: "PreCompact", state: "compacting" },
36
+ // PostCompact is the authoritative "compaction finished" signal (verified in
37
+ // the Claude hooks docs). Without it the pet stays stuck on `compacting` until
38
+ // the 30s stale sweep. The `trigger` matcher splits the two outcomes: a manual
39
+ // /compact hands control back to an idle prompt, while an AUTO compaction fires
40
+ // mid-turn and the agent immediately resumes working (thinking) — the next real
41
+ // event (PreToolUse/Stop) refines from there. Mirrors codex-hooks' PostCompact.
42
+ { event: "PostCompact", matcher: "manual", state: "idle", summary: "compacted" },
43
+ { event: "PostCompact", matcher: "auto", state: "thinking", summary: "compacted" },
36
44
  { event: "Stop", state: "idle" },
37
45
  { event: "StopFailure", state: "idle", summary: "stopped" }
38
46
  ]);
@@ -49,6 +57,11 @@ export function mapClaudeEventToState(event, detail) {
49
57
  if (detail === "idle_prompt") return "idle";
50
58
  return undefined;
51
59
  }
60
+ if (event === "PostCompact") {
61
+ // `detail` is the compaction trigger ("manual" | "auto"). A manual /compact
62
+ // ends at an idle prompt; an auto compaction resumes the turn (thinking).
63
+ return detail === "manual" ? "idle" : "thinking";
64
+ }
52
65
  const entry = HOOK_TABLE.find((row) => row.event === event && row.matcher === undefined);
53
66
  return entry?.state;
54
67
  }
@@ -37,11 +37,12 @@
37
37
  // source): fires ONCE at approval-request creation, BEFORE the request is
38
38
  // routed to the guardian auto-reviewer or the user. Under "Approve for me"
39
39
  // (approvals_reviewer=auto_review, legacy alias guardian_subagent) the user
40
- // is never prompted at all, so waiting_approval from this hook over-reports;
41
- // the wrapper's codex-guardian-watcher refines it to reviewing /
42
- // running_tool / thinking from the guardian's own rollout. The guardian
40
+ // is never prompted at all, so this hook calls a Codex-specific permission
41
+ // reporter instead of hard-coding waiting_approval. The wrapper passes the
42
+ // resolved approvals reviewer mode in env; auto_review reports reviewing,
43
+ // while manual/unknown reviewer config reports waiting_approval. The guardian
43
44
  // fires NO hooks itself (SubAgentSource::Other is excluded from Subagent
44
- // hooks), so these entries can't see it.
45
+ // hooks), so the wrapper also tails the guardian rollout directly.
45
46
  // - UNTESTED: PreCompact / SubagentStart|Stop (no compaction / subagent
46
47
  // occurred in the probe).
47
48
  //
@@ -76,7 +77,7 @@ const HOOK_TABLE = Object.freeze([
76
77
  { event: "PreToolUse", matcher: EDIT_TOOLS_MATCHER, state: "editing_files" },
77
78
  { event: "PreToolUse", matcher: COMMAND_TOOLS_MATCHER, state: "running_tool" },
78
79
  { event: "PostToolUse", state: "thinking" },
79
- { event: "PermissionRequest", state: "waiting_approval" },
80
+ { event: "PermissionRequest", command: "codex-permission-request" },
80
81
  { event: "PreCompact", state: "compacting" },
81
82
  { event: "PostCompact", state: "thinking", summary: "compacted" },
82
83
  // A subagent finishing is mid-turn — the main agent keeps working, so this is
@@ -111,15 +112,21 @@ export function mapCodexEventToState(event, toolName) {
111
112
  // node path, which is space-free for fnm/scoop/nvm layouts; a space-tolerant
112
113
  // path (short 8.3 name, or `command_windows`) is a follow-up before shipping.
113
114
  export function buildCodexHookSettings({ nodePath, cliPath }) {
114
- const command = (state, summary) => {
115
+ const stateCommand = (state, summary) => {
115
116
  // nodePath unquoted (must not lead with a quote); cliPath quoted for spaces.
116
- const base = `${nodePath} ${quote(cliPath)} state ${state}`;
117
- return summary ? `${base} --summary ${summary}` : base;
117
+ let output = `${nodePath} ${quote(cliPath)} state ${state}`;
118
+ if (summary) {
119
+ output += ` --summary ${summary}`;
120
+ }
121
+ return output;
118
122
  };
123
+ const command = (row) => row.command
124
+ ? `${nodePath} ${quote(cliPath)} ${row.command}`
125
+ : stateCommand(row.state, row.summary);
119
126
 
120
127
  const hooks = {};
121
128
  for (const row of HOOK_TABLE) {
122
- const hookEntry = { hooks: [{ type: "command", command: command(row.state, row.summary) }] };
129
+ const hookEntry = { hooks: [{ type: "command", command: command(row) }] };
123
130
  if (row.matcher !== undefined) {
124
131
  hookEntry.matcher = row.matcher;
125
132
  }
@@ -31,6 +31,15 @@ test("mapClaudeEventToState branches Notification on type (approval vs idle)", (
31
31
  assert.equal(mapClaudeEventToState("Notification", "auth_success"), undefined);
32
32
  });
33
33
 
34
+ test("mapClaudeEventToState branches PostCompact on compaction trigger", () => {
35
+ // Manual /compact returns to an idle prompt; auto compaction resumes the turn.
36
+ assert.equal(mapClaudeEventToState("PostCompact", "manual"), "idle");
37
+ assert.equal(mapClaudeEventToState("PostCompact", "auto"), "thinking");
38
+ // Unknown/missing trigger defaults to the "still working" assumption, which the
39
+ // next real event corrects — never leaving the pet stuck on `compacting`.
40
+ assert.equal(mapClaudeEventToState("PostCompact"), "thinking");
41
+ });
42
+
34
43
  test("buildClaudeHookSettings bakes node + cli, no volatile session id", () => {
35
44
  const settings = buildClaudeHookSettings({
36
45
  nodePath: "/usr/bin/node",
@@ -62,6 +71,15 @@ test("buildClaudeHookSettings splits Notification into approval + idle matchers"
62
71
  assert.match(idle.hooks[0].command, /state idle --summary idle$/);
63
72
  });
64
73
 
74
+ test("buildClaudeHookSettings splits PostCompact into manual + auto triggers", () => {
75
+ const post = buildClaudeHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PostCompact;
76
+ assert.equal(post.length, 2);
77
+ const manual = post.find((e) => e.matcher === "manual");
78
+ const auto = post.find((e) => e.matcher === "auto");
79
+ assert.match(manual.hooks[0].command, /state idle --summary compacted$/);
80
+ assert.match(auto.hooks[0].command, /state thinking --summary compacted$/);
81
+ });
82
+
65
83
  test("buildClaudeHookSettings keeps two non-overlapping PreToolUse matchers", () => {
66
84
  const pre = buildClaudeHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PreToolUse;
67
85
  assert.equal(pre.length, 2);
@@ -83,7 +101,7 @@ test("buildClaudeHookSettings includes all subscribed events", () => {
83
101
  for (const event of [
84
102
  "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure",
85
103
  "PermissionRequest", "Notification", "PermissionDenied", "PreCompact",
86
- "Stop", "StopFailure"
104
+ "PostCompact", "Stop", "StopFailure"
87
105
  ]) {
88
106
  assert.ok(settings.hooks[event], `missing hook event ${event}`);
89
107
  }
@@ -10,7 +10,7 @@ import {
10
10
  test("mapCodexEventToState covers activity events", () => {
11
11
  assert.equal(mapCodexEventToState("UserPromptSubmit"), "thinking");
12
12
  assert.equal(mapCodexEventToState("PostToolUse"), "thinking");
13
- assert.equal(mapCodexEventToState("PermissionRequest"), "waiting_approval");
13
+ assert.equal(mapCodexEventToState("PermissionRequest"), undefined);
14
14
  assert.equal(mapCodexEventToState("PreCompact"), "compacting");
15
15
  assert.equal(mapCodexEventToState("PostCompact"), "thinking");
16
16
  assert.equal(mapCodexEventToState("SubagentStart"), "running_tool");
@@ -64,6 +64,13 @@ test("buildCodexHookSettings splits PreToolUse into edit + command matchers", ()
64
64
  assert.equal(other.matcher, "shell_command");
65
65
  });
66
66
 
67
+ test("buildCodexHookSettings routes PermissionRequest through the Codex reporter", () => {
68
+ const permission = buildCodexHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PermissionRequest;
69
+ assert.equal(permission.length, 1);
70
+ assert.match(permission[0].hooks[0].command, /codex-permission-request$/);
71
+ assert.doesNotMatch(permission[0].hooks[0].command, /--defer-ms/);
72
+ });
73
+
67
74
  test("no matcher uses look-around (Codex's Rust regex crate rejects it)", () => {
68
75
  // Regression guard: a `(?!…)` / `(?=…)` matcher is a hard parse error in Codex
69
76
  // and disables that hook. Keep all matchers look-around-free.
@@ -50,6 +50,11 @@ export function watchCodexGuardianReviews(options = {}) {
50
50
  return undefined;
51
51
  }
52
52
  const meta = classifyCodexSessionMeta(firstLine) ?? null;
53
+ const sessionStartedAt = readSessionMetaTimestamp(firstLine);
54
+ if (meta && minMtime > 0 && (!Number.isFinite(sessionStartedAt) || sessionStartedAt < minMtime)) {
55
+ metaByPath.set(file, null);
56
+ return null;
57
+ }
53
58
  metaByPath.set(file, meta);
54
59
  return meta;
55
60
  };
@@ -134,3 +139,19 @@ export function watchCodexGuardianReviews(options = {}) {
134
139
  _tick: tick
135
140
  };
136
141
  }
142
+
143
+ function readSessionMetaTimestamp(line) {
144
+ let entry;
145
+ try {
146
+ entry = JSON.parse(line);
147
+ } catch {
148
+ return undefined;
149
+ }
150
+
151
+ if (entry?.type !== "session_meta" || typeof entry.timestamp !== "string") {
152
+ return undefined;
153
+ }
154
+
155
+ const timestampMs = Date.parse(entry.timestamp);
156
+ return Number.isFinite(timestampMs) ? timestampMs : undefined;
157
+ }
@@ -4,7 +4,7 @@
4
4
  import { existsSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { parseCodexTranscriptLines } from "../../adapters/src/codex-transcript.js";
7
- import { listJsonlFiles, readRange, safeMtime, safeSize } from "./codex-rollout-fs.js";
7
+ import { listJsonlFiles, readFirstLine, readRange, safeMtime, safeSize } from "./codex-rollout-fs.js";
8
8
 
9
9
  const DEFAULT_POLL_MS = 700;
10
10
  const MTIME_SKEW_MS = 2000;
@@ -89,9 +89,34 @@ export function discoverCodexTranscript(root, minMtime = 0) {
89
89
  if (mtime < minMtime) {
90
90
  continue;
91
91
  }
92
+ const sessionStartedAt = readCodexSessionStartedAt(file);
93
+ if (!Number.isFinite(sessionStartedAt) || sessionStartedAt < minMtime) {
94
+ continue;
95
+ }
92
96
  if (!newest || mtime > newest.mtime) {
93
97
  newest = { file, mtime };
94
98
  }
95
99
  }
96
100
  return newest?.file;
97
101
  }
102
+
103
+ function readCodexSessionStartedAt(file) {
104
+ const line = readFirstLine(file);
105
+ if (line === undefined) {
106
+ return undefined;
107
+ }
108
+
109
+ let entry;
110
+ try {
111
+ entry = JSON.parse(line);
112
+ } catch {
113
+ return undefined;
114
+ }
115
+
116
+ if (entry?.type !== "session_meta" || typeof entry.timestamp !== "string") {
117
+ return undefined;
118
+ }
119
+
120
+ const timestampMs = Date.parse(entry.timestamp);
121
+ return Number.isFinite(timestampMs) ? timestampMs : undefined;
122
+ }
@@ -8,7 +8,11 @@ import { watchCodexGuardianReviews } from "../src/codex-guardian-watcher.js";
8
8
  const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
9
9
 
10
10
  function metaLine(payload) {
11
- return `${JSON.stringify({ type: "session_meta", payload })}\n`;
11
+ return `${JSON.stringify({ timestamp: "2026-06-12T01:36:41.556Z", type: "session_meta", payload })}\n`;
12
+ }
13
+
14
+ function metaLineAt(timestamp, payload) {
15
+ return `${JSON.stringify({ timestamp, type: "session_meta", payload })}\n`;
12
16
  }
13
17
 
14
18
  function reviewStarted(turnId = "turn-1", timestamp) {
@@ -136,6 +140,41 @@ test("watchCodexGuardianReviews skips review records from before the session sta
136
140
  watcher.stop();
137
141
  });
138
142
 
143
+ test("watchCodexGuardianReviews ignores guardian trunks for sessions that started before this wrapper", () => {
144
+ const { root, dir } = makeSessionsRoot();
145
+ writeFileSync(
146
+ join(dir, "rollout-main.jsonl"),
147
+ metaLineAt("2026-06-12T00:00:00.000Z", {
148
+ id: "main-1",
149
+ parent_thread_id: null,
150
+ source: "cli",
151
+ thread_source: "user"
152
+ })
153
+ );
154
+ writeFileSync(
155
+ join(dir, "rollout-guardian.jsonl"),
156
+ metaLineAt("2026-06-12T00:01:00.000Z", {
157
+ id: "guardian-1",
158
+ parent_thread_id: "main-1",
159
+ source: { subagent: { other: "guardian" } }
160
+ }) + reviewStarted("turn-new", "2026-06-12T02:00:00.000Z")
161
+ );
162
+
163
+ const events = [];
164
+ const watcher = watchCodexGuardianReviews({
165
+ sessionsRoot: root,
166
+ startedAt: Date.parse("2026-06-12T01:00:00.000Z"),
167
+ onReviewEvent: (event) => events.push(event),
168
+ ...noopTimers
169
+ });
170
+
171
+ watcher._tick();
172
+
173
+ assert.deepEqual(events, []);
174
+
175
+ watcher.stop();
176
+ });
177
+
139
178
  test("watchCodexGuardianReviews emits nothing without a classifiable main session", () => {
140
179
  const { root, dir } = makeSessionsRoot();
141
180
  // Guardian trunk exists but there is no main rollout to bind its parent to.
@@ -7,6 +7,14 @@ import { discoverCodexTranscript, watchCodexTranscript } from "../src/codex-tran
7
7
 
8
8
  const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
9
9
 
10
+ function sessionMeta(timestamp, id = "thread-1") {
11
+ return `${JSON.stringify({
12
+ timestamp,
13
+ type: "session_meta",
14
+ payload: { id, parent_thread_id: null, source: "cli", thread_source: "user" }
15
+ })}\n`;
16
+ }
17
+
10
18
  function toolStart(toolName = "shell_command", callId = "call_1", timestamp) {
11
19
  return `${JSON.stringify({
12
20
  ...(timestamp ? { timestamp } : {}),
@@ -32,8 +40,8 @@ test("discoverCodexTranscript finds the newest session jsonl under date folders"
32
40
 
33
41
  const oldFile = join(oldDir, "rollout-old.jsonl");
34
42
  const newFile = join(newDir, "rollout-new.jsonl");
35
- writeFileSync(oldFile, "{}\n");
36
- writeFileSync(newFile, "{}\n");
43
+ writeFileSync(oldFile, sessionMeta("2026-06-07T10:00:00.000Z", "old-thread"));
44
+ writeFileSync(newFile, sessionMeta("2026-06-08T10:00:00.000Z", "new-thread"));
37
45
  appendFileSync(newFile, "{}\n");
38
46
 
39
47
  assert.equal(discoverCodexTranscript(root), newFile);
@@ -45,7 +53,7 @@ test("discoverCodexTranscript skips files older than session start", () => {
45
53
  mkdirSync(dir, { recursive: true });
46
54
 
47
55
  const oldFile = join(dir, "rollout-old.jsonl");
48
- writeFileSync(oldFile, "{}\n");
56
+ writeFileSync(oldFile, sessionMeta("2026-06-08T10:00:00.000Z", "old-thread"));
49
57
  const past = new Date(Date.now() - 3_600_000);
50
58
  utimesSync(oldFile, past, past);
51
59
 
@@ -88,6 +96,7 @@ test("watchCodexTranscript replays current-session records when a transcript is
88
96
  writeFileSync(
89
97
  path,
90
98
  [
99
+ sessionMeta("2026-06-08T11:00:00.500Z", "new-thread"),
91
100
  toolStart("shell_command", "call_old", "2026-06-08T10:59:59.000Z"),
92
101
  toolStart("shell_command", "call_new", "2026-06-08T11:00:01.000Z")
93
102
  ].join("")
@@ -115,6 +124,34 @@ test("watchCodexTranscript replays current-session records when a transcript is
115
124
  watcher.stop();
116
125
  });
117
126
 
127
+ test("watchCodexTranscript ignores fresh writes to sessions that started before this wrapper", () => {
128
+ const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
129
+ const dir = join(root, "2026", "06", "08");
130
+ mkdirSync(dir, { recursive: true });
131
+ const path = join(dir, "rollout-old-active.jsonl");
132
+ writeFileSync(
133
+ path,
134
+ [
135
+ sessionMeta("2026-06-08T10:00:00.000Z", "older-thread"),
136
+ toolStart("shell_command", "call_other_session", "2026-06-08T11:00:01.000Z")
137
+ ].join("")
138
+ );
139
+
140
+ const events = [];
141
+ const watcher = watchCodexTranscript({
142
+ sessionsRoot: root,
143
+ startedAt: Date.parse("2026-06-08T11:00:00.000Z"),
144
+ onToolEvent: (event) => events.push(event),
145
+ ...noopTimers
146
+ });
147
+
148
+ watcher._tick();
149
+
150
+ assert.deepEqual(events, []);
151
+
152
+ watcher.stop();
153
+ });
154
+
118
155
  test("watchCodexTranscript forwards a turn_aborted interrupt event", () => {
119
156
  const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
120
157
  const path = join(dir, "session.jsonl");
@@ -37,6 +37,7 @@ test("parseStateArgs reads state, summary, and session", () => {
37
37
  test("parseStateArgs rejects a missing state and unknown options", () => {
38
38
  assert.throws(() => parseStateArgs([]), /state requires a state name/);
39
39
  assert.throws(() => parseStateArgs(["thinking", "--bogus"]), /Unknown state option/);
40
+ assert.throws(() => parseStateArgs(["waiting_approval", "--defer-ms", "1200"]), /Unknown state option/);
40
41
  });
41
42
 
42
43
  test("runStateCommand sends one official_plugin state message", async () => {
@@ -1,3 +1,4 @@
1
+ import { assertProtocolMessage } from "../../protocol/src/messages.js";
1
2
  import { createSessionRegistry } from "../../session-core/src/registry.js";
2
3
  import { attachProtocolStream } from "./ipc-transport.js";
3
4
 
@@ -10,7 +11,8 @@ export function createDaemonRuntime(options = {}) {
10
11
  registry,
11
12
 
12
13
  handleMessage(message) {
13
- const session = registry.applyMessage(message);
14
+ const checked = assertProtocolMessage(message);
15
+ const session = registry.applyMessage(checked);
14
16
  onSessionChanged(session);
15
17
  return session;
16
18
  },
@@ -107,6 +107,10 @@ test("rejects unknown message types", () => {
107
107
  () => assertProtocolMessage({ type: "unknown", sessionId: "sess_abc123" }),
108
108
  /Unknown protocol message type: unknown/
109
109
  );
110
+ assert.throws(
111
+ () => assertProtocolMessage({ type: "deferred_state", sessionId: "sess_abc123" }),
112
+ /Unknown protocol message type: deferred_state/
113
+ );
110
114
  });
111
115
 
112
116
  test("accepts a shutdown control message without a sessionId", () => {