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