@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.
@@ -1,49 +1,121 @@
1
- # Known Issues (deferred)
2
-
3
- Issues found in live use that are **recorded for later** — not yet fixed. The
4
- decision on *how* to fix is pending.
5
-
6
- ## 1. Terminal scrolling breaks when running a CLI through `haya-pet run` (observe/PTY mode)
7
-
8
- - **Symptom:** While a CLI is running under `haya-pet run` (default `--observe`), the
9
- terminal window can no longer scroll normally.
10
- - **Trigger:** Only in observe (PTY) mode. The plain `--no-observe` path
11
- (`stdio: "inherit"`) does not have this problem.
12
- - **Diagnosis:** Observe mode runs the CLI inside a `node-pty`/ConPTY pseudo-terminal
13
- with a fixed `cols × rows` and tees its output to our `stdout`
14
- (`packages/cli-core/src/pty-runner.js`). Full-screen TUIs (Claude Code, Codex)
15
- render into that fixed grid, so the outer terminal's scrollback only ever sees
16
- redraws of the grid rather than a growing transcript — there is effectively
17
- nothing meaningful to scroll. On Windows there is an extra layer: shims are run
18
- as `cmd /d /s /c "<shim> ..."` *inside* the PTY, so `cmd.exe` is the PTY's
19
- foreground process and the TUI is its child.
20
- - **Notes for a future fix:** Tools like `script`/`asciinema` interpose a PTY
21
- without breaking the terminal, so a faithful passthrough is achievable. Avenues:
22
- resolve the shim and spawn the real executable directly in the PTY (drop the
23
- `cmd /c` layer); verify `cols/rows` always match the host; revisit whether
24
- observe should be the default for interactive TUIs vs. native passthrough.
25
-
26
- ## 2. Backspace deletes a whole word instead of one character (observe/PTY mode)
27
-
28
- - **Symptom:** While a CLI is running under `haya-pet run`, pressing Backspace
29
- deletes an entire word rather than a single character.
30
- - **Trigger:** Only in observe (PTY) mode. Native (`--no-observe`) is unaffected.
31
- - **Diagnosis:** stdin is put in raw mode and forwarded byte-for-byte into the PTY
32
- (`pty-runner.js` `forwardInput` `child.write(chunk.toString("utf8"))`). The
33
- whole-word deletion points to a key-encoding/line-discipline mismatch between the
34
- host terminal (Windows Terminal/conhost) and the nested ConPTY (and possibly the
35
- intermediate `cmd /c`) e.g. Backspace `0x7f`/`0x08` being interpreted by the
36
- inner app as a word-delete (Ctrl+W `0x17` / Alt+Backspace `ESC 0x7f`), or input
37
- bytes being re-segmented across `data` events.
38
- - **Notes for a future fix:** Forward stdin as raw bytes without a UTF-8 round-trip;
39
- audit the exact bytes the host sends for Backspace vs. what the inner app receives
40
- (a small PTY echo harness); consider removing the `cmd /c` layer; re-evaluate
41
- raw-mode handling. Hard to fully verify without interactive testing.
42
-
43
- ## Shared root cause & the open decision
44
-
45
- Both issues stem from observe mode interposing a pseudo-terminal on an interactive
46
- session. The plain wrapper path avoids them entirely but cannot report fine-grained
47
- "thinking / running tools" status. The open product decision: **native terminal by
48
- default (perfect fidelity, coarser status) vs. keep PTY observation default and
49
- invest in faithful passthrough.** Deferred until the maintainer decides.
1
+ # Known Issues
2
+
3
+ Issues found in live use, with their current status.
4
+
5
+ ## ✅ Resolved: pet stuck on "waiting for approval" after a manual denial
6
+
7
+ - **Symptom:** With Claude Code hooks enabled, denying a permission prompt left the
8
+ pet showing *waiting for approval* indefinitely (until the next turn), even
9
+ though nothing was pending.
10
+ - **Root cause:** Claude Code fires **no hook** when the user manually denies a
11
+ permission — not `Stop`, not `PostToolUse`, not `PermissionDenied` (that one is
12
+ only for *auto-mode* classifier denials). Verified by `HAYA_PET_HOOK_DEBUG`
13
+ traces: the event stream simply stops after `Notification(permission_prompt)`.
14
+ So the hook-driven status had no event to clear `waiting_approval`. A timeout was
15
+ rejected it would wrongly clear a *genuinely* pending approval if the user
16
+ stepped away.
17
+ - **Fix:** An **L3 transcript watcher** (`claude-transcript-watcher.js`) tails the
18
+ session JSONL (`~/.claude/projects/<sanitized-cwd>/<id>.jsonl`, matched
19
+ case-insensitively). Claude records a denial as a `tool_result` with
20
+ `is_error: true` and a "user doesn't want to proceed / user rejected" marker —
21
+ ground truth, not a timer. On seeing it, the wrapper reports `idle`
22
+ (source `client_log`). A genuinely-pending approval has no such result yet, so
23
+ the alert correctly stays up until the user actually decides. Also split the
24
+ `Notification` hook by type (`permission_prompt`→approval, `idle_prompt`→idle) so
25
+ non-approval notifications no longer masquerade as approvals.
26
+
27
+ ## ✅ Resolved: Claude Code TUI accepted no keyboard input when hooks were injected
28
+
29
+ - **Symptom:** With per-session hooks injected by default (`claude --settings
30
+ <tempfile>`), Claude Code launched but the interactive TUI accepted no typing
31
+ the session was unusable. Native passthrough *without* injection was fine.
32
+ - **Root cause:** Two compounding problems. (1) The injected hook **command string
33
+ baked a per-session `--session <uuid>`**, and the settings file used a fresh
34
+ `mkdtemp` path each launch so Claude saw "new, untrusted hooks" every time and
35
+ blocked the TUI on its *review hooks* trust prompt. (2) Injection was the
36
+ **default**, so the very first interactive Claude run broke out of the box.
37
+ - **Fix:** (a) Hook injection is now **opt-in** via `HAYA_PET_HOOKS=1`; the default
38
+ is native passthrough with lifecycle status, which never disrupts the session.
39
+ (b) When enabled, the hook command string is **stable** (the session id is passed
40
+ via the `HAYA_PET_SESSION_ID` env var, read by `haya-pet state`, instead of being
41
+ baked in) and written to a **stable settings path**, so Claude's trust prompt
42
+ only needs approving once rather than on every launch.
43
+
44
+ ## ✅ Resolved: terminal fidelity broke under `haya-pet run` (Shift+Tab / scroll / word-edit)
45
+
46
+ - **Symptoms (older builds):** While a CLI ran under `haya-pet run` (which used to
47
+ default to `--observe`), the terminal lost fidelity **Shift+Tab** did nothing,
48
+ the **mouse wheel** couldn't scroll, and **Backspace/word-editing** behaved wrong
49
+ (e.g. deleting a whole word, or no letter-by-letter delete).
50
+ - **Root cause:** Observe mode interposed a `node-pty`/ConPTY pseudo-terminal
51
+ (plus, on Windows, an intermediate `cmd /d /s /c "<shim>"` layer) between the host
52
+ terminal and the interactive TUI. Special input — Shift+Tab (`ESC [ Z`),
53
+ Alt/Ctrl word-edits, and mouse-wheel reporting — is not ordinary bytes; it must
54
+ survive ConPTY's VT→`INPUT_RECORD`→VT round-trip, which on Windows mangles or
55
+ drops those sequences and does not transparently pass mouse events. The fixed
56
+ `cols × rows` grid also left the host's scrollback with nothing to scroll. This
57
+ was **architectural**: no amount of stdin-forwarding tweaking makes the ConPTY
58
+ round-trip lossless for mouse + special keys. The plain `stdio: "inherit"` path
59
+ never had the problem.
60
+ - **Fix:** `haya-pet run` now defaults to **native passthrough** (`stdio: "inherit"`)
61
+ — the CLI talks directly to your terminal, so all input modes work exactly as they
62
+ do without the wrapper. Rich live status is available **opt-in** instead via the
63
+ client's own hooks; for **Claude Code** these are injected **per session** via
64
+ `claude --settings` when you set `HAYA_PET_HOOKS=1` (no change to the user's
65
+ global config, no overhead on their other Claude sessions), which is *higher*
66
+ fidelity than the old output scraping and costs nothing in the terminal. PTY
67
+ observation is still available as
68
+ an explicit opt-in (`haya-pet run --observe …`) for non-interactive runs, where the
69
+ fidelity tradeoff doesn't matter.
70
+ - **Native-mode follow-ups (also fixed):** the wrapper now installs an interrupt
71
+ guard so Ctrl+C reaches the CLI (which exits gracefully) instead of killing the
72
+ wrapper before it can report the exit, and forwards `SIGTERM`/`SIGBREAK` to the
73
+ child. Sessions whose wrapper is hard-killed without unregistering are marked
74
+ stale ~15s after the heartbeat stops and dropped at 60s, so the pet no longer
75
+ sits on a phantom *idle*. The Claude hooks use Claude Code's documented
76
+ empty-string "match all tools" matcher (a bare `*` is an invalid regex that would
77
+ silently never fire).
78
+
79
+ ## Per-client status adapters
80
+
81
+ Status comes from each client's native hooks where implemented, with PTY
82
+ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
83
+
84
+ - **Claude Code** — native passthrough by default (full terminal fidelity,
85
+ lifecycle status). Live in-session status is **opt-in** via `HAYA_PET_HOOKS=1`,
86
+ which injects a settings file (`claude --settings <stable-file>`, no change to
87
+ your global config) wiring Claude's `UserPromptSubmit`/`PreToolUse`/`PostToolUse`/
88
+ `Notification`/`PreCompact`/`Stop`/`SubagentStop` events to `haya-pet state
89
+ <state>`, reported to the daemon over the IPC pipe. `PreToolUse` distinguishes
90
+ file-editing tools (`Edit`/`Write`/`MultiEdit`/`NotebookEdit` → *editing files*)
91
+ from other tools (→ *running tools*) via the hook `matcher`. **Why opt-in:**
92
+ injecting hooks makes Claude show a one-time *review hooks* trust prompt; the
93
+ command string + settings path are kept stable across sessions so it only needs
94
+ approving once (a volatile per-session argument would re-trigger it every
95
+ launch — see the resolved note below). `--observe` is a separate PTY opt-in for
96
+ non-interactive runs (terminal-fidelity tradeoff).
97
+ - **Codex** — **not yet implemented** (no hook injection). Uses `--observe` PTY
98
+ observation or L1 lifecycle. A Codex hook adapter (temp `<cwd>/.codex/hooks.json`
99
+ + `--dangerously-bypass-hook-trust`) is a planned follow-up; note the open
100
+ upstream bug where hooks may not fire in interactive sessions
101
+ ([#17532](https://github.com/openai/codex/issues/17532)).
102
+ - **Antigravity (`agy`)** — **not yet implemented** (no hook injection). Uses
103
+ `--observe` or L1 lifecycle. A Gemini-schema hook adapter is a planned follow-up.
104
+ - **Generic / unknown** — no hooks; PTY observation (`--observe`) or L1 lifecycle.
105
+
106
+ A future **L3 client-log adapter** (tailing e.g. `~/.codex/sessions` or
107
+ Antigravity's `transcript.jsonl`) could provide activity without a PTY or hooks
108
+ for the clients that lack a hook adapter.
109
+
110
+ ## Status sources, by fidelity
111
+
112
+ | Tier | Source | How |
113
+ |---|---|---|
114
+ | L1 | process wrapper | default; session lifecycle + exit code |
115
+ | L4 | client hooks | opt-in via `HAYA_PET_HOOKS=1` (Claude Code); reports through `haya-pet state …` |
116
+ | L2 | PTY output scraping | opt-in via `--observe` (terminal-fidelity tradeoff) |
117
+
118
+ Native passthrough (L1) + opt-in hooks (L4) is the recommended setup for interactive
119
+ TUIs: perfect terminal fidelity *and* the richest status. Use `--observe` (L2)
120
+ only when you want coarse activity tracking for a non-interactive command and
121
+ don't care about terminal fidelity.
@@ -8,13 +8,43 @@ deferred problems with known root causes.
8
8
  | `haya-pet: command not found` | Install globally (`npm i -g …`), or in a source checkout run `npm link` in the repo root, or call the file directly: `node <repo>/apps/cli/src/haya-pet.js`. |
9
9
  | Running a CLI starts the pet but not the command | Fixed — update to the latest version. (Was caused by the auto-start poll exiting early.) |
10
10
  | Pet doesn't react to a session | Launch the CLI via `haya-pet run …`. If the overlay didn't auto-start, run `haya-pet start`, or check `HAYA_PET_NO_AUTOSTART` isn't set. |
11
+ | Pet shows complete/working while an approval prompt is waiting | Fixed — update to the latest version. See "Approval prompt hidden by idle/working" below. |
11
12
  | Pet shows a blue placeholder box | No spritesheet found — add a pet (see the README); behaviour is otherwise correct. |
12
13
  | Pet is off-screen / can't find it | Tray icon → **Reset Position**. |
13
14
  | Can't exit the pet | `haya-pet stop`, or right-click the tray icon → **Quit**. |
14
15
  | `haya-pet pets` shows "No pets found" | Add a pet folder with **both** `pet.json` and a spritesheet to a search path. |
15
- | Terminal scroll / backspace odd while a CLI runs under `haya-pet run` | Known PTY-observation tradeoff see [known-issues.md](known-issues.md). Workaround: `haya-pet run --no-observe …`. |
16
+ | Terminal scroll / Shift+Tab / backspace odd while a CLI runs under `haya-pet run` | Fixed — `haya-pet run` now uses native passthrough by default (full fidelity). If you opted into `--observe`, drop it. See [known-issues.md](known-issues.md). |
17
+ | Pet shows only **idle/lifecycle** while **Claude Code** works | Live in-session status is opt-in: run `haya-pet hooks on` once (persisted). The first `haya-pet run` afterward shows a one-time Claude *review hooks* prompt — approve it. Also make sure the companion is running (`haya-pet start`). Check the toggle with `haya-pet hooks status`. |
18
+ | Typing doesn't work / **Claude Code** TUI frozen under `haya-pet run` | You have hooks enabled and Claude is showing its *review hooks* trust prompt (approve it once), or your Claude is too old for `--settings`. Run `haya-pet hooks off` (or set `HAYA_PET_NO_HOOKS=1`) for native passthrough with lifecycle-only status — typing and Shift+Tab work normally. |
19
+ | Pet shows only **idle/lifecycle** while **Codex** works | Codex has no hook adapter yet — only Claude Code reports live status via hooks. Add `--observe` for coarse PTY activity (terminal-fidelity tradeoff), or accept lifecycle-only status. |
20
+ | Pet shows only **idle/lifecycle** while **Antigravity** (`agy`) works | Antigravity has no hook adapter yet. Add `--observe` for coarse PTY activity, or accept lifecycle-only status. |
21
+ | Claude hooks fail with **"hook exited with code 1"** | The hook command must not bake an **fnm**/node-manager *per-shell* node path (`…\fnm_multishells\<pid>_…\node.exe`) that dies when the shell exits. haya-pet bakes the stable `realpath`-resolved node path into the temp settings instead. Update to the latest version. |
22
+ | Pet shows only **idle** for a generic / unknown CLI | Expected without a hook adapter — add `--observe` for PTY observation, otherwise lifecycle only. |
23
+ | Pet stayed on **waiting for approval** after I denied a tool | Fixed — Claude fires no hook on a manual denial, so the wrapper tails the session transcript and clears to **idle** when the denial is recorded. A genuinely-pending approval (you haven't decided yet) correctly keeps alerting — there's no timer. |
24
+ | Want to see which status events fire | Set `HAYA_PET_HOOK_DEBUG=<file.jsonl>` before `haya-pet run`; each hook- and transcript-sourced status appends one JSON line (timestamp, state, and source/event). |
25
+ | Pet stays **idle** after force-quitting a CLI | The wrapper marks the session stale ~15s after the heartbeat stops, then drops it. Exiting normally (incl. Ctrl+C) reports **exited** immediately. |
26
+ | Ctrl+C doesn't exit the CLI cleanly under `haya-pet run` | Fixed — the wrapper no longer dies on Ctrl+C; the signal reaches the CLI, which exits, and the pet shows the result. |
16
27
  | `ENOENT … electron\path.txt` | Electron's install extraction was interrupted — see below. |
17
28
 
29
+ ## Approval prompt hidden by idle/working
30
+
31
+ Older builds could show a green complete check when the AI CLI was actually
32
+ waiting on approval, or show working when other terminal output arrived beside
33
+ the approval prompt. The cause was low-fidelity PTY activity/idle observation
34
+ overwriting `waiting_approval`.
35
+
36
+ The fix keeps user-action states sticky in the observer and preserves native
37
+ hook/plugin approval states over lower-fidelity PTY updates. With native
38
+ passthrough now the default, the most reliable approval signal is the client's
39
+ own hooks — for Claude Code, enabling `HAYA_PET_HOOKS=1` injects settings that
40
+ wire the `Notification` hook to a sticky *waiting for approval* state. Wrapped
41
+ clients also receive `HAYA_PET_SESSION_ID`, so any hook can report directly:
42
+
43
+ ```bash
44
+ haya-pet state waiting_approval --summary "approval needed"
45
+ haya-pet state running_tool --summary "approval resolved"
46
+ ```
47
+
18
48
  ## Fixing a broken Electron install
19
49
 
20
50
  If launching the overlay fails with `ENOENT … node_modules\electron\path.txt`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "license": "MIT",
@@ -0,0 +1,77 @@
1
+ // Pure builders for Claude Code per-session hook settings. No I/O here — callers
2
+ // resolve paths and write the file. Each hook entry runs the `haya-pet state`
3
+ // reporter with a fixed state (and optional summary) baked into the command.
4
+
5
+ const EDIT_TOOLS = ["Edit", "Write", "MultiEdit", "NotebookEdit"];
6
+ const EDIT_TOOLS_MATCHER = EDIT_TOOLS.join("|");
7
+ // Negative lookahead: match any tool that is NOT one of the edit tools, so the
8
+ // two PreToolUse entries never both fire for the same tool.
9
+ const NON_EDIT_MATCHER = `^(?!(${EDIT_TOOLS_MATCHER})$).*`;
10
+
11
+ // The hook table. Each entry → one Claude hook that reports a fixed pet state.
12
+ // `matcher` (when present) filters the event: tool name for PreToolUse, or the
13
+ // notification type for Notification. `summary` is an optional short label that
14
+ // both shows in the bubble and identifies the event in HAYA_PET_HOOK_DEBUG logs.
15
+ //
16
+ // Notification is split by type: only `permission_prompt` means "waiting for
17
+ // approval"; `idle_prompt` (Claude sat idle waiting for input) means idle. Other
18
+ // notification types (auth_success, elicitation_*) are intentionally ignored.
19
+ //
20
+ // PermissionDenied / PostToolUseFailure / StopFailure are subscribed so the pet
21
+ // recovers from a denied or failed tool call — Claude fires no Stop on a denial,
22
+ // so without these the pet would stay on `waiting_approval` until the next turn.
23
+ const HOOK_TABLE = Object.freeze([
24
+ { event: "UserPromptSubmit", state: "thinking" },
25
+ { event: "PreToolUse", matcher: EDIT_TOOLS_MATCHER, state: "editing_files" },
26
+ { event: "PreToolUse", matcher: NON_EDIT_MATCHER, state: "running_tool" },
27
+ { event: "PostToolUse", state: "thinking" },
28
+ { event: "PostToolUseFailure", state: "thinking", summary: "tool-failed" },
29
+ // PermissionRequest fires the instant the dialog appears — earlier than the
30
+ // permission_prompt Notification, so the "waiting for approval" cue is snappy.
31
+ { event: "PermissionRequest", state: "waiting_approval" },
32
+ { event: "Notification", matcher: "permission_prompt", state: "waiting_approval" },
33
+ { event: "Notification", matcher: "idle_prompt", state: "idle", summary: "idle" },
34
+ { event: "PermissionDenied", state: "idle", summary: "denied" },
35
+ { event: "PreCompact", state: "compacting" },
36
+ { event: "Stop", state: "idle" },
37
+ { event: "StopFailure", state: "idle", summary: "stopped" },
38
+ { event: "SubagentStop", state: "idle" }
39
+ ]);
40
+
41
+ // Resolve the pet state for a Claude event. `detail` is the tool name for
42
+ // PreToolUse or the notification type for Notification. Exposed for testing and
43
+ // to keep the mapping logic in one place.
44
+ export function mapClaudeEventToState(event, detail) {
45
+ if (event === "PreToolUse") {
46
+ return EDIT_TOOLS.includes(detail) ? "editing_files" : "running_tool";
47
+ }
48
+ if (event === "Notification") {
49
+ if (detail === "permission_prompt") return "waiting_approval";
50
+ if (detail === "idle_prompt") return "idle";
51
+ return undefined;
52
+ }
53
+ const entry = HOOK_TABLE.find((row) => row.event === event && row.matcher === undefined);
54
+ return entry?.state;
55
+ }
56
+
57
+ export function buildClaudeHookSettings({ nodePath, cliPath }) {
58
+ const command = (state, summary) => {
59
+ const base = `${quote(nodePath)} ${quote(cliPath)} state ${state}`;
60
+ return summary ? `${base} --summary ${summary}` : base;
61
+ };
62
+
63
+ const hooks = {};
64
+ for (const row of HOOK_TABLE) {
65
+ const hookEntry = { hooks: [{ type: "command", command: command(row.state, row.summary) }] };
66
+ if (row.matcher !== undefined) {
67
+ hookEntry.matcher = row.matcher;
68
+ }
69
+ (hooks[row.event] ??= []).push(hookEntry);
70
+ }
71
+
72
+ return { hooks };
73
+ }
74
+
75
+ function quote(value) {
76
+ return `"${String(value).replace(/"/g, '\\"')}"`;
77
+ }
@@ -0,0 +1,74 @@
1
+ // Pure parser for Claude Code session transcript (JSONL) lines. The transcript
2
+ // is ground truth for what actually happened in a turn — in particular it records
3
+ // a `tool_result` for every tool call, including when the USER REJECTS a
4
+ // permission prompt (Claude fires no hook on a manual denial, so this is the only
5
+ // reliable signal that a pending approval was resolved by a denial).
6
+ //
7
+ // The transcript format is stable but undocumented, so every access is defensive.
8
+
9
+ // Marker phrases Claude writes into a rejected tool's result. Matched
10
+ // case-insensitively; kept broad so a wording tweak doesn't silently break us.
11
+ const REJECTION_MARKERS = Object.freeze([
12
+ "user doesn't want to proceed",
13
+ "user rejected",
14
+ "tool use was rejected",
15
+ "rejected the tool",
16
+ "user has denied",
17
+ "user chose not to"
18
+ ]);
19
+
20
+ // Returns a resolution event for a single transcript line, or undefined.
21
+ // Currently we only surface denials — the approve path is already covered by the
22
+ // PostToolUse hook, and emitting on every result would race with it.
23
+ export function parseTranscriptLine(line) {
24
+ let entry;
25
+ try {
26
+ entry = JSON.parse(line);
27
+ } catch {
28
+ return undefined;
29
+ }
30
+
31
+ const content = entry?.message?.content;
32
+ if (!Array.isArray(content)) {
33
+ return undefined;
34
+ }
35
+
36
+ for (const block of content) {
37
+ if (block?.type === "tool_result" && block.is_error === true) {
38
+ const text = extractText(block.content).toLowerCase();
39
+ if (REJECTION_MARKERS.some((marker) => text.includes(marker))) {
40
+ return { type: "tool_denied", toolUseId: block.tool_use_id };
41
+ }
42
+ }
43
+ }
44
+
45
+ return undefined;
46
+ }
47
+
48
+ // Parse a batch of newly-appended lines, returning the resolution events found.
49
+ export function parseTranscriptLines(lines) {
50
+ const events = [];
51
+ for (const line of lines) {
52
+ if (typeof line !== "string" || line.trim() === "") {
53
+ continue;
54
+ }
55
+ const event = parseTranscriptLine(line);
56
+ if (event) {
57
+ events.push(event);
58
+ }
59
+ }
60
+ return events;
61
+ }
62
+
63
+ function extractText(content) {
64
+ if (typeof content === "string") {
65
+ return content;
66
+ }
67
+ if (Array.isArray(content)) {
68
+ return content.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join(" ");
69
+ }
70
+ if (content && typeof content === "object" && typeof content.text === "string") {
71
+ return content.text;
72
+ }
73
+ return "";
74
+ }
@@ -0,0 +1,87 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { buildClaudeHookSettings, mapClaudeEventToState } from "../src/claude-hooks.js";
4
+
5
+ test("mapClaudeEventToState covers activity events", () => {
6
+ assert.equal(mapClaudeEventToState("UserPromptSubmit"), "thinking");
7
+ assert.equal(mapClaudeEventToState("PostToolUse"), "thinking");
8
+ assert.equal(mapClaudeEventToState("PostToolUseFailure"), "thinking");
9
+ assert.equal(mapClaudeEventToState("PreCompact"), "compacting");
10
+ assert.equal(mapClaudeEventToState("Stop"), "idle");
11
+ assert.equal(mapClaudeEventToState("StopFailure"), "idle");
12
+ assert.equal(mapClaudeEventToState("SubagentStop"), "idle");
13
+ assert.equal(mapClaudeEventToState("PermissionDenied"), "idle");
14
+ assert.equal(mapClaudeEventToState("PermissionRequest"), "waiting_approval");
15
+ assert.equal(mapClaudeEventToState("Unknown"), undefined);
16
+ });
17
+
18
+ test("mapClaudeEventToState branches PreToolUse on tool name", () => {
19
+ assert.equal(mapClaudeEventToState("PreToolUse", "Bash"), "running_tool");
20
+ assert.equal(mapClaudeEventToState("PreToolUse", "Edit"), "editing_files");
21
+ assert.equal(mapClaudeEventToState("PreToolUse", "MultiEdit"), "editing_files");
22
+ });
23
+
24
+ test("mapClaudeEventToState branches Notification on type (approval vs idle)", () => {
25
+ assert.equal(mapClaudeEventToState("Notification", "permission_prompt"), "waiting_approval");
26
+ assert.equal(mapClaudeEventToState("Notification", "idle_prompt"), "idle");
27
+ // Other notification types are ignored, not treated as approval.
28
+ assert.equal(mapClaudeEventToState("Notification", "auth_success"), undefined);
29
+ });
30
+
31
+ test("buildClaudeHookSettings bakes node + cli, no volatile session id", () => {
32
+ const settings = buildClaudeHookSettings({
33
+ nodePath: "/usr/bin/node",
34
+ cliPath: "/app/haya-pet.js"
35
+ });
36
+
37
+ const cmd = settings.hooks.UserPromptSubmit[0].hooks[0].command;
38
+ assert.equal(settings.hooks.UserPromptSubmit[0].hooks[0].type, "command");
39
+ assert.match(cmd, /"\/usr\/bin\/node"/);
40
+ assert.match(cmd, /"\/app\/haya-pet\.js"/);
41
+ assert.match(cmd, /state thinking$/);
42
+ assert.doesNotMatch(JSON.stringify(settings), /--session/);
43
+ });
44
+
45
+ test("buildClaudeHookSettings is stable across calls (for trust caching)", () => {
46
+ const a = buildClaudeHookSettings({ nodePath: "n", cliPath: "c" });
47
+ const b = buildClaudeHookSettings({ nodePath: "n", cliPath: "c" });
48
+ assert.deepEqual(a, b);
49
+ });
50
+
51
+ test("buildClaudeHookSettings splits Notification into approval + idle matchers", () => {
52
+ const settings = buildClaudeHookSettings({ nodePath: "n", cliPath: "c" });
53
+ const note = settings.hooks.Notification;
54
+ assert.equal(note.length, 2);
55
+
56
+ const approval = note.find((e) => e.matcher === "permission_prompt");
57
+ const idle = note.find((e) => e.matcher === "idle_prompt");
58
+ assert.match(approval.hooks[0].command, /state waiting_approval$/);
59
+ assert.match(idle.hooks[0].command, /state idle --summary idle$/);
60
+ });
61
+
62
+ test("buildClaudeHookSettings keeps two non-overlapping PreToolUse matchers", () => {
63
+ const pre = buildClaudeHookSettings({ nodePath: "n", cliPath: "c" }).hooks.PreToolUse;
64
+ assert.equal(pre.length, 2);
65
+ const edit = pre.find((e) => /editing_files/.test(e.hooks[0].command));
66
+ const other = pre.find((e) => /running_tool/.test(e.hooks[0].command));
67
+ assert.equal(edit.matcher, "Edit|Write|MultiEdit|NotebookEdit");
68
+ assert.equal(other.matcher, "^(?!(Edit|Write|MultiEdit|NotebookEdit)$).*");
69
+ });
70
+
71
+ test("buildClaudeHookSettings subscribes to denial/failure recovery events", () => {
72
+ const settings = buildClaudeHookSettings({ nodePath: "n", cliPath: "c" });
73
+ assert.match(settings.hooks.PermissionDenied[0].hooks[0].command, /state idle --summary denied$/);
74
+ assert.match(settings.hooks.PostToolUseFailure[0].hooks[0].command, /state thinking --summary tool-failed$/);
75
+ assert.match(settings.hooks.StopFailure[0].hooks[0].command, /state idle --summary stopped$/);
76
+ });
77
+
78
+ test("buildClaudeHookSettings includes all subscribed events", () => {
79
+ const settings = buildClaudeHookSettings({ nodePath: "n", cliPath: "c" });
80
+ for (const event of [
81
+ "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure",
82
+ "PermissionRequest", "Notification", "PermissionDenied", "PreCompact",
83
+ "Stop", "StopFailure", "SubagentStop"
84
+ ]) {
85
+ assert.ok(settings.hooks[event], `missing hook event ${event}`);
86
+ }
87
+ });
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import { parseTranscriptLine, parseTranscriptLines } from "../src/claude-transcript.js";
4
+
5
+ function rejectionLine(toolUseId, phrase = "The user doesn't want to proceed with this tool use.") {
6
+ return JSON.stringify({
7
+ type: "user",
8
+ message: {
9
+ role: "user",
10
+ content: [{ type: "tool_result", tool_use_id: toolUseId, is_error: true, content: phrase }]
11
+ }
12
+ });
13
+ }
14
+
15
+ test("parseTranscriptLine detects a user-rejected tool result", () => {
16
+ assert.deepEqual(parseTranscriptLine(rejectionLine("toolu_1")), {
17
+ type: "tool_denied",
18
+ toolUseId: "toolu_1"
19
+ });
20
+ });
21
+
22
+ test("parseTranscriptLine ignores a normal tool error (not a rejection)", () => {
23
+ const line = JSON.stringify({
24
+ message: { content: [{ type: "tool_result", tool_use_id: "t", is_error: true, content: "command failed: exit 1" }] }
25
+ });
26
+ assert.equal(parseTranscriptLine(line), undefined);
27
+ });
28
+
29
+ test("parseTranscriptLine ignores successful results and other entries", () => {
30
+ const ok = JSON.stringify({
31
+ message: { content: [{ type: "tool_result", tool_use_id: "t", is_error: false, content: "done" }] }
32
+ });
33
+ const assistant = JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "hi" }] } });
34
+ assert.equal(parseTranscriptLine(ok), undefined);
35
+ assert.equal(parseTranscriptLine(assistant), undefined);
36
+ });
37
+
38
+ test("parseTranscriptLine handles array-form tool_result content", () => {
39
+ const line = JSON.stringify({
40
+ message: {
41
+ content: [{
42
+ type: "tool_result",
43
+ tool_use_id: "toolu_2",
44
+ is_error: true,
45
+ content: [{ type: "text", text: "The user rejected this command." }]
46
+ }]
47
+ }
48
+ });
49
+ assert.deepEqual(parseTranscriptLine(line), { type: "tool_denied", toolUseId: "toolu_2" });
50
+ });
51
+
52
+ test("parseTranscriptLine never throws on malformed input", () => {
53
+ assert.equal(parseTranscriptLine("not json"), undefined);
54
+ assert.equal(parseTranscriptLine("{}"), undefined);
55
+ assert.equal(parseTranscriptLine(JSON.stringify({ message: { content: "x" } })), undefined);
56
+ });
57
+
58
+ test("parseTranscriptLines collects only denial events from a batch", () => {
59
+ const lines = [
60
+ JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "ok" }] } }),
61
+ "",
62
+ rejectionLine("toolu_a"),
63
+ "garbage",
64
+ rejectionLine("toolu_b")
65
+ ];
66
+ assert.deepEqual(parseTranscriptLines(lines), [
67
+ { type: "tool_denied", toolUseId: "toolu_a" },
68
+ { type: "tool_denied", toolUseId: "toolu_b" }
69
+ ]);
70
+ });
@@ -12,11 +12,26 @@ export function createDefaultPositionState() {
12
12
  sessions: {},
13
13
  settings: {
14
14
  displayMode: "hybrid",
15
- attachBubblesToTerminals: true
15
+ attachBubblesToTerminals: true,
16
+ claudeHooks: false
16
17
  }
17
18
  };
18
19
  }
19
20
 
21
+ export function setClaudeHooksEnabled(state, enabled) {
22
+ return {
23
+ ...state,
24
+ settings: {
25
+ ...state.settings,
26
+ claudeHooks: Boolean(enabled)
27
+ }
28
+ };
29
+ }
30
+
31
+ export function getClaudeHooksEnabled(state) {
32
+ return Boolean(state?.settings?.claudeHooks);
33
+ }
34
+
20
35
  export function updateGlobalPetPosition(state, position) {
21
36
  return {
22
37
  ...state,
@@ -0,0 +1,42 @@
1
+ // Resolves stable paths, builds the per-session Claude settings, and writes them
2
+ // to a STABLE temp path. Both the command strings and the file path are kept
3
+ // identical across sessions so Claude Code's hook-trust review (which would
4
+ // otherwise block the interactive TUI) only has to be approved once. node version
5
+ // managers (fnm) hand out a per-shell symlink for process.execPath that dies when
6
+ // the launching shell exits, so we realpath it before baking it into the hook.
7
+ import { realpathSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { buildClaudeHookSettings } from "../../adapters/src/claude-hooks.js";
12
+
13
+ const DEFAULT_CLI_PATH = fileURLToPath(new URL("../../../apps/cli/src/haya-pet.js", import.meta.url));
14
+ const SETTINGS_FILE = "haya-pet-claude-settings.json";
15
+
16
+ export function injectClaudeHooks({ nodePath, cliPath } = {}) {
17
+ const resolvedNode = nodePath ?? safeRealpath(process.execPath);
18
+ const resolvedCli = cliPath ?? safeRealpath(DEFAULT_CLI_PATH);
19
+
20
+ const settings = buildClaudeHookSettings({
21
+ nodePath: resolvedNode,
22
+ cliPath: resolvedCli
23
+ });
24
+
25
+ // A fixed path with session-independent content: concurrent sessions just
26
+ // rewrite identical bytes, and the hooks stay "trusted" across launches.
27
+ const settingsPath = join(tmpdir(), SETTINGS_FILE);
28
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
29
+
30
+ // The settings file is stable and reusable on purpose — leaving it in place is
31
+ // what lets Claude remember the hooks are trusted. cleanup is a no-op kept for
32
+ // API symmetry with the caller's finally block.
33
+ return { settingsPath, cleanup: () => {} };
34
+ }
35
+
36
+ function safeRealpath(target) {
37
+ try {
38
+ return realpathSync(target);
39
+ } catch {
40
+ return target;
41
+ }
42
+ }