@hayasaka7/haya-pet 0.2.0 → 0.2.2

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.
Files changed (30) hide show
  1. package/.github/workflows/ci.yml +75 -0
  2. package/CHANGELOG.md +112 -0
  3. package/README.md +31 -14
  4. package/apps/cli/src/haya-pet.js +110 -21
  5. package/apps/cli/test/haya-pet.test.mjs +111 -7
  6. package/apps/companion/src/main/index.js +40 -1
  7. package/apps/companion/src/renderer/task-talk-window.js +1 -1
  8. package/apps/companion/test/position-store.test.mjs +1 -1
  9. package/docs/architecture.md +33 -10
  10. package/docs/cross-os-qa.md +72 -0
  11. package/docs/known-issues.md +92 -9
  12. package/docs/troubleshooting.md +3 -1
  13. package/eslint.config.js +32 -0
  14. package/package.json +7 -1
  15. package/packages/adapters/src/codex-hooks.js +152 -0
  16. package/packages/adapters/src/codex-transcript.js +73 -0
  17. package/packages/adapters/test/codex-hooks.test.mjs +120 -0
  18. package/packages/adapters/test/codex-transcript.test.mjs +97 -0
  19. package/packages/app-state/src/state.js +10 -5
  20. package/packages/cli-core/src/codex-hook-injection.js +49 -0
  21. package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
  22. package/packages/cli-core/src/run-command.js +0 -1
  23. package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
  24. package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
  25. package/packages/daemon-core/src/approval-process-watcher.js +169 -0
  26. package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
  27. package/packages/platform-core/src/process-snapshot.js +88 -0
  28. package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
  29. package/packages/session-core/src/bubble-view.js +10 -7
  30. package/packages/session-core/test/bubble-view.test.mjs +30 -5
@@ -0,0 +1,75 @@
1
+ name: CI
2
+
3
+ # Run code quality checks and the test suite on every push that touches code.
4
+ on:
5
+ push:
6
+ paths:
7
+ - "**/*.js"
8
+ - "**/*.mjs"
9
+ - "**/*.cjs"
10
+ - "package.json"
11
+ - "package-lock.json"
12
+ - ".github/workflows/ci.yml"
13
+ pull_request:
14
+ paths:
15
+ - "**/*.js"
16
+ - "**/*.mjs"
17
+ - "**/*.cjs"
18
+ - "package.json"
19
+ - "package-lock.json"
20
+ - ".github/workflows/ci.yml"
21
+
22
+ concurrency:
23
+ group: ci-${{ github.workflow }}-${{ github.ref }}
24
+ cancel-in-progress: true
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ lint:
31
+ name: Code quality (ESLint)
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+
36
+ - name: Set up Node.js
37
+ uses: actions/setup-node@v4
38
+ with:
39
+ node-version: 22
40
+ cache: npm
41
+
42
+ - name: Install dependencies
43
+ # Electron's binary isn't needed for linting or tests; skip the ~150 MB
44
+ # download so CI is fast and isn't at the mercy of the Electron CDN.
45
+ env:
46
+ ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
47
+ run: npm ci
48
+
49
+ - name: Run ESLint
50
+ run: npm run lint
51
+
52
+ test:
53
+ name: Tests (Node ${{ matrix.node }} on ${{ matrix.os }})
54
+ runs-on: ${{ matrix.os }}
55
+ strategy:
56
+ fail-fast: false
57
+ matrix:
58
+ os: [ubuntu-latest, windows-latest, macos-latest]
59
+ node: [20, 22]
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+
63
+ - name: Set up Node.js
64
+ uses: actions/setup-node@v4
65
+ with:
66
+ node-version: ${{ matrix.node }}
67
+ cache: npm
68
+
69
+ - name: Install dependencies
70
+ env:
71
+ ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
72
+ run: npm ci
73
+
74
+ - name: Run the test suite
75
+ run: npm test
package/CHANGELOG.md ADDED
@@ -0,0 +1,112 @@
1
+ # Changelog
2
+
3
+ All notable changes to Haya Pet are documented here. This project adheres to
4
+ [Semantic Versioning](https://semver.org/).
5
+
6
+ > Note: some entries originally drafted under 0.2.0 actually landed *after* the
7
+ > 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
8
+ > ships them.
9
+
10
+ ## [0.2.2]
11
+
12
+ ### Fixed
13
+ - **Session bubbles no longer reshuffle while sessions run.** Bubbles used to be
14
+ sorted by state urgency and latest activity, so every status change could move
15
+ a bubble up or down the stack mid-progress. They now stack by the time each
16
+ session **connected to the pet** — newest on top, first one at the bottom —
17
+ and that order stays fixed for the session's whole life. Urgency still shows
18
+ through each bubble's status icon, the collapsed-folder summary dot, and the
19
+ pet animation.
20
+
21
+ ### Internal
22
+ - **CI on every code push** — a new GitHub Actions workflow lints and runs the
23
+ test suite (Ubuntu + Windows + macOS, Node 20/22) for any push or PR touching
24
+ code.
25
+ - **ESLint adopted** (`npm run lint`, flat config); the few existing findings
26
+ were fixed with no behavior change.
27
+
28
+ ## [0.2.1]
29
+
30
+ ### Added
31
+ - **Approval-accept detection** — when you **approve** a permission prompt for a
32
+ command, the pet now flips from *waiting for approval* to *working* a couple of
33
+ seconds after the command actually starts, instead of showing "waiting" for the
34
+ tool's whole run. Clients emit **no event at the accept moment** (verified for
35
+ Claude Code: no hook, no transcript record; `PostToolUse` only fires when the
36
+ tool *finishes*) — so for a long approved build/test the pet used to sit on
37
+ "waiting" for minutes. Detection is **event-based, never a timer**: while a
38
+ session waits, the companion watches the client's **process tree**, and only a
39
+ new process that verifiably starts under the client (and survives two
40
+ consecutive polls, filtering hook blips) counts as an approval. An unanswered
41
+ prompt spawns nothing, so the warning stays up until you actually decide.
42
+ In-process approvals (file edits) aren't detected but complete in milliseconds
43
+ after approval anyway. Windows verified live; macOS (`ps`) and Linux (`/proc`)
44
+ listers are included pending live hardware verification. Details in
45
+ `docs/known-issues.md`.
46
+ - **`haya-pet hooks on` / `off` / `status`** — persists the live-status preference,
47
+ so you enable it once instead of setting an env var every shell. The toggle is
48
+ **global**: it covers every hook-capable client (Claude Code and Codex).
49
+ `HAYA_PET_HOOKS=1` (on) / `HAYA_PET_NO_HOOKS=1` (off) still work as per-run overrides.
50
+ - **Codex live status via per-session hooks** (opt-in: `haya-pet hooks on`). haya-pet
51
+ injects a stable `~/.codex/haya-pet.config.toml` profile and launches
52
+ `codex -p haya-pet`, layering the hooks on top of your base config (auth/model/MCP
53
+ untouched). The hooks report through the same `haya-pet state` reporter, with full
54
+ terminal fidelity. First run shows Codex's one-time *review hooks* prompt; approve
55
+ it once. If you already pass your own `-p/--profile`, haya-pet skips injection and
56
+ says so (Codex allows only one profile). Hooks cover `thinking` (turn start /
57
+ after tools) and `idle` (turn end); a **Codex transcript watcher** fills in tool
58
+ activity (`running_tool` / `editing_files`) by tailing the session JSONL, since
59
+ Codex's `PreToolUse` hook doesn't fire upstream yet
60
+ ([openai/codex#16732](https://github.com/openai/codex/issues/16732)).
61
+ *Waiting for approval* stays unavailable for Codex until that lands.
62
+ - **L3 transcript watcher (Claude Code)** — tails Claude's session JSONL to reliably
63
+ clear *waiting for approval* when a permission is **denied** (Claude fires no hook
64
+ on a manual denial). Ground-truth based, never a timer, so a genuinely-pending
65
+ approval keeps alerting until you actually decide.
66
+ - **`PermissionRequest` hook** for a snappier *waiting for approval* cue (fires the
67
+ instant the dialog appears, ahead of the notification).
68
+
69
+ ### Fixed
70
+ - Pet stuck on *waiting for approval* after a manual **denial** (see the Claude
71
+ transcript watcher above).
72
+ - Pet stuck on *waiting for approval* after an **accept**, for as long as the
73
+ approved tool kept running (see approval-accept detection above).
74
+ - `Notification` events other than permission prompts (e.g. `idle_prompt`) were
75
+ mislabeled as *waiting for approval*; they are now mapped correctly.
76
+
77
+ ## [0.2.0]
78
+
79
+ ### Changed
80
+ - **`haya-pet run` now defaults to native passthrough** (`stdio: "inherit"`). The
81
+ wrapped CLI talks directly to your terminal, so **Shift+Tab**, mouse-wheel
82
+ scroll, and word-edit all work normally. PTY observation is now opt-in via
83
+ `--observe` (it routes input through ConPTY on Windows, which can mangle special
84
+ keys — use it only for non-interactive runs).
85
+
86
+ ### Added
87
+ - **Claude Code live status via per-session hooks** (opt-in: `HAYA_PET_HOOKS=1` in
88
+ this release; 0.2.1 adds the persisted `haya-pet hooks on` toggle).
89
+ Injects a stable settings file through `claude --settings <file>` — no change to
90
+ your global config — wiring Claude's events to a new `haya-pet state` reporter so
91
+ the pet shows thinking / running tools / editing files / waiting for approval,
92
+ with full terminal fidelity (no PTY). First run shows Claude's one-time
93
+ *review hooks* prompt; approve it once.
94
+ - **`haya-pet state <state>` command** — reporter used by client hooks to push live
95
+ status to the daemon over IPC.
96
+ - **`HAYA_PET_HOOK_DEBUG=<file>`** — append one JSONL line per status event
97
+ (hook- and transcript-sourced) for diagnostics.
98
+
99
+ ### Fixed
100
+ - Claude Code TUI accepted no keyboard input when hooks were injected — caused by a
101
+ volatile per-session argument and temp path that re-triggered Claude's hook-trust
102
+ review every launch. Hook commands and the settings path are now stable; the
103
+ session id is passed via the `HAYA_PET_SESSION_ID` env var.
104
+
105
+ ### Notes
106
+ - In this release Codex and Antigravity had no hook adapter — native passthrough
107
+ with lifecycle status, or `--observe` for coarse PTY activity. 0.2.1 adds the
108
+ Codex adapter; Antigravity remains a planned follow-up.
109
+
110
+ ## [0.1.0]
111
+ - Initial generic AI CLI pet runtime: overlay companion, session bubbles, daemon
112
+ IPC, client adapters, pet asset pipeline, cross-OS paths.
package/README.md CHANGED
@@ -38,8 +38,9 @@ Haya Pet watches all of them and presents one ambient interface:
38
38
  draggable, and position-persistent like a real desktop companion.
39
39
  - **Session bubbles** — one compact bubble per active session showing client,
40
40
  project, the latest activity, and a status icon (a spinning *working* circle, a
41
- green *done* check, a yellow *needs you*, or a red *failed* cross). A folder
42
- button beside the pet folds them away.
41
+ green *done* check, a yellow *needs you*, or a red *failed* cross). Bubbles stack
42
+ by connect time — the newest session on top — so the stack never reshuffles while
43
+ work is in progress. A folder button beside the pet folds them away.
43
44
 
44
45
  ## Features
45
46
 
@@ -52,8 +53,8 @@ Haya Pet watches all of them and presents one ambient interface:
52
53
  (`thinking`, `running_tool`, `waiting_approval`, `reviewing`, `failed`, …).
53
54
  - 🧩 **Client adapters** with tiered support (process wrapper → PTY observer →
54
55
  client hooks) so the daemon never bakes in client-specific logic. Default is
55
- lifecycle status; richer status is opt-in (Claude Code hooks via `HAYA_PET_HOOKS=1`,
56
- or PTY `--observe` for any client).
56
+ lifecycle status; richer status is opt-in (Claude Code / Codex hooks via
57
+ `haya-pet hooks on`, or PTY `--observe` for any client).
57
58
  - 🚀 **Zero-setup launch** — `haya-pet run …` auto-starts the overlay; no separate
58
59
  daemon to manage.
59
60
  - 🖼️ **Codex-compatible pet assets** (1536×1872 sprite atlas, 9 actions).
@@ -94,8 +95,8 @@ Haya Pet watches all of them and presents one ambient interface:
94
95
  | **Node ≥ 18** | Runtime + companion (Electron) |
95
96
  | **npm** | Install + scripts |
96
97
 
97
- > Default status is lifecycle-only and needs no extra modules. Opt-in Claude Code
98
- > hooks (`HAYA_PET_HOOKS=1`) also need none. The opt-in `--observe` PTY mode uses
98
+ > Default status is lifecycle-only and needs no extra modules. Opt-in Claude Code /
99
+ > Codex hooks (`haya-pet hooks on`) also need none. The opt-in `--observe` PTY mode uses
99
100
  > `node-pty` (installed automatically when it can build; without it, `--observe`
100
101
  > degrades to lifecycle-only tracking).
101
102
 
@@ -157,11 +158,12 @@ Two **opt-in** ways to get richer *in-session* status (thinking / running tools
157
158
  editing files / waiting for approval):
158
159
 
159
160
  ```bash
160
- # Claude Code — live status via per-session hooks, NO terminal-fidelity tradeoff.
161
- # Enable once (persisted); the first run shows a one-time Claude "review hooks"
162
- # prompt you approve once.
161
+ # Claude Code AND Codex — live status via per-session hooks, NO terminal-fidelity
162
+ # tradeoff. Enable once (persisted, global); the first run for each client shows a
163
+ # one-time "review hooks" prompt you approve once.
163
164
  haya-pet hooks on
164
165
  haya-pet run --client claude-code -- claude
166
+ haya-pet run --client codex -- codex
165
167
  # (per-run override without persisting: HAYA_PET_HOOKS=1 …, or $env:HAYA_PET_HOOKS=1 in PowerShell)
166
168
  # (turn back off: haya-pet hooks off · check: haya-pet hooks status)
167
169
 
@@ -169,10 +171,25 @@ haya-pet run --client claude-code -- claude
169
171
  haya-pet run --observe --client codex -- codex
170
172
  ```
171
173
 
174
+ > **Codex coverage.** Codex shows `thinking` (working) and `idle` (done) via hooks,
175
+ > plus `running_tool` / `editing_files` via a session-transcript watcher.
176
+ > *Waiting for approval* doesn't arrive yet because of an upstream gap where
177
+ > Codex's `PermissionRequest` hook doesn't fire
178
+ > ([openai/codex#16732](https://github.com/openai/codex/issues/16732)); it'll start
179
+ > working automatically once Codex fixes it. Also: if you pass your own
180
+ > `-p/--profile` to codex, haya-pet skips hook injection (Codex allows one
181
+ > profile) and tells you. Claude Code has full coverage.
182
+
183
+ > **Approval prompts resolve correctly** (Claude Code): deny → the pet returns to
184
+ > idle the moment the denial lands in the session transcript; accept a command →
185
+ > the pet flips to *working* a couple of seconds after the approved command
186
+ > actually starts running (detected from the client's process tree — a real
187
+ > event, never a timeout, so an unanswered prompt keeps warning until you decide).
188
+
172
189
  > **Why opt-in?**
173
- > - **Hooks (Claude Code):** injecting hooks makes Claude show a one-time
174
- > *review hooks* trust prompt. We don't disrupt your session by default; turn it
175
- > on once with `haya-pet hooks on` when you're happy to approve the hooks.
190
+ > - **Hooks (Claude Code / Codex):** injecting hooks makes the client show a
191
+ > one-time *review hooks* trust prompt. We don't disrupt your session by default;
192
+ > turn it on once with `haya-pet hooks on` when you're happy to approve the hooks.
176
193
  > - **`--observe` (any client):** PTY observation infers status from output, but on
177
194
  > Windows it routes input through ConPTY, which can break **Shift+Tab**, mouse
178
195
  > scroll, and word-edit. Use it only for non-interactive runs. See
@@ -246,8 +263,8 @@ Full list (incl. repairing a broken Electron install): [docs/troubleshooting.md]
246
263
  | Client | Status | Support level |
247
264
  |---|---|---|
248
265
  | Generic CLI | ✅ | L1 process wrapper (+ L2 PTY via `--observe`) |
249
- | Codex | ✅ | L1 wrapper (+ L2 PTY via `--observe`) |
250
- | Claude Code | ✅ | L1 wrapper + **L4 live-status hooks** (opt-in `HAYA_PET_HOOKS=1`) |
266
+ | Codex | ✅ | L1 wrapper + **L4 live-status hooks** (opt-in `haya-pet hooks on`; partial — see note) |
267
+ | Claude Code | ✅ | L1 wrapper + **L4 live-status hooks** (opt-in `haya-pet hooks on`) |
251
268
  | Antigravity | ✅ | L1 wrapper (+ L2 PTY via `--observe`) |
252
269
  | Gemini CLI / Aider / others | 🔜 | via the generic adapter |
253
270
 
@@ -6,13 +6,15 @@ import { fileURLToPath } from "node:url";
6
6
  import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
7
7
  import { parseStateArgs, runStateCommand } from "../../../packages/cli-core/src/run-state.js";
8
8
  import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages/cli-core/src/claude-hook-injection.js";
9
+ import { injectCodexHooks as defaultInjectCodexHooks } from "../../../packages/cli-core/src/codex-hook-injection.js";
9
10
  import { watchClaudeTranscript as defaultWatchClaudeTranscript } from "../../../packages/cli-core/src/claude-transcript-watcher.js";
11
+ import { watchCodexTranscript as defaultWatchCodexTranscript } from "../../../packages/cli-core/src/codex-transcript-watcher.js";
10
12
  import { ensureCompanionConnection } from "../../../packages/cli-core/src/companion-launcher.js";
11
13
  import { createIpcClient as defaultCreateIpcClient } from "../../../packages/daemon-core/src/ipc-server.js";
12
14
  import { getDefaultPaths } from "../../../packages/platform-core/src/paths.js";
13
15
  import { discoverPets as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
14
16
  import { createStateFile as defaultCreateStateFile } from "../../../packages/app-state/src/state-file.js";
15
- import { getSelectedPetId, setSelectedPet, getClaudeHooksEnabled, setClaudeHooksEnabled } from "../../../packages/app-state/src/state.js";
17
+ import { getSelectedPetId, setSelectedPet, getHooksEnabled, setHooksEnabled } from "../../../packages/app-state/src/state.js";
16
18
  import { getAdapterInfo } from "../../../packages/adapters/src/adapter-info.js";
17
19
 
18
20
  const CLIENT_DISPLAY_NAMES = Object.freeze({
@@ -123,7 +125,10 @@ export async function runStartCommand(_parsed, dependencies = {}) {
123
125
  async function runRunCommand(parsed, dependencies) {
124
126
  const runGenericCommand = dependencies.runGenericCommand ?? defaultRunGenericCommand;
125
127
  const injectClaudeHooks = dependencies.injectClaudeHooks ?? defaultInjectClaudeHooks;
128
+ const injectCodexHooks = dependencies.injectCodexHooks ?? defaultInjectCodexHooks;
126
129
  const watchClaudeTranscript = dependencies.watchClaudeTranscript ?? defaultWatchClaudeTranscript;
130
+ const watchCodexTranscript = dependencies.watchCodexTranscript ?? defaultWatchCodexTranscript;
131
+ const print = dependencies.print ?? defaultPrint;
127
132
  const env = dependencies.env ?? process.env;
128
133
  const now = dependencies.now ?? Date.now;
129
134
  const cwd = dependencies.cwd ?? process.cwd();
@@ -135,14 +140,16 @@ async function runRunCommand(parsed, dependencies) {
135
140
  let cleanup = () => {};
136
141
  let stopWatcher = () => {};
137
142
 
138
- // Claude Code: native passthrough is always the default (full terminal fidelity).
139
- // Live-status hooks are OPT-IN — persisted via `haya-pet hooks on`, or per-run via
140
- // HAYA_PET_HOOKS=1 — because injecting hooks makes Claude show a one-time "review
141
- // hooks" trust prompt; we never disrupt the user's session uninvited. When enabled,
142
- // inject a stable settings file so Claude reports live status via `haya-pet state`
143
- // (no PTY, so Shift+Tab works).
144
- const claudeHooksOn =
145
- parsed.clientId === "claude-code" && (await resolveClaudeHooksEnabled(env, dependencies));
143
+ // Native passthrough is always the default (full terminal fidelity). Live-status
144
+ // hooks are OPT-IN — persisted via `haya-pet hooks on`, or per-run via
145
+ // HAYA_PET_HOOKS=1 — because injecting hooks makes the client show a one-time
146
+ // "review hooks" trust prompt; we never disrupt the user's session uninvited.
147
+ // Both clients report live status via the `haya-pet state` reporter (no PTY, so
148
+ // Shift+Tab works); the session id rides in via HAYA_PET_SESSION_ID.
149
+ const hooksOn = await resolveHooksEnabled(env, dependencies);
150
+
151
+ // Claude Code: inject a stable `--settings` file.
152
+ const claudeHooksOn = hooksOn && parsed.clientId === "claude-code";
146
153
  if (claudeHooksOn) {
147
154
  const injected = injectClaudeHooks();
148
155
  childArgs = [...parsed.childArgs, "--settings", injected.settingsPath];
@@ -175,6 +182,78 @@ async function runRunCommand(parsed, dependencies) {
175
182
  stopWatcher = watcher.stop;
176
183
  }
177
184
 
185
+ // Codex: no `--settings` equivalent, so inject a stable profile and add
186
+ // `-p <name>` at the FRONT (a global flag must precede any subcommand). Codex
187
+ // takes only one profile, so if the user already passes their own -p/--profile
188
+ // we skip injection and say so rather than clobber their choice. Codex
189
+ // PreToolUse is not reliable, so a transcript watcher supplies tool activity.
190
+ const codexHooksOn = hooksOn && parsed.clientId === "codex";
191
+ if (codexHooksOn) {
192
+ if (hasProfileArg(parsed.childArgs)) {
193
+ print(
194
+ "haya-pet: Codex live-status hooks skipped — you passed your own -p/--profile (Codex allows only one)."
195
+ );
196
+ } else {
197
+ const injected = injectCodexHooks();
198
+ childArgs = ["-p", injected.profileName, ...parsed.childArgs];
199
+ childEnv = { ...env, HAYA_PET_SESSION_ID: sessionId };
200
+ cleanup = injected.cleanup;
201
+
202
+ const activeToolCalls = new Set();
203
+ const watcher = watchCodexTranscript({
204
+ homeDir: dependencies.homeDir,
205
+ sessionsRoot: dependencies.codexSessionsRoot,
206
+ startedAt: now(),
207
+ onToolEvent: (event) => {
208
+ hookDebugLog(env, now, {
209
+ source: "codex_transcript",
210
+ event: event.type,
211
+ toolCallId: event.toolCallId,
212
+ toolName: event.toolName,
213
+ state: event.state
214
+ });
215
+
216
+ if (event.type === "tool_started") {
217
+ activeToolCalls.add(event.toolCallId);
218
+ messageSender
219
+ .send({
220
+ type: "state",
221
+ sessionId,
222
+ state: event.state,
223
+ summary: event.toolName,
224
+ confidence: 0.85,
225
+ source: "client_log",
226
+ updatedAt: now()
227
+ })
228
+ .catch(() => {});
229
+ return;
230
+ }
231
+
232
+ if (event.type === "tool_finished") {
233
+ activeToolCalls.delete(event.toolCallId);
234
+ if (activeToolCalls.size === 0) {
235
+ messageSender
236
+ .send({
237
+ type: "state",
238
+ sessionId,
239
+ state: "thinking",
240
+ confidence: 0.85,
241
+ source: "client_log",
242
+ updatedAt: now()
243
+ })
244
+ .catch(() => {});
245
+ }
246
+ }
247
+ }
248
+ });
249
+ const previousStopWatcher = stopWatcher;
250
+ stopWatcher = () => {
251
+ watcher.stop();
252
+ previousStopWatcher();
253
+ };
254
+ }
255
+ }
256
+
178
257
  try {
179
258
  return await runGenericCommand({
180
259
  command: parsed.childCommand,
@@ -197,10 +276,11 @@ async function runRunCommand(parsed, dependencies) {
197
276
  }
198
277
  }
199
278
 
200
- // Resolve whether Claude Code hooks should be injected for this run.
201
- // Precedence: HAYA_PET_NO_HOOKS forces off, HAYA_PET_HOOKS forces on (per-run
202
- // overrides), otherwise the persisted `haya-pet hooks on/off` preference.
203
- async function resolveClaudeHooksEnabled(env, dependencies) {
279
+ // Resolve whether live-status hooks should be injected for this run (any
280
+ // hook-capable client). Precedence: HAYA_PET_NO_HOOKS forces off, HAYA_PET_HOOKS
281
+ // forces on (per-run overrides), otherwise the persisted `haya-pet hooks on/off`
282
+ // preference.
283
+ async function resolveHooksEnabled(env, dependencies) {
204
284
  if (isTruthyFlag(env.HAYA_PET_NO_HOOKS)) {
205
285
  return false;
206
286
  }
@@ -209,7 +289,7 @@ async function resolveClaudeHooksEnabled(env, dependencies) {
209
289
  }
210
290
  try {
211
291
  const state = await createConfigStateFile(dependencies).load();
212
- return getClaudeHooksEnabled(state);
292
+ return getHooksEnabled(state);
213
293
  } catch {
214
294
  return false;
215
295
  }
@@ -219,6 +299,14 @@ function isTruthyFlag(value) {
219
299
  return value === "1" || value === "true";
220
300
  }
221
301
 
302
+ // Detect a user-supplied Codex profile flag so we don't clobber it: -p, --profile,
303
+ // or the `--profile=foo` / `-p=foo` forms.
304
+ function hasProfileArg(args) {
305
+ return args.some(
306
+ (arg) => arg === "-p" || arg === "--profile" || arg.startsWith("--profile=") || arg.startsWith("-p=")
307
+ );
308
+ }
309
+
222
310
  function createConfigStateFile(dependencies) {
223
311
  const paths = getDefaultPaths({
224
312
  platform: dependencies.platform,
@@ -310,25 +398,26 @@ function parseHooksArgs(args) {
310
398
  throw new Error(`Unknown hooks action: ${action} (use on, off, or status)`);
311
399
  }
312
400
 
313
- // Persisted toggle for Claude Code live-status hooks (the convenient alternative
314
- // to setting HAYA_PET_HOOKS every shell).
401
+ // Persisted GLOBAL toggle for live-status hooks (the convenient alternative to
402
+ // setting HAYA_PET_HOOKS every shell). Covers every hook-capable client — Claude
403
+ // Code and Codex today.
315
404
  export async function runHooksCommand(parsed, dependencies = {}) {
316
405
  const print = dependencies.print ?? defaultPrint;
317
406
  const stateFile = createConfigStateFile(dependencies);
318
407
  const state = await stateFile.load();
319
408
 
320
409
  if (parsed.action === "status") {
321
- const enabled = getClaudeHooksEnabled(state);
322
- print(`Claude Code live-status hooks: ${enabled ? "on" : "off"}`);
410
+ const enabled = getHooksEnabled(state);
411
+ print(`Live-status hooks: ${enabled ? "on" : "off"}`);
323
412
  return { command: "hooks", action: "status", enabled };
324
413
  }
325
414
 
326
415
  const enabled = parsed.action === "on";
327
- await stateFile.save(setClaudeHooksEnabled(state, enabled));
416
+ await stateFile.save(setHooksEnabled(state, enabled));
328
417
  print(
329
418
  enabled
330
- ? "Claude Code live-status hooks: on. The first `haya-pet run --client claude-code` asks Claude to review the hooks once — approve it."
331
- : "Claude Code live-status hooks: off."
419
+ ? "Live-status hooks: on. The first `haya-pet run` for Claude Code or Codex asks the client to review the hooks once — approve it."
420
+ : "Live-status hooks: off."
332
421
  );
333
422
  return { command: "hooks", action: parsed.action, enabled };
334
423
  }
@@ -335,8 +335,8 @@ test("parses the state command", () => {
335
335
  });
336
336
  });
337
337
 
338
- const hooksStateFile = (claudeHooks) => () => ({
339
- load: async () => ({ settings: { claudeHooks } }),
338
+ const hooksStateFile = (hooksEnabled) => () => ({
339
+ load: async () => ({ settings: { hooksEnabled } }),
340
340
  save: async (state) => state
341
341
  });
342
342
 
@@ -409,7 +409,7 @@ test("hooks command parses and persists the toggle", async () => {
409
409
  let saved;
410
410
  const lines = [];
411
411
  const store = {
412
- load: async () => ({ settings: { claudeHooks: false } }),
412
+ load: async () => ({ settings: { hooksEnabled: false } }),
413
413
  save: async (state) => { saved = state; return state; }
414
414
  };
415
415
  const result = await runAiPet(["hooks", "on"], {
@@ -419,10 +419,113 @@ test("hooks command parses and persists the toggle", async () => {
419
419
  });
420
420
 
421
421
  assert.equal(result.enabled, true);
422
- assert.equal(saved.settings.claudeHooks, true);
422
+ assert.equal(saved.settings.hooksEnabled, true);
423
423
  assert.ok(lines.some((l) => l.includes("on")));
424
424
  });
425
425
 
426
+ test("persisted `hooks on` injects a Codex profile via -p at the front of args", async () => {
427
+ const calls = [];
428
+ let injected = 0;
429
+ await runAiPet(["run", "--client", "codex", "--", "codex"], {
430
+ cwd: process.cwd(),
431
+ env: { USERPROFILE: "C:\\Users\\A" }, // no HAYA_PET_HOOKS
432
+ heartbeatIntervalMs: 10,
433
+ send: async () => {},
434
+ createStateFile: hooksStateFile(true),
435
+ injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
436
+ runGenericCommand: async (options) => {
437
+ calls.push(options);
438
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
439
+ }
440
+ });
441
+
442
+ assert.equal(injected, 1, "config preference enables Codex hooks");
443
+ assert.deepEqual(calls[0].args, ["-p", "haya-pet"], "profile flag goes at the front");
444
+ });
445
+
446
+ test("codex hooks also start a transcript watcher for tool activity", async () => {
447
+ const sent = [];
448
+ let fireToolEvent;
449
+ let stopped = false;
450
+
451
+ await runAiPet(["run", "--client", "codex", "--", "codex"], {
452
+ cwd: process.cwd(),
453
+ env: { USERPROFILE: "C:\\Users\\A" },
454
+ now: () => 42,
455
+ heartbeatIntervalMs: 10,
456
+ send: async (message) => sent.push(message),
457
+ createStateFile: hooksStateFile(true),
458
+ injectCodexHooks: () => ({ profileName: "haya-pet", cleanup: () => {} }),
459
+ watchCodexTranscript: ({ onToolEvent }) => {
460
+ fireToolEvent = onToolEvent;
461
+ return { stop: () => { stopped = true; } };
462
+ },
463
+ runGenericCommand: async (options) => {
464
+ fireToolEvent({
465
+ type: "tool_started",
466
+ toolCallId: "call_shell",
467
+ toolName: "shell_command",
468
+ state: "running_tool"
469
+ });
470
+ fireToolEvent({
471
+ type: "tool_finished",
472
+ toolCallId: "call_shell"
473
+ });
474
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
475
+ }
476
+ });
477
+
478
+ assert.ok(stopped, "transcript watcher is stopped after the wrapped command exits");
479
+ assert.deepEqual(
480
+ sent.filter((message) => message.type === "state" && message.source === "client_log").map((message) => message.state),
481
+ ["running_tool", "thinking"]
482
+ );
483
+ assert.ok(sent.every((message) => message.updatedAt === undefined || message.updatedAt === 42));
484
+ });
485
+
486
+ test("codex hooks are skipped (with a notice) when the user passes their own -p", async () => {
487
+ const calls = [];
488
+ let injected = 0;
489
+ const lines = [];
490
+ await runAiPet(["run", "--client", "codex", "--", "codex", "-p", "mine"], {
491
+ cwd: process.cwd(),
492
+ env: { USERPROFILE: "C:\\Users\\A" },
493
+ heartbeatIntervalMs: 10,
494
+ send: async () => {},
495
+ createStateFile: hooksStateFile(true),
496
+ print: (line) => lines.push(line),
497
+ injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
498
+ runGenericCommand: async (options) => {
499
+ calls.push(options);
500
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
501
+ }
502
+ });
503
+
504
+ assert.equal(injected, 0, "user's profile is respected — no injection");
505
+ assert.deepEqual(calls[0].args, ["-p", "mine"], "user args untouched");
506
+ assert.ok(lines.some((l) => /skipped/i.test(l)), "user is told why");
507
+ });
508
+
509
+ test("codex does NOT inject hooks by default (safe out-of-box)", async () => {
510
+ const calls = [];
511
+ let injected = 0;
512
+ await runAiPet(["run", "--client", "codex", "--", "codex"], {
513
+ cwd: process.cwd(),
514
+ env: { USERPROFILE: "C:\\Users\\A" },
515
+ heartbeatIntervalMs: 10,
516
+ send: async () => {},
517
+ createStateFile: hooksStateFile(false),
518
+ injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
519
+ runGenericCommand: async (options) => {
520
+ calls.push(options);
521
+ return { sessionId: "s", pid: 1, exitCode: 0 };
522
+ }
523
+ });
524
+
525
+ assert.equal(injected, 0, "no hook injection unless opted in");
526
+ assert.deepEqual(calls[0].args, []);
527
+ });
528
+
426
529
  test("HAYA_PET_HOOKS=1 opts claude-code into --settings + HAYA_PET_SESSION_ID", async () => {
427
530
  const calls = [];
428
531
  let watched = 0;
@@ -471,14 +574,15 @@ test("a transcript denial clears the stuck approval to idle", async () => {
471
574
  assert.equal(idle.updatedAt, 42);
472
575
  });
473
576
 
474
- test("non-claude clients are never injected even with HAYA_PET_HOOKS=1", async () => {
577
+ test("non-hook-capable clients are never injected even with HAYA_PET_HOOKS=1", async () => {
475
578
  const calls = [];
476
- await runAiPet(["run", "--client", "codex", "--", "codex"], {
579
+ await runAiPet(["run", "--client", "generic", "--", "aider"], {
477
580
  cwd: process.cwd(),
478
581
  env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
479
582
  heartbeatIntervalMs: 10,
480
583
  send: async () => {},
481
- injectClaudeHooks: () => { throw new Error("should not inject for codex"); },
584
+ injectClaudeHooks: () => { throw new Error("should not inject for generic"); },
585
+ injectCodexHooks: () => { throw new Error("should not inject for generic"); },
482
586
  runGenericCommand: async (options) => {
483
587
  calls.push(options);
484
588
  return { sessionId: "s", pid: 1, exitCode: 0 };