@hayasaka7/haya-pet 0.1.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +59 -17
  3. package/apps/cli/src/haya-pet.js +246 -5
  4. package/apps/cli/test/haya-pet.test.mjs +269 -4
  5. package/apps/companion/package.json +1 -1
  6. package/apps/companion/src/main/index.js +40 -1
  7. package/apps/companion/test/position-store.test.mjs +2 -1
  8. package/docs/architecture.md +84 -7
  9. package/docs/cross-os-qa.md +72 -0
  10. package/docs/known-issues.md +204 -49
  11. package/docs/troubleshooting.md +33 -1
  12. package/package.json +1 -1
  13. package/packages/adapters/src/claude-hooks.js +77 -0
  14. package/packages/adapters/src/claude-transcript.js +74 -0
  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/claude-hooks.test.mjs +87 -0
  18. package/packages/adapters/test/claude-transcript.test.mjs +70 -0
  19. package/packages/adapters/test/codex-hooks.test.mjs +120 -0
  20. package/packages/adapters/test/codex-transcript.test.mjs +97 -0
  21. package/packages/app-state/src/state.js +21 -1
  22. package/packages/cli-core/src/claude-hook-injection.js +42 -0
  23. package/packages/cli-core/src/claude-transcript-watcher.js +185 -0
  24. package/packages/cli-core/src/codex-hook-injection.js +49 -0
  25. package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
  26. package/packages/cli-core/src/run-command.js +7 -3
  27. package/packages/cli-core/src/run-state.js +87 -0
  28. package/packages/cli-core/test/claude-hook-injection.test.mjs +45 -0
  29. package/packages/cli-core/test/claude-transcript-watcher.test.mjs +121 -0
  30. package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
  31. package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
  32. package/packages/cli-core/test/run-command.test.mjs +20 -0
  33. package/packages/cli-core/test/run-state.test.mjs +113 -0
  34. package/packages/daemon-core/src/approval-process-watcher.js +169 -0
  35. package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
  36. package/packages/platform-core/src/process-snapshot.js +88 -0
  37. package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
@@ -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,271 @@ 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 = (hooksEnabled) => () => ({
339
+ load: async () => ({ settings: { hooksEnabled } }),
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: { hooksEnabled: 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.hooksEnabled, true);
423
+ assert.ok(lines.some((l) => l.includes("on")));
424
+ });
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
+
529
+ test("HAYA_PET_HOOKS=1 opts claude-code into --settings + HAYA_PET_SESSION_ID", async () => {
530
+ const calls = [];
531
+ let watched = 0;
532
+ await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
533
+ cwd: process.cwd(),
534
+ env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A", HOME: "/home/a" },
535
+ heartbeatIntervalMs: 10,
536
+ send: async () => {},
537
+ injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
538
+ watchClaudeTranscript: () => { watched += 1; return { stop: () => {} }; },
539
+ runGenericCommand: async (options) => {
540
+ calls.push(options);
541
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
542
+ }
543
+ });
544
+
545
+ assert.equal(calls.length, 1);
546
+ assert.deepEqual(calls[0].args, ["--settings", "/tmp/s.json"]);
547
+ assert.equal(calls[0].env.HAYA_PET_SESSION_ID, calls[0].sessionId);
548
+ assert.ok(calls[0].sessionId, "a session id was generated and shared via env");
549
+ assert.equal(watched, 1, "transcript watcher started for approval-denial recovery");
550
+ });
551
+
552
+ test("a transcript denial clears the stuck approval to idle", async () => {
553
+ const sent = [];
554
+ let fireDenial;
555
+ await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
556
+ cwd: process.cwd(),
557
+ env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
558
+ now: () => 42,
559
+ heartbeatIntervalMs: 10,
560
+ send: async (message) => sent.push(message),
561
+ injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
562
+ watchClaudeTranscript: ({ onDenial }) => { fireDenial = onDenial; return { stop: () => {} }; },
563
+ runGenericCommand: async (options) => {
564
+ // Simulate the user denying a permission mid-session.
565
+ fireDenial({ type: "tool_denied", toolUseId: "toolu_1" });
566
+ return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
567
+ }
568
+ });
569
+
570
+ const idle = sent.find((m) => m.type === "state" && m.source === "client_log");
571
+ assert.ok(idle, "a client_log state was sent on denial");
572
+ assert.equal(idle.state, "idle");
573
+ assert.equal(idle.summary, "approval denied");
574
+ assert.equal(idle.updatedAt, 42);
575
+ });
576
+
577
+ test("non-hook-capable clients are never injected even with HAYA_PET_HOOKS=1", async () => {
578
+ const calls = [];
579
+ await runAiPet(["run", "--client", "generic", "--", "aider"], {
580
+ cwd: process.cwd(),
581
+ env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
582
+ heartbeatIntervalMs: 10,
583
+ send: async () => {},
584
+ injectClaudeHooks: () => { throw new Error("should not inject for generic"); },
585
+ injectCodexHooks: () => { throw new Error("should not inject for generic"); },
586
+ runGenericCommand: async (options) => {
587
+ calls.push(options);
588
+ return { sessionId: "s", pid: 1, exitCode: 0 };
589
+ }
590
+ });
591
+ assert.deepEqual(calls[0].args, []);
592
+ });
593
+
329
594
  async function waitFor(predicate) {
330
595
  const startedAt = Date.now();
331
596
 
@@ -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.",
@@ -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
  }
@@ -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
+ hooksEnabled: false
21
22
  }
22
23
  });
23
24
  });
@@ -48,14 +48,46 @@ 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 |
52
- | L3 | Client logs / state files | client-specific (future) |
53
- | L4 | Official plugin/hooks | richest (future) |
51
+ | L2 | PTY output observation (`--observe`, opt-in) | activity-based working/idle |
52
+ | L3 | Client logs / state files / process tree | transcript watchers (Claude denial, Codex tools) + approval-accept detection |
53
+ | L4 | Client hooks | richest — implemented for Claude Code (full) and Codex (partial) |
54
+
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** and **Codex** gain **L4 hooks** when enabled with the global
58
+ `haya-pet hooks on` (persisted; or per-run via `HAYA_PET_HOOKS=1`). Both report
59
+ in-session activity through the shared, client-agnostic `haya-pet state` command
60
+ (lifecycle still comes from the wrapper's exit code); any client gains **L2** with
61
+ `--observe`. Hooks are opt-in because injecting them triggers the client's one-time
62
+ *review hooks* trust prompt.
63
+
64
+ The injection mechanism differs per client. **Claude Code** takes a stable
65
+ `claude --settings <file>`. **Codex** has no per-invocation settings flag, so the
66
+ wrapper writes a stable `$CODEX_HOME/haya-pet.config.toml` profile and prepends
67
+ `-p haya-pet` to the codex args (a profile layers on top of the user's base config,
68
+ leaving auth/model/MCP intact, and is inert otherwise). Codex allows only one
69
+ profile, so if the user already passes `-p/--profile`, injection is skipped with a
70
+ notice. Codex's hook command must be unquoted at the program position (it runs via
71
+ `cmd /c`, which strips a leading quote) and its matchers can't use look-around
72
+ (Rust regex) — see [known-issues.md](known-issues.md). Codex's L4 is **partial**:
73
+ `PreToolUse`/`PermissionRequest` don't fire upstream yet, so only `thinking`/`idle`
74
+ arrive today.
75
+
76
+ Hooks alone can't see one moment: clients emit **no event when the user accepts a
77
+ permission prompt** (denial and completion are observable; the accept click is
78
+ not). The companion bridges it with **L3 process-tree observation**: while a
79
+ session sits in `waiting_approval`, it polls the client's process subtree (the
80
+ wrapper reported the pid at register), and when a new descendant process appears
81
+ and persists across two polls — the approved command verifiably running — the
82
+ session flips to `running_tool`. No timers: an unanswered prompt keeps warning
83
+ until a real event resolves it (`approval-process-watcher.js`,
84
+ `process-snapshot.js`; Windows/macOS/Linux listers).
54
85
 
55
86
  L2 is **activity-based**: any visible output → *working*; a short quiet window →
56
87
  *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.
88
+ text. It is opt-in because routing input through a PTY (ConPTY on Windows) breaks
89
+ special keys like Shift+Tab — see [known-issues.md](known-issues.md) for the
90
+ L2/PTY tradeoffs.
59
91
 
60
92
  ## Overlay model
61
93
 
@@ -85,8 +117,9 @@ packages/
85
117
  session-core/ registry, priority, summaries, bubble views, linger, pet-state
86
118
  task-core/ task status, events, store, approvals, replies, controls
87
119
  adapters/ client info, heuristics, capabilities, output observer, routing
88
- daemon-core/ IPC server/transport, runtime bridge, singleton
89
- platform-core/ platform, paths, capabilities
120
+ daemon-core/ IPC server/transport, runtime bridge, singleton,
121
+ approval process watcher (waiting_approval -> running_tool)
122
+ platform-core/ platform, paths, capabilities, process snapshots (win/mac/linux)
90
123
  apps/
91
124
  cli/ haya-pet entrypoint + parser (run / start / stop / pets)
92
125
  companion/ Electron overlay app (main + renderer)
@@ -141,4 +174,48 @@ helper. In progress:
141
174
  - Faithful PTY passthrough (see [known-issues.md](known-issues.md)).
142
175
  - Production overlay/IPC validation across all platforms.
143
176
 
177
+ ### Deferred: focus a session's terminal on bubble click
178
+
179
+ Clicking a session bubble should raise/focus the terminal window running that
180
+ session. Deferred because it can't be done as a clean cross-OS feature yet:
181
+
182
+ - **Windows** — doable now: the helper already *locates* the window (HWND); add a
183
+ `focus` op that calls `SetForegroundWindow` (+ the usual `AllowSetForegroundWindow`
184
+ / attach-thread-input dance), then wire bubble click → IPC → helper.
185
+ - **macOS** — needs an (unbuilt) Accessibility/window-list helper and a
186
+ user-granted Accessibility permission.
187
+ - **Linux X11** — needs the (unbuilt) X11 helper (EWMH `_NET_ACTIVE_WINDOW`).
188
+ - **Linux Wayland** — blocked by the compositor security model; no portable API to
189
+ focus another app's window.
190
+
191
+ Implementation sketch when picked up: bubble `click` in `session-bubbles.js` →
192
+ `haya-pet:focus-session` IPC with `sessionId` → main resolves `session.pid`
193
+ (/`terminalPid`) → terminal helper `focus` op (per-OS), with a graceful no-op
194
+ where unsupported.
195
+
196
+ ### Deferred: per-session token usage
197
+
198
+ Show each session's token usage on its bubble. Feasible as an **L3 client-log
199
+ adapter** (`source: "client_log"`) — and it's cross-OS, since only the log path
200
+ differs by client, not by OS. There is no generic source: the process wrapper
201
+ only sees terminal bytes, so usage must come from each client's own logs.
202
+
203
+ - **Claude Code** — confirmed: per-turn `usage` (`input_tokens`, `output_tokens`,
204
+ `cache_creation_input_tokens`, `cache_read_input_tokens`) in
205
+ `~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl`. Clean JSONL to parse.
206
+ - **Codex** — usage exists in its logs (`~/.codex/history.jsonl`, `sessions/`,
207
+ sqlite) but in a messier shape; needs a dedicated adapter + investigation.
208
+ - **Generic / other clients** — no reliable source; the adapter should no-op.
209
+
210
+ Implementation sketch when picked up: a per-client usage adapter tails the
211
+ session's transcript (matched via the session's `cwd` → the newest `.jsonl` in
212
+ that project dir), sums usage across turns, and emits an optional `usage` field
213
+ (protocol addition) → `session-core` stores it → the bubble renders it
214
+ (e.g. `↑ in / ↓ out`). Open questions: (1) which metric to surface — cache-read
215
+ tokens are huge under prompt caching, so likely show output + input, with total
216
+ context separate; (2) disambiguating multiple concurrent sessions in the same
217
+ project dir (by start time / newest file). The JSONL parser is pure and
218
+ TDD-friendly. Investigate non-Claude client adapters (Codex, etc.) as part of
219
+ this.
220
+
144
221
  See [`../PROGRESS.md`](../PROGRESS.md) for the detailed log.
@@ -0,0 +1,72 @@
1
+ # Cross-OS QA Matrix
2
+
3
+ Use this checklist before release candidates and after changes to IPC, windowing, display handling, terminal attachment, or CLI process wrapping.
4
+
5
+ ## Automated Gates
6
+
7
+ - [ ] `npm test` passes on the target branch.
8
+ - [ ] Generic command lifecycle emits register, running state, heartbeat, final state, and unregister.
9
+ - [ ] Daemon accepts local IPC messages and updates the session registry.
10
+ - [ ] CLI can run through daemon IPC without an injected sender.
11
+ - [ ] Pet preview loads a Codex-compatible `1536x1872` spritesheet.
12
+ - [ ] Pet manifest parsing and atlas/action validation pass (`pet-core`).
13
+ - [ ] Generic regex heuristics map sample output to normalized states (`adapters`).
14
+ - [ ] Task status mapping, event normalization, and control gating pass (`task-core`).
15
+ - [ ] Session bubble view models build with status label, summary, action, and elapsed (`session-core`).
16
+ - [ ] PTY output observer infers debounced `pty_output` states from sample client output (`adapters`).
17
+ - [ ] Reply/approval routing dispatches to injectors and safely refuses unsupported adapters (`adapters`).
18
+
19
+ ## Manual Platform Matrix
20
+
21
+ | Platform | Shell/Terminal | Display Setup | Required Checks |
22
+ |---|---|---|---|
23
+ | Windows 11 | PowerShell | 100% DPI | `haya-pet run --client generic -- node -e "setTimeout(() => process.exit(0), 1000)"`; daemon sees session exit; overlay opens without focus stealing. |
24
+ | Windows 11 | Windows Terminal | 125% DPI | Pet drag/click/double-click; position persists after restart; terminal attachment helper reports best-effort capability. |
25
+ | Windows 11 | Windows Terminal | 150% or mixed DPI | Saved offscreen position clamps to visible work area; session bubbles remain visible. |
26
+ | macOS current stable | Terminal.app | Retina display | Unix socket IPC works; transparent overlay opens; click/drag behavior works; position persists. |
27
+ | macOS current stable | iTerm2 | External display | Moving terminal across displays does not lose session bubble fallback; permission denial produces best-effort/fallback state. |
28
+ | Ubuntu Linux X11 | GNOME Terminal | Single display | Unix socket IPC works; transparent overlay opens; X11 terminal strategy reports best-effort. |
29
+ | Ubuntu or Fedora Linux Wayland | Default terminal | Single display | Overlay fallback mode works; terminal attachment reports manual fallback; global pet plus cluster/session bubbles remain usable. |
30
+
31
+ ## Release Acceptance Gates
32
+
33
+ - [ ] No platform stores prompts, screenshots, or raw terminal logs by default.
34
+ - [ ] Windows uses `\\.\pipe\haya-petd` for local IPC.
35
+ - [ ] macOS/Linux use `~/.haya-pet/haya-petd.sock` for local IPC.
36
+ - [ ] Windows state path is under `%LOCALAPPDATA%\haya-pet\state.json`.
37
+ - [ ] macOS/Linux state path is under `~/.haya-pet/state.json`.
38
+ - [ ] If transparent overlay fails, a normal companion window is available.
39
+ - [ ] If terminal attachment fails, global pet plus manual/cluster bubbles remain available.
40
+ - [ ] Wayland does not use unsupported global positioning assumptions.
41
+ - [ ] Saved display IDs are validated; missing displays fall back to primary visible work area.
42
+ - [ ] Task talk controls are hidden or disabled when adapter capability is unsupported.
43
+ - [ ] Reply composer shows "Open terminal to reply" for wrapper-only adapters (no blind injection).
44
+ - [ ] Approvals require explicit approve/deny and are never auto-approved.
45
+ - [ ] Companion runs as a single instance; a second launch focuses the existing pet.
46
+ - [ ] A stale daemon lock (dead PID) is reclaimed; a live one forwards.
47
+
48
+ ## Companion App Smoke Test (per OS)
49
+
50
+ Run from `apps/companion` after `npm install`:
51
+
52
+ - [ ] `npm start` opens the overlay; empty space stays click-through.
53
+ - [ ] Single click → waving; double click → jumping; drag moves and persists.
54
+ - [ ] Running `haya-pet run --client generic -- sleep 10` shows a session bubble.
55
+ - [ ] Two concurrent sessions show two bubbles without renderer conflicts.
56
+ - [ ] Selecting a bubble opens the task talk window (peek mode).
57
+ - [ ] Tray menu can show/hide the pet and reset its position.
58
+
59
+ ## Current Implementation Status
60
+
61
+ - Shared protocol/session/pet core: automated coverage exists.
62
+ - Pet asset manifest + validation + frame animator: automated coverage exists.
63
+ - Client adapters (info, generic/PTY heuristics, capabilities): automated coverage exists.
64
+ - Task talk core (status, events, store, approvals, replies, controls): automated coverage exists.
65
+ - Session summaries + bubble view models: automated coverage exists.
66
+ - Daemon singleton decision logic + tray menu model + position state file: automated coverage exists.
67
+ - Cross-OS platform paths and capabilities: automated coverage exists.
68
+ - Test-mode IPC server/client: automated coverage exists.
69
+ - Electron overlay app: implemented as glue (`apps/companion`) consuming the pure cores; requires `electron` install and manual run/QA per OS (not unit-tested).
70
+ - Production OS endpoint behavior: needs manual validation on Windows, macOS, and Linux.
71
+ - Terminal attachment: facade + documented IPC contract (`native/README.md`). Windows helper is implemented in .NET and compiles + runs; macOS/Linux helper binaries are still TODO. Node helper client (`terminal-helper-client.js`) is unit-tested.
72
+ - PTY output observer + reply/approval routing: implemented and unit-tested as pure cores. Live wiring (real PTY via node-pty; bidirectional IPC + real injectors) is a later phase.