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