@hayasaka7/haya-pet 0.3.9 → 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,35 @@ 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
+
21
+ ## [0.3.10]
22
+
23
+ ### Fixed
24
+ - **A running Claude Code subagent no longer drives the main session's status.**
25
+ With hooks enabled and a multi-agent run, once the main agent had stopped but a
26
+ subagent was still working, two things went wrong: the pet dropped to *idle*
27
+ (even though work was ongoing), and while the subagent ran its tool calls flipped
28
+ the pet between *running tools* / *editing files* / *thinking*. Fix, checked
29
+ **only at the main agent's `Stop`** (no timers, no persisted state): (1) Claude's
30
+ `Stop` payload carries a live `background_tasks` snapshot — when it still lists a
31
+ running **subagent**, the pet shows *running tools* with the message **"Subagent
32
+ running"**, and the follow-up `Stop` (empty `background_tasks`) clears it back to
33
+ *idle*; (2) every subagent-originated hook event carries an `agent_id`, so the
34
+ reporter now **drops any event with an `agent_id`**, and a subagent's activity can
35
+ no longer overwrite the main session's status. Background **shells** are
36
+ deliberately not surfaced (their completion isn't reliably observable). See
37
+ `docs/known-issues.md`.
38
+
10
39
  ## [0.3.9]
11
40
 
12
41
  ### 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.
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
8
- import { parseStateArgs, runStateCommand, readHookTranscriptPathFromStdin } from "../../../packages/cli-core/src/run-state.js";
8
+ import { parseStateArgs, runStateCommand, readHookPayloadFromStdin } from "../../../packages/cli-core/src/run-state.js";
9
9
  import { removeSessionTranscriptLink } from "../../../packages/cli-core/src/session-transcript-link.js";
10
10
  import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages/cli-core/src/claude-hook-injection.js";
11
11
  import { injectCodexHooks as defaultInjectCodexHooks } from "../../../packages/cli-core/src/codex-hook-injection.js";
@@ -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({
@@ -969,20 +993,31 @@ if (isDirectRun(import.meta.url, process.argv[1])) {
969
993
  }
970
994
 
971
995
  // Real-process entry. For a `haya-pet state` invocation — which is ALWAYS a client
972
- // hook child — read the hook payload from stdin once to learn this session's real
973
- // transcript path, and hand it to the reporter so it can record the
974
- // session->transcript link. Done here (not inside main/runStateCommand) so unit
975
- // tests, and every other command that needs stdin passed through to its child
976
- // (e.g. `run`), never touch stdin.
996
+ // hook child — read the hook payload from stdin once to learn: this session's real
997
+ // transcript path (for the session->transcript link); the live background_tasks
998
+ // snapshot (so a Stop with a still-running subagent keeps a working cue instead of
999
+ // idle); and the agent_id (present only for subagent-originated events, which the
1000
+ // reporter drops so a subagent's activity never overwrites the main status). All
1001
+ // are handed to the reporter via dependencies. Done here (not inside
1002
+ // main/runStateCommand) so unit tests, and every other command that needs stdin
1003
+ // passed through to its child (e.g. `run`), never touch stdin.
977
1004
  async function bootstrap() {
978
1005
  const argv = process.argv.slice(2);
979
1006
  const dependencies = {};
980
1007
  if (argv[0] === "state") {
981
1008
  try {
982
- const transcriptPath = await readHookTranscriptPathFromStdin();
1009
+ const { transcriptPath, backgroundTasks, agentId } = await readHookPayloadFromStdin();
983
1010
  if (transcriptPath) {
984
1011
  dependencies.transcriptPath = transcriptPath;
985
1012
  }
1013
+ if (Array.isArray(backgroundTasks) && backgroundTasks.length > 0) {
1014
+ dependencies.backgroundTasks = backgroundTasks;
1015
+ }
1016
+ // Present only for subagent-originated events — the reporter drops those so a
1017
+ // subagent's tool use never overwrites the main session's status.
1018
+ if (agentId) {
1019
+ dependencies.agentId = agentId;
1020
+ }
986
1021
  } catch {
987
1022
  // a missing/garbled payload just means no binding this time — never fatal
988
1023
  }
@@ -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