@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 +11 -0
- package/README.md +6 -0
- package/apps/cli/src/haya-pet.js +172 -148
- package/apps/cli/test/haya-pet.test.mjs +33 -7
- package/docs/architecture.md +4 -5
- package/docs/known-issues.md +14 -17
- package/docs/troubleshooting.md +2 -2
- package/package.json +1 -1
- package/packages/adapters/src/codex-hooks.js +6 -8
- package/packages/cli-core/src/codex-hook-injection.js +99 -54
- package/packages/cli-core/test/codex-hook-injection.test.mjs +29 -26
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.
|
package/apps/cli/src/haya-pet.js
CHANGED
|
@@ -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(
|
|
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
|
|
411
|
-
//
|
|
412
|
-
//
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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:
|
|
551
|
-
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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`
|
|
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 {
|
|
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, [
|
|
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
|
|
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 {
|
|
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,
|
|
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.
|
|
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 () => {
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
package/docs/known-issues.md
CHANGED
|
@@ -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:**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
-
|
|
268
|
-
|
|
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
|
|
485
|
-
`$CODEX_HOME/
|
|
486
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
@@ -49,14 +49,12 @@
|
|
|
49
49
|
// - UNTESTED: PreCompact / SubagentStart|Stop live firing (no compaction /
|
|
50
50
|
// subagent occurred in the probe).
|
|
51
51
|
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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
|
|
19
|
-
const
|
|
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
|
|
32
|
-
//
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
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
|
|
49
|
+
function readHooksJson(hooksPath) {
|
|
53
50
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
72
|
+
return { hooks };
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
function
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 (
|
|
84
|
-
|
|
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
|
-
|
|
95
|
+
output.hooks = hooks;
|
|
96
|
+
return output;
|
|
89
97
|
}
|
|
90
98
|
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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.
|
|
18
|
-
assert.equal(result.profilePath, join(home, "haya-pet.config.toml"));
|
|
17
|
+
assert.equal(result.hooksPath, join(home, "hooks.json"));
|
|
19
18
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
assert.
|
|
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
|
-
|
|
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.
|
|
36
|
+
const first = readFileSync(a.hooksPath, "utf8");
|
|
37
37
|
const b = injectCodexHooks(opts);
|
|
38
|
-
const second = readFileSync(b.
|
|
38
|
+
const second = readFileSync(b.hooksPath, "utf8");
|
|
39
39
|
|
|
40
|
-
assert.equal(a.
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
69
|
-
|
|
70
|
-
assert.
|
|
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
|
}
|