@hayasaka7/haya-pet 0.3.5 → 0.3.7
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 +35 -11
- package/README.md +3 -3
- package/apps/cli/src/haya-pet.js +2 -0
- package/apps/companion/src/main/tray-menu.js +2 -28
- package/apps/companion/test/tray-menu.test.mjs +3 -14
- package/docs/known-issues.md +46 -16
- package/docs/screenshots/README.md +1 -1
- package/docs/troubleshooting.md +1 -0
- package/package.json +1 -1
- package/packages/adapters/src/codex-hooks.js +18 -9
- package/packages/adapters/test/codex-hooks.test.mjs +15 -0
- package/packages/cli-core/src/codex-guardian-watcher.js +25 -4
- package/packages/cli-core/src/codex-hook-injection.js +51 -2
- package/packages/cli-core/src/codex-transcript-watcher.js +29 -6
- package/packages/cli-core/test/codex-guardian-watcher.test.mjs +37 -0
- package/packages/cli-core/test/codex-hook-injection.test.mjs +30 -1
- package/packages/cli-core/test/codex-transcript-watcher.test.mjs +33 -2
- package/packages/session-core/src/registry.js +13 -1
- package/packages/session-core/test/registry.test.mjs +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,15 @@ 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.
|
|
10
|
+
## [0.3.7]
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **The tray menu no longer shows state-only controls.** Hidden **Display Mode**
|
|
14
|
+
and **Attach Bubbles to Terminals** until those settings have real runtime
|
|
15
|
+
behavior. **Active Sessions** stays visible while session actions continue in a
|
|
16
|
+
separate workflow.
|
|
17
|
+
|
|
18
|
+
## [0.3.6]
|
|
11
19
|
|
|
12
20
|
### Fixed
|
|
13
21
|
- **The pet no longer gets stuck on "compacting" in Claude Code.** `PreCompact`
|
|
@@ -19,21 +27,37 @@ All notable changes to HAYA Pet are documented here. This project adheres to
|
|
|
19
27
|
an **auto** compaction (context filled mid-turn) resumes to *thinking* and the
|
|
20
28
|
next real event refines from there. Mirrors Codex, which already handled
|
|
21
29
|
`PostCompact`.
|
|
30
|
+
- **Codex interrupts no longer get clobbered by stale working states.** The Codex
|
|
31
|
+
transcript watcher already detected `turn_aborted` and emitted
|
|
32
|
+
*interrupted*, but the daemon registry applied state by IPC arrival order. A
|
|
33
|
+
slower hook reporter could therefore deliver an older *thinking* / *running*
|
|
34
|
+
state after the interrupt and overwrite it. The registry now keeps a separate
|
|
35
|
+
per-session state timestamp and ignores state messages older than the latest
|
|
36
|
+
accepted state, while heartbeats still update liveness independently.
|
|
37
|
+
- **Codex immediate interrupts in resumed sessions are detected.** In a resumed
|
|
38
|
+
Codex session, `session_meta.timestamp` stays at the original session start.
|
|
39
|
+
The prompt-start hook could still set the pet to *thinking*, but the transcript
|
|
40
|
+
watcher rejected the old rollout before it could see the immediately appended
|
|
41
|
+
`turn_aborted`. The watcher now also follows a fresh rollout from the wrapped
|
|
42
|
+
cwd, so resumed sessions can report interrupts while unrelated old sessions
|
|
43
|
+
remain filtered.
|
|
44
|
+
- **Codex auto-review status works in resumed sessions too.** The guardian-review
|
|
45
|
+
watcher had the same old-`session_meta.timestamp` filter as the transcript
|
|
46
|
+
watcher, so a resumed main rollout could be rejected before the guardian trunk
|
|
47
|
+
was matched to it. The guardian watcher now uses the same fresh-mtime + wrapped
|
|
48
|
+
cwd rule for resumed main sessions before following the guardian review trunk.
|
|
49
|
+
- **Codex hook review is one-time again.** Codex stores approved hook hashes in
|
|
50
|
+
the generated `$CODEX_HOME/haya-pet.config.toml` profile under `[hooks.state]`.
|
|
51
|
+
The injector used to rewrite the whole managed profile on every launch, deleting
|
|
52
|
+
that trust state and forcing Codex to ask for hook review every time. The
|
|
53
|
+
injector now preserves Codex's hook trust tables while regenerating the HAYA
|
|
54
|
+
hook definitions.
|
|
22
55
|
|
|
23
56
|
### Added
|
|
24
57
|
- **`HAYA_PET_DAEMON_DEBUG` diagnostic.** When set to a file path, the companion
|
|
25
58
|
appends one JSONL line per incoming non-heartbeat message in daemon **arrival
|
|
26
59
|
order** (with `updatedAt`), making out-of-order state delivery observable. Added
|
|
27
|
-
to investigate the Codex interrupt issue
|
|
28
|
-
|
|
29
|
-
### Known issues
|
|
30
|
-
- **Codex interrupt can still leave the pet "working".** On some interrupts the
|
|
31
|
-
pet keeps a working state instead of *interrupted*. The transcript watcher does
|
|
32
|
-
record `turn_aborted` (the "a late tool result resets it" theory was ruled out
|
|
33
|
-
across 257 real aborts), so the suspect is the daemon applying state by IPC
|
|
34
|
-
**arrival order**, letting a stale "working" message land after *interrupted*.
|
|
35
|
-
Instrumented via `HAYA_PET_DAEMON_DEBUG`; **fix to follow shortly.** See
|
|
36
|
-
`docs/known-issues.md`.
|
|
60
|
+
to investigate state-order races such as the Codex interrupt issue.
|
|
37
61
|
|
|
38
62
|
## [0.3.4]
|
|
39
63
|
|
package/README.md
CHANGED
|
@@ -136,7 +136,7 @@ success or failure briefly, then fades.
|
|
|
136
136
|
| Global pet | Reacts to the highest-priority session and can be dragged anywhere. |
|
|
137
137
|
| Session bubbles | One bubble per running AI session, ordered by connect time. |
|
|
138
138
|
| Folder button | Folds the bubbles away when you want a cleaner desktop. |
|
|
139
|
-
| Tray menu | Show/hide,
|
|
139
|
+
| Tray menu | Show/hide, active sessions, installed pets, reset position, updates, and quit. |
|
|
140
140
|
| Resize grip | Hover the pet, drag the corner, and keep the size you like. |
|
|
141
141
|
|
|
142
142
|
## Screenshots
|
|
@@ -144,7 +144,7 @@ success or failure briefly, then fades.
|
|
|
144
144
|
| | |
|
|
145
145
|
|---|---|
|
|
146
146
|
| **The global pet** - reacting to the highest-priority session.<br> | **Session bubbles** - one per active session, with status icons.<br> |
|
|
147
|
-
| **Folder collapsed** - bubbles tucked away beside the pet.<br> | **Tray menu** - show/hide, pets, reset position, quit.<br> |
|
|
147
|
+
| **Folder collapsed** - bubbles tucked away beside the pet.<br> | **Tray menu** - show/hide, sessions, pets, reset position, quit.<br> |
|
|
148
148
|
|
|
149
149
|
## Supported Clients
|
|
150
150
|
|
|
@@ -217,7 +217,7 @@ non-observe mode keeps terminal input native.
|
|
|
217
217
|
| Drag | Move the pet; position is saved. |
|
|
218
218
|
| Drag corner grip | Resize from 0.5x to 2x; size is saved. |
|
|
219
219
|
| Double-click grip | Reset to normal size. |
|
|
220
|
-
| Tray icon | Open menu for
|
|
220
|
+
| Tray icon | Open menu for sessions, pets, reset, updates, and quit. |
|
|
221
221
|
|
|
222
222
|
## Commands
|
|
223
223
|
|
package/apps/cli/src/haya-pet.js
CHANGED
|
@@ -426,6 +426,7 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
426
426
|
const watcher = watchCodexTranscript({
|
|
427
427
|
homeDir: dependencies.homeDir,
|
|
428
428
|
sessionsRoot: dependencies.codexSessionsRoot,
|
|
429
|
+
cwd,
|
|
429
430
|
startedAt: now(),
|
|
430
431
|
onToolEvent: (event) => {
|
|
431
432
|
hookDebugLog(env, now, {
|
|
@@ -507,6 +508,7 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
507
508
|
const guardianWatcher = watchCodexGuardianReviews({
|
|
508
509
|
homeDir: dependencies.homeDir,
|
|
509
510
|
sessionsRoot: dependencies.codexSessionsRoot,
|
|
511
|
+
cwd,
|
|
510
512
|
startedAt: now(),
|
|
511
513
|
onReviewEvent: (event) => {
|
|
512
514
|
hookDebugLog(env, now, {
|
|
@@ -2,13 +2,6 @@
|
|
|
2
2
|
// converts these descriptors into a native Menu; keeping it pure makes the
|
|
3
3
|
// recovery controls testable.
|
|
4
4
|
|
|
5
|
-
const DISPLAY_MODES = Object.freeze([
|
|
6
|
-
{ value: "global", label: "Global" },
|
|
7
|
-
{ value: "cluster", label: "Cluster" },
|
|
8
|
-
{ value: "per-terminal", label: "Per Terminal" },
|
|
9
|
-
{ value: "hybrid", label: "Hybrid" }
|
|
10
|
-
]);
|
|
11
|
-
|
|
12
5
|
export function buildTrayTooltip() {
|
|
13
6
|
return "HAYA Pet";
|
|
14
7
|
}
|
|
@@ -22,17 +15,6 @@ export function buildTrayMenu(state = {}) {
|
|
|
22
15
|
id: "toggle_pet",
|
|
23
16
|
label: state.petVisible ? "Hide Pet" : "Show Pet"
|
|
24
17
|
},
|
|
25
|
-
{
|
|
26
|
-
id: "display_mode",
|
|
27
|
-
label: "Display Mode",
|
|
28
|
-
submenu: DISPLAY_MODES.map((mode) => ({
|
|
29
|
-
id: `display_mode:${mode.value}`,
|
|
30
|
-
label: mode.label,
|
|
31
|
-
value: mode.value,
|
|
32
|
-
type: "radio",
|
|
33
|
-
checked: state.displayMode === mode.value
|
|
34
|
-
}))
|
|
35
|
-
},
|
|
36
18
|
{
|
|
37
19
|
id: "sessions",
|
|
38
20
|
label: "Active Sessions",
|
|
@@ -43,17 +25,9 @@ export function buildTrayMenu(state = {}) {
|
|
|
43
25
|
label: "Installed Pets",
|
|
44
26
|
submenu: buildPetItems(pets, state.selectedPetId)
|
|
45
27
|
},
|
|
46
|
-
{
|
|
47
|
-
id: "attach_bubbles",
|
|
48
|
-
label: "Attach Bubbles to Terminals",
|
|
49
|
-
type: "checkbox",
|
|
50
|
-
checked: Boolean(state.attachBubblesToTerminals)
|
|
51
|
-
},
|
|
52
28
|
{ id: "reset_position", label: "Reset Position" },
|
|
53
|
-
// Parked until a real settings window exists
|
|
54
|
-
//
|
|
55
|
-
// item would be a dead button. Re-enable once settings outgrow the tray
|
|
56
|
-
// (e.g. bubble text size, linger duration) and a handler is wired up.
|
|
29
|
+
// Parked until a real settings window exists; partially implemented knobs
|
|
30
|
+
// stay hidden instead of showing dead or state-only controls.
|
|
57
31
|
// { id: "settings", label: "Open Settings" },
|
|
58
32
|
// Only present when the daily npm update check found a newer version;
|
|
59
33
|
// clicking it opens the package page (the app never runs npm itself).
|
|
@@ -13,9 +13,11 @@ const baseState = {
|
|
|
13
13
|
test("includes the documented recovery controls", () => {
|
|
14
14
|
const menu = buildTrayMenu(baseState);
|
|
15
15
|
const ids = menu.map((item) => item.id);
|
|
16
|
-
for (const id of ["toggle_pet", "
|
|
16
|
+
for (const id of ["toggle_pet", "sessions", "pets", "reset_position", "quit"]) {
|
|
17
17
|
assert.ok(ids.includes(id), `missing ${id}`);
|
|
18
18
|
}
|
|
19
|
+
assert.ok(!ids.includes("display_mode"), "display mode should stay hidden until implemented");
|
|
20
|
+
assert.ok(!ids.includes("attach_bubbles"), "attach bubbles should stay hidden until implemented");
|
|
19
21
|
// "Open Settings" is parked until a settings window exists (every current
|
|
20
22
|
// setting already has a tray/CLI/gesture home) — it must not be shown dead.
|
|
21
23
|
assert.ok(!ids.includes("settings"), "settings item should stay hidden until implemented");
|
|
@@ -26,14 +28,6 @@ test("toggles the pet label based on visibility", () => {
|
|
|
26
28
|
assert.equal(buildTrayMenu({ ...baseState, petVisible: false }).find((i) => i.id === "toggle_pet").label, "Show Pet");
|
|
27
29
|
});
|
|
28
30
|
|
|
29
|
-
test("checks the current display mode in the submenu", () => {
|
|
30
|
-
const submenu = buildTrayMenu(baseState).find((i) => i.id === "display_mode").submenu;
|
|
31
|
-
const hybrid = submenu.find((i) => i.value === "hybrid");
|
|
32
|
-
const global = submenu.find((i) => i.value === "global");
|
|
33
|
-
assert.equal(hybrid.checked, true);
|
|
34
|
-
assert.equal(global.checked, false);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
31
|
test("lists active sessions or shows an empty placeholder", () => {
|
|
38
32
|
const withSessions = buildTrayMenu(baseState).find((i) => i.id === "sessions").submenu;
|
|
39
33
|
assert.equal(withSessions[0].label, "Codex · netdisk-server");
|
|
@@ -42,11 +36,6 @@ test("lists active sessions or shows an empty placeholder", () => {
|
|
|
42
36
|
assert.equal(empty[0].enabled, false);
|
|
43
37
|
});
|
|
44
38
|
|
|
45
|
-
test("reflects the attach-bubbles checkbox state", () => {
|
|
46
|
-
assert.equal(buildTrayMenu(baseState).find((i) => i.id === "attach_bubbles").checked, true);
|
|
47
|
-
assert.equal(buildTrayMenu({ ...baseState, attachBubblesToTerminals: false }).find((i) => i.id === "attach_bubbles").checked, false);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
39
|
test("shows the update item only when a newer version is known", () => {
|
|
51
40
|
const withoutUpdate = buildTrayMenu(baseState);
|
|
52
41
|
assert.ok(!withoutUpdate.some((i) => i.id === "update"), "no update item by default");
|
package/docs/known-issues.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Issues found in live use, with their current status.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## ✅ Resolved: Codex interrupt sometimes left the pet "working"
|
|
6
6
|
|
|
7
7
|
- **Symptom:** Pressing Esc to interrupt a Codex turn occasionally does **not**
|
|
8
8
|
flip the pet to *interrupted* — it keeps showing a working state (*thinking* /
|
|
@@ -12,18 +12,32 @@ Issues found in live use, with their current status.
|
|
|
12
12
|
live `~/.codex/sessions`, **zero** had a tool result after the abort. Codex
|
|
13
13
|
fires no hook on an abort, and the L3 transcript watcher does record
|
|
14
14
|
`turn_aborted` and emit `interrupted`.
|
|
15
|
-
- **
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
- **Root cause:** The daemon registry applied session state **last-writer-wins by
|
|
16
|
+
IPC arrival order** (`registry.js` `applyState` ignored state ordering). Hooks
|
|
17
|
+
(separate `haya-pet state` subprocesses) and the interrupt watcher use different
|
|
18
|
+
IPC connections, so a stale "working" message could arrive *after*
|
|
19
|
+
`interrupted` and clobber it.
|
|
20
|
+
- **Fix:** The registry now keeps a separate per-session state timestamp and ignores
|
|
21
|
+
state messages older than the latest accepted state. Heartbeats still update
|
|
22
|
+
liveness independently, so a newer heartbeat cannot block a legitimate
|
|
23
|
+
later-delivered state message.
|
|
24
|
+
- **Follow-up root cause:** Immediate Esc after prompt submit still failed in
|
|
25
|
+
**resumed** Codex sessions. `UserPromptSubmit` fired and set *thinking*, and
|
|
26
|
+
Codex wrote a normal `turn_aborted` record, but the watcher rejected the rollout
|
|
27
|
+
because `session_meta.timestamp` was from the original session start, before the
|
|
28
|
+
HAYA wrapper launch. The transcript watcher now allows a fresh rollout from the
|
|
29
|
+
wrapped cwd, preserving the old guard against unrelated stale sessions while
|
|
30
|
+
covering resumed sessions.
|
|
31
|
+
- **Other affected case checked:** The Codex guardian-review watcher had the same
|
|
32
|
+
resumed-session shape. It matched guardian trunks by the main thread id, but the
|
|
33
|
+
old resumed main rollout could be rejected before that id was accepted. It now
|
|
34
|
+
uses the same fresh-mtime + wrapped-cwd rule for resumed main sessions, so
|
|
35
|
+
"Approve for me" review status is not lost after a Codex resume.
|
|
36
|
+
- **How to diagnose if it recurs:** Set `HAYA_PET_DAEMON_DEBUG=<path>` before
|
|
37
|
+
launching the companion. The companion writes daemon-arrival JSONL for
|
|
38
|
+
non-heartbeat messages. If `interrupted` never appears, the issue is in
|
|
39
|
+
transcript discovery/watching; if `interrupted` appears before a stale
|
|
40
|
+
hook-sourced working state, it is an ordering regression.
|
|
27
41
|
|
|
28
42
|
## ✅ Resolved: Claude pet stuck on "compacting" after a compaction
|
|
29
43
|
|
|
@@ -103,6 +117,20 @@ Issues found in live use, with their current status.
|
|
|
103
117
|
surfaces as turn-end *idle*). The TUI's passive `/approve` denial-override
|
|
104
118
|
picker is not a blocking prompt.
|
|
105
119
|
|
|
120
|
+
## ✅ Resolved: Codex asked to review HAYA hooks on every launch
|
|
121
|
+
|
|
122
|
+
- **Symptom:** Even after approving HAYA Pet's Codex hooks once, every new
|
|
123
|
+
`haya-pet run --client codex` showed Codex's hook review prompt again.
|
|
124
|
+
- **Root cause:** HAYA Pet correctly wrote a stable
|
|
125
|
+
`$CODEX_HOME/haya-pet.config.toml` profile, but Codex stores the user's hook
|
|
126
|
+
trust decisions back into that same profile under `[hooks.state]` as
|
|
127
|
+
`trusted_hash` entries. The injector rewrote the entire profile on every
|
|
128
|
+
launch, so it deleted Codex's trust cache before Codex could reuse it.
|
|
129
|
+
- **Fix:** The Codex hook injector now regenerates the HAYA-managed hook tables
|
|
130
|
+
while preserving the Codex-managed `[hooks.state]` tables from the existing
|
|
131
|
+
profile. Users may need to approve once after updating; after that, unchanged
|
|
132
|
+
hook commands should stay trusted.
|
|
133
|
+
|
|
106
134
|
## ✅ Resolved: Codex pet looked busy immediately after startup
|
|
107
135
|
|
|
108
136
|
- **Symptom:** Starting a wrapped Codex session and doing nothing could still make
|
|
@@ -112,9 +140,11 @@ Issues found in live use, with their current status.
|
|
|
112
140
|
Another already-running Codex session could keep writing fresh records after
|
|
113
141
|
HAYA Pet started, making its rollout look like the wrapped session even though
|
|
114
142
|
it began earlier.
|
|
115
|
-
- **Fix:** Both watchers
|
|
116
|
-
|
|
117
|
-
|
|
143
|
+
- **Fix:** Both watchers inspect the first `session_meta` line and require either
|
|
144
|
+
a timestamp that belongs to this wrapper launch, or a fresh rollout whose cwd
|
|
145
|
+
matches the wrapped Codex cwd for resumed sessions. Old-but-active Codex
|
|
146
|
+
sessions from unrelated projects are ignored even if their files continue to
|
|
147
|
+
receive fresh writes.
|
|
118
148
|
|
|
119
149
|
## ✅ Resolved: Codex `/quit` hung on its goodbye (and the pet kept showing "working")
|
|
120
150
|
|
|
@@ -4,4 +4,4 @@ Place screenshot PNGs referenced by the root README here (~800px wide):
|
|
|
4
4
|
- `pet-overlay.png` — the pet reacting to the highest-priority session
|
|
5
5
|
- `session-bubbles.png` — bubbles expanded, showing per-session status icons
|
|
6
6
|
- `folder-collapsed.png` — bubbles folded away beside the pet
|
|
7
|
-
- `tray-menu.png` — the tray menu (show/hide, pets, reset position, Quit)
|
|
7
|
+
- `tray-menu.png` — the tray menu (show/hide, sessions, pets, reset position, Quit)
|
package/docs/troubleshooting.md
CHANGED
|
@@ -18,6 +18,7 @@ deferred problems with known root causes.
|
|
|
18
18
|
| Typing doesn't work / **Claude Code** TUI frozen under `haya-pet run` | You have hooks enabled and Claude is showing its *review hooks* trust prompt (approve it once), or your Claude is too old for `--settings`. Run `haya-pet hooks off` (or set `HAYA_PET_NO_HOOKS=1`) for native passthrough with lifecycle-only status — typing and Shift+Tab work normally. |
|
|
19
19
|
| Pet changes status after a **Claude Code** subagent finishes, even though the main agent already stopped | Fixed — Claude `SubagentStop` is ignored because it is not a reliable main-turn state. Update to the latest version and restart the wrapped Claude session so the new hook settings are used. |
|
|
20
20
|
| Pet shows only **idle/lifecycle** while **Codex** works | Live status is opt-in: run `haya-pet hooks on` once (persisted, global), then `haya-pet run --client codex -- codex`; approve Codex's one-time *review hooks* prompt. `thinking`/`idle` come from hooks, `running_tool`/`editing_files` from a transcript watcher, and approval states from the `PermissionRequest` hook plus a guardian-review watcher. |
|
|
21
|
+
| **Codex** asks to review HAYA hooks on every launch | Fixed — update to the latest version, then approve once more. Codex writes trusted hook hashes into `$CODEX_HOME/haya-pet.config.toml` under `[hooks.state]`; HAYA Pet now preserves that Codex-managed block when refreshing the hook profile. |
|
|
21
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. |
|
|
22
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. |
|
|
23
24
|
| **Codex** live status didn't turn on / you pass your own `-p`/`--profile` | Codex allows only one profile, so haya-pet skips hook injection when you supply your own and prints a notice. Drop your `-p` for that run to get live status, or accept lifecycle-only. |
|
package/package.json
CHANGED
|
@@ -43,8 +43,11 @@
|
|
|
43
43
|
// while manual/unknown reviewer config reports waiting_approval. The guardian
|
|
44
44
|
// fires NO hooks itself (SubAgentSource::Other is excluded from Subagent
|
|
45
45
|
// hooks), so the wrapper also tails the guardian rollout directly.
|
|
46
|
-
// -
|
|
47
|
-
//
|
|
46
|
+
// - PreCompact / PostCompact trigger split follows the same manual/auto matcher
|
|
47
|
+
// shape as Claude. A manual `/compact` returns to a prompt, while auto compact
|
|
48
|
+
// happens mid-turn and should resume the pet's working state.
|
|
49
|
+
// - UNTESTED: PreCompact / SubagentStart|Stop live firing (no compaction /
|
|
50
|
+
// subagent occurred in the probe).
|
|
48
51
|
//
|
|
49
52
|
// OPEN QUESTION (injection): unlike `claude --settings <file>`, Codex has no
|
|
50
53
|
// per-invocation settings-file flag. Candidate non-mutating paths, best first:
|
|
@@ -70,8 +73,9 @@ const EDIT_TOOLS_MATCHER = EDIT_TOOLS.join("|");
|
|
|
70
73
|
const COMMAND_TOOLS_MATCHER = "shell_command";
|
|
71
74
|
|
|
72
75
|
// The hook table. Each entry → one Codex hook that reports a fixed pet state.
|
|
73
|
-
// `matcher` (when present) filters PreToolUse by tool name
|
|
74
|
-
// optional short label shown in the bubble and
|
|
76
|
+
// `matcher` (when present) filters PreToolUse by tool name or PostCompact by
|
|
77
|
+
// compaction trigger. `summary` is an optional short label shown in the bubble and
|
|
78
|
+
// in HAYA_PET_HOOK_DEBUG logs.
|
|
75
79
|
const HOOK_TABLE = Object.freeze([
|
|
76
80
|
{ event: "UserPromptSubmit", state: "thinking" },
|
|
77
81
|
{ event: "PreToolUse", matcher: EDIT_TOOLS_MATCHER, state: "editing_files" },
|
|
@@ -79,7 +83,8 @@ const HOOK_TABLE = Object.freeze([
|
|
|
79
83
|
{ event: "PostToolUse", state: "thinking" },
|
|
80
84
|
{ event: "PermissionRequest", command: "codex-permission-request" },
|
|
81
85
|
{ event: "PreCompact", state: "compacting" },
|
|
82
|
-
{ event: "PostCompact", state: "
|
|
86
|
+
{ event: "PostCompact", matcher: "manual", state: "idle", summary: "compacted" },
|
|
87
|
+
{ event: "PostCompact", matcher: "auto", state: "thinking", summary: "compacted" },
|
|
83
88
|
// A subagent finishing is mid-turn — the main agent keeps working, so this is
|
|
84
89
|
// NOT idle. Turn-end is the dedicated `Stop` event below.
|
|
85
90
|
{ event: "SubagentStart", state: "running_tool", summary: "subagent" },
|
|
@@ -87,11 +92,15 @@ const HOOK_TABLE = Object.freeze([
|
|
|
87
92
|
{ event: "Stop", state: "idle" }
|
|
88
93
|
]);
|
|
89
94
|
|
|
90
|
-
// Resolve the pet state for a Codex event. `
|
|
91
|
-
// Exposed for testing and to keep the
|
|
92
|
-
|
|
95
|
+
// Resolve the pet state for a Codex event. `detail` is the tool for PreToolUse or
|
|
96
|
+
// the compaction trigger for PostCompact. Exposed for testing and to keep the
|
|
97
|
+
// mapping in one place.
|
|
98
|
+
export function mapCodexEventToState(event, detail) {
|
|
93
99
|
if (event === "PreToolUse") {
|
|
94
|
-
return EDIT_TOOLS.includes(
|
|
100
|
+
return EDIT_TOOLS.includes(detail) ? "editing_files" : "running_tool";
|
|
101
|
+
}
|
|
102
|
+
if (event === "PostCompact") {
|
|
103
|
+
return detail === "manual" ? "idle" : "thinking";
|
|
95
104
|
}
|
|
96
105
|
const entry = HOOK_TABLE.find((row) => row.event === event && row.matcher === undefined);
|
|
97
106
|
return entry?.state;
|
|
@@ -25,6 +25,12 @@ test("mapCodexEventToState branches PreToolUse on tool name (apply_patch vs comm
|
|
|
25
25
|
assert.equal(mapCodexEventToState("PreToolUse", "read_file"), "running_tool");
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
test("mapCodexEventToState branches PostCompact on compaction trigger", () => {
|
|
29
|
+
assert.equal(mapCodexEventToState("PostCompact", "manual"), "idle");
|
|
30
|
+
assert.equal(mapCodexEventToState("PostCompact", "auto"), "thinking");
|
|
31
|
+
assert.equal(mapCodexEventToState("PostCompact"), "thinking");
|
|
32
|
+
});
|
|
33
|
+
|
|
28
34
|
test("Stop is the only idle signal — SubagentStop stays working", () => {
|
|
29
35
|
// Regression guard for the key Codex-vs-Claude difference: a subagent finishing
|
|
30
36
|
// mid-turn must NOT flip the pet to idle.
|
|
@@ -64,6 +70,15 @@ test("buildCodexHookSettings splits PreToolUse into edit + command matchers", ()
|
|
|
64
70
|
assert.equal(other.matcher, "shell_command");
|
|
65
71
|
});
|
|
66
72
|
|
|
73
|
+
test("buildCodexHookSettings splits PostCompact into manual + auto triggers", () => {
|
|
74
|
+
const post = buildCodexHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PostCompact;
|
|
75
|
+
assert.equal(post.length, 2);
|
|
76
|
+
const manual = post.find((e) => e.matcher === "manual");
|
|
77
|
+
const auto = post.find((e) => e.matcher === "auto");
|
|
78
|
+
assert.match(manual.hooks[0].command, /state idle --summary compacted$/);
|
|
79
|
+
assert.match(auto.hooks[0].command, /state thinking --summary compacted$/);
|
|
80
|
+
});
|
|
81
|
+
|
|
67
82
|
test("buildCodexHookSettings routes PermissionRequest through the Codex reporter", () => {
|
|
68
83
|
const permission = buildCodexHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PermissionRequest;
|
|
69
84
|
assert.equal(permission.length, 1);
|
|
@@ -21,6 +21,7 @@ const MTIME_SKEW_MS = 2000;
|
|
|
21
21
|
export function watchCodexGuardianReviews(options = {}) {
|
|
22
22
|
const {
|
|
23
23
|
homeDir = process.env.USERPROFILE || process.env.HOME,
|
|
24
|
+
cwd,
|
|
24
25
|
startedAt = 0,
|
|
25
26
|
onReviewEvent = () => {},
|
|
26
27
|
pollIntervalMs = DEFAULT_POLL_MS,
|
|
@@ -31,6 +32,7 @@ export function watchCodexGuardianReviews(options = {}) {
|
|
|
31
32
|
|
|
32
33
|
const root = sessionsRoot ?? (homeDir ? join(homeDir, ".codex", "sessions") : undefined);
|
|
33
34
|
const minMtime = startedAt > 0 ? startedAt - MTIME_SKEW_MS : 0;
|
|
35
|
+
const expectedCwd = normalizePathForCompare(cwd);
|
|
34
36
|
|
|
35
37
|
// session_meta classifications are immutable once written, so cache them by
|
|
36
38
|
// path. A file with no complete first line yet is NOT cached — it is retried
|
|
@@ -50,8 +52,13 @@ export function watchCodexGuardianReviews(options = {}) {
|
|
|
50
52
|
return undefined;
|
|
51
53
|
}
|
|
52
54
|
const meta = classifyCodexSessionMeta(firstLine) ?? null;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
+
const sessionMeta = readSessionMeta(firstLine);
|
|
56
|
+
const isFreshSession = sessionMeta && sessionMeta.startedAt >= minMtime;
|
|
57
|
+
const isFreshResume =
|
|
58
|
+
meta?.kind === "main" &&
|
|
59
|
+
expectedCwd !== undefined &&
|
|
60
|
+
normalizePathForCompare(sessionMeta?.cwd) === expectedCwd;
|
|
61
|
+
if (meta && minMtime > 0 && !isFreshSession && !isFreshResume) {
|
|
55
62
|
metaByPath.set(file, null);
|
|
56
63
|
return null;
|
|
57
64
|
}
|
|
@@ -140,7 +147,7 @@ export function watchCodexGuardianReviews(options = {}) {
|
|
|
140
147
|
};
|
|
141
148
|
}
|
|
142
149
|
|
|
143
|
-
function
|
|
150
|
+
function readSessionMeta(line) {
|
|
144
151
|
let entry;
|
|
145
152
|
try {
|
|
146
153
|
entry = JSON.parse(line);
|
|
@@ -153,5 +160,19 @@ function readSessionMetaTimestamp(line) {
|
|
|
153
160
|
}
|
|
154
161
|
|
|
155
162
|
const timestampMs = Date.parse(entry.timestamp);
|
|
156
|
-
|
|
163
|
+
if (!Number.isFinite(timestampMs)) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
startedAt: timestampMs,
|
|
169
|
+
cwd: typeof entry.payload?.cwd === "string" ? entry.payload.cwd : undefined
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizePathForCompare(value) {
|
|
174
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
return value.trim().replace(/\\/g, "/").replace(/\/+$/g, "").toLowerCase();
|
|
157
178
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// across sessions so Codex's hook-trust review only needs approving once. fnm hands
|
|
9
9
|
// out a per-shell symlink for process.execPath that dies when the launching shell
|
|
10
10
|
// exits, so we realpath it before baking it into the hook command.
|
|
11
|
-
import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
@@ -32,7 +32,8 @@ export function injectCodexHooks({ nodePath, cliPath, codexHome, env = process.e
|
|
|
32
32
|
// rewrite identical bytes, and the hooks stay "trusted" across launches.
|
|
33
33
|
mkdirSync(home, { recursive: true });
|
|
34
34
|
const profilePath = join(home, PROFILE_FILE);
|
|
35
|
-
|
|
35
|
+
const trustedState = readCodexHookTrustState(profilePath);
|
|
36
|
+
writeFileSync(profilePath, appendCodexHookTrustState(toml, trustedState), "utf8");
|
|
36
37
|
|
|
37
38
|
// The profile file is stable and reusable on purpose — leaving it in place is
|
|
38
39
|
// what lets Codex remember the hooks are trusted. cleanup is a no-op kept for
|
|
@@ -47,3 +48,51 @@ function safeRealpath(target) {
|
|
|
47
48
|
return target;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
function readCodexHookTrustState(profilePath) {
|
|
53
|
+
try {
|
|
54
|
+
return extractCodexHookTrustState(readFileSync(profilePath, "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function appendCodexHookTrustState(toml, trustedState) {
|
|
61
|
+
if (!trustedState) {
|
|
62
|
+
return toml;
|
|
63
|
+
}
|
|
64
|
+
return `${toml.trimEnd()}\n\n${trustedState.trim()}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractCodexHookTrustState(toml) {
|
|
68
|
+
const lines = String(toml).split(/\r?\n/);
|
|
69
|
+
const output = [];
|
|
70
|
+
let inHookState = false;
|
|
71
|
+
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const tableName = readTomlTableName(line);
|
|
74
|
+
if (tableName) {
|
|
75
|
+
const isHookStateTable = tableName === "hooks.state" || tableName.startsWith("hooks.state.");
|
|
76
|
+
if (isHookStateTable) {
|
|
77
|
+
inHookState = true;
|
|
78
|
+
} else if (inHookState) {
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (inHookState) {
|
|
84
|
+
output.push(line);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return output.join("\n").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readTomlTableName(line) {
|
|
92
|
+
const table = /^\s*\[([^\]]+)\]\s*$/.exec(line);
|
|
93
|
+
if (table) {
|
|
94
|
+
return table[1];
|
|
95
|
+
}
|
|
96
|
+
const arrayTable = /^\s*\[\[([^\]]+)\]\]\s*$/.exec(line);
|
|
97
|
+
return arrayTable?.[1];
|
|
98
|
+
}
|
|
@@ -12,6 +12,7 @@ const MTIME_SKEW_MS = 2000;
|
|
|
12
12
|
export function watchCodexTranscript(options = {}) {
|
|
13
13
|
const {
|
|
14
14
|
homeDir = process.env.USERPROFILE || process.env.HOME,
|
|
15
|
+
cwd,
|
|
15
16
|
startedAt = 0,
|
|
16
17
|
onToolEvent = () => {},
|
|
17
18
|
pollIntervalMs = DEFAULT_POLL_MS,
|
|
@@ -31,7 +32,7 @@ export function watchCodexTranscript(options = {}) {
|
|
|
31
32
|
const tick = () => {
|
|
32
33
|
try {
|
|
33
34
|
if (!transcriptPath) {
|
|
34
|
-
transcriptPath = discoverCodexTranscript(root, minMtime);
|
|
35
|
+
transcriptPath = discoverCodexTranscript(root, minMtime, { cwd });
|
|
35
36
|
if (!transcriptPath) {
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
@@ -78,21 +79,29 @@ export function watchCodexTranscript(options = {}) {
|
|
|
78
79
|
};
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
export function discoverCodexTranscript(root, minMtime = 0) {
|
|
82
|
+
export function discoverCodexTranscript(root, minMtime = 0, options = {}) {
|
|
82
83
|
if (!root || !existsSync(root)) {
|
|
83
84
|
return undefined;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
const expectedCwd = normalizePathForCompare(options.cwd);
|
|
86
88
|
let newest;
|
|
87
89
|
for (const file of listJsonlFiles(root)) {
|
|
88
90
|
const mtime = safeMtime(file);
|
|
89
91
|
if (mtime < minMtime) {
|
|
90
92
|
continue;
|
|
91
93
|
}
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
94
|
+
const meta = readCodexSessionMeta(file);
|
|
95
|
+
if (!meta) {
|
|
94
96
|
continue;
|
|
95
97
|
}
|
|
98
|
+
|
|
99
|
+
const isFreshSession = meta.startedAt >= minMtime;
|
|
100
|
+
const isFreshResume = expectedCwd !== undefined && normalizePathForCompare(meta.cwd) === expectedCwd;
|
|
101
|
+
if (!isFreshSession && !isFreshResume) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
96
105
|
if (!newest || mtime > newest.mtime) {
|
|
97
106
|
newest = { file, mtime };
|
|
98
107
|
}
|
|
@@ -100,7 +109,7 @@ export function discoverCodexTranscript(root, minMtime = 0) {
|
|
|
100
109
|
return newest?.file;
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
function
|
|
112
|
+
function readCodexSessionMeta(file) {
|
|
104
113
|
const line = readFirstLine(file);
|
|
105
114
|
if (line === undefined) {
|
|
106
115
|
return undefined;
|
|
@@ -118,5 +127,19 @@ function readCodexSessionStartedAt(file) {
|
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
const timestampMs = Date.parse(entry.timestamp);
|
|
121
|
-
|
|
130
|
+
if (!Number.isFinite(timestampMs)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
startedAt: timestampMs,
|
|
136
|
+
cwd: typeof entry.payload?.cwd === "string" ? entry.payload.cwd : undefined
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function normalizePathForCompare(value) {
|
|
141
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
return value.trim().replace(/\\/g, "/").replace(/\/+$/g, "").toLowerCase();
|
|
122
145
|
}
|
|
@@ -175,6 +175,43 @@ test("watchCodexGuardianReviews ignores guardian trunks for sessions that starte
|
|
|
175
175
|
watcher.stop();
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
test("watchCodexGuardianReviews follows a resumed main session in the same cwd", () => {
|
|
179
|
+
const { root, dir } = makeSessionsRoot();
|
|
180
|
+
writeFileSync(
|
|
181
|
+
join(dir, "rollout-main-resumed.jsonl"),
|
|
182
|
+
metaLineAt("2026-06-12T00:00:00.000Z", {
|
|
183
|
+
id: "main-1",
|
|
184
|
+
parent_thread_id: null,
|
|
185
|
+
source: "cli",
|
|
186
|
+
thread_source: "user",
|
|
187
|
+
cwd: "D:\\Work\\project"
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
writeFileSync(
|
|
191
|
+
join(dir, "rollout-guardian.jsonl"),
|
|
192
|
+
metaLineAt("2026-06-12T01:01:00.000Z", {
|
|
193
|
+
id: "guardian-1",
|
|
194
|
+
parent_thread_id: "main-1",
|
|
195
|
+
source: { subagent: { other: "guardian" } }
|
|
196
|
+
}) + reviewStarted("turn-new", "2026-06-12T01:02:00.000Z")
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const events = [];
|
|
200
|
+
const watcher = watchCodexGuardianReviews({
|
|
201
|
+
sessionsRoot: root,
|
|
202
|
+
cwd: "D:\\Work\\project",
|
|
203
|
+
startedAt: Date.parse("2026-06-12T01:00:00.000Z"),
|
|
204
|
+
onReviewEvent: (event) => events.push(event),
|
|
205
|
+
...noopTimers
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
watcher._tick();
|
|
209
|
+
|
|
210
|
+
assert.deepEqual(events, [{ type: "review_started" }]);
|
|
211
|
+
|
|
212
|
+
watcher.stop();
|
|
213
|
+
});
|
|
214
|
+
|
|
178
215
|
test("watchCodexGuardianReviews emits nothing without a classifiable main session", () => {
|
|
179
216
|
const { root, dir } = makeSessionsRoot();
|
|
180
217
|
// Guardian trunk exists but there is no main rollout to bind its parent to.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "../../../test/harness.mjs";
|
|
@@ -43,3 +43,32 @@ test("injectCodexHooks honors CODEX_HOME from env and is stable across calls", (
|
|
|
43
43
|
rmSync(home, { recursive: true, force: true });
|
|
44
44
|
}
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
test("injectCodexHooks preserves Codex hook trust state in the managed profile", () => {
|
|
48
|
+
const home = mkdtempSync(join(tmpdir(), "haya-codex-home-"));
|
|
49
|
+
try {
|
|
50
|
+
const first = injectCodexHooks({
|
|
51
|
+
nodePath: "n",
|
|
52
|
+
cliPath: "c",
|
|
53
|
+
codexHome: home
|
|
54
|
+
});
|
|
55
|
+
const trustedState = `[hooks.state]
|
|
56
|
+
|
|
57
|
+
[hooks.state.'${first.profilePath}:user_prompt_submit:0:0']
|
|
58
|
+
trusted_hash = "sha256:abc123"
|
|
59
|
+
`;
|
|
60
|
+
writeFileSync(first.profilePath, `${readFileSync(first.profilePath, "utf8")}\n${trustedState}`, "utf8");
|
|
61
|
+
|
|
62
|
+
injectCodexHooks({
|
|
63
|
+
nodePath: "n",
|
|
64
|
+
cliPath: "c",
|
|
65
|
+
codexHome: home
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const next = readFileSync(first.profilePath, "utf8");
|
|
69
|
+
assert.match(next, /\[hooks\.state\]/);
|
|
70
|
+
assert.match(next, /trusted_hash = "sha256:abc123"/);
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(home, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
@@ -7,11 +7,11 @@ import { discoverCodexTranscript, watchCodexTranscript } from "../src/codex-tran
|
|
|
7
7
|
|
|
8
8
|
const noopTimers = { setInterval: () => ({}), clearInterval: () => {} };
|
|
9
9
|
|
|
10
|
-
function sessionMeta(timestamp, id = "thread-1") {
|
|
10
|
+
function sessionMeta(timestamp, id = "thread-1", cwd) {
|
|
11
11
|
return `${JSON.stringify({
|
|
12
12
|
timestamp,
|
|
13
13
|
type: "session_meta",
|
|
14
|
-
payload: { id, parent_thread_id: null, source: "cli", thread_source: "user" }
|
|
14
|
+
payload: { id, parent_thread_id: null, source: "cli", thread_source: "user", ...(cwd ? { cwd } : {}) }
|
|
15
15
|
})}\n`;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -152,6 +152,37 @@ test("watchCodexTranscript ignores fresh writes to sessions that started before
|
|
|
152
152
|
watcher.stop();
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
test("watchCodexTranscript follows a fresh resumed session in the same cwd", () => {
|
|
156
|
+
const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
|
|
157
|
+
const dir = join(root, "2026", "06", "08");
|
|
158
|
+
mkdirSync(dir, { recursive: true });
|
|
159
|
+
const path = join(dir, "rollout-resumed.jsonl");
|
|
160
|
+
writeFileSync(
|
|
161
|
+
path,
|
|
162
|
+
[
|
|
163
|
+
sessionMeta("2026-06-08T10:00:00.000Z", "resumed-thread", "D:\\Work\\project"),
|
|
164
|
+
turnAborted("2026-06-08T11:00:01.000Z")
|
|
165
|
+
].join("")
|
|
166
|
+
);
|
|
167
|
+
const fresh = new Date("2026-06-08T11:00:01.500Z");
|
|
168
|
+
utimesSync(path, fresh, fresh);
|
|
169
|
+
|
|
170
|
+
const events = [];
|
|
171
|
+
const watcher = watchCodexTranscript({
|
|
172
|
+
sessionsRoot: root,
|
|
173
|
+
cwd: "D:\\Work\\project",
|
|
174
|
+
startedAt: Date.parse("2026-06-08T11:00:00.000Z"),
|
|
175
|
+
onToolEvent: (event) => events.push(event),
|
|
176
|
+
...noopTimers
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
watcher._tick();
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(events, [{ type: "turn_aborted", reason: "interrupted" }]);
|
|
182
|
+
|
|
183
|
+
watcher.stop();
|
|
184
|
+
});
|
|
185
|
+
|
|
155
186
|
test("watchCodexTranscript forwards a turn_aborted interrupt event", () => {
|
|
156
187
|
const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
|
|
157
188
|
const path = join(dir, "session.jsonl");
|
|
@@ -11,6 +11,7 @@ export function createSessionRegistry(options = {}) {
|
|
|
11
11
|
class SessionRegistry {
|
|
12
12
|
constructor(options) {
|
|
13
13
|
this.sessions = new Map();
|
|
14
|
+
this.lastStateUpdatedAt = new Map();
|
|
14
15
|
this.staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
15
16
|
this.dropAfterMs = options.dropAfterMs ?? DEFAULT_DROP_AFTER_MS;
|
|
16
17
|
}
|
|
@@ -58,6 +59,7 @@ class SessionRegistry {
|
|
|
58
59
|
// real update — marking stale must NOT bump updatedAt, or it never elapses.
|
|
59
60
|
if (now - session.updatedAt > this.dropAfterMs) {
|
|
60
61
|
this.sessions.delete(sessionId);
|
|
62
|
+
this.lastStateUpdatedAt.delete(sessionId);
|
|
61
63
|
continue;
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -70,6 +72,7 @@ class SessionRegistry {
|
|
|
70
72
|
session.source = "wrapper";
|
|
71
73
|
session.confidence = 0.3;
|
|
72
74
|
session.summary = "heartbeat stale";
|
|
75
|
+
this.lastStateUpdatedAt.set(sessionId, now);
|
|
73
76
|
staleSessions.push(snapshotSession(session));
|
|
74
77
|
}
|
|
75
78
|
}
|
|
@@ -93,6 +96,7 @@ class SessionRegistry {
|
|
|
93
96
|
};
|
|
94
97
|
|
|
95
98
|
this.sessions.set(message.sessionId, session);
|
|
99
|
+
this.lastStateUpdatedAt.set(message.sessionId, message.startedAt);
|
|
96
100
|
return snapshotSession(session);
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -104,10 +108,17 @@ class SessionRegistry {
|
|
|
104
108
|
|
|
105
109
|
applyState(message) {
|
|
106
110
|
const session = this.requireSession(message.sessionId);
|
|
111
|
+
const lastStateUpdatedAt = this.lastStateUpdatedAt.get(message.sessionId) ?? session.startedAt;
|
|
112
|
+
|
|
113
|
+
if (message.updatedAt < lastStateUpdatedAt) {
|
|
114
|
+
return snapshotSession(session);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.lastStateUpdatedAt.set(message.sessionId, message.updatedAt);
|
|
107
118
|
session.state = message.state;
|
|
108
119
|
session.confidence = message.confidence;
|
|
109
120
|
session.source = message.source;
|
|
110
|
-
session.updatedAt = message.updatedAt;
|
|
121
|
+
session.updatedAt = Math.max(session.updatedAt, message.updatedAt);
|
|
111
122
|
|
|
112
123
|
if (Object.prototype.hasOwnProperty.call(message, "summary")) {
|
|
113
124
|
session.summary = message.summary;
|
|
@@ -126,6 +137,7 @@ class SessionRegistry {
|
|
|
126
137
|
session.exitCode = message.exitCode;
|
|
127
138
|
session.finishedAt = message.finishedAt;
|
|
128
139
|
session.updatedAt = message.finishedAt;
|
|
140
|
+
this.lastStateUpdatedAt.set(message.sessionId, message.finishedAt);
|
|
129
141
|
return snapshotSession(session);
|
|
130
142
|
}
|
|
131
143
|
|
|
@@ -65,6 +65,60 @@ test("applies state and heartbeat messages without losing session metadata", ()
|
|
|
65
65
|
assert.equal(session.projectName, "project");
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("ignores late state messages older than the latest accepted state", () => {
|
|
69
|
+
const registry = createSessionRegistry();
|
|
70
|
+
|
|
71
|
+
registry.applyMessage(registerMessage("sess_a"));
|
|
72
|
+
registry.applyMessage({
|
|
73
|
+
type: "state",
|
|
74
|
+
sessionId: "sess_a",
|
|
75
|
+
state: "interrupted",
|
|
76
|
+
summary: "interrupted",
|
|
77
|
+
confidence: 0.9,
|
|
78
|
+
source: "client_log",
|
|
79
|
+
updatedAt: 2000
|
|
80
|
+
});
|
|
81
|
+
registry.applyMessage({
|
|
82
|
+
type: "state",
|
|
83
|
+
sessionId: "sess_a",
|
|
84
|
+
state: "thinking",
|
|
85
|
+
confidence: 0.9,
|
|
86
|
+
source: "official_plugin",
|
|
87
|
+
updatedAt: 1500
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const session = registry.getSession("sess_a");
|
|
91
|
+
assert.equal(session.state, "interrupted");
|
|
92
|
+
assert.equal(session.summary, "interrupted");
|
|
93
|
+
assert.equal(session.source, "client_log");
|
|
94
|
+
assert.equal(session.updatedAt, 2000);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("heartbeats do not block later-delivered state messages", () => {
|
|
98
|
+
const registry = createSessionRegistry();
|
|
99
|
+
|
|
100
|
+
registry.applyMessage(registerMessage("sess_a"));
|
|
101
|
+
registry.applyMessage({
|
|
102
|
+
type: "heartbeat",
|
|
103
|
+
sessionId: "sess_a",
|
|
104
|
+
updatedAt: 3000
|
|
105
|
+
});
|
|
106
|
+
registry.applyMessage({
|
|
107
|
+
type: "state",
|
|
108
|
+
sessionId: "sess_a",
|
|
109
|
+
state: "running_tool",
|
|
110
|
+
summary: "shell_command",
|
|
111
|
+
confidence: 0.85,
|
|
112
|
+
source: "client_log",
|
|
113
|
+
updatedAt: 2000
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const session = registry.getSession("sess_a");
|
|
117
|
+
assert.equal(session.state, "running_tool");
|
|
118
|
+
assert.equal(session.summary, "shell_command");
|
|
119
|
+
assert.equal(session.updatedAt, 3000);
|
|
120
|
+
});
|
|
121
|
+
|
|
68
122
|
test("unregister marks sessions as exited and preserves exit details", () => {
|
|
69
123
|
const registry = createSessionRegistry();
|
|
70
124
|
|