@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,94 @@
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.1]
11
+
12
+ ### Added
13
+ - **Approval-accept detection** — when you **approve** a permission prompt for a
14
+ command, the pet now flips from *waiting for approval* to *working* a couple of
15
+ seconds after the command actually starts, instead of showing "waiting" for the
16
+ tool's whole run. Clients emit **no event at the accept moment** (verified for
17
+ Claude Code: no hook, no transcript record; `PostToolUse` only fires when the
18
+ tool *finishes*) — so for a long approved build/test the pet used to sit on
19
+ "waiting" for minutes. Detection is **event-based, never a timer**: while a
20
+ session waits, the companion watches the client's **process tree**, and only a
21
+ new process that verifiably starts under the client (and survives two
22
+ consecutive polls, filtering hook blips) counts as an approval. An unanswered
23
+ prompt spawns nothing, so the warning stays up until you actually decide.
24
+ In-process approvals (file edits) aren't detected but complete in milliseconds
25
+ after approval anyway. Windows verified live; macOS (`ps`) and Linux (`/proc`)
26
+ listers are included pending live hardware verification. Details in
27
+ `docs/known-issues.md`.
28
+ - **`haya-pet hooks on` / `off` / `status`** — persists the live-status preference,
29
+ so you enable it once instead of setting an env var every shell. The toggle is
30
+ **global**: it covers every hook-capable client (Claude Code and Codex).
31
+ `HAYA_PET_HOOKS=1` (on) / `HAYA_PET_NO_HOOKS=1` (off) still work as per-run overrides.
32
+ - **Codex live status via per-session hooks** (opt-in: `haya-pet hooks on`). haya-pet
33
+ injects a stable `~/.codex/haya-pet.config.toml` profile and launches
34
+ `codex -p haya-pet`, layering the hooks on top of your base config (auth/model/MCP
35
+ untouched). The hooks report through the same `haya-pet state` reporter, with full
36
+ terminal fidelity. First run shows Codex's one-time *review hooks* prompt; approve
37
+ it once. If you already pass your own `-p/--profile`, haya-pet skips injection and
38
+ says so (Codex allows only one profile). Hooks cover `thinking` (turn start /
39
+ after tools) and `idle` (turn end); a **Codex transcript watcher** fills in tool
40
+ activity (`running_tool` / `editing_files`) by tailing the session JSONL, since
41
+ Codex's `PreToolUse` hook doesn't fire upstream yet
42
+ ([openai/codex#16732](https://github.com/openai/codex/issues/16732)).
43
+ *Waiting for approval* stays unavailable for Codex until that lands.
44
+ - **L3 transcript watcher (Claude Code)** — tails Claude's session JSONL to reliably
45
+ clear *waiting for approval* when a permission is **denied** (Claude fires no hook
46
+ on a manual denial). Ground-truth based, never a timer, so a genuinely-pending
47
+ approval keeps alerting until you actually decide.
48
+ - **`PermissionRequest` hook** for a snappier *waiting for approval* cue (fires the
49
+ instant the dialog appears, ahead of the notification).
50
+
51
+ ### Fixed
52
+ - Pet stuck on *waiting for approval* after a manual **denial** (see the Claude
53
+ transcript watcher above).
54
+ - Pet stuck on *waiting for approval* after an **accept**, for as long as the
55
+ approved tool kept running (see approval-accept detection above).
56
+ - `Notification` events other than permission prompts (e.g. `idle_prompt`) were
57
+ mislabeled as *waiting for approval*; they are now mapped correctly.
58
+
59
+ ## [0.2.0]
60
+
61
+ ### Changed
62
+ - **`haya-pet run` now defaults to native passthrough** (`stdio: "inherit"`). The
63
+ wrapped CLI talks directly to your terminal, so **Shift+Tab**, mouse-wheel
64
+ scroll, and word-edit all work normally. PTY observation is now opt-in via
65
+ `--observe` (it routes input through ConPTY on Windows, which can mangle special
66
+ keys — use it only for non-interactive runs).
67
+
68
+ ### Added
69
+ - **Claude Code live status via per-session hooks** (opt-in: `HAYA_PET_HOOKS=1` in
70
+ this release; 0.2.1 adds the persisted `haya-pet hooks on` toggle).
71
+ Injects a stable settings file through `claude --settings <file>` — no change to
72
+ your global config — wiring Claude's events to a new `haya-pet state` reporter so
73
+ the pet shows thinking / running tools / editing files / waiting for approval,
74
+ with full terminal fidelity (no PTY). First run shows Claude's one-time
75
+ *review hooks* prompt; approve it once.
76
+ - **`haya-pet state <state>` command** — reporter used by client hooks to push live
77
+ status to the daemon over IPC.
78
+ - **`HAYA_PET_HOOK_DEBUG=<file>`** — append one JSONL line per status event
79
+ (hook- and transcript-sourced) for diagnostics.
80
+
81
+ ### Fixed
82
+ - Claude Code TUI accepted no keyboard input when hooks were injected — caused by a
83
+ volatile per-session argument and temp path that re-triggered Claude's hook-trust
84
+ review every launch. Hook commands and the settings path are now stable; the
85
+ session id is passed via the `HAYA_PET_SESSION_ID` env var.
86
+
87
+ ### Notes
88
+ - In this release Codex and Antigravity had no hook adapter — native passthrough
89
+ with lifecycle status, or `--observe` for coarse PTY activity. 0.2.1 adds the
90
+ Codex adapter; Antigravity remains a planned follow-up.
91
+
92
+ ## [0.1.0]
93
+ - Initial generic AI CLI pet runtime: overlay companion, session bubbles, daemon
94
+ IPC, client adapters, pet asset pipeline, cross-OS paths.
package/README.md CHANGED
@@ -52,8 +52,8 @@ Haya Pet watches all of them and presents one ambient interface:
52
52
  (`thinking`, `running_tool`, `waiting_approval`, `reviewing`, `failed`, …).
53
53
  - 🧩 **Client adapters** with tiered support (process wrapper → PTY observer →
54
54
  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).
55
+ lifecycle status; richer status is opt-in (Claude Code / Codex hooks via
56
+ `haya-pet hooks on`, or PTY `--observe` for any client).
57
57
  - 🚀 **Zero-setup launch** — `haya-pet run …` auto-starts the overlay; no separate
58
58
  daemon to manage.
59
59
  - 🖼️ **Codex-compatible pet assets** (1536×1872 sprite atlas, 9 actions).
@@ -94,8 +94,8 @@ Haya Pet watches all of them and presents one ambient interface:
94
94
  | **Node ≥ 18** | Runtime + companion (Electron) |
95
95
  | **npm** | Install + scripts |
96
96
 
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
97
+ > Default status is lifecycle-only and needs no extra modules. Opt-in Claude Code /
98
+ > Codex hooks (`haya-pet hooks on`) also need none. The opt-in `--observe` PTY mode uses
99
99
  > `node-pty` (installed automatically when it can build; without it, `--observe`
100
100
  > degrades to lifecycle-only tracking).
101
101
 
@@ -157,11 +157,12 @@ Two **opt-in** ways to get richer *in-session* status (thinking / running tools
157
157
  editing files / waiting for approval):
158
158
 
159
159
  ```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.
160
+ # Claude Code AND Codex — live status via per-session hooks, NO terminal-fidelity
161
+ # tradeoff. Enable once (persisted, global); the first run for each client shows a
162
+ # one-time "review hooks" prompt you approve once.
163
163
  haya-pet hooks on
164
164
  haya-pet run --client claude-code -- claude
165
+ haya-pet run --client codex -- codex
165
166
  # (per-run override without persisting: HAYA_PET_HOOKS=1 …, or $env:HAYA_PET_HOOKS=1 in PowerShell)
166
167
  # (turn back off: haya-pet hooks off · check: haya-pet hooks status)
167
168
 
@@ -169,10 +170,25 @@ haya-pet run --client claude-code -- claude
169
170
  haya-pet run --observe --client codex -- codex
170
171
  ```
171
172
 
173
+ > **Codex coverage.** Codex shows `thinking` (working) and `idle` (done) via hooks,
174
+ > plus `running_tool` / `editing_files` via a session-transcript watcher.
175
+ > *Waiting for approval* doesn't arrive yet because of an upstream gap where
176
+ > Codex's `PermissionRequest` hook doesn't fire
177
+ > ([openai/codex#16732](https://github.com/openai/codex/issues/16732)); it'll start
178
+ > working automatically once Codex fixes it. Also: if you pass your own
179
+ > `-p/--profile` to codex, haya-pet skips hook injection (Codex allows one
180
+ > profile) and tells you. Claude Code has full coverage.
181
+
182
+ > **Approval prompts resolve correctly** (Claude Code): deny → the pet returns to
183
+ > idle the moment the denial lands in the session transcript; accept a command →
184
+ > the pet flips to *working* a couple of seconds after the approved command
185
+ > actually starts running (detected from the client's process tree — a real
186
+ > event, never a timeout, so an unanswered prompt keeps warning until you decide).
187
+
172
188
  > **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.
189
+ > - **Hooks (Claude Code / Codex):** injecting hooks makes the client show a
190
+ > one-time *review hooks* trust prompt. We don't disrupt your session by default;
191
+ > turn it on once with `haya-pet hooks on` when you're happy to approve the hooks.
176
192
  > - **`--observe` (any client):** PTY observation infers status from output, but on
177
193
  > Windows it routes input through ConPTY, which can break **Shift+Tab**, mouse
178
194
  > scroll, and word-edit. Use it only for non-interactive runs. See
@@ -246,8 +262,8 @@ Full list (incl. repairing a broken Electron install): [docs/troubleshooting.md]
246
262
  | Client | Status | Support level |
247
263
  |---|---|---|
248
264
  | 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`) |
265
+ | Codex | ✅ | L1 wrapper + **L4 live-status hooks** (opt-in `haya-pet hooks on`; partial — see note) |
266
+ | Claude Code | ✅ | L1 wrapper + **L4 live-status hooks** (opt-in `haya-pet hooks on`) |
251
267
  | Antigravity | ✅ | L1 wrapper (+ L2 PTY via `--observe`) |
252
268
  | Gemini CLI / Aider / others | 🔜 | via the generic adapter |
253
269
 
@@ -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 };
@@ -3,6 +3,11 @@ import { fileURLToPath } from "node:url";
3
3
  import { dirname, join } from "node:path";
4
4
  import { createDaemonRuntime } from "../../../../packages/daemon-core/src/daemon-runtime.js";
5
5
  import { createIpcServer } from "../../../../packages/daemon-core/src/ipc-server.js";
6
+ import {
7
+ createApprovalWatchCoordinator,
8
+ watchForApprovedProcess
9
+ } from "../../../../packages/daemon-core/src/approval-process-watcher.js";
10
+ import { createProcessSnapshotLister } from "../../../../packages/platform-core/src/process-snapshot.js";
6
11
  import { getDefaultPaths } from "../../../../packages/platform-core/src/paths.js";
7
12
  import { getPlatformCapabilities } from "../../../../packages/platform-core/src/capabilities.js";
8
13
  import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
@@ -38,6 +43,7 @@ let runtime;
38
43
  let currentWorkArea;
39
44
  let currentDisplayId;
40
45
  let petLocal = { x: 0, y: 0 };
46
+ let approvalWatch;
41
47
 
42
48
  // Electron singleton: a second launch forwards to the running instance.
43
49
  if (!app.requestSingleInstanceLock()) {
@@ -54,8 +60,40 @@ async function bootstrap() {
54
60
  positionState = await stateFile.load();
55
61
  pets = await discoverPets(paths.petSearchPaths);
56
62
 
63
+ // Clients fire no event at the moment the user ACCEPTS a permission prompt
64
+ // (only denial/finish are observable), so a waiting_approval session would
65
+ // otherwise look stuck until its tool completed. The approval watcher flips
66
+ // it to running_tool when the approved command verifiably starts — a new
67
+ // persistent process under the client — and never on a timer, so a genuinely
68
+ // unanswered prompt keeps warning. Unsupported platforms simply skip this.
69
+ const processLister = createProcessSnapshotLister();
70
+ approvalWatch = processLister
71
+ ? createApprovalWatchCoordinator({
72
+ createWatcher: ({ rootPid, onApproved }) =>
73
+ watchForApprovedProcess({ rootPid, listProcesses: processLister, onApproved }),
74
+ onApproved: (sessionId) => {
75
+ try {
76
+ runtime.handleMessage({
77
+ type: "state",
78
+ sessionId,
79
+ state: "running_tool",
80
+ summary: "approved",
81
+ confidence: 0.6,
82
+ source: "client_log",
83
+ updatedAt: Date.now()
84
+ });
85
+ } catch {
86
+ // The session may have unregistered between detection and report.
87
+ }
88
+ }
89
+ })
90
+ : undefined;
91
+
57
92
  runtime = createDaemonRuntime({
58
- onSessionChanged: () => pushSessions()
93
+ onSessionChanged: (session) => {
94
+ approvalWatch?.onSessionChanged(session);
95
+ pushSessions();
96
+ }
59
97
  });
60
98
 
61
99
  ipcServer = await createIpcServer({
@@ -85,6 +123,7 @@ async function bootstrap() {
85
123
 
86
124
  app.on("before-quit", async () => {
87
125
  clearInterval(sweep);
126
+ approvalWatch?.stopAll();
88
127
  await ipcServer?.close();
89
128
  });
90
129
  }
@@ -18,7 +18,7 @@ test("creates default position state", () => {
18
18
  settings: {
19
19
  displayMode: "hybrid",
20
20
  attachBubblesToTerminals: true,
21
- claudeHooks: false
21
+ hooksEnabled: false
22
22
  }
23
23
  });
24
24
  });