@hayasaka7/haya-pet 0.3.2 → 0.3.4

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,31 @@ 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.4]
11
+
12
+ ### Fixed
13
+ - **Codex "Approve for me" status no longer depends on a timer.** The
14
+ `PermissionRequest` hook now calls a Codex-specific reporter instead of
15
+ emitting delayed `waiting_approval`. The wrapper resolves
16
+ `approvals_reviewer` for the session: `auto_review` / legacy
17
+ `guardian_subagent` reports *reviewing*, while manual review still reports
18
+ *waiting for approval*. The daemon no longer has a deferred-state protocol or
19
+ timer-based approval fallback.
20
+ - **Fresh Codex sessions no longer inherit status from an older active Codex
21
+ session.** The transcript and guardian watchers now require the rollout's
22
+ first `session_meta.timestamp` to belong to this wrapper launch, so a
23
+ different Codex session writing `shell_command` / `thinking` after startup
24
+ cannot make an idle pet look busy.
25
+
26
+ ## [0.3.3]
27
+
28
+ ### Fixed
29
+ - **Claude Code subagent completion no longer changes the main session status.**
30
+ Claude Code can emit `SubagentStop` after the main agent has already stopped,
31
+ so treating that event as `idle` could make the pet react to a stale subagent
32
+ completion instead of the main agent's real state. The Claude hook adapter now
33
+ ignores `SubagentStop`; the main turn still ends on Claude's `Stop` event.
34
+
10
35
  ## [0.3.2]
11
36
 
12
37
  ### Changed
package/README.md CHANGED
@@ -64,7 +64,7 @@ npm install -g @hayasaka7/haya-pet
64
64
  From source:
65
65
 
66
66
  ```bash
67
- git clone <repo-url> haya-pet
67
+ git clone https://github.com/HAYASAKA7/HAYA-PET.git haya-pet
68
68
  cd haya-pet
69
69
  npm install
70
70
  npm link
@@ -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 () => {
@@ -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,20 @@
2
2
 
3
3
  Issues found in live use, with their current status.
4
4
 
5
+ ## ✅ Resolved: Claude Code subagent completion changed the main session status
6
+
7
+ - **Symptom:** In Claude Code multi-agent runs, the main agent could already be
8
+ stopped while a subagent was still finishing. When that late subagent emitted
9
+ `SubagentStop`, the pet treated it as a main-session `idle` update and could
10
+ show a misleading working/done transition after the main agent had settled.
11
+ - **Root cause:** The Claude hook table mapped `SubagentStop` to `idle`. That is
12
+ only safe if subagent completion is ordered before the main turn finishes, which
13
+ Claude Code does not guarantee.
14
+ - **Fix:** Claude `SubagentStop` is now ignored. Main-session idle still comes
15
+ from Claude's real `Stop` hook, while late subagent completion cannot override
16
+ the current main-agent state. Codex keeps its separate behavior because Codex
17
+ uses `Stop` as the only idle signal and treats `SubagentStop` as mid-turn.
18
+
5
19
  ## ✅ Resolved: false "waiting for approval" while Codex auto-reviews an approval (Approve for me)
6
20
 
7
21
  - **Symptom:** Running Codex under the pet with the **"Approve for me"** preset
@@ -18,12 +32,17 @@ Issues found in live use, with their current status.
18
32
  guardian `deny` returns the rationale to the **model** as a rejected tool call
19
33
  ("This action was rejected due to unacceptable risk. …"), so no human decision
20
34
  is ever pending. Our Codex hook table mapped `PermissionRequest` →
21
- `waiting_approval` unconditionally. No better hook exists: nothing fires on
22
- guardian start/finish (the guardian session is `SubAgentSource::Other`, which
23
- is excluded from Subagent hooks), and `GuardianAssessment` events are
24
- explicitly not persisted to the main rollout (`rollout/src/policy.rs`).
25
- - **Fix:** an **L3 guardian-trunk watcher** (`codex-guardian-watcher.js` +
26
- `adapters/codex-guardian.js`). The guardian runs as its own Codex session that
35
+ `waiting_approval` unconditionally. Nothing fires on guardian start/finish
36
+ (the guardian session is `SubAgentSource::Other`, which is excluded from
37
+ Subagent hooks), and `GuardianAssessment` events are explicitly not persisted
38
+ to the main rollout (`rollout/src/policy.rs`).
39
+ - **Fix:** a Codex-specific `PermissionRequest` reporter plus an **L3
40
+ guardian-trunk watcher** (`codex-guardian-watcher.js` +
41
+ `adapters/codex-guardian.js`). The reporter checks the wrapped session's
42
+ resolved Codex `approvals_reviewer` config: `auto_review` / legacy
43
+ `guardian_subagent` reports **reviewing** immediately, while manual/unknown
44
+ reviewer config reports **waiting for approval**. This is config/event-backed,
45
+ not a timer. The guardian runs as its own Codex session that
27
46
  writes its own rollout under `~/.codex/sessions` — session_meta has
28
47
  `source: {subagent: {other: "guardian"}}` and `parent_thread_id` = the main
29
48
  thread; each review is one turn (`task_started` → `task_complete` with the
@@ -37,17 +56,29 @@ Issues found in live use, with their current status.
37
56
  there is no trunk and behavior is unchanged: `PermissionRequest` →
38
57
  *waiting for approval* until the user decides (process-tree/denial detection
39
58
  resolve it, as before).
40
- - **Known limitations (accepted):** (1) A ~1 s *waiting for approval* flicker
41
- can precede *reviewing* (the hook fires immediately; the trunk poll is 700 ms).
42
- (2) Reviews of a **collab subagent's** actions (multi-agent runs) have their
59
+ - **Known limitations (accepted):** (1) Reviews of a **collab subagent's** actions (multi-agent runs) have their
43
60
  own trunks keyed to the subagent's thread and are not watched; a subagent's
44
- `PermissionRequest` can still briefly show *waiting for approval* until the
45
- next main-session event. (3) After a guardian deny the pet shows *thinking*,
61
+ `PermissionRequest` still follows the wrapped session's resolved reviewer
62
+ config; if that subagent is using different approval settings, the parent
63
+ session may not be able to distinguish it. (2) After a guardian deny the pet shows *thinking*,
46
64
  not *waiting for approval* — by design: Codex resolves the request itself and
47
65
  the model decides what to do next (it may ask the user in chat, which then
48
66
  surfaces as turn-end *idle*). The TUI's passive `/approve` denial-override
49
67
  picker is not a blocking prompt.
50
68
 
69
+ ## ✅ Resolved: Codex pet looked busy immediately after startup
70
+
71
+ - **Symptom:** Starting a wrapped Codex session and doing nothing could still make
72
+ the pet show `shell_command` or `thinking` instead of `idle`.
73
+ - **Root cause:** The Codex transcript and guardian watchers originally chose the
74
+ newest rollout by file mtime, then filtered individual records by timestamp.
75
+ Another already-running Codex session could keep writing fresh records after
76
+ HAYA Pet started, making its rollout look like the wrapped session even though
77
+ it began earlier.
78
+ - **Fix:** Both watchers now inspect the first `session_meta` line and require
79
+ its timestamp to belong to this wrapper launch. Old-but-active Codex sessions
80
+ are ignored even if their files continue to receive fresh writes.
81
+
51
82
  ## ✅ Resolved: Codex `/quit` hung on its goodbye (and the pet kept showing "working")
52
83
 
53
84
  - **Symptom:** Exiting Codex with `/quit` printed the token-usage goodbye and the
@@ -228,8 +259,9 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
228
259
  lifecycle status). Live in-session status is **opt-in** via `HAYA_PET_HOOKS=1`,
229
260
  which injects a settings file (`claude --settings <stable-file>`, no change to
230
261
  your global config) wiring Claude's `UserPromptSubmit`/`PreToolUse`/`PostToolUse`/
231
- `Notification`/`PreCompact`/`Stop`/`SubagentStop` events to `haya-pet state
232
- <state>`, reported to the daemon over the IPC pipe. `PreToolUse` distinguishes
262
+ `Notification`/`PreCompact`/`Stop` events to `haya-pet state <state>`, reported
263
+ to the daemon over the IPC pipe. `SubagentStop` is intentionally ignored because
264
+ it is not a main-turn idle signal. `PreToolUse` distinguishes
233
265
  file-editing tools (`Edit`/`Write`/`MultiEdit`/`NotebookEdit` → *editing files*)
234
266
  from other tools (→ *running tools*) via the hook `matcher`. **Why opt-in:**
235
267
  injecting hooks makes Claude show a one-time *review hooks* trust prompt; the
@@ -283,14 +315,15 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
283
315
  reports `editing_files`, and HAYA Pet returns to `thinking` after active tool
284
316
  calls drain.
285
317
  - **`PermissionRequest` fires** (confirmed live on 0.139.0), but **once, at
286
- approval-request creation, before routing** under "Approve for me"
287
- (`approvals_reviewer = auto_review` / legacy `guardian_subagent`) the user is
288
- never actually prompted, so the hook alone over-reports *waiting for
289
- approval*. An L3 **guardian-trunk watcher** tails the guardian reviewer's own
290
- rollout (`source: {subagent:{other:"guardian"}}`, `parent_thread_id` = main
291
- thread) and refines the state: review running → *reviewing*, verdict `allow`
292
- *running_tool*, verdict `deny` *thinking*. See the resolved
293
- false-waiting-for-approval entry above.
318
+ approval-request creation, before routing**. The hook calls
319
+ `haya-pet codex-permission-request`, which uses the wrapped session's
320
+ resolved `approvals_reviewer` config: `auto_review` / legacy
321
+ `guardian_subagent` reports *reviewing*, while manual review reports
322
+ *waiting for approval*. An L3 **guardian-trunk watcher** tails the guardian
323
+ reviewer's own rollout (`source: {subagent:{other:"guardian"}}`,
324
+ `parent_thread_id` = main thread) and refines the state: review running →
325
+ *reviewing*, verdict `allow` → *running_tool*, verdict `deny` → *thinking*.
326
+ See the resolved false-waiting-for-approval entry above.
294
327
  - **Antigravity (`agy`)** — **not yet implemented** (no hook injection). Uses
295
328
  `--observe` or L1 lifecycle. A Gemini-schema hook adapter is a planned follow-up.
296
329
  - **Generic / unknown** — no hooks; PTY observation (`--observe`) or L1 lifecycle.
@@ -16,8 +16,10 @@ deferred problems with known root causes.
16
16
  | Terminal scroll / Shift+Tab / backspace odd while a CLI runs under `haya-pet run` | Fixed — `haya-pet run` now uses native passthrough by default (full fidelity). If you opted into `--observe`, drop it. See [known-issues.md](known-issues.md). |
17
17
  | Pet shows only **idle/lifecycle** while **Claude Code** works | Live in-session status is opt-in: run `haya-pet hooks on` once (persisted). The first `haya-pet run` afterward shows a one-time Claude *review hooks* prompt — approve it. Also make sure the companion is running (`haya-pet start`). Check the toggle with `haya-pet hooks status`. |
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
+ | 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. |
19
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. |
20
- | 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. |
21
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. |
22
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. |
23
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.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -34,8 +34,7 @@ const HOOK_TABLE = Object.freeze([
34
34
  { event: "PermissionDenied", state: "idle", summary: "denied" },
35
35
  { event: "PreCompact", state: "compacting" },
36
36
  { event: "Stop", state: "idle" },
37
- { event: "StopFailure", state: "idle", summary: "stopped" },
38
- { event: "SubagentStop", state: "idle" }
37
+ { event: "StopFailure", state: "idle", summary: "stopped" }
39
38
  ]);
40
39
 
41
40
  // Resolve the pet state for a Claude event. `detail` is the tool name for
@@ -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
  }
@@ -9,12 +9,15 @@ test("mapClaudeEventToState covers activity events", () => {
9
9
  assert.equal(mapClaudeEventToState("PreCompact"), "compacting");
10
10
  assert.equal(mapClaudeEventToState("Stop"), "idle");
11
11
  assert.equal(mapClaudeEventToState("StopFailure"), "idle");
12
- assert.equal(mapClaudeEventToState("SubagentStop"), "idle");
13
12
  assert.equal(mapClaudeEventToState("PermissionDenied"), "idle");
14
13
  assert.equal(mapClaudeEventToState("PermissionRequest"), "waiting_approval");
15
14
  assert.equal(mapClaudeEventToState("Unknown"), undefined);
16
15
  });
17
16
 
17
+ test("mapClaudeEventToState ignores Claude SubagentStop", () => {
18
+ assert.equal(mapClaudeEventToState("SubagentStop"), undefined);
19
+ });
20
+
18
21
  test("mapClaudeEventToState branches PreToolUse on tool name", () => {
19
22
  assert.equal(mapClaudeEventToState("PreToolUse", "Bash"), "running_tool");
20
23
  assert.equal(mapClaudeEventToState("PreToolUse", "Edit"), "editing_files");
@@ -80,8 +83,9 @@ test("buildClaudeHookSettings includes all subscribed events", () => {
80
83
  for (const event of [
81
84
  "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure",
82
85
  "PermissionRequest", "Notification", "PermissionDenied", "PreCompact",
83
- "Stop", "StopFailure", "SubagentStop"
86
+ "Stop", "StopFailure"
84
87
  ]) {
85
88
  assert.ok(settings.hooks[event], `missing hook event ${event}`);
86
89
  }
90
+ assert.equal(settings.hooks.SubagentStop, undefined);
87
91
  });
@@ -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", () => {