@hayasaka7/haya-pet 0.3.10 → 0.3.11

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,17 @@ 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.11]
11
+
12
+ ### Fixed
13
+ - **Codex live-status hooks now work with custom profiles.** HAYA Pet no longer
14
+ injects its own `-p haya-pet` profile or skips hooks when the wrapped Codex
15
+ command already has `-p` / `--profile`. Instead it merges stable HAYA-managed
16
+ hook entries into `$CODEX_HOME/hooks.json`, preserving any existing user hooks
17
+ and leaving profile args such as `--profile fugu` untouched. The wrapper still
18
+ resolves profile-specific `approvals_reviewer` settings so approval status
19
+ matches the selected Codex profile.
20
+
10
21
  ## [0.3.10]
11
22
 
12
23
  ### Fixed
package/README.md CHANGED
@@ -193,6 +193,12 @@ resolved `approvals_reviewer` setting, and transcript watchers report tool/file
193
193
  activity plus guardian-review outcomes. With **"Approve for me"** the pet shows
194
194
  *reviewing* immediately; *waiting for approval* is reserved for Codex's manual
195
195
  "Ask for approval" mode.
196
+
197
+ Codex custom profiles are preserved. HAYA Pet installs its live-status hooks in
198
+ `$CODEX_HOME/hooks.json` instead of passing its own `-p` profile, so launches such
199
+ as `haya-pet run --client codex -- codex --profile fugu` keep both the selected
200
+ Codex profile and HAYA live status.
201
+
196
202
  Per-tool `PreToolUse` hooks still depend on an upstream Codex gap
197
203
  ([openai/codex#16732](https://github.com/openai/codex/issues/16732)); the
198
204
  transcript watcher covers that in the meantime.
@@ -219,8 +219,10 @@ function resolveCodexApprovalsReviewer(options = {}) {
219
219
 
220
220
  const home = options.codexHome ?? env.CODEX_HOME ?? resolveHomeCodexDir(options.homeDir, env);
221
221
  const readFile = options.readFile ?? readFileSync;
222
+ const profileName = findCodexProfileInArgs(options.childArgs ?? []);
223
+ const fromProfile = profileName ? readCodexApprovalsReviewerFromProfile(home, profileName, readFile) : undefined;
222
224
  const fromConfig = readCodexApprovalsReviewerFromConfig(home, readFile);
223
- return fromConfig ?? "user";
225
+ return fromProfile ?? fromConfig ?? "user";
224
226
  }
225
227
 
226
228
  function resolveHomeCodexDir(homeDir, env) {
@@ -250,12 +252,49 @@ function findCodexApprovalsReviewerInArgs(args) {
250
252
  return reviewer;
251
253
  }
252
254
 
255
+ function findCodexProfileInArgs(args) {
256
+ let profileName;
257
+ for (let index = 0; index < args.length; index += 1) {
258
+ const arg = args[index];
259
+ let value;
260
+ if (arg === "-p" || arg === "--profile") {
261
+ value = args[index + 1];
262
+ index += 1;
263
+ } else if (arg.startsWith("--profile=")) {
264
+ value = arg.slice("--profile=".length);
265
+ } else if (arg.startsWith("-p=")) {
266
+ value = arg.slice("-p=".length);
267
+ } else if (arg.startsWith("-p") && arg.length > 2) {
268
+ value = arg.slice(2);
269
+ }
270
+
271
+ if (isCodexProfileName(value)) {
272
+ profileName = value;
273
+ }
274
+ }
275
+ return profileName;
276
+ }
277
+
278
+ function isCodexProfileName(value) {
279
+ return typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value);
280
+ }
281
+
282
+ function readCodexApprovalsReviewerFromProfile(codexHome, profileName, readFile) {
283
+ if (!codexHome || !isCodexProfileName(profileName)) {
284
+ return undefined;
285
+ }
286
+ return readCodexApprovalsReviewerFromFile(join(codexHome, `${profileName}.config.toml`), readFile);
287
+ }
253
288
  function readCodexApprovalsReviewerFromConfig(codexHome, readFile) {
254
289
  if (!codexHome) {
255
290
  return undefined;
256
291
  }
292
+ return readCodexApprovalsReviewerFromFile(join(codexHome, "config.toml"), readFile);
293
+ }
294
+
295
+ function readCodexApprovalsReviewerFromFile(path, readFile) {
257
296
  try {
258
- return parseTopLevelApprovalsReviewer(readFile(join(codexHome, "config.toml"), "utf8"));
297
+ return parseTopLevelApprovalsReviewer(readFile(path, "utf8"));
259
298
  } catch {
260
299
  return undefined;
261
300
  }
@@ -407,162 +446,154 @@ async function runRunCommand(parsed, dependencies) {
407
446
  };
408
447
  }
409
448
 
410
- // Codex: no `--settings` equivalent, so inject a stable profile and add
411
- // `-p <name>` at the FRONT (a global flag must precede any subcommand). Codex
412
- // takes only one profile, so if the user already passes their own -p/--profile
413
- // we skip injection and say so rather than clobber their choice. Codex
449
+ // Codex: no `--settings` equivalent, so install stable user-level hooks in
450
+ // CODEX_HOME/hooks.json. Codex loads user hooks alongside any selected
451
+ // -p/--profile, so the wrapper never consumes the user's single profile slot.
414
452
  // PreToolUse is not reliable, so a transcript watcher supplies tool activity.
415
453
  const codexHooksOn = hooksOn && parsed.clientId === "codex";
416
454
  if (codexHooksOn) {
417
- if (hasProfileArg(parsed.childArgs)) {
418
- print(
419
- "haya-pet: Codex live-status hooks skipped — you passed your own -p/--profile (Codex allows only one)."
420
- );
421
- } else {
422
- const injected = injectCodexHooks();
423
- childArgs = ["-p", injected.profileName, ...parsed.childArgs];
424
- childEnv = {
425
- ...env,
426
- HAYA_PET_SESSION_ID: sessionId,
427
- HAYA_PET_CODEX_APPROVAL_REVIEWER: resolveCodexApprovalsReviewer({
428
- childArgs: parsed.childArgs,
429
- env,
430
- homeDir: dependencies.homeDir,
431
- codexHome: dependencies.codexHome,
432
- readFile: dependencies.readFile
433
- })
434
- };
435
- cleanup = injected.cleanup;
436
-
437
- // Pin both Codex watchers to THIS session's rollout via the
438
- // session->transcript link the `haya-pet state` reporter records from the
439
- // hook payload's transcript_path, instead of guessing newest-by-mtime (which
440
- // leaks a concurrent same-cwd session's activity/interrupts).
441
- const sessionDir = resolveSessionDir(dependencies, env);
442
-
443
- const activeToolCalls = new Set();
444
- const watcher = watchCodexTranscript({
455
+ const injected = injectCodexHooks();
456
+ childEnv = {
457
+ ...env,
458
+ HAYA_PET_SESSION_ID: sessionId,
459
+ HAYA_PET_CODEX_APPROVAL_REVIEWER: resolveCodexApprovalsReviewer({
460
+ childArgs: parsed.childArgs,
461
+ env,
445
462
  homeDir: dependencies.homeDir,
446
- sessionsRoot: dependencies.codexSessionsRoot,
447
- cwd,
448
- startedAt: now(),
449
- sessionId,
450
- sessionDir,
451
- onToolEvent: (event) => {
452
- hookDebugLog(env, now, {
453
- source: "codex_transcript",
454
- event: event.type,
455
- toolCallId: event.toolCallId,
456
- toolName: event.toolName,
457
- state: event.state
458
- });
459
-
460
- // Esc-interrupt fires no Stop hook, so without this the pet stays stuck
461
- // on "thinking"/"running" until the stale sweep.
462
- if (event.type === "turn_aborted") {
463
- activeToolCalls.clear();
464
- messageSender
465
- .send({
466
- type: "state",
467
- sessionId,
468
- state: "interrupted",
469
- summary: "interrupted",
470
- confidence: 0.9,
471
- source: "client_log",
472
- updatedAt: now()
473
- })
474
- .catch(() => {});
475
- return;
476
- }
463
+ codexHome: dependencies.codexHome,
464
+ readFile: dependencies.readFile
465
+ })
466
+ };
467
+ cleanup = injected.cleanup;
477
468
 
478
- if (event.type === "tool_started") {
479
- activeToolCalls.add(event.toolCallId);
480
- messageSender
481
- .send({
482
- type: "state",
483
- sessionId,
484
- state: event.state,
485
- summary: event.toolName,
486
- confidence: 0.85,
487
- source: "client_log",
488
- updatedAt: now()
489
- })
490
- .catch(() => {});
491
- return;
492
- }
469
+ // Pin both Codex watchers to THIS session's rollout via the
470
+ // session->transcript link the `haya-pet state` reporter records from the
471
+ // hook payload's transcript_path, instead of guessing newest-by-mtime (which
472
+ // leaks a concurrent same-cwd session's activity/interrupts).
473
+ const sessionDir = resolveSessionDir(dependencies, env);
493
474
 
494
- if (event.type === "tool_finished") {
495
- activeToolCalls.delete(event.toolCallId);
496
- if (activeToolCalls.size === 0) {
497
- messageSender
498
- .send({
499
- type: "state",
500
- sessionId,
501
- state: "thinking",
502
- confidence: 0.85,
503
- source: "client_log",
504
- updatedAt: now()
505
- })
506
- .catch(() => {});
507
- }
508
- }
475
+ const activeToolCalls = new Set();
476
+ const watcher = watchCodexTranscript({
477
+ homeDir: dependencies.homeDir,
478
+ sessionsRoot: dependencies.codexSessionsRoot,
479
+ cwd,
480
+ startedAt: now(),
481
+ sessionId,
482
+ sessionDir,
483
+ onToolEvent: (event) => {
484
+ hookDebugLog(env, now, {
485
+ source: "codex_transcript",
486
+ event: event.type,
487
+ toolCallId: event.toolCallId,
488
+ toolName: event.toolName,
489
+ state: event.state
490
+ });
491
+
492
+ // Esc-interrupt fires no Stop hook, so without this the pet stays stuck
493
+ // on "thinking"/"running" until the stale sweep.
494
+ if (event.type === "turn_aborted") {
495
+ activeToolCalls.clear();
496
+ messageSender
497
+ .send({
498
+ type: "state",
499
+ sessionId,
500
+ state: "interrupted",
501
+ summary: "interrupted",
502
+ confidence: 0.9,
503
+ source: "client_log",
504
+ updatedAt: now()
505
+ })
506
+ .catch(() => {});
507
+ return;
509
508
  }
510
- });
511
- const previousStopWatcher = stopWatcher;
512
- stopWatcher = () => {
513
- watcher.stop();
514
- previousStopWatcher();
515
- };
516
-
517
- // With "Approve for me" (approvals_reviewer=auto_review, legacy alias
518
- // guardian_subagent), Codex routes approval requests to a guardian
519
- // subagent and never shows the human approval UI — yet the
520
- // PermissionRequest hook still fires at request creation. The hook's
521
- // reporter uses the resolved approvals reviewer config: auto-review
522
- // reports reviewing immediately, while manual review reports waiting.
523
- // The guardian's own rollout is the only observable record of the
524
- // review, so tail it: a review turn starting proves the agent is
525
- // reviewing; an "allow" verdict proves the action proceeds; a "deny"
526
- // verdict goes back to the model, which keeps working. An unreadable
527
- // verdict reports nothing — a pending cue is never cleared on a guess.
528
- const guardianWatcher = watchCodexGuardianReviews({
529
- homeDir: dependencies.homeDir,
530
- sessionsRoot: dependencies.codexSessionsRoot,
531
- cwd,
532
- startedAt: now(),
533
- sessionId,
534
- sessionDir,
535
- onReviewEvent: (event) => {
536
- hookDebugLog(env, now, {
537
- source: "codex_guardian",
538
- event: event.type,
539
- outcome: event.outcome
540
- });
541
-
542
- const report = resolveGuardianStateReport(event);
543
- if (!report) {
544
- return;
545
- }
509
+
510
+ if (event.type === "tool_started") {
511
+ activeToolCalls.add(event.toolCallId);
546
512
  messageSender
547
513
  .send({
548
514
  type: "state",
549
515
  sessionId,
550
- state: report.state,
551
- summary: report.summary,
516
+ state: event.state,
517
+ summary: event.toolName,
552
518
  confidence: 0.85,
553
519
  source: "client_log",
554
520
  updatedAt: now()
555
521
  })
556
522
  .catch(() => {});
523
+ return;
557
524
  }
558
- });
559
- const stopWithoutGuardian = stopWatcher;
560
- stopWatcher = () => {
561
- guardianWatcher.stop();
562
- stopWithoutGuardian();
563
- removeSessionTranscriptLink({ sessionDir, sessionId });
564
- };
565
- }
525
+
526
+ if (event.type === "tool_finished") {
527
+ activeToolCalls.delete(event.toolCallId);
528
+ if (activeToolCalls.size === 0) {
529
+ messageSender
530
+ .send({
531
+ type: "state",
532
+ sessionId,
533
+ state: "thinking",
534
+ confidence: 0.85,
535
+ source: "client_log",
536
+ updatedAt: now()
537
+ })
538
+ .catch(() => {});
539
+ }
540
+ }
541
+ }
542
+ });
543
+ const previousStopWatcher = stopWatcher;
544
+ stopWatcher = () => {
545
+ watcher.stop();
546
+ previousStopWatcher();
547
+ };
548
+
549
+ // With "Approve for me" (approvals_reviewer=auto_review, legacy alias
550
+ // guardian_subagent), Codex routes approval requests to a guardian
551
+ // subagent and never shows the human approval UI — yet the
552
+ // PermissionRequest hook still fires at request creation. The hook's
553
+ // reporter uses the resolved approvals reviewer config: auto-review
554
+ // reports reviewing immediately, while manual review reports waiting.
555
+ // The guardian's own rollout is the only observable record of the
556
+ // review, so tail it: a review turn starting proves the agent is
557
+ // reviewing; an "allow" verdict proves the action proceeds; a "deny"
558
+ // verdict goes back to the model, which keeps working. An unreadable
559
+ // verdict reports nothing — a pending cue is never cleared on a guess.
560
+ const guardianWatcher = watchCodexGuardianReviews({
561
+ homeDir: dependencies.homeDir,
562
+ sessionsRoot: dependencies.codexSessionsRoot,
563
+ cwd,
564
+ startedAt: now(),
565
+ sessionId,
566
+ sessionDir,
567
+ onReviewEvent: (event) => {
568
+ hookDebugLog(env, now, {
569
+ source: "codex_guardian",
570
+ event: event.type,
571
+ outcome: event.outcome
572
+ });
573
+
574
+ const report = resolveGuardianStateReport(event);
575
+ if (!report) {
576
+ return;
577
+ }
578
+ messageSender
579
+ .send({
580
+ type: "state",
581
+ sessionId,
582
+ state: report.state,
583
+ summary: report.summary,
584
+ confidence: 0.85,
585
+ source: "client_log",
586
+ updatedAt: now()
587
+ })
588
+ .catch(() => {});
589
+ }
590
+ });
591
+ const stopWithoutGuardian = stopWatcher;
592
+ stopWatcher = () => {
593
+ guardianWatcher.stop();
594
+ stopWithoutGuardian();
595
+ removeSessionTranscriptLink({ sessionDir, sessionId });
596
+ };
566
597
  }
567
598
 
568
599
  try {
@@ -629,13 +660,6 @@ function isTruthyFlag(value) {
629
660
  return value === "1" || value === "true";
630
661
  }
631
662
 
632
- // Detect a user-supplied Codex profile flag so we don't clobber it: -p, --profile,
633
- // or the `--profile=foo` / `-p=foo` forms.
634
- function hasProfileArg(args) {
635
- return args.some(
636
- (arg) => arg === "-p" || arg === "--profile" || arg.startsWith("--profile=") || arg.startsWith("-p=")
637
- );
638
- }
639
663
 
640
664
  function createConfigStateFile(dependencies) {
641
665
  const paths = getDefaultPaths({
@@ -537,7 +537,7 @@ test("hooks command parses and persists the toggle", async () => {
537
537
  assert.ok(lines.some((l) => l.includes("on")));
538
538
  });
539
539
 
540
- test("persisted `hooks on` injects a Codex profile via -p at the front of args", async () => {
540
+ test("persisted `hooks on` installs Codex hooks without consuming the profile slot", async () => {
541
541
  const calls = [];
542
542
  let injected = 0;
543
543
  await runAiPet(["run", "--client", "codex", "--", "codex"], {
@@ -546,7 +546,7 @@ test("persisted `hooks on` injects a Codex profile via -p at the front of args",
546
546
  heartbeatIntervalMs: 10,
547
547
  send: async () => {},
548
548
  createStateFile: hooksStateFile(true),
549
- injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
549
+ injectCodexHooks: () => { injected += 1; return { hooksPath: "C:\\Users\\A\\.codex\\hooks.json", cleanup: () => {} }; },
550
550
  runGenericCommand: async (options) => {
551
551
  calls.push(options);
552
552
  return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
@@ -554,7 +554,8 @@ test("persisted `hooks on` injects a Codex profile via -p at the front of args",
554
554
  });
555
555
 
556
556
  assert.equal(injected, 1, "config preference enables Codex hooks");
557
- assert.deepEqual(calls[0].args, ["-p", "haya-pet"], "profile flag goes at the front");
557
+ assert.deepEqual(calls[0].args, [], "Codex args stay untouched");
558
+ assert.equal(calls[0].env.HAYA_PET_SESSION_ID, calls[0].sessionId);
558
559
  assert.equal(calls[0].env.HAYA_PET_CODEX_APPROVAL_REVIEWER, "user");
559
560
  });
560
561
 
@@ -577,6 +578,27 @@ test("codex hooks pass auto-review config to the PermissionRequest reporter", as
577
578
  assert.equal(calls[0].env.HAYA_PET_CODEX_APPROVAL_REVIEWER, "auto_review");
578
579
  });
579
580
 
581
+ test("codex hooks read approvals reviewer from the selected profile config", async () => {
582
+ const calls = [];
583
+ await runAiPet(["run", "--client", "codex", "--", "codex", "--profile=fugu"], {
584
+ cwd: process.cwd(),
585
+ env: { USERPROFILE: "C:\\Users\\A" },
586
+ heartbeatIntervalMs: 10,
587
+ send: async () => {},
588
+ createStateFile: hooksStateFile(true),
589
+ injectCodexHooks: () => ({ hooksPath: "C:\\Users\\A\\.codex\\hooks.json", cleanup: () => {} }),
590
+ readFile: (path) => String(path).endsWith("fugu.config.toml")
591
+ ? 'approvals_reviewer = "auto_review"\n'
592
+ : 'approvals_reviewer = "user"\n',
593
+ runGenericCommand: async (options) => {
594
+ calls.push(options);
595
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
596
+ }
597
+ });
598
+
599
+ assert.deepEqual(calls[0].args, ["--profile=fugu"], "profile arg is untouched");
600
+ assert.equal(calls[0].env.HAYA_PET_CODEX_APPROVAL_REVIEWER, "auto_review");
601
+ });
580
602
  test("codex hooks also start a transcript watcher for tool activity", async () => {
581
603
  const sent = [];
582
604
  let fireToolEvent;
@@ -658,9 +680,10 @@ test("codex hooks also start a guardian-review watcher that reports review state
658
680
  ]);
659
681
  });
660
682
 
661
- test("codex hooks are skipped (with a notice) when the user passes their own -p", async () => {
683
+ test("codex hooks preserve user profile args and still wire live status", async () => {
662
684
  const calls = [];
663
685
  let injected = 0;
686
+ let watched = 0;
664
687
  const lines = [];
665
688
  await runAiPet(["run", "--client", "codex", "--", "codex", "-p", "mine"], {
666
689
  cwd: process.cwd(),
@@ -669,16 +692,19 @@ test("codex hooks are skipped (with a notice) when the user passes their own -p"
669
692
  send: async () => {},
670
693
  createStateFile: hooksStateFile(true),
671
694
  print: (line) => lines.push(line),
672
- injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
695
+ injectCodexHooks: () => { injected += 1; return { hooksPath: "C:\\Users\\A\\.codex\\hooks.json", cleanup: () => {} }; },
696
+ watchCodexTranscript: () => { watched += 1; return { stop: () => {} }; },
673
697
  runGenericCommand: async (options) => {
674
698
  calls.push(options);
675
699
  return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
676
700
  }
677
701
  });
678
702
 
679
- assert.equal(injected, 0, "user's profile is respected no injection");
703
+ assert.equal(injected, 1, "hooks are installed even when Codex gets a user profile");
704
+ assert.equal(watched, 1, "transcript watcher still starts for profiled runs");
680
705
  assert.deepEqual(calls[0].args, ["-p", "mine"], "user args untouched");
681
- assert.ok(lines.some((l) => /skipped/i.test(l)), "user is told why");
706
+ assert.equal(calls[0].env.HAYA_PET_SESSION_ID, calls[0].sessionId);
707
+ assert.ok(!lines.some((l) => /skipped/i.test(l)), "profile runs no longer skip hooks");
682
708
  });
683
709
 
684
710
  test("codex does NOT inject hooks by default (safe out-of-box)", async () => {
@@ -63,11 +63,10 @@ in-session activity through the shared, client-agnostic `haya-pet state` command
63
63
 
64
64
  The injection mechanism differs per client. **Claude Code** takes a stable
65
65
  `claude --settings <file>`. **Codex** has no per-invocation settings flag, so the
66
- wrapper writes a stable `$CODEX_HOME/haya-pet.config.toml` profile and prepends
67
- `-p haya-pet` to the codex args (a profile layers on top of the user's base config,
68
- leaving auth/model/MCP intact, and is inert otherwise). Codex allows only one
69
- profile, so if the user already passes `-p/--profile`, injection is skipped with a
70
- notice. Codex's hook command must be unquoted at the program position (it runs via
66
+ wrapper merges stable user-level hooks into `$CODEX_HOME/hooks.json`. Codex loads
67
+ that hook source alongside any selected `-p/--profile`, so custom profiles remain
68
+ untouched while HAYA Pet still sets the per-session environment and watchers.
69
+ Codex's hook command must be unquoted at the program position (it runs via
71
70
  `cmd /c`, which strips a leading quote) and its matchers can't use look-around
72
71
  (Rust regex) — see [known-issues.md](known-issues.md). Codex's L4 is **partial**:
73
72
  `PreToolUse` doesn't fire upstream yet, so tool activity comes from an L3
@@ -259,15 +259,13 @@ Issues found in live use, with their current status.
259
259
 
260
260
  - **Symptom:** Even after approving HAYA Pet's Codex hooks once, every new
261
261
  `haya-pet run --client codex` showed Codex's hook review prompt again.
262
- - **Root cause:** HAYA Pet correctly wrote a stable
263
- `$CODEX_HOME/haya-pet.config.toml` profile, but Codex stores the user's hook
264
- trust decisions back into that same profile under `[hooks.state]` as
265
- `trusted_hash` entries. The injector rewrote the entire profile on every
266
- launch, so it deleted Codex's trust cache before Codex could reuse it.
267
- - **Fix:** The Codex hook injector now regenerates the HAYA-managed hook tables
268
- while preserving the Codex-managed `[hooks.state]` tables from the existing
269
- profile. Users may need to approve once after updating; after that, unchanged
270
- hook commands should stay trusted.
262
+ - **Root cause:** Older builds wrote a stable `$CODEX_HOME/haya-pet.config.toml`
263
+ profile, but Codex stored hook trust decisions back into that same profile
264
+ under `[hooks.state]`. Rewriting the profile on launch deleted that trust cache.
265
+ - **Fix:** The Codex hook injector now writes stable commands into
266
+ `$CODEX_HOME/hooks.json` and preserves existing user hooks while refreshing
267
+ HAYA-managed entries. Users may need to approve once after updating; after
268
+ that, unchanged hook commands should stay trusted.
271
269
 
272
270
  ## ✅ Resolved: Codex pet looked busy immediately after startup
273
271
 
@@ -481,9 +479,9 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
481
479
  launch — see the resolved note below). `--observe` is a separate PTY opt-in for
482
480
  non-interactive runs (terminal-fidelity tradeoff).
483
481
  - **Codex** — **implemented (partial).** Opt-in via the global `haya-pet hooks on`;
484
- the wrapper injects `packages/adapters/src/codex-hooks.js` as a stable
485
- `$CODEX_HOME/haya-pet.config.toml` profile and launches `codex -p haya-pet`
486
- (`packages/cli-core/src/codex-hook-injection.js`). Falls back to `--observe` / L1
482
+ the wrapper injects `packages/adapters/src/codex-hooks.js` as stable user-level
483
+ hooks in `$CODEX_HOME/hooks.json` (`packages/cli-core/src/codex-hook-injection.js`).
484
+ Custom Codex `-p/--profile` args remain untouched. Falls back to `--observe` / L1
487
485
  when not enabled. Findings (verified against `codex-cli` 0.137.0 on Windows):
488
486
  - **Mechanism fits.** Codex has a lifecycle-hooks system (`[[hooks.<Event>]]` in
489
487
  `config.toml` or a `hooks.json`), with the `hooks` feature flag `stable` and ON
@@ -497,11 +495,10 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
497
495
  signal (`SubagentStop` is mid-turn → stays *thinking*). `PermissionRequest`
498
496
  exists, so the approval cue is reachable.
499
497
  - **Injection differs** — Codex has no `claude --settings <file>` equivalent.
500
- Candidate non-mutating paths: `codex -p haya-pet` layering a generated
501
- `$CODEX_HOME/haya-pet.config.toml` profile on top of the user's base config, or a
502
- `hooks.json` next to the active config layer. Codex has its own *review hooks*
503
- trust prompt (bypass: `--dangerously-bypass-hook-trust`), so the same one-time
504
- trust UX as Claude applies.
498
+ HAYA Pet uses `$CODEX_HOME/hooks.json`, merging HAYA-managed entries with any
499
+ existing user hooks. Codex loads that hook source alongside selected profiles,
500
+ and has its own *review hooks* trust prompt (bypass:
501
+ `--dangerously-bypass-hook-trust`), so the same one-time trust UX as Claude applies.
505
502
  - **Windows command quoting (fixed in the adapter):** Codex runs a hook `command`
506
503
  via `cmd /c "<cmd>"`, which strips a **leading** quote — so Claude's
507
504
  `"<node>" "<cli>" …` form dies with *"hook exited with code 1"*. The Codex
@@ -18,10 +18,10 @@ 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
- | **Codex** asks to review HAYA hooks on every launch | Fixed — update to the latest version, then approve once more. Codex writes trusted hook hashes into `$CODEX_HOME/haya-pet.config.toml` under `[hooks.state]`; HAYA Pet now preserves that Codex-managed block when refreshing the hook profile. |
21
+ | **Codex** asks to review HAYA hooks on every launch | Fixed — update to the latest version, then approve once more. HAYA Pet now writes stable hook commands into `$CODEX_HOME/hooks.json` and preserves existing user hooks when refreshing that file. |
22
22
  | 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. |
23
23
  | 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. |
24
- | **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. |
24
+ | **Codex** live status didn't turn on with a custom `-p`/`--profile` | Fixed HAYA Pet installs Codex hooks through `$CODEX_HOME/hooks.json`, so profile args are preserved. Run `haya-pet hooks on`, restart the wrapped Codex session, and approve the one-time hook review if Codex asks. |
25
25
  | 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. |
26
26
  | 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. |
27
27
  | Pet shows only **idle** for a generic / unknown CLI | Expected without a hook adapter — add `--observe` for PTY observation, otherwise lifecycle only. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -49,14 +49,12 @@
49
49
  // - UNTESTED: PreCompact / SubagentStart|Stop live firing (no compaction /
50
50
  // subagent occurred in the probe).
51
51
  //
52
- // OPEN QUESTION (injection): unlike `claude --settings <file>`, Codex has no
53
- // per-invocation settings-file flag. Candidate non-mutating paths, best first:
54
- // 1. `codex -p haya-pet` + a generated `$CODEX_HOME/haya-pet.config.toml` profile
55
- // that layers ON TOP of the user's base config (auth/config preserved).
56
- // 2. a `hooks.json` next to the active config layer ($CODEX_HOME/hooks.json) —
57
- // simple but global to every codex session (harmless: the reporter no-ops
58
- // without HAYA_PET_SESSION_ID).
59
- // Both still trip Codex's one-time hook-trust prompt, exactly like the Claude path.
52
+ // Injection: unlike `claude --settings <file>`, Codex has no per-invocation
53
+ // settings-file flag. The wrapper writes stable user-level hooks to
54
+ // $CODEX_HOME/hooks.json, merging HAYA-managed entries with any existing user
55
+ // hooks. This avoids consuming Codex's single -p/--profile slot; the reporter
56
+ // still no-ops without HAYA_PET_SESSION_ID.
57
+ // This still trips Codex's one-time hook-trust prompt, exactly like the Claude path.
60
58
 
61
59
  // Codex's file-editing tool(s) vs. its command tool.
62
60
  const EDIT_TOOLS = ["apply_patch"];
@@ -1,8 +1,7 @@
1
- // Resolves stable paths, builds the Codex hook settings, serializes them to TOML,
2
- // and writes them to a STABLE named-profile file inside CODEX_HOME. The wrapper
3
- // then launches `codex -p <profileName>`, which layers these hooks ON TOP of the
4
- // user's base config (auth/model/MCP untouched) and is inert for any codex run
5
- // that doesn't pass `-p <profileName>`.
1
+ // Resolves stable paths, builds the Codex hook settings, and writes them to the
2
+ // user-level hooks.json inside CODEX_HOME. Codex loads user-level hooks alongside
3
+ // any selected profile, so HAYA Pet does not consume Codex's single -p/--profile
4
+ // slot and custom profiles keep working.
6
5
  //
7
6
  // Like the Claude injector, the file path and command strings are kept identical
8
7
  // across sessions so Codex's hook-trust review only needs approving once. fnm hands
@@ -12,33 +11,31 @@ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
12
11
  import { homedir } from "node:os";
13
12
  import { join } from "node:path";
14
13
  import { fileURLToPath } from "node:url";
15
- import { buildCodexHookSettings, serializeCodexHooksToml } from "../../adapters/src/codex-hooks.js";
14
+ import { buildCodexHookSettings } from "../../adapters/src/codex-hooks.js";
16
15
 
17
16
  const DEFAULT_CLI_PATH = fileURLToPath(new URL("../../../apps/cli/src/haya-pet.js", import.meta.url));
18
- const PROFILE_NAME = "haya-pet";
19
- const PROFILE_FILE = `${PROFILE_NAME}.config.toml`;
17
+ const HOOKS_FILE = "hooks.json";
18
+ const HAYA_HOOK_STATUS = "HAYA Pet live status";
20
19
 
21
20
  export function injectCodexHooks({ nodePath, cliPath, codexHome, env = process.env } = {}) {
22
21
  const resolvedNode = nodePath ?? safeRealpath(process.execPath);
23
22
  const resolvedCli = cliPath ?? safeRealpath(DEFAULT_CLI_PATH);
24
23
  const home = codexHome ?? env.CODEX_HOME ?? join(homedir(), ".codex");
25
24
 
26
- const settings = buildCodexHookSettings({ nodePath: resolvedNode, cliPath: resolvedCli });
27
- const toml = serializeCodexHooksToml(settings, {
28
- header: "haya-pet live-status hooks profile. Managed by haya-pet; safe to delete."
29
- });
25
+ const settings = markManagedHooks(buildCodexHookSettings({ nodePath: resolvedNode, cliPath: resolvedCli }));
30
26
 
31
- // A fixed path with session-independent content: concurrent sessions just
32
- // rewrite identical bytes, and the hooks stay "trusted" across launches.
27
+ // A fixed user-level hook source works with every Codex profile. We merge rather
28
+ // than overwrite because hooks.json may already contain user hooks.
33
29
  mkdirSync(home, { recursive: true });
34
- const profilePath = join(home, PROFILE_FILE);
35
- const trustedState = readCodexHookTrustState(profilePath);
36
- writeFileSync(profilePath, appendCodexHookTrustState(toml, trustedState), "utf8");
37
-
38
- // The profile file is stable and reusable on purpose — leaving it in place is
39
- // what lets Codex remember the hooks are trusted. cleanup is a no-op kept for
40
- // API symmetry with the caller's finally block.
41
- return { profileName: PROFILE_NAME, profilePath, cleanup: () => {} };
30
+ const hooksPath = join(home, HOOKS_FILE);
31
+ const existing = readHooksJson(hooksPath);
32
+ const next = mergeHooksJson(existing, settings);
33
+ writeFileSync(hooksPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
34
+
35
+ // The hook file is stable and reusable on purpose leaving it in place is what
36
+ // lets Codex remember the hooks are trusted. cleanup is a no-op kept for API
37
+ // symmetry with the caller's finally block.
38
+ return { hooksPath, cleanup: () => {} };
42
39
  }
43
40
 
44
41
  function safeRealpath(target) {
@@ -49,50 +46,98 @@ function safeRealpath(target) {
49
46
  }
50
47
  }
51
48
 
52
- function readCodexHookTrustState(profilePath) {
49
+ function readHooksJson(hooksPath) {
53
50
  try {
54
- return extractCodexHookTrustState(readFileSync(profilePath, "utf8"));
55
- } catch {
56
- return "";
51
+ const parsed = JSON.parse(readFileSync(hooksPath, "utf8"));
52
+ return isPlainObject(parsed) ? parsed : {};
53
+ } catch (error) {
54
+ if (error?.code === "ENOENT") {
55
+ return {};
56
+ }
57
+ throw new Error(`haya-pet: could not update Codex ${HOOKS_FILE} (${error.message})`, { cause: error });
57
58
  }
58
59
  }
59
60
 
60
- function appendCodexHookTrustState(toml, trustedState) {
61
- if (!trustedState) {
62
- return toml;
61
+ function markManagedHooks(settings) {
62
+ const hooks = {};
63
+ for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
64
+ hooks[event] = entries.map((entry) => ({
65
+ ...entry,
66
+ hooks: (entry.hooks ?? []).map((hook) => ({
67
+ ...hook,
68
+ statusMessage: HAYA_HOOK_STATUS
69
+ }))
70
+ }));
63
71
  }
64
- return `${toml.trimEnd()}\n\n${trustedState.trim()}\n`;
72
+ return { hooks };
65
73
  }
66
74
 
67
- function extractCodexHookTrustState(toml) {
68
- const lines = String(toml).split(/\r?\n/);
69
- const output = [];
70
- let inHookState = false;
71
-
72
- for (const line of lines) {
73
- const tableName = readTomlTableName(line);
74
- if (tableName) {
75
- const isHookStateTable = tableName === "hooks.state" || tableName.startsWith("hooks.state.");
76
- if (isHookStateTable) {
77
- inHookState = true;
78
- } else if (inHookState) {
79
- break;
80
- }
81
- }
75
+ function mergeHooksJson(existing, managed) {
76
+ const output = isPlainObject(existing) ? { ...existing } : {};
77
+ const existingHooks = isPlainObject(output.hooks) ? output.hooks : {};
78
+ const managedHooks = managed.hooks ?? {};
79
+ const hooks = {};
80
+ const events = new Set([...Object.keys(existingHooks), ...Object.keys(managedHooks)]);
81
+
82
+ for (const event of events) {
83
+ const preserved = removeManagedEntries(existingHooks[event]);
84
+ const nextEntries = managedHooks[event] ?? [];
82
85
 
83
- if (inHookState) {
84
- output.push(line);
86
+ if (Array.isArray(preserved)) {
87
+ hooks[event] = [...preserved, ...nextEntries];
88
+ } else if (nextEntries.length > 0) {
89
+ hooks[event] = nextEntries;
90
+ } else if (preserved !== undefined) {
91
+ hooks[event] = preserved;
85
92
  }
86
93
  }
87
94
 
88
- return output.join("\n").trim();
95
+ output.hooks = hooks;
96
+ return output;
89
97
  }
90
98
 
91
- function readTomlTableName(line) {
92
- const table = /^\s*\[([^\]]+)\]\s*$/.exec(line);
93
- if (table) {
94
- return table[1];
99
+ function removeManagedEntries(entries) {
100
+ if (!Array.isArray(entries)) {
101
+ return entries;
102
+ }
103
+
104
+ return entries
105
+ .map((entry) => removeManagedHooksFromEntry(entry))
106
+ .filter((entry) => entry !== undefined);
107
+ }
108
+
109
+ function removeManagedHooksFromEntry(entry) {
110
+ if (!isPlainObject(entry) || !Array.isArray(entry.hooks)) {
111
+ return entry;
112
+ }
113
+
114
+ const hooks = entry.hooks.filter((hook) => !isManagedHook(hook));
115
+ if (hooks.length === 0) {
116
+ return undefined;
95
117
  }
96
- const arrayTable = /^\s*\[\[([^\]]+)\]\]\s*$/.exec(line);
97
- return arrayTable?.[1];
118
+ if (hooks.length === entry.hooks.length) {
119
+ return entry;
120
+ }
121
+ return { ...entry, hooks };
122
+ }
123
+
124
+ function isManagedHook(hook) {
125
+ if (!isPlainObject(hook)) {
126
+ return false;
127
+ }
128
+ if (hook.statusMessage === HAYA_HOOK_STATUS) {
129
+ return true;
130
+ }
131
+ return isLegacyHayaPetCommand(hook.command);
132
+ }
133
+
134
+ function isLegacyHayaPetCommand(command) {
135
+ if (typeof command !== "string") {
136
+ return false;
137
+ }
138
+ return /haya-pet\.js"?\s+(state|codex-permission-request)\b/.test(command);
139
+ }
140
+
141
+ function isPlainObject(value) {
142
+ return value !== null && typeof value === "object" && !Array.isArray(value);
98
143
  }
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { test } from "../../../test/harness.mjs";
6
6
  import { injectCodexHooks } from "../src/codex-hook-injection.js";
7
7
 
8
- test("injectCodexHooks writes a stable profile into CODEX_HOME and returns its name", () => {
8
+ test("injectCodexHooks writes stable user-level hooks into CODEX_HOME", () => {
9
9
  const home = mkdtempSync(join(tmpdir(), "haya-codex-home-"));
10
10
  try {
11
11
  const result = injectCodexHooks({
@@ -14,15 +14,15 @@ test("injectCodexHooks writes a stable profile into CODEX_HOME and returns its n
14
14
  codexHome: home
15
15
  });
16
16
 
17
- assert.equal(result.profileName, "haya-pet");
18
- assert.equal(result.profilePath, join(home, "haya-pet.config.toml"));
17
+ assert.equal(result.hooksPath, join(home, "hooks.json"));
19
18
 
20
- const toml = readFileSync(result.profilePath, "utf8");
21
- assert.match(toml, /\[\[hooks\.UserPromptSubmit\]\]/);
22
- assert.match(toml, /state thinking/);
19
+ const json = JSON.parse(readFileSync(result.hooksPath, "utf8"));
20
+ const promptHook = json.hooks.UserPromptSubmit[0].hooks[0];
21
+ assert.equal(promptHook.type, "command");
22
+ assert.match(promptHook.command, /state thinking/);
23
+ assert.equal(promptHook.statusMessage, "HAYA Pet live status");
23
24
  // Program unquoted (cmd /c strips a leading quote on Windows).
24
- const cmdLine = toml.split("\n").find((l) => l.startsWith("command ="));
25
- assert.doesNotMatch(cmdLine, /^command = "\\"/);
25
+ assert.doesNotMatch(promptHook.command, /^"/);
26
26
  } finally {
27
27
  rmSync(home, { recursive: true, force: true });
28
28
  }
@@ -33,31 +33,32 @@ test("injectCodexHooks honors CODEX_HOME from env and is stable across calls", (
33
33
  try {
34
34
  const opts = { nodePath: "n", cliPath: "c", env: { CODEX_HOME: home } };
35
35
  const a = injectCodexHooks(opts);
36
- const first = readFileSync(a.profilePath, "utf8");
36
+ const first = readFileSync(a.hooksPath, "utf8");
37
37
  const b = injectCodexHooks(opts);
38
- const second = readFileSync(b.profilePath, "utf8");
38
+ const second = readFileSync(b.hooksPath, "utf8");
39
39
 
40
- assert.equal(a.profilePath, join(home, "haya-pet.config.toml"));
40
+ assert.equal(a.hooksPath, join(home, "hooks.json"));
41
41
  assert.equal(first, second, "stable content keeps Codex hook-trust cached");
42
42
  } finally {
43
43
  rmSync(home, { recursive: true, force: true });
44
44
  }
45
45
  });
46
46
 
47
- test("injectCodexHooks preserves Codex hook trust state in the managed profile", () => {
47
+ test("injectCodexHooks preserves user hooks and replaces prior HAYA hooks", () => {
48
48
  const home = mkdtempSync(join(tmpdir(), "haya-codex-home-"));
49
49
  try {
50
- const first = injectCodexHooks({
51
- nodePath: "n",
52
- cliPath: "c",
53
- codexHome: home
54
- });
55
- const trustedState = `[hooks.state]
56
-
57
- [hooks.state.'${first.profilePath}:user_prompt_submit:0:0']
58
- trusted_hash = "sha256:abc123"
59
- `;
60
- writeFileSync(first.profilePath, `${readFileSync(first.profilePath, "utf8")}\n${trustedState}`, "utf8");
50
+ writeFileSync(join(home, "hooks.json"), JSON.stringify({
51
+ hooks: {
52
+ Stop: [
53
+ {
54
+ hooks: [
55
+ { type: "command", command: "echo user" },
56
+ { type: "command", command: "old-node old-cli state idle", statusMessage: "HAYA Pet live status" }
57
+ ]
58
+ }
59
+ ]
60
+ }
61
+ }, null, 2), "utf8");
61
62
 
62
63
  injectCodexHooks({
63
64
  nodePath: "n",
@@ -65,9 +66,11 @@ trusted_hash = "sha256:abc123"
65
66
  codexHome: home
66
67
  });
67
68
 
68
- const next = readFileSync(first.profilePath, "utf8");
69
- assert.match(next, /\[hooks\.state\]/);
70
- assert.match(next, /trusted_hash = "sha256:abc123"/);
69
+ const next = JSON.parse(readFileSync(join(home, "hooks.json"), "utf8"));
70
+ const stopCommands = next.hooks.Stop.flatMap((entry) => entry.hooks.map((hook) => hook.command));
71
+ assert.ok(stopCommands.includes("echo user"), "existing user hook is preserved");
72
+ assert.ok(stopCommands.some((command) => command === 'n "c" state idle'), "fresh HAYA hook is installed");
73
+ assert.ok(!stopCommands.some((command) => command.includes("old-node")), "stale HAYA hook is removed");
71
74
  } finally {
72
75
  rmSync(home, { recursive: true, force: true });
73
76
  }