@hayasaka7/haya-pet 0.1.0 โ†’ 0.2.0

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/README.md CHANGED
@@ -51,7 +51,9 @@ Haya Pet watches all of them and presents one ambient interface:
51
51
  - ๐Ÿง  **Normalized state model** โ€” every client maps to a shared state vocabulary
52
52
  (`thinking`, `running_tool`, `waiting_approval`, `reviewing`, `failed`, โ€ฆ).
53
53
  - ๐Ÿงฉ **Client adapters** with tiered support (process wrapper โ†’ PTY observer โ†’
54
- log/state โ†’ official plugin) so the daemon never bakes in client-specific logic.
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
57
  - ๐Ÿš€ **Zero-setup launch** โ€” `haya-pet run โ€ฆ` auto-starts the overlay; no separate
56
58
  daemon to manage.
57
59
  - ๐Ÿ–ผ๏ธ **Codex-compatible pet assets** (1536ร—1872 sprite atlas, 9 actions).
@@ -92,8 +94,10 @@ Haya Pet watches all of them and presents one ambient interface:
92
94
  | **Node โ‰ฅ 18** | Runtime + companion (Electron) |
93
95
  | **npm** | Install + scripts |
94
96
 
95
- > Live activity status uses the optional `node-pty` (installed automatically when
96
- > it can build; the pet degrades to lifecycle-only tracking without it).
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
99
+ > `node-pty` (installed automatically when it can build; without it, `--observe`
100
+ > degrades to lifecycle-only tracking).
97
101
 
98
102
  ## Install
99
103
 
@@ -138,19 +142,41 @@ shows success (a green check) or failure (a red cross), then fades.
138
142
 
139
143
  ### Live activity status
140
144
 
141
- By default the wrapper runs the CLI through a pseudo-terminal and shows *working*
142
- while the AI produces output, returning to *idle* after a short quiet window.
143
- Success/failure come from the real exit code โ€” never from scraping the word
144
- "error" out of output. Your terminal stays fully interactive.
145
+ `haya-pet run` uses **native passthrough by default** โ€” the CLI talks directly to
146
+ your terminal, so every input mode (Shift+Tab, mouse wheel, word-edit) works
147
+ exactly as it does without the wrapper. Out of the box, every client shows
148
+ **lifecycle status** (a session bubble while it runs; success/failure from the
149
+ real exit code, never from scraping "error" out of output).
145
150
 
146
151
  ```bash
147
- haya-pet run -- claude # live status (default)
148
- haya-pet run --no-observe -- claude # lifecycle only (opt out of PTY observation)
152
+ haya-pet run --client claude-code -- claude # full fidelity, lifecycle status
153
+ haya-pet run --client codex -- codex # full fidelity, lifecycle status
149
154
  ```
150
155
 
151
- > โš ๏ธ Running a CLI through the default PTY observation currently affects terminal
152
- > scrolling and backspace in some setups โ€” see
153
- > [docs/known-issues.md](docs/known-issues.md). `--no-observe` avoids it.
156
+ Two **opt-in** ways to get richer *in-session* status (thinking / running tools /
157
+ editing files / waiting for approval):
158
+
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.
163
+ haya-pet hooks on
164
+ haya-pet run --client claude-code -- claude
165
+ # (per-run override without persisting: HAYA_PET_HOOKS=1 โ€ฆ, or $env:HAYA_PET_HOOKS=1 in PowerShell)
166
+ # (turn back off: haya-pet hooks off ยท check: haya-pet hooks status)
167
+
168
+ # Any client โ€” coarse live status by watching output through a PTY.
169
+ haya-pet run --observe --client codex -- codex
170
+ ```
171
+
172
+ > **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.
176
+ > - **`--observe` (any client):** PTY observation infers status from output, but on
177
+ > Windows it routes input through ConPTY, which can break **Shift+Tab**, mouse
178
+ > scroll, and word-edit. Use it only for non-interactive runs. See
179
+ > [docs/known-issues.md](docs/known-issues.md).
154
180
 
155
181
  ## Add and choose a pet
156
182
 
@@ -219,14 +245,14 @@ Full list (incl. repairing a broken Electron install): [docs/troubleshooting.md]
219
245
 
220
246
  | Client | Status | Support level |
221
247
  |---|---|---|
222
- | Generic CLI | โœ… | L1 process wrapper |
223
- | Codex | โœ… | L1 + L2 PTY observation |
224
- | Claude Code | โœ… | L1 + L2 PTY observation |
225
- | Antigravity | โœ… | L1 wrapper |
248
+ | 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`) |
251
+ | Antigravity | โœ… | L1 wrapper (+ L2 PTY via `--observe`) |
226
252
  | Gemini CLI / Aider / others | ๐Ÿ”œ | via the generic adapter |
227
253
 
228
254
  (See [docs/architecture.md](docs/architecture.md) for the support tiers and the
229
- platform matrix.)
255
+ platform matrix, and [CHANGELOG.md](CHANGELOG.md) for release notes.)
230
256
 
231
257
  ## Privacy
232
258
 
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from "node:fs";
2
+ import { realpathSync, appendFileSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
7
+ import { parseStateArgs, runStateCommand } from "../../../packages/cli-core/src/run-state.js";
8
+ import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages/cli-core/src/claude-hook-injection.js";
9
+ import { watchClaudeTranscript as defaultWatchClaudeTranscript } from "../../../packages/cli-core/src/claude-transcript-watcher.js";
6
10
  import { ensureCompanionConnection } from "../../../packages/cli-core/src/companion-launcher.js";
7
11
  import { createIpcClient as defaultCreateIpcClient } from "../../../packages/daemon-core/src/ipc-server.js";
8
12
  import { getDefaultPaths } from "../../../packages/platform-core/src/paths.js";
9
13
  import { discoverPets as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
10
14
  import { createStateFile as defaultCreateStateFile } from "../../../packages/app-state/src/state-file.js";
11
- import { getSelectedPetId, setSelectedPet } from "../../../packages/app-state/src/state.js";
15
+ import { getSelectedPetId, setSelectedPet, getClaudeHooksEnabled, setClaudeHooksEnabled } from "../../../packages/app-state/src/state.js";
12
16
  import { getAdapterInfo } from "../../../packages/adapters/src/adapter-info.js";
13
17
 
14
18
  const CLIENT_DISPLAY_NAMES = Object.freeze({
@@ -41,6 +45,14 @@ export function parseAiPetArgs(argv) {
41
45
  return { command: "stop" };
42
46
  }
43
47
 
48
+ if (command === "state") {
49
+ return parseStateArgs(rest);
50
+ }
51
+
52
+ if (command === "hooks") {
53
+ return parseHooksArgs(rest);
54
+ }
55
+
44
56
  throw new Error(`Unsupported haya-pet command: ${command}`);
45
57
  }
46
58
 
@@ -59,6 +71,14 @@ export async function runAiPet(argv, dependencies = {}) {
59
71
  return runStopCommand(parsed, dependencies);
60
72
  }
61
73
 
74
+ if (parsed.command === "state") {
75
+ return runStateCommand(parsed, dependencies);
76
+ }
77
+
78
+ if (parsed.command === "hooks") {
79
+ return runHooksCommand(parsed, dependencies);
80
+ }
81
+
62
82
  return runRunCommand(parsed, dependencies);
63
83
  }
64
84
 
@@ -102,26 +122,127 @@ export async function runStartCommand(_parsed, dependencies = {}) {
102
122
 
103
123
  async function runRunCommand(parsed, dependencies) {
104
124
  const runGenericCommand = dependencies.runGenericCommand ?? defaultRunGenericCommand;
125
+ const injectClaudeHooks = dependencies.injectClaudeHooks ?? defaultInjectClaudeHooks;
126
+ const watchClaudeTranscript = dependencies.watchClaudeTranscript ?? defaultWatchClaudeTranscript;
127
+ const env = dependencies.env ?? process.env;
128
+ const now = dependencies.now ?? Date.now;
129
+ const cwd = dependencies.cwd ?? process.cwd();
105
130
  const messageSender = await createMessageSender(dependencies);
106
131
 
132
+ const sessionId = dependencies.sessionId ?? `sess_${randomUUID()}`;
133
+ let childArgs = parsed.childArgs;
134
+ let childEnv = env;
135
+ let cleanup = () => {};
136
+ let stopWatcher = () => {};
137
+
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));
146
+ if (claudeHooksOn) {
147
+ const injected = injectClaudeHooks();
148
+ childArgs = [...parsed.childArgs, "--settings", injected.settingsPath];
149
+ childEnv = { ...env, HAYA_PET_SESSION_ID: sessionId };
150
+ cleanup = injected.cleanup;
151
+
152
+ // Claude fires NO hook when the user manually denies a permission, so the
153
+ // pet would stay stuck on "waiting for approval". Tail the session transcript
154
+ // (ground truth) and clear to idle the moment a denial is recorded โ€” never on
155
+ // a timer, so a genuinely-pending approval keeps alerting until it's resolved.
156
+ const watcher = watchClaudeTranscript({
157
+ cwd,
158
+ homeDir: dependencies.homeDir,
159
+ startedAt: now(),
160
+ onDenial: (event) => {
161
+ hookDebugLog(env, now, { source: "transcript", event: "denied", state: "idle", toolUseId: event?.toolUseId });
162
+ messageSender
163
+ .send({
164
+ type: "state",
165
+ sessionId,
166
+ state: "idle",
167
+ summary: "approval denied",
168
+ confidence: 0.9,
169
+ source: "client_log",
170
+ updatedAt: now()
171
+ })
172
+ .catch(() => {});
173
+ }
174
+ });
175
+ stopWatcher = watcher.stop;
176
+ }
177
+
107
178
  try {
108
179
  return await runGenericCommand({
109
180
  command: parsed.childCommand,
110
- args: parsed.childArgs,
111
- cwd: dependencies.cwd ?? process.cwd(),
181
+ args: childArgs,
182
+ cwd,
112
183
  clientId: parsed.clientId,
113
184
  clientDisplayName: CLIENT_DISPLAY_NAMES[parsed.clientId] ?? parsed.clientId,
114
185
  observe: parsed.observe,
186
+ sessionId,
187
+ env: childEnv,
115
188
  heartbeatIntervalMs: dependencies.heartbeatIntervalMs,
116
189
  now: dependencies.now,
117
190
  stdio: dependencies.stdio,
118
191
  send: messageSender.send
119
192
  });
120
193
  } finally {
194
+ stopWatcher();
195
+ cleanup();
121
196
  await messageSender.close();
122
197
  }
123
198
  }
124
199
 
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) {
204
+ if (isTruthyFlag(env.HAYA_PET_NO_HOOKS)) {
205
+ return false;
206
+ }
207
+ if (isTruthyFlag(env.HAYA_PET_HOOKS)) {
208
+ return true;
209
+ }
210
+ try {
211
+ const state = await createConfigStateFile(dependencies).load();
212
+ return getClaudeHooksEnabled(state);
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ function isTruthyFlag(value) {
219
+ return value === "1" || value === "true";
220
+ }
221
+
222
+ function createConfigStateFile(dependencies) {
223
+ const paths = getDefaultPaths({
224
+ platform: dependencies.platform,
225
+ env: dependencies.env,
226
+ homeDir: dependencies.homeDir
227
+ });
228
+ const createStateFile = dependencies.createStateFile ?? defaultCreateStateFile;
229
+ return createStateFile({ statePath: paths.statePath });
230
+ }
231
+
232
+ // Best-effort: mirror the reporter's HAYA_PET_HOOK_DEBUG log so transcript-driven
233
+ // events (which don't go through `haya-pet state`) show up in the same trace.
234
+ function hookDebugLog(env, now, entry) {
235
+ const target = env.HAYA_PET_HOOK_DEBUG;
236
+ if (!target) {
237
+ return;
238
+ }
239
+ try {
240
+ appendFileSync(target, `${JSON.stringify({ ts: now(), ...entry })}\n`);
241
+ } catch {
242
+ // diagnostics must never break the run
243
+ }
244
+ }
245
+
125
246
  export async function runPetsCommand(parsed, dependencies = {}) {
126
247
  const paths = getDefaultPaths({
127
248
  platform: dependencies.platform,
@@ -181,6 +302,37 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
181
302
  return result;
182
303
  }
183
304
 
305
+ function parseHooksArgs(args) {
306
+ const [action = "status"] = args;
307
+ if (action === "on" || action === "off" || action === "status") {
308
+ return { command: "hooks", action };
309
+ }
310
+ throw new Error(`Unknown hooks action: ${action} (use on, off, or status)`);
311
+ }
312
+
313
+ // Persisted toggle for Claude Code live-status hooks (the convenient alternative
314
+ // to setting HAYA_PET_HOOKS every shell).
315
+ export async function runHooksCommand(parsed, dependencies = {}) {
316
+ const print = dependencies.print ?? defaultPrint;
317
+ const stateFile = createConfigStateFile(dependencies);
318
+ const state = await stateFile.load();
319
+
320
+ if (parsed.action === "status") {
321
+ const enabled = getClaudeHooksEnabled(state);
322
+ print(`Claude Code live-status hooks: ${enabled ? "on" : "off"}`);
323
+ return { command: "hooks", action: "status", enabled };
324
+ }
325
+
326
+ const enabled = parsed.action === "on";
327
+ await stateFile.save(setClaudeHooksEnabled(state, enabled));
328
+ print(
329
+ 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."
332
+ );
333
+ return { command: "hooks", action: parsed.action, enabled };
334
+ }
335
+
184
336
  function parsePetsArgs(args) {
185
337
  if (args.length === 0) {
186
338
  return { command: "pets", action: "list" };
@@ -205,7 +357,7 @@ function parsePetsArgs(args) {
205
357
 
206
358
  function parseRunArgs(args) {
207
359
  let clientId = "generic";
208
- let observe = true; // live PTY observation is on by default; --no-observe opts out
360
+ let observe = false; // native passthrough by default (full terminal fidelity); --observe opts in
209
361
  let childStart = -1;
210
362
 
211
363
  for (let index = 0; index < args.length; index += 1) {
@@ -17,12 +17,12 @@ test("parses generic run command arguments", () => {
17
17
  );
18
18
  });
19
19
 
20
- test("observation is on by default and --no-observe opts out", () => {
21
- assert.equal(parseAiPetArgs(["run", "--client", "codex"]).observe, true);
22
- assert.equal(parseAiPetArgs(["run", "--no-observe", "--client", "codex"]).observe, false);
20
+ test("native passthrough is the default and --observe opts in", () => {
21
+ assert.equal(parseAiPetArgs(["run", "--client", "codex"]).observe, false);
22
+ assert.equal(parseAiPetArgs(["run", "--observe", "--client", "codex"]).observe, true);
23
23
 
24
24
  const parsedWithCommand = parseAiPetArgs(["run", "--", "claude", "--resume"]);
25
- assert.equal(parsedWithCommand.observe, true);
25
+ assert.equal(parsedWithCommand.observe, false);
26
26
  assert.equal(parsedWithCommand.childCommand, "claude");
27
27
  assert.deepEqual(parsedWithCommand.childArgs, ["--resume"]);
28
28
  });
@@ -326,6 +326,167 @@ test("stop command is a no-op when nothing is running", async () => {
326
326
  assert.ok(lines.some((line) => line.includes("not running")));
327
327
  });
328
328
 
329
+ test("parses the state command", () => {
330
+ assert.deepEqual(parseAiPetArgs(["state", "thinking", "--session", "sess_q"]), {
331
+ command: "state",
332
+ state: "thinking",
333
+ summary: undefined,
334
+ session: "sess_q"
335
+ });
336
+ });
337
+
338
+ const hooksStateFile = (claudeHooks) => () => ({
339
+ load: async () => ({ settings: { claudeHooks } }),
340
+ save: async (state) => state
341
+ });
342
+
343
+ test("claude-code does NOT inject hooks by default (safe out-of-box)", async () => {
344
+ const calls = [];
345
+ let injected = 0;
346
+ await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
347
+ cwd: process.cwd(),
348
+ env: { USERPROFILE: "C:\\Users\\A" },
349
+ heartbeatIntervalMs: 10,
350
+ send: async () => {},
351
+ createStateFile: hooksStateFile(false),
352
+ injectClaudeHooks: () => { injected += 1; return { settingsPath: "x", cleanup: () => {} }; },
353
+ runGenericCommand: async (options) => {
354
+ calls.push(options);
355
+ return { sessionId: "s", pid: 1, exitCode: 0 };
356
+ }
357
+ });
358
+
359
+ assert.equal(injected, 0, "no hook injection unless opted in");
360
+ assert.deepEqual(calls[0].args, []);
361
+ });
362
+
363
+ test("persisted `hooks on` opts claude-code into injection without an env var", async () => {
364
+ const calls = [];
365
+ let injected = 0;
366
+ await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
367
+ cwd: process.cwd(),
368
+ env: { USERPROFILE: "C:\\Users\\A" }, // no HAYA_PET_HOOKS
369
+ heartbeatIntervalMs: 10,
370
+ send: async () => {},
371
+ createStateFile: hooksStateFile(true), // persisted preference = on
372
+ injectClaudeHooks: () => { injected += 1; return { settingsPath: "/tmp/s.json", cleanup: () => {} }; },
373
+ watchClaudeTranscript: () => ({ stop: () => {} }),
374
+ runGenericCommand: async (options) => {
375
+ calls.push(options);
376
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
377
+ }
378
+ });
379
+
380
+ assert.equal(injected, 1, "config preference enables hooks");
381
+ assert.deepEqual(calls[0].args, ["--settings", "/tmp/s.json"]);
382
+ });
383
+
384
+ test("HAYA_PET_NO_HOOKS=1 overrides a persisted `hooks on`", async () => {
385
+ const calls = [];
386
+ let injected = 0;
387
+ await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
388
+ cwd: process.cwd(),
389
+ env: { HAYA_PET_NO_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
390
+ heartbeatIntervalMs: 10,
391
+ send: async () => {},
392
+ createStateFile: hooksStateFile(true),
393
+ injectClaudeHooks: () => { injected += 1; return { settingsPath: "x", cleanup: () => {} }; },
394
+ runGenericCommand: async (options) => {
395
+ calls.push(options);
396
+ return { sessionId: "s", pid: 1, exitCode: 0 };
397
+ }
398
+ });
399
+
400
+ assert.equal(injected, 0, "env override forces hooks off");
401
+ assert.deepEqual(calls[0].args, []);
402
+ });
403
+
404
+ test("hooks command parses and persists the toggle", async () => {
405
+ assert.deepEqual(parseAiPetArgs(["hooks"]), { command: "hooks", action: "status" });
406
+ assert.deepEqual(parseAiPetArgs(["hooks", "on"]), { command: "hooks", action: "on" });
407
+ assert.throws(() => parseAiPetArgs(["hooks", "bogus"]), /Unknown hooks action/);
408
+
409
+ let saved;
410
+ const lines = [];
411
+ const store = {
412
+ load: async () => ({ settings: { claudeHooks: false } }),
413
+ save: async (state) => { saved = state; return state; }
414
+ };
415
+ const result = await runAiPet(["hooks", "on"], {
416
+ homeDir: "C:\\Users\\A",
417
+ createStateFile: () => store,
418
+ print: (line) => lines.push(line)
419
+ });
420
+
421
+ assert.equal(result.enabled, true);
422
+ assert.equal(saved.settings.claudeHooks, true);
423
+ assert.ok(lines.some((l) => l.includes("on")));
424
+ });
425
+
426
+ test("HAYA_PET_HOOKS=1 opts claude-code into --settings + HAYA_PET_SESSION_ID", async () => {
427
+ const calls = [];
428
+ let watched = 0;
429
+ await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
430
+ cwd: process.cwd(),
431
+ env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A", HOME: "/home/a" },
432
+ heartbeatIntervalMs: 10,
433
+ send: async () => {},
434
+ injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
435
+ watchClaudeTranscript: () => { watched += 1; return { stop: () => {} }; },
436
+ runGenericCommand: async (options) => {
437
+ calls.push(options);
438
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
439
+ }
440
+ });
441
+
442
+ assert.equal(calls.length, 1);
443
+ assert.deepEqual(calls[0].args, ["--settings", "/tmp/s.json"]);
444
+ assert.equal(calls[0].env.HAYA_PET_SESSION_ID, calls[0].sessionId);
445
+ assert.ok(calls[0].sessionId, "a session id was generated and shared via env");
446
+ assert.equal(watched, 1, "transcript watcher started for approval-denial recovery");
447
+ });
448
+
449
+ test("a transcript denial clears the stuck approval to idle", async () => {
450
+ const sent = [];
451
+ let fireDenial;
452
+ await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
453
+ cwd: process.cwd(),
454
+ env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
455
+ now: () => 42,
456
+ heartbeatIntervalMs: 10,
457
+ send: async (message) => sent.push(message),
458
+ injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
459
+ watchClaudeTranscript: ({ onDenial }) => { fireDenial = onDenial; return { stop: () => {} }; },
460
+ runGenericCommand: async (options) => {
461
+ // Simulate the user denying a permission mid-session.
462
+ fireDenial({ type: "tool_denied", toolUseId: "toolu_1" });
463
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
464
+ }
465
+ });
466
+
467
+ const idle = sent.find((m) => m.type === "state" && m.source === "client_log");
468
+ assert.ok(idle, "a client_log state was sent on denial");
469
+ assert.equal(idle.state, "idle");
470
+ assert.equal(idle.summary, "approval denied");
471
+ assert.equal(idle.updatedAt, 42);
472
+ });
473
+
474
+ test("non-claude clients are never injected even with HAYA_PET_HOOKS=1", async () => {
475
+ const calls = [];
476
+ await runAiPet(["run", "--client", "codex", "--", "codex"], {
477
+ cwd: process.cwd(),
478
+ env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
479
+ heartbeatIntervalMs: 10,
480
+ send: async () => {},
481
+ injectClaudeHooks: () => { throw new Error("should not inject for codex"); },
482
+ runGenericCommand: async (options) => {
483
+ calls.push(options);
484
+ return { sessionId: "s", pid: 1, exitCode: 0 };
485
+ }
486
+ });
487
+ assert.deepEqual(calls[0].args, []);
488
+ });
489
+
329
490
  async function waitFor(predicate) {
330
491
  const startedAt = Date.now();
331
492
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haya-pet/companion",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Electron overlay companion app for the AI CLI pet runtime.",
@@ -17,7 +17,8 @@ test("creates default position state", () => {
17
17
  sessions: {},
18
18
  settings: {
19
19
  displayMode: "hybrid",
20
- attachBubblesToTerminals: true
20
+ attachBubblesToTerminals: true,
21
+ claudeHooks: false
21
22
  }
22
23
  });
23
24
  });
@@ -48,14 +48,24 @@ as each client allows:
48
48
  | Tier | Source | Fidelity |
49
49
  |---|---|---|
50
50
  | L1 | Process wrapper (lifecycle only) | session exists / exit code |
51
- | L2 | PTY output observation (`--observe`, default) | activity-based working/idle |
51
+ | L2 | PTY output observation (`--observe`, opt-in) | activity-based working/idle |
52
52
  | L3 | Client logs / state files | client-specific (future) |
53
- | L4 | Official plugin/hooks | richest (future) |
53
+ | L4 | Client hooks | richest โ€” implemented for Claude Code |
54
+
55
+ The **default** is native passthrough (`stdio: "inherit"`) for full terminal
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.
54
63
 
55
64
  L2 is **activity-based**: any visible output โ†’ *working*; a short quiet window โ†’
56
65
  *idle*; success/failure come from the real exit code, never from scraping output
57
- text. Keyword heuristics exist but are opt-in (unreliable on rich TUIs). See
58
- [known-issues.md](known-issues.md) for the current L2/PTY tradeoffs.
66
+ text. It is opt-in because routing input through a PTY (ConPTY on Windows) breaks
67
+ special keys like Shift+Tab โ€” see [known-issues.md](known-issues.md) for the
68
+ L2/PTY tradeoffs.
59
69
 
60
70
  ## Overlay model
61
71
 
@@ -141,4 +151,48 @@ helper. In progress:
141
151
  - Faithful PTY passthrough (see [known-issues.md](known-issues.md)).
142
152
  - Production overlay/IPC validation across all platforms.
143
153
 
154
+ ### Deferred: focus a session's terminal on bubble click
155
+
156
+ Clicking a session bubble should raise/focus the terminal window running that
157
+ session. Deferred because it can't be done as a clean cross-OS feature yet:
158
+
159
+ - **Windows** โ€” doable now: the helper already *locates* the window (HWND); add a
160
+ `focus` op that calls `SetForegroundWindow` (+ the usual `AllowSetForegroundWindow`
161
+ / attach-thread-input dance), then wire bubble click โ†’ IPC โ†’ helper.
162
+ - **macOS** โ€” needs an (unbuilt) Accessibility/window-list helper and a
163
+ user-granted Accessibility permission.
164
+ - **Linux X11** โ€” needs the (unbuilt) X11 helper (EWMH `_NET_ACTIVE_WINDOW`).
165
+ - **Linux Wayland** โ€” blocked by the compositor security model; no portable API to
166
+ focus another app's window.
167
+
168
+ Implementation sketch when picked up: bubble `click` in `session-bubbles.js` โ†’
169
+ `haya-pet:focus-session` IPC with `sessionId` โ†’ main resolves `session.pid`
170
+ (/`terminalPid`) โ†’ terminal helper `focus` op (per-OS), with a graceful no-op
171
+ where unsupported.
172
+
173
+ ### Deferred: per-session token usage
174
+
175
+ Show each session's token usage on its bubble. Feasible as an **L3 client-log
176
+ adapter** (`source: "client_log"`) โ€” and it's cross-OS, since only the log path
177
+ differs by client, not by OS. There is no generic source: the process wrapper
178
+ only sees terminal bytes, so usage must come from each client's own logs.
179
+
180
+ - **Claude Code** โ€” confirmed: per-turn `usage` (`input_tokens`, `output_tokens`,
181
+ `cache_creation_input_tokens`, `cache_read_input_tokens`) in
182
+ `~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl`. Clean JSONL to parse.
183
+ - **Codex** โ€” usage exists in its logs (`~/.codex/history.jsonl`, `sessions/`,
184
+ sqlite) but in a messier shape; needs a dedicated adapter + investigation.
185
+ - **Generic / other clients** โ€” no reliable source; the adapter should no-op.
186
+
187
+ Implementation sketch when picked up: a per-client usage adapter tails the
188
+ session's transcript (matched via the session's `cwd` โ†’ the newest `.jsonl` in
189
+ that project dir), sums usage across turns, and emits an optional `usage` field
190
+ (protocol addition) โ†’ `session-core` stores it โ†’ the bubble renders it
191
+ (e.g. `โ†‘ in / โ†“ out`). Open questions: (1) which metric to surface โ€” cache-read
192
+ tokens are huge under prompt caching, so likely show output + input, with total
193
+ context separate; (2) disambiguating multiple concurrent sessions in the same
194
+ project dir (by start time / newest file). The JSONL parser is pure and
195
+ TDD-friendly. Investigate non-Claude client adapters (Codex, etc.) as part of
196
+ this.
197
+
144
198
  See [`../PROGRESS.md`](../PROGRESS.md) for the detailed log.