@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 +43 -17
- package/apps/cli/src/haya-pet.js +157 -5
- package/apps/cli/test/haya-pet.test.mjs +165 -4
- package/apps/companion/package.json +1 -1
- package/apps/companion/test/position-store.test.mjs +2 -1
- package/docs/architecture.md +58 -4
- package/docs/known-issues.md +121 -49
- package/docs/troubleshooting.md +31 -1
- package/package.json +1 -1
- package/packages/adapters/src/claude-hooks.js +77 -0
- package/packages/adapters/src/claude-transcript.js +74 -0
- package/packages/adapters/test/claude-hooks.test.mjs +87 -0
- package/packages/adapters/test/claude-transcript.test.mjs +70 -0
- package/packages/app-state/src/state.js +16 -1
- package/packages/cli-core/src/claude-hook-injection.js +42 -0
- package/packages/cli-core/src/claude-transcript-watcher.js +185 -0
- package/packages/cli-core/src/run-command.js +7 -3
- package/packages/cli-core/src/run-state.js +87 -0
- package/packages/cli-core/test/claude-hook-injection.test.mjs +45 -0
- package/packages/cli-core/test/claude-transcript-watcher.test.mjs +121 -0
- package/packages/cli-core/test/run-command.test.mjs +20 -0
- package/packages/cli-core/test/run-state.test.mjs +113 -0
package/docs/known-issues.md
CHANGED
|
@@ -1,49 +1,121 @@
|
|
|
1
|
-
# Known Issues
|
|
2
|
-
|
|
3
|
-
Issues found in live use
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- **
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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` |
|
|
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
|
@@ -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
|
+
}
|