@hayasaka7/haya-pet 0.2.0 → 0.2.1

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.
@@ -49,17 +49,39 @@ as each client allows:
49
49
  |---|---|---|
50
50
  | L1 | Process wrapper (lifecycle only) | session exists / exit code |
51
51
  | L2 | PTY output observation (`--observe`, opt-in) | activity-based working/idle |
52
- | L3 | Client logs / state files | client-specific (future) |
53
- | L4 | Client hooks | richest — implemented for Claude Code |
52
+ | L3 | Client logs / state files / process tree | transcript watchers (Claude denial, Codex tools) + approval-accept detection |
53
+ | L4 | Client hooks | richest — implemented for Claude Code (full) and Codex (partial) |
54
54
 
55
55
  The **default** is native passthrough (`stdio: "inherit"`) for full terminal
56
56
  fidelity, with **L1 lifecycle** status for every client. Richer status is opt-in:
57
- **Claude Code** gains **L4 hooks** when enabled with `haya-pet hooks on`
58
- (persisted; or per-run via `HAYA_PET_HOOKS=1`) injected via
59
- `claude --settings <stable-file>`, reporting in-session activity through the
60
- `haya-pet state` command — lifecycle still comes from the wrapper's exit code);
61
- any client gains **L2** with `--observe`. Hooks are opt-in because injecting them
62
- triggers Claude's one-time *review hooks* trust prompt.
57
+ **Claude Code** and **Codex** gain **L4 hooks** when enabled with the global
58
+ `haya-pet hooks on` (persisted; or per-run via `HAYA_PET_HOOKS=1`). Both report
59
+ in-session activity through the shared, client-agnostic `haya-pet state` command
60
+ (lifecycle still comes from the wrapper's exit code); any client gains **L2** with
61
+ `--observe`. Hooks are opt-in because injecting them triggers the client's one-time
62
+ *review hooks* trust prompt.
63
+
64
+ The injection mechanism differs per client. **Claude Code** takes a stable
65
+ `claude --settings <file>`. **Codex** has no per-invocation settings flag, so the
66
+ wrapper writes a stable `$CODEX_HOME/haya-pet.config.toml` profile and prepends
67
+ `-p haya-pet` to the codex args (a profile layers on top of the user's base config,
68
+ leaving auth/model/MCP intact, and is inert otherwise). Codex allows only one
69
+ profile, so if the user already passes `-p/--profile`, injection is skipped with a
70
+ notice. Codex's hook command must be unquoted at the program position (it runs via
71
+ `cmd /c`, which strips a leading quote) and its matchers can't use look-around
72
+ (Rust regex) — see [known-issues.md](known-issues.md). Codex's L4 is **partial**:
73
+ `PreToolUse`/`PermissionRequest` don't fire upstream yet, so only `thinking`/`idle`
74
+ arrive today.
75
+
76
+ Hooks alone can't see one moment: clients emit **no event when the user accepts a
77
+ permission prompt** (denial and completion are observable; the accept click is
78
+ not). The companion bridges it with **L3 process-tree observation**: while a
79
+ session sits in `waiting_approval`, it polls the client's process subtree (the
80
+ wrapper reported the pid at register), and when a new descendant process appears
81
+ and persists across two polls — the approved command verifiably running — the
82
+ session flips to `running_tool`. No timers: an unanswered prompt keeps warning
83
+ until a real event resolves it (`approval-process-watcher.js`,
84
+ `process-snapshot.js`; Windows/macOS/Linux listers).
63
85
 
64
86
  L2 is **activity-based**: any visible output → *working*; a short quiet window →
65
87
  *idle*; success/failure come from the real exit code, never from scraping output
@@ -95,8 +117,9 @@ packages/
95
117
  session-core/ registry, priority, summaries, bubble views, linger, pet-state
96
118
  task-core/ task status, events, store, approvals, replies, controls
97
119
  adapters/ client info, heuristics, capabilities, output observer, routing
98
- daemon-core/ IPC server/transport, runtime bridge, singleton
99
- platform-core/ platform, paths, capabilities
120
+ daemon-core/ IPC server/transport, runtime bridge, singleton,
121
+ approval process watcher (waiting_approval -> running_tool)
122
+ platform-core/ platform, paths, capabilities, process snapshots (win/mac/linux)
100
123
  apps/
101
124
  cli/ haya-pet entrypoint + parser (run / start / stop / pets)
102
125
  companion/ Electron overlay app (main + renderer)
@@ -0,0 +1,72 @@
1
+ # Cross-OS QA Matrix
2
+
3
+ Use this checklist before release candidates and after changes to IPC, windowing, display handling, terminal attachment, or CLI process wrapping.
4
+
5
+ ## Automated Gates
6
+
7
+ - [ ] `npm test` passes on the target branch.
8
+ - [ ] Generic command lifecycle emits register, running state, heartbeat, final state, and unregister.
9
+ - [ ] Daemon accepts local IPC messages and updates the session registry.
10
+ - [ ] CLI can run through daemon IPC without an injected sender.
11
+ - [ ] Pet preview loads a Codex-compatible `1536x1872` spritesheet.
12
+ - [ ] Pet manifest parsing and atlas/action validation pass (`pet-core`).
13
+ - [ ] Generic regex heuristics map sample output to normalized states (`adapters`).
14
+ - [ ] Task status mapping, event normalization, and control gating pass (`task-core`).
15
+ - [ ] Session bubble view models build with status label, summary, action, and elapsed (`session-core`).
16
+ - [ ] PTY output observer infers debounced `pty_output` states from sample client output (`adapters`).
17
+ - [ ] Reply/approval routing dispatches to injectors and safely refuses unsupported adapters (`adapters`).
18
+
19
+ ## Manual Platform Matrix
20
+
21
+ | Platform | Shell/Terminal | Display Setup | Required Checks |
22
+ |---|---|---|---|
23
+ | Windows 11 | PowerShell | 100% DPI | `haya-pet run --client generic -- node -e "setTimeout(() => process.exit(0), 1000)"`; daemon sees session exit; overlay opens without focus stealing. |
24
+ | Windows 11 | Windows Terminal | 125% DPI | Pet drag/click/double-click; position persists after restart; terminal attachment helper reports best-effort capability. |
25
+ | Windows 11 | Windows Terminal | 150% or mixed DPI | Saved offscreen position clamps to visible work area; session bubbles remain visible. |
26
+ | macOS current stable | Terminal.app | Retina display | Unix socket IPC works; transparent overlay opens; click/drag behavior works; position persists. |
27
+ | macOS current stable | iTerm2 | External display | Moving terminal across displays does not lose session bubble fallback; permission denial produces best-effort/fallback state. |
28
+ | Ubuntu Linux X11 | GNOME Terminal | Single display | Unix socket IPC works; transparent overlay opens; X11 terminal strategy reports best-effort. |
29
+ | Ubuntu or Fedora Linux Wayland | Default terminal | Single display | Overlay fallback mode works; terminal attachment reports manual fallback; global pet plus cluster/session bubbles remain usable. |
30
+
31
+ ## Release Acceptance Gates
32
+
33
+ - [ ] No platform stores prompts, screenshots, or raw terminal logs by default.
34
+ - [ ] Windows uses `\\.\pipe\haya-petd` for local IPC.
35
+ - [ ] macOS/Linux use `~/.haya-pet/haya-petd.sock` for local IPC.
36
+ - [ ] Windows state path is under `%LOCALAPPDATA%\haya-pet\state.json`.
37
+ - [ ] macOS/Linux state path is under `~/.haya-pet/state.json`.
38
+ - [ ] If transparent overlay fails, a normal companion window is available.
39
+ - [ ] If terminal attachment fails, global pet plus manual/cluster bubbles remain available.
40
+ - [ ] Wayland does not use unsupported global positioning assumptions.
41
+ - [ ] Saved display IDs are validated; missing displays fall back to primary visible work area.
42
+ - [ ] Task talk controls are hidden or disabled when adapter capability is unsupported.
43
+ - [ ] Reply composer shows "Open terminal to reply" for wrapper-only adapters (no blind injection).
44
+ - [ ] Approvals require explicit approve/deny and are never auto-approved.
45
+ - [ ] Companion runs as a single instance; a second launch focuses the existing pet.
46
+ - [ ] A stale daemon lock (dead PID) is reclaimed; a live one forwards.
47
+
48
+ ## Companion App Smoke Test (per OS)
49
+
50
+ Run from `apps/companion` after `npm install`:
51
+
52
+ - [ ] `npm start` opens the overlay; empty space stays click-through.
53
+ - [ ] Single click → waving; double click → jumping; drag moves and persists.
54
+ - [ ] Running `haya-pet run --client generic -- sleep 10` shows a session bubble.
55
+ - [ ] Two concurrent sessions show two bubbles without renderer conflicts.
56
+ - [ ] Selecting a bubble opens the task talk window (peek mode).
57
+ - [ ] Tray menu can show/hide the pet and reset its position.
58
+
59
+ ## Current Implementation Status
60
+
61
+ - Shared protocol/session/pet core: automated coverage exists.
62
+ - Pet asset manifest + validation + frame animator: automated coverage exists.
63
+ - Client adapters (info, generic/PTY heuristics, capabilities): automated coverage exists.
64
+ - Task talk core (status, events, store, approvals, replies, controls): automated coverage exists.
65
+ - Session summaries + bubble view models: automated coverage exists.
66
+ - Daemon singleton decision logic + tray menu model + position state file: automated coverage exists.
67
+ - Cross-OS platform paths and capabilities: automated coverage exists.
68
+ - Test-mode IPC server/client: automated coverage exists.
69
+ - Electron overlay app: implemented as glue (`apps/companion`) consuming the pure cores; requires `electron` install and manual run/QA per OS (not unit-tested).
70
+ - Production OS endpoint behavior: needs manual validation on Windows, macOS, and Linux.
71
+ - Terminal attachment: facade + documented IPC contract (`native/README.md`). Windows helper is implemented in .NET and compiles + runs; macOS/Linux helper binaries are still TODO. Node helper client (`terminal-helper-client.js`) is unit-tested.
72
+ - PTY output observer + reply/approval routing: implemented and unit-tested as pure cores. Live wiring (real PTY via node-pty; bidirectional IPC + real injectors) is a later phase.
@@ -24,6 +24,44 @@ Issues found in live use, with their current status.
24
24
  `Notification` hook by type (`permission_prompt`→approval, `idle_prompt`→idle) so
25
25
  non-approval notifications no longer masquerade as approvals.
26
26
 
27
+ ## ✅ Resolved: pet stuck on "waiting for approval" after the user ACCEPTS
28
+
29
+ - **Symptom:** The denial fix above covered "deny", but **accepting** a prompt
30
+ still left the pet on *waiting for approval* for the whole run of the approved
31
+ tool — often the user approves a command (build/test) and Claude immediately
32
+ starts working, with no further `PreToolUse`/`PostToolUse` until the tool
33
+ *finishes*. For a long command that was minutes of misleading "waiting"
34
+ (observed: 240 s in a `HAYA_PET_HOOK_DEBUG` trace).
35
+ - **Root cause:** Claude Code emits **nothing at the moment of a manual accept** —
36
+ verified three ways: the official hooks lifecycle (`PreToolUse` → dialog →
37
+ `PermissionRequest` → … → `PostToolUse` only *after* the tool completes; there
38
+ is no "permission granted" event), a live hook trace, and the session
39
+ transcript (the `tool_use`/`tool_result`/hook records are flushed in one batch
40
+ at completion; nothing is written at approval time). So between *prompt shown*
41
+ and *tool finished*, outside observers get zero signal. Timers were rejected
42
+ again: flipping the state on a delay would hide a genuinely unanswered prompt.
43
+ - **Fix:** **Process-tree observation** (`approval-process-watcher.js` +
44
+ `process-snapshot.js`). The wrapper already reports the client's `pid` on
45
+ register. While a session sits in `waiting_approval`, the companion polls the
46
+ client's process subtree (~1.5 s; only during the waiting window): when a
47
+ **new descendant process appears and is still alive on the next poll**, the
48
+ approved command is verifiably running → the session flips to `running_tool`
49
+ (summary `approved`, source `client_log`). The two-poll persistence filter
50
+ keeps short-lived blips (our own hook reporter, the user's hooks) from
51
+ triggering it; the subtree walk covers shim layers (`cmd.exe` → `claude.exe` →
52
+ command). Every transition is event-based: a real process appeared, the tool
53
+ finished (`PostToolUse`), or the user denied (transcript). If nothing happens,
54
+ the warning stays up — by design.
55
+ - **Known limitations (accepted):** (1) In-process approvals (Edit/Write file
56
+ edits) spawn no OS process and aren't detected — but they complete in
57
+ milliseconds after approval, so `PostToolUse` resolves them near-instantly
58
+ anyway. (2) Detection lags the accept by ~2–3 s (two polls + snapshot cost).
59
+ (3) An unrelated *persistent* process spawned by the client mid-wait (e.g. an
60
+ MCP server starting) would read as an approval — considered acceptable since
61
+ the client is in fact actively doing something then. Cross-OS: Windows via a
62
+ PowerShell CIM snapshot, macOS via `ps`, Linux via `/proc` (macOS/Linux listers
63
+ are implemented but need live verification on real hardware).
64
+
27
65
  ## ✅ Resolved: Claude Code TUI accepted no keyboard input when hooks were injected
28
66
 
29
67
  - **Symptom:** With per-session hooks injected by default (`claude --settings
@@ -94,25 +132,70 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
94
132
  approving once (a volatile per-session argument would re-trigger it every
95
133
  launch — see the resolved note below). `--observe` is a separate PTY opt-in for
96
134
  non-interactive runs (terminal-fidelity tradeoff).
97
- - **Codex** — **not yet implemented** (no hook injection). Uses `--observe` PTY
98
- observation or L1 lifecycle. A Codex hook adapter (temp `<cwd>/.codex/hooks.json`
99
- + `--dangerously-bypass-hook-trust`) is a planned follow-up; note the open
100
- upstream bug where hooks may not fire in interactive sessions
101
- ([#17532](https://github.com/openai/codex/issues/17532)).
135
+ - **Codex** — **implemented (partial).** Opt-in via the global `haya-pet hooks on`;
136
+ the wrapper injects `packages/adapters/src/codex-hooks.js` as a stable
137
+ `$CODEX_HOME/haya-pet.config.toml` profile and launches `codex -p haya-pet`
138
+ (`packages/cli-core/src/codex-hook-injection.js`). Falls back to `--observe` / L1
139
+ when not enabled. Findings (verified against `codex-cli` 0.137.0 on Windows):
140
+ - **Mechanism fits.** Codex has a lifecycle-hooks system (`[[hooks.<Event>]]` in
141
+ `config.toml` or a `hooks.json`), with the `hooks` feature flag `stable` and ON
142
+ by default. Command hooks receive a JSON payload on **stdin**
143
+ (`session_id`, `hook_event_name`, `tool_name`, `cwd`) and treat *exit 0 with no
144
+ output* as continue — so our existing client-agnostic `haya-pet state` reporter
145
+ works unchanged. The hooks.json shape is identical to Claude's settings block;
146
+ **only the hook table differs**, which is all `codex-hooks.js` adds.
147
+ - **Event vocabulary differs** from Claude: no `Notification` / `PermissionDenied`
148
+ / `*Failure`; adds `PostCompact` / `SubagentStart`. `Stop` is the *only* idle
149
+ signal (`SubagentStop` is mid-turn → stays *thinking*). `PermissionRequest`
150
+ exists, so the approval cue is reachable.
151
+ - **Injection differs** — Codex has no `claude --settings <file>` equivalent.
152
+ Candidate non-mutating paths: `codex -p haya-pet` layering a generated
153
+ `$CODEX_HOME/haya-pet.config.toml` profile on top of the user's base config, or a
154
+ `hooks.json` next to the active config layer. Codex has its own *review hooks*
155
+ trust prompt (bypass: `--dangerously-bypass-hook-trust`), so the same one-time
156
+ trust UX as Claude applies.
157
+ - **Windows command quoting (fixed in the adapter):** Codex runs a hook `command`
158
+ via `cmd /c "<cmd>"`, which strips a **leading** quote — so Claude's
159
+ `"<node>" "<cli>" …` form dies with *"hook exited with code 1"*. The Codex
160
+ builder leaves the **program unquoted** (`<node> "<cli>" …`); args may be quoted.
161
+ Caveat: an unquoted program breaks if `node`'s path contains spaces (fine for
162
+ fnm/scoop/nvm layouts; a `command_windows` / short-path fallback is a follow-up).
163
+ - **No look-around in matchers:** Codex compiles matchers with the Rust `regex`
164
+ crate, which rejects `(?!…)` — Claude's negative-lookahead catch-all is a hard
165
+ parse error. Matchers are **anchored full matches** against the tool name, so
166
+ they must name tools exactly (`apply_patch`, `shell_command`).
167
+ - **Verified end-to-end** against `codex-cli` 0.137.0 (interactive TUI, Windows,
168
+ real `haya-pet state` reporter, `HAYA_PET_HOOK_DEBUG`): **`UserPromptSubmit`→
169
+ thinking, `PostToolUse`→thinking, `Stop`→idle all fire**, the parent env is
170
+ forwarded to hooks (session via `HAYA_PET_SESSION_ID`), and the reporter exits 0
171
+ cleanly. Note `codex exec` can't be used to test this — it forces
172
+ `approval_policy=never` + `sandbox=read`, a posture that disables hooks entirely.
173
+ - **`PreToolUse` does not fire** in 0.137 for tool calls (the entries are kept as
174
+ harmless no-ops for when it's fixed —
175
+ [openai/codex#16732](https://github.com/openai/codex/issues/16732)). Tool
176
+ activity is covered by an L3 Codex transcript watcher that tails
177
+ `~/.codex/sessions` JSONL: normal tools report `running_tool`, `apply_patch`
178
+ reports `editing_files`, and Haya Pet returns to `thinking` after active tool
179
+ calls drain. `PermissionRequest` (the *waiting for approval* cue — the
180
+ highest-value state) is **unconfirmed**; it likely depends on an
181
+ approval-required flow and needs a dedicated test before the feature is worth
182
+ wiring in.
102
183
  - **Antigravity (`agy`)** — **not yet implemented** (no hook injection). Uses
103
184
  `--observe` or L1 lifecycle. A Gemini-schema hook adapter is a planned follow-up.
104
185
  - **Generic / unknown** — no hooks; PTY observation (`--observe`) or L1 lifecycle.
105
186
 
106
- A future **L3 client-log adapter** (tailing e.g. `~/.codex/sessions` or
107
- Antigravity's `transcript.jsonl`) could provide activity without a PTY or hooks
108
- for the clients that lack a hook adapter.
187
+ The Codex **L3 client-log adapter** now covers tool activity without a PTY or
188
+ working `PreToolUse` hook. A similar adapter for Antigravity's `transcript.jsonl`
189
+ remains a possible follow-up.
109
190
 
110
191
  ## Status sources, by fidelity
111
192
 
112
193
  | Tier | Source | How |
113
194
  |---|---|---|
114
195
  | L1 | process wrapper | default; session lifecycle + exit code |
115
- | L4 | client hooks | opt-in via `HAYA_PET_HOOKS=1` (Claude Code); reports through `haya-pet state …` |
196
+ | L4 | client hooks | opt-in via `haya-pet hooks on` (Claude Code full, Codex partial); reports through `haya-pet state …` |
197
+ | L3 | client logs | Codex session JSONL watcher for tool activity; Claude denial recovery; future clients can add similar transcript adapters |
198
+ | L3 | process tree | approval-accept detection: a `waiting_approval` session flips to `running_tool` when the approved command verifiably starts under the client's pid |
116
199
  | L2 | PTY output scraping | opt-in via `--observe` (terminal-fidelity tradeoff) |
117
200
 
118
201
  Native passthrough (L1) + opt-in hooks (L4) is the recommended setup for interactive
@@ -16,11 +16,13 @@ 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 shows only **idle/lifecycle** while **Codex** works | Codex has no hook adapter yet only Claude Code reports live status via hooks. Add `--observe` for coarse PTY activity (terminal-fidelity tradeoff), or accept lifecycle-only status. |
19
+ | 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 and `running_tool`/`editing_files` from a transcript watcher; *waiting for approval* doesn't fire yet (upstream [openai/codex#16732](https://github.com/openai/codex/issues/16732)). |
20
+ | **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. |
20
21
  | 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. |
21
22
  | 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. |
22
23
  | Pet shows only **idle** for a generic / unknown CLI | Expected without a hook adapter — add `--observe` for PTY observation, otherwise lifecycle only. |
23
24
  | Pet stayed on **waiting for approval** after I denied a tool | Fixed — Claude fires no hook on a manual denial, so the wrapper tails the session transcript and clears to **idle** when the denial is recorded. A genuinely-pending approval (you haven't decided yet) correctly keeps alerting — there's no timer. |
25
+ | Pet stayed on **waiting for approval** after I *approved* a command | Fixed — Claude also fires no hook at the accept moment, so the companion watches the client's process tree while a session waits: when the approved command verifiably starts (a new persistent process under the client), the pet flips to **working**. Expect a ~2–3s lag after your click. File-edit approvals (no process) resolve at completion, which is near-instant. |
24
26
  | Want to see which status events fire | Set `HAYA_PET_HOOK_DEBUG=<file.jsonl>` before `haya-pet run`; each hook- and transcript-sourced status appends one JSON line (timestamp, state, and source/event). |
25
27
  | Pet stays **idle** after force-quitting a CLI | The wrapper marks the session stale ~15s after the heartbeat stops, then drops it. Exiting normally (incl. Ctrl+C) reports **exited** immediately. |
26
28
  | Ctrl+C doesn't exit the CLI cleanly under `haya-pet run` | Fixed — the wrapper no longer dies on Ctrl+C; the signal reaches the CLI, which exits, and the pet shows the result. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "license": "MIT",
@@ -0,0 +1,152 @@
1
+ // Codex CLI hook adapter. Wired into `haya-pet run --client codex` (opt-in via
2
+ // `haya-pet hooks on`); injected through codex-hook-injection.js as a profile.
3
+ //
4
+ // Mirrors claude-hooks.js: pure builders that map Codex lifecycle events onto the
5
+ // shared pet-state vocabulary and emit a hooks config. No I/O here — callers
6
+ // resolve paths and write the file. Each hook entry runs the SAME client-agnostic
7
+ // `haya-pet state` reporter with a fixed state baked into the command; the session
8
+ // id rides in via HAYA_PET_SESSION_ID (injected by the wrapper at spawn), so Codex
9
+ // never needs to pass it on stdin for our purposes.
10
+ //
11
+ // Why a Codex hook adapter is feasible (verified against codex-cli 0.137.0):
12
+ // - Codex command hooks receive a JSON payload on stdin and treat `exit 0 with no
13
+ // output` as success/continue — so our reporter, which prints nothing, is safe.
14
+ // - The hooks.json / [hooks] shape is identical to Claude's settings.json hooks
15
+ // block (per-event matcher groups of { type:"command", command }).
16
+ // - Codex hooks are gated behind `features.hooks = true` and carry their own
17
+ // Claude-style hook-trust prompt (bypassable with --dangerously-bypass-hook-trust).
18
+ //
19
+ // Differences from Claude that this file encodes:
20
+ // - Event set: no Notification / PermissionDenied / *Failure; adds PostCompact /
21
+ // SubagentStart. Turn-end is `Stop` (the only idle signal); SubagentStop is
22
+ // mid-turn so it returns to `thinking`, not idle.
23
+ // - Tool names: Codex uses `apply_patch` for edits and `shell_command` for
24
+ // commands, not Claude's Edit|Write|Bash. Matchers are ANCHORED full matches
25
+ // (a bare `shell` did NOT match `shell_command`), and Codex's Rust regex crate
26
+ // rejects look-around — so no negative-lookahead catch-all.
27
+ //
28
+ // VERIFIED end-to-end against codex-cli 0.137.0 (interactive TUI, Windows), with
29
+ // the real `haya-pet state` reporter and HAYA_PET_HOOK_DEBUG:
30
+ // - FIRING: UserPromptSubmit→thinking, PostToolUse→thinking, Stop→idle. Codex
31
+ // forwards the parent env to hooks, so the session rides in via
32
+ // HAYA_PET_SESSION_ID and the reporter exits 0 cleanly (no "hook exited 1").
33
+ // - NOT FIRING (0.137): PreToolUse — so `running_tool` / `editing_files` never
34
+ // arrive in practice yet (upstream coverage gap, openai/codex#16732). The
35
+ // entries are kept (harmless no-ops) so they light up once Codex fixes it.
36
+ // - UNTESTED: PermissionRequest / PreCompact / SubagentStart|Stop (no approval /
37
+ // compaction / subagent occurred in the probe).
38
+ //
39
+ // OPEN QUESTION (injection): unlike `claude --settings <file>`, Codex has no
40
+ // per-invocation settings-file flag. Candidate non-mutating paths, best first:
41
+ // 1. `codex -p haya-pet` + a generated `$CODEX_HOME/haya-pet.config.toml` profile
42
+ // that layers ON TOP of the user's base config (auth/config preserved).
43
+ // 2. a `hooks.json` next to the active config layer ($CODEX_HOME/hooks.json) —
44
+ // simple but global to every codex session (harmless: the reporter no-ops
45
+ // without HAYA_PET_SESSION_ID).
46
+ // Both still trip Codex's one-time hook-trust prompt, exactly like the Claude path.
47
+
48
+ // Codex's file-editing tool(s) vs. its command tool.
49
+ const EDIT_TOOLS = ["apply_patch"];
50
+ const EDIT_TOOLS_MATCHER = EDIT_TOOLS.join("|");
51
+ // IMPORTANT: Codex compiles matchers with the Rust `regex` crate, which does NOT
52
+ // support look-around. Claude's negative-lookahead catch-all (`^(?!…).*`) is a
53
+ // hard parse error in Codex ("look-around … is not supported"), so we match the
54
+ // command tool EXPLICITLY instead of "everything that isn't an edit". Tools other
55
+ // than the command/edit tools simply don't update state on PreToolUse —
56
+ // acceptable, as PostToolUse resets to `thinking` right after.
57
+ //
58
+ // The matcher is an ANCHORED full match against the tool name (verified: a bare
59
+ // `shell` did NOT match the real tool `shell_command`). So name the tool exactly.
60
+ const COMMAND_TOOLS_MATCHER = "shell_command";
61
+
62
+ // The hook table. Each entry → one Codex hook that reports a fixed pet state.
63
+ // `matcher` (when present) filters PreToolUse by tool name. `summary` is an
64
+ // optional short label shown in the bubble and in HAYA_PET_HOOK_DEBUG logs.
65
+ const HOOK_TABLE = Object.freeze([
66
+ { event: "UserPromptSubmit", state: "thinking" },
67
+ { event: "PreToolUse", matcher: EDIT_TOOLS_MATCHER, state: "editing_files" },
68
+ { event: "PreToolUse", matcher: COMMAND_TOOLS_MATCHER, state: "running_tool" },
69
+ { event: "PostToolUse", state: "thinking" },
70
+ { event: "PermissionRequest", state: "waiting_approval" },
71
+ { event: "PreCompact", state: "compacting" },
72
+ { event: "PostCompact", state: "thinking", summary: "compacted" },
73
+ // A subagent finishing is mid-turn — the main agent keeps working, so this is
74
+ // NOT idle. Turn-end is the dedicated `Stop` event below.
75
+ { event: "SubagentStart", state: "running_tool", summary: "subagent" },
76
+ { event: "SubagentStop", state: "thinking", summary: "subagent-done" },
77
+ { event: "Stop", state: "idle" }
78
+ ]);
79
+
80
+ // Resolve the pet state for a Codex event. `toolName` is the tool for PreToolUse.
81
+ // Exposed for testing and to keep the mapping in one place.
82
+ export function mapCodexEventToState(event, toolName) {
83
+ if (event === "PreToolUse") {
84
+ return EDIT_TOOLS.includes(toolName) ? "editing_files" : "running_tool";
85
+ }
86
+ const entry = HOOK_TABLE.find((row) => row.event === event && row.matcher === undefined);
87
+ return entry?.state;
88
+ }
89
+
90
+ // Build the hooks config object (hooks.json shape; also valid as the [hooks] table
91
+ // once serialized to TOML). Same structure as buildClaudeHookSettings, with one
92
+ // Windows-critical difference in command quoting (see below).
93
+ //
94
+ // CRITICAL (verified on Windows): Codex runs a hook `command` via `cmd /c "<cmd>"`,
95
+ // which strips the first and last quote if the string STARTS with a quote. So the
96
+ // Claude builder's `"<node>" "<cli>" …` form (leading quote on the program) gets
97
+ // mangled and the hook dies with "exited with code 1". The program (first token)
98
+ // must therefore be UNQUOTED; interior argument quotes are preserved fine.
99
+ //
100
+ // Caveat: an unquoted program breaks if nodePath itself contains spaces (e.g.
101
+ // "C:\Program Files\nodejs\node.exe"). The wrapper bakes the realpath-resolved
102
+ // node path, which is space-free for fnm/scoop/nvm layouts; a space-tolerant
103
+ // path (short 8.3 name, or `command_windows`) is a follow-up before shipping.
104
+ export function buildCodexHookSettings({ nodePath, cliPath }) {
105
+ const command = (state, summary) => {
106
+ // nodePath unquoted (must not lead with a quote); cliPath quoted for spaces.
107
+ const base = `${nodePath} ${quote(cliPath)} state ${state}`;
108
+ return summary ? `${base} --summary ${summary}` : base;
109
+ };
110
+
111
+ const hooks = {};
112
+ for (const row of HOOK_TABLE) {
113
+ const hookEntry = { hooks: [{ type: "command", command: command(row.state, row.summary) }] };
114
+ if (row.matcher !== undefined) {
115
+ hookEntry.matcher = row.matcher;
116
+ }
117
+ (hooks[row.event] ??= []).push(hookEntry);
118
+ }
119
+
120
+ return { hooks };
121
+ }
122
+
123
+ function quote(value) {
124
+ return `"${String(value).replace(/"/g, '\\"')}"`;
125
+ }
126
+
127
+ // Serialize a hook settings object (from buildCodexHookSettings) to the TOML form
128
+ // Codex loads from a config layer: `[[hooks.<Event>]]` (with optional `matcher`)
129
+ // each containing `[[hooks.<Event>.hooks]]` command entries. Pure (no I/O).
130
+ export function serializeCodexHooksToml(settings, { header } = {}) {
131
+ let toml = header ? `# ${header}\n` : "";
132
+ for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
133
+ for (const entry of entries) {
134
+ toml += `[[hooks.${event}]]\n`;
135
+ if (entry.matcher !== undefined) {
136
+ toml += `matcher = ${tomlString(entry.matcher)}\n`;
137
+ }
138
+ for (const hook of entry.hooks ?? []) {
139
+ toml += `[[hooks.${event}.hooks]]\n`;
140
+ toml += `type = ${tomlString(hook.type)}\n`;
141
+ toml += `command = ${tomlString(hook.command)}\n`;
142
+ }
143
+ toml += "\n";
144
+ }
145
+ }
146
+ return toml;
147
+ }
148
+
149
+ // TOML basic-string escaping: backslash and double-quote must be escaped.
150
+ function tomlString(value) {
151
+ return `"${String(value).split("\\").join("\\\\").split('"').join('\\"')}"`;
152
+ }
@@ -0,0 +1,73 @@
1
+ // Pure parser for Codex session JSONL records. This is the L3 fallback for live
2
+ // tool activity because Codex PreToolUse hooks may not fire in some builds, while
3
+ // the session transcript still records every tool call and tool result.
4
+
5
+ const EDIT_TOOLS = new Set(["apply_patch"]);
6
+
7
+ export function parseCodexTranscriptLine(line, options = {}) {
8
+ let entry;
9
+ try {
10
+ entry = JSON.parse(line);
11
+ } catch {
12
+ return undefined;
13
+ }
14
+
15
+ if (entry?.type !== "response_item") {
16
+ return undefined;
17
+ }
18
+
19
+ // Skip records from before the current session (used when replaying a
20
+ // freshly-discovered transcript so an earlier session's tool calls don't
21
+ // masquerade as live activity). Records without a parseable timestamp are
22
+ // kept — losing live events is worse than a rare stale one.
23
+ const minTimestampMs = options.minTimestampMs ?? 0;
24
+ if (minTimestampMs > 0 && typeof entry.timestamp === "string") {
25
+ const timestampMs = Date.parse(entry.timestamp);
26
+ if (Number.isFinite(timestampMs) && timestampMs < minTimestampMs) {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ const payload = entry.payload;
32
+ if (!payload || typeof payload !== "object") {
33
+ return undefined;
34
+ }
35
+
36
+ if (payload.type === "function_call" || payload.type === "custom_tool_call") {
37
+ const toolName = typeof payload.name === "string" ? payload.name : undefined;
38
+ const toolCallId = typeof payload.call_id === "string" ? payload.call_id : undefined;
39
+ if (!toolName || !toolCallId) {
40
+ return undefined;
41
+ }
42
+ return {
43
+ type: "tool_started",
44
+ toolCallId,
45
+ toolName,
46
+ state: EDIT_TOOLS.has(toolName) ? "editing_files" : "running_tool"
47
+ };
48
+ }
49
+
50
+ if (payload.type === "function_call_output" || payload.type === "custom_tool_call_output") {
51
+ const toolCallId = typeof payload.call_id === "string" ? payload.call_id : undefined;
52
+ if (!toolCallId) {
53
+ return undefined;
54
+ }
55
+ return { type: "tool_finished", toolCallId };
56
+ }
57
+
58
+ return undefined;
59
+ }
60
+
61
+ export function parseCodexTranscriptLines(lines, options = {}) {
62
+ const events = [];
63
+ for (const line of lines) {
64
+ if (typeof line !== "string" || line.trim() === "") {
65
+ continue;
66
+ }
67
+ const event = parseCodexTranscriptLine(line, options);
68
+ if (event) {
69
+ events.push(event);
70
+ }
71
+ }
72
+ return events;
73
+ }
@@ -0,0 +1,120 @@
1
+ // SPIKE tests for the Codex hook adapter prototype.
2
+ import assert from "node:assert/strict";
3
+ import { test } from "../../../test/harness.mjs";
4
+ import {
5
+ buildCodexHookSettings,
6
+ mapCodexEventToState,
7
+ serializeCodexHooksToml
8
+ } from "../src/codex-hooks.js";
9
+
10
+ test("mapCodexEventToState covers activity events", () => {
11
+ assert.equal(mapCodexEventToState("UserPromptSubmit"), "thinking");
12
+ assert.equal(mapCodexEventToState("PostToolUse"), "thinking");
13
+ assert.equal(mapCodexEventToState("PermissionRequest"), "waiting_approval");
14
+ assert.equal(mapCodexEventToState("PreCompact"), "compacting");
15
+ assert.equal(mapCodexEventToState("PostCompact"), "thinking");
16
+ assert.equal(mapCodexEventToState("SubagentStart"), "running_tool");
17
+ assert.equal(mapCodexEventToState("SubagentStop"), "thinking");
18
+ assert.equal(mapCodexEventToState("Stop"), "idle");
19
+ assert.equal(mapCodexEventToState("Unknown"), undefined);
20
+ });
21
+
22
+ test("mapCodexEventToState branches PreToolUse on tool name (apply_patch vs command)", () => {
23
+ assert.equal(mapCodexEventToState("PreToolUse", "apply_patch"), "editing_files");
24
+ assert.equal(mapCodexEventToState("PreToolUse", "shell_command"), "running_tool");
25
+ assert.equal(mapCodexEventToState("PreToolUse", "read_file"), "running_tool");
26
+ });
27
+
28
+ test("Stop is the only idle signal — SubagentStop stays working", () => {
29
+ // Regression guard for the key Codex-vs-Claude difference: a subagent finishing
30
+ // mid-turn must NOT flip the pet to idle.
31
+ assert.notEqual(mapCodexEventToState("SubagentStop"), "idle");
32
+ assert.equal(mapCodexEventToState("Stop"), "idle");
33
+ });
34
+
35
+ test("buildCodexHookSettings bakes node + cli, no volatile session id", () => {
36
+ const settings = buildCodexHookSettings({
37
+ nodePath: "/usr/bin/node",
38
+ cliPath: "/app/haya-pet.js"
39
+ });
40
+
41
+ const cmd = settings.hooks.UserPromptSubmit[0].hooks[0].command;
42
+ assert.equal(settings.hooks.UserPromptSubmit[0].hooks[0].type, "command");
43
+ // Program (node) must be UNQUOTED and must not lead with a quote — cmd /c on
44
+ // Windows strips a leading quote and breaks the hook. The cli path is quoted.
45
+ assert.doesNotMatch(cmd, /^"/);
46
+ assert.match(cmd, /^\/usr\/bin\/node /);
47
+ assert.match(cmd, /"\/app\/haya-pet\.js"/);
48
+ assert.match(cmd, /state thinking$/);
49
+ assert.doesNotMatch(JSON.stringify(settings), /--session/);
50
+ });
51
+
52
+ test("buildCodexHookSettings is stable across calls (for hook-trust caching)", () => {
53
+ const a = buildCodexHookSettings({ nodePath: "n", cliPath: "c" });
54
+ const b = buildCodexHookSettings({ nodePath: "n", cliPath: "c" });
55
+ assert.deepEqual(a, b);
56
+ });
57
+
58
+ test("buildCodexHookSettings splits PreToolUse into edit + command matchers", () => {
59
+ const pre = buildCodexHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PreToolUse;
60
+ assert.equal(pre.length, 2);
61
+ const edit = pre.find((e) => /editing_files/.test(e.hooks[0].command));
62
+ const other = pre.find((e) => /running_tool/.test(e.hooks[0].command));
63
+ assert.equal(edit.matcher, "apply_patch");
64
+ assert.equal(other.matcher, "shell_command");
65
+ });
66
+
67
+ test("no matcher uses look-around (Codex's Rust regex crate rejects it)", () => {
68
+ // Regression guard: a `(?!…)` / `(?=…)` matcher is a hard parse error in Codex
69
+ // and disables that hook. Keep all matchers look-around-free.
70
+ const settings = buildCodexHookSettings({ nodePath: "n", cliPath: "c" });
71
+ for (const entries of Object.values(settings.hooks)) {
72
+ for (const entry of entries) {
73
+ if (entry.matcher !== undefined) {
74
+ assert.doesNotMatch(entry.matcher, /\(\?[=!<]/, `look-around in matcher "${entry.matcher}"`);
75
+ }
76
+ }
77
+ }
78
+ });
79
+
80
+ test("serializeCodexHooksToml emits [[hooks.X]] tables with unquoted program", () => {
81
+ const settings = buildCodexHookSettings({
82
+ nodePath: "C:\\nodedir\\node.exe",
83
+ cliPath: "C:\\app\\haya-pet.js"
84
+ });
85
+ const toml = serializeCodexHooksToml(settings, { header: "test header" });
86
+
87
+ assert.match(toml, /^# test header\n/);
88
+ assert.match(toml, /\[\[hooks\.UserPromptSubmit\]\]/);
89
+ assert.match(toml, /\[\[hooks\.UserPromptSubmit\.hooks\]\]/);
90
+ assert.match(toml, /type = "command"/);
91
+ // matcher present for PreToolUse
92
+ assert.match(toml, /matcher = "apply_patch"/);
93
+ // The command value (a TOML basic string) must NOT start with an escaped quote
94
+ // right after the opening quote — i.e. the program is unquoted: command = "C:\\...
95
+ const cmdLine = toml.split("\n").find((l) => l.startsWith("command ="));
96
+ assert.doesNotMatch(cmdLine, /^command = "\\"/);
97
+ // Backslashes are TOML-escaped (doubled).
98
+ assert.match(toml, /C:\\\\nodedir\\\\node\.exe/);
99
+ });
100
+
101
+ test("serializeCodexHooksToml round-trips backslashes/quotes safely", () => {
102
+ const settings = { hooks: { Stop: [{ hooks: [{ type: "command", command: 'a\\b "c"' }] }] } };
103
+ const toml = serializeCodexHooksToml(settings);
104
+ // a\b "c" -> "a\\b \"c\""
105
+ assert.match(toml, /command = "a\\\\b \\"c\\""/);
106
+ });
107
+
108
+ test("buildCodexHookSettings includes Codex's event set (and omits Claude-only events)", () => {
109
+ const settings = buildCodexHookSettings({ nodePath: "n", cliPath: "c" });
110
+ for (const event of [
111
+ "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest",
112
+ "PreCompact", "PostCompact", "SubagentStart", "SubagentStop", "Stop"
113
+ ]) {
114
+ assert.ok(settings.hooks[event], `missing hook event ${event}`);
115
+ }
116
+ // Claude-only events Codex does not emit must not be registered.
117
+ for (const event of ["Notification", "PermissionDenied", "PostToolUseFailure", "StopFailure"]) {
118
+ assert.equal(settings.hooks[event], undefined, `unexpected Claude-only event ${event}`);
119
+ }
120
+ });