@hayasaka7/haya-pet 0.2.0 → 0.2.2
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/.github/workflows/ci.yml +75 -0
- package/CHANGELOG.md +112 -0
- package/README.md +31 -14
- package/apps/cli/src/haya-pet.js +110 -21
- package/apps/cli/test/haya-pet.test.mjs +111 -7
- package/apps/companion/src/main/index.js +40 -1
- package/apps/companion/src/renderer/task-talk-window.js +1 -1
- package/apps/companion/test/position-store.test.mjs +1 -1
- package/docs/architecture.md +33 -10
- package/docs/cross-os-qa.md +72 -0
- package/docs/known-issues.md +92 -9
- package/docs/troubleshooting.md +3 -1
- package/eslint.config.js +32 -0
- package/package.json +7 -1
- package/packages/adapters/src/codex-hooks.js +152 -0
- package/packages/adapters/src/codex-transcript.js +73 -0
- package/packages/adapters/test/codex-hooks.test.mjs +120 -0
- package/packages/adapters/test/codex-transcript.test.mjs +97 -0
- package/packages/app-state/src/state.js +10 -5
- package/packages/cli-core/src/codex-hook-injection.js +49 -0
- package/packages/cli-core/src/codex-transcript-watcher.js +160 -0
- package/packages/cli-core/src/run-command.js +0 -1
- package/packages/cli-core/test/codex-hook-injection.test.mjs +45 -0
- package/packages/cli-core/test/codex-transcript-watcher.test.mjs +108 -0
- package/packages/daemon-core/src/approval-process-watcher.js +169 -0
- package/packages/daemon-core/test/approval-process-watcher.test.mjs +295 -0
- package/packages/platform-core/src/process-snapshot.js +88 -0
- package/packages/platform-core/test/process-snapshot.test.mjs +105 -0
- package/packages/session-core/src/bubble-view.js +10 -7
- package/packages/session-core/test/bubble-view.test.mjs +30 -5
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# Run code quality checks and the test suite on every push that touches code.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
paths:
|
|
7
|
+
- "**/*.js"
|
|
8
|
+
- "**/*.mjs"
|
|
9
|
+
- "**/*.cjs"
|
|
10
|
+
- "package.json"
|
|
11
|
+
- "package-lock.json"
|
|
12
|
+
- ".github/workflows/ci.yml"
|
|
13
|
+
pull_request:
|
|
14
|
+
paths:
|
|
15
|
+
- "**/*.js"
|
|
16
|
+
- "**/*.mjs"
|
|
17
|
+
- "**/*.cjs"
|
|
18
|
+
- "package.json"
|
|
19
|
+
- "package-lock.json"
|
|
20
|
+
- ".github/workflows/ci.yml"
|
|
21
|
+
|
|
22
|
+
concurrency:
|
|
23
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
24
|
+
cancel-in-progress: true
|
|
25
|
+
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
|
|
29
|
+
jobs:
|
|
30
|
+
lint:
|
|
31
|
+
name: Code quality (ESLint)
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
|
|
36
|
+
- name: Set up Node.js
|
|
37
|
+
uses: actions/setup-node@v4
|
|
38
|
+
with:
|
|
39
|
+
node-version: 22
|
|
40
|
+
cache: npm
|
|
41
|
+
|
|
42
|
+
- name: Install dependencies
|
|
43
|
+
# Electron's binary isn't needed for linting or tests; skip the ~150 MB
|
|
44
|
+
# download so CI is fast and isn't at the mercy of the Electron CDN.
|
|
45
|
+
env:
|
|
46
|
+
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
|
47
|
+
run: npm ci
|
|
48
|
+
|
|
49
|
+
- name: Run ESLint
|
|
50
|
+
run: npm run lint
|
|
51
|
+
|
|
52
|
+
test:
|
|
53
|
+
name: Tests (Node ${{ matrix.node }} on ${{ matrix.os }})
|
|
54
|
+
runs-on: ${{ matrix.os }}
|
|
55
|
+
strategy:
|
|
56
|
+
fail-fast: false
|
|
57
|
+
matrix:
|
|
58
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
59
|
+
node: [20, 22]
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@v4
|
|
62
|
+
|
|
63
|
+
- name: Set up Node.js
|
|
64
|
+
uses: actions/setup-node@v4
|
|
65
|
+
with:
|
|
66
|
+
node-version: ${{ matrix.node }}
|
|
67
|
+
cache: npm
|
|
68
|
+
|
|
69
|
+
- name: Install dependencies
|
|
70
|
+
env:
|
|
71
|
+
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
|
72
|
+
run: npm ci
|
|
73
|
+
|
|
74
|
+
- name: Run the test suite
|
|
75
|
+
run: npm test
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Haya Pet are documented here. This project adheres to
|
|
4
|
+
[Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
> Note: some entries originally drafted under 0.2.0 actually landed *after* the
|
|
7
|
+
> 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
|
|
8
|
+
> ships them.
|
|
9
|
+
|
|
10
|
+
## [0.2.2]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Session bubbles no longer reshuffle while sessions run.** Bubbles used to be
|
|
14
|
+
sorted by state urgency and latest activity, so every status change could move
|
|
15
|
+
a bubble up or down the stack mid-progress. They now stack by the time each
|
|
16
|
+
session **connected to the pet** — newest on top, first one at the bottom —
|
|
17
|
+
and that order stays fixed for the session's whole life. Urgency still shows
|
|
18
|
+
through each bubble's status icon, the collapsed-folder summary dot, and the
|
|
19
|
+
pet animation.
|
|
20
|
+
|
|
21
|
+
### Internal
|
|
22
|
+
- **CI on every code push** — a new GitHub Actions workflow lints and runs the
|
|
23
|
+
test suite (Ubuntu + Windows + macOS, Node 20/22) for any push or PR touching
|
|
24
|
+
code.
|
|
25
|
+
- **ESLint adopted** (`npm run lint`, flat config); the few existing findings
|
|
26
|
+
were fixed with no behavior change.
|
|
27
|
+
|
|
28
|
+
## [0.2.1]
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **Approval-accept detection** — when you **approve** a permission prompt for a
|
|
32
|
+
command, the pet now flips from *waiting for approval* to *working* a couple of
|
|
33
|
+
seconds after the command actually starts, instead of showing "waiting" for the
|
|
34
|
+
tool's whole run. Clients emit **no event at the accept moment** (verified for
|
|
35
|
+
Claude Code: no hook, no transcript record; `PostToolUse` only fires when the
|
|
36
|
+
tool *finishes*) — so for a long approved build/test the pet used to sit on
|
|
37
|
+
"waiting" for minutes. Detection is **event-based, never a timer**: while a
|
|
38
|
+
session waits, the companion watches the client's **process tree**, and only a
|
|
39
|
+
new process that verifiably starts under the client (and survives two
|
|
40
|
+
consecutive polls, filtering hook blips) counts as an approval. An unanswered
|
|
41
|
+
prompt spawns nothing, so the warning stays up until you actually decide.
|
|
42
|
+
In-process approvals (file edits) aren't detected but complete in milliseconds
|
|
43
|
+
after approval anyway. Windows verified live; macOS (`ps`) and Linux (`/proc`)
|
|
44
|
+
listers are included pending live hardware verification. Details in
|
|
45
|
+
`docs/known-issues.md`.
|
|
46
|
+
- **`haya-pet hooks on` / `off` / `status`** — persists the live-status preference,
|
|
47
|
+
so you enable it once instead of setting an env var every shell. The toggle is
|
|
48
|
+
**global**: it covers every hook-capable client (Claude Code and Codex).
|
|
49
|
+
`HAYA_PET_HOOKS=1` (on) / `HAYA_PET_NO_HOOKS=1` (off) still work as per-run overrides.
|
|
50
|
+
- **Codex live status via per-session hooks** (opt-in: `haya-pet hooks on`). haya-pet
|
|
51
|
+
injects a stable `~/.codex/haya-pet.config.toml` profile and launches
|
|
52
|
+
`codex -p haya-pet`, layering the hooks on top of your base config (auth/model/MCP
|
|
53
|
+
untouched). The hooks report through the same `haya-pet state` reporter, with full
|
|
54
|
+
terminal fidelity. First run shows Codex's one-time *review hooks* prompt; approve
|
|
55
|
+
it once. If you already pass your own `-p/--profile`, haya-pet skips injection and
|
|
56
|
+
says so (Codex allows only one profile). Hooks cover `thinking` (turn start /
|
|
57
|
+
after tools) and `idle` (turn end); a **Codex transcript watcher** fills in tool
|
|
58
|
+
activity (`running_tool` / `editing_files`) by tailing the session JSONL, since
|
|
59
|
+
Codex's `PreToolUse` hook doesn't fire upstream yet
|
|
60
|
+
([openai/codex#16732](https://github.com/openai/codex/issues/16732)).
|
|
61
|
+
*Waiting for approval* stays unavailable for Codex until that lands.
|
|
62
|
+
- **L3 transcript watcher (Claude Code)** — tails Claude's session JSONL to reliably
|
|
63
|
+
clear *waiting for approval* when a permission is **denied** (Claude fires no hook
|
|
64
|
+
on a manual denial). Ground-truth based, never a timer, so a genuinely-pending
|
|
65
|
+
approval keeps alerting until you actually decide.
|
|
66
|
+
- **`PermissionRequest` hook** for a snappier *waiting for approval* cue (fires the
|
|
67
|
+
instant the dialog appears, ahead of the notification).
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
- Pet stuck on *waiting for approval* after a manual **denial** (see the Claude
|
|
71
|
+
transcript watcher above).
|
|
72
|
+
- Pet stuck on *waiting for approval* after an **accept**, for as long as the
|
|
73
|
+
approved tool kept running (see approval-accept detection above).
|
|
74
|
+
- `Notification` events other than permission prompts (e.g. `idle_prompt`) were
|
|
75
|
+
mislabeled as *waiting for approval*; they are now mapped correctly.
|
|
76
|
+
|
|
77
|
+
## [0.2.0]
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
- **`haya-pet run` now defaults to native passthrough** (`stdio: "inherit"`). The
|
|
81
|
+
wrapped CLI talks directly to your terminal, so **Shift+Tab**, mouse-wheel
|
|
82
|
+
scroll, and word-edit all work normally. PTY observation is now opt-in via
|
|
83
|
+
`--observe` (it routes input through ConPTY on Windows, which can mangle special
|
|
84
|
+
keys — use it only for non-interactive runs).
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
- **Claude Code live status via per-session hooks** (opt-in: `HAYA_PET_HOOKS=1` in
|
|
88
|
+
this release; 0.2.1 adds the persisted `haya-pet hooks on` toggle).
|
|
89
|
+
Injects a stable settings file through `claude --settings <file>` — no change to
|
|
90
|
+
your global config — wiring Claude's events to a new `haya-pet state` reporter so
|
|
91
|
+
the pet shows thinking / running tools / editing files / waiting for approval,
|
|
92
|
+
with full terminal fidelity (no PTY). First run shows Claude's one-time
|
|
93
|
+
*review hooks* prompt; approve it once.
|
|
94
|
+
- **`haya-pet state <state>` command** — reporter used by client hooks to push live
|
|
95
|
+
status to the daemon over IPC.
|
|
96
|
+
- **`HAYA_PET_HOOK_DEBUG=<file>`** — append one JSONL line per status event
|
|
97
|
+
(hook- and transcript-sourced) for diagnostics.
|
|
98
|
+
|
|
99
|
+
### Fixed
|
|
100
|
+
- Claude Code TUI accepted no keyboard input when hooks were injected — caused by a
|
|
101
|
+
volatile per-session argument and temp path that re-triggered Claude's hook-trust
|
|
102
|
+
review every launch. Hook commands and the settings path are now stable; the
|
|
103
|
+
session id is passed via the `HAYA_PET_SESSION_ID` env var.
|
|
104
|
+
|
|
105
|
+
### Notes
|
|
106
|
+
- In this release Codex and Antigravity had no hook adapter — native passthrough
|
|
107
|
+
with lifecycle status, or `--observe` for coarse PTY activity. 0.2.1 adds the
|
|
108
|
+
Codex adapter; Antigravity remains a planned follow-up.
|
|
109
|
+
|
|
110
|
+
## [0.1.0]
|
|
111
|
+
- Initial generic AI CLI pet runtime: overlay companion, session bubbles, daemon
|
|
112
|
+
IPC, client adapters, pet asset pipeline, cross-OS paths.
|
package/README.md
CHANGED
|
@@ -38,8 +38,9 @@ Haya Pet watches all of them and presents one ambient interface:
|
|
|
38
38
|
draggable, and position-persistent like a real desktop companion.
|
|
39
39
|
- **Session bubbles** — one compact bubble per active session showing client,
|
|
40
40
|
project, the latest activity, and a status icon (a spinning *working* circle, a
|
|
41
|
-
green *done* check, a yellow *needs you*, or a red *failed* cross).
|
|
42
|
-
|
|
41
|
+
green *done* check, a yellow *needs you*, or a red *failed* cross). Bubbles stack
|
|
42
|
+
by connect time — the newest session on top — so the stack never reshuffles while
|
|
43
|
+
work is in progress. A folder button beside the pet folds them away.
|
|
43
44
|
|
|
44
45
|
## Features
|
|
45
46
|
|
|
@@ -52,8 +53,8 @@ Haya Pet watches all of them and presents one ambient interface:
|
|
|
52
53
|
(`thinking`, `running_tool`, `waiting_approval`, `reviewing`, `failed`, …).
|
|
53
54
|
- 🧩 **Client adapters** with tiered support (process wrapper → PTY observer →
|
|
54
55
|
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
|
|
56
|
-
or PTY `--observe` for any client).
|
|
56
|
+
lifecycle status; richer status is opt-in (Claude Code / Codex hooks via
|
|
57
|
+
`haya-pet hooks on`, or PTY `--observe` for any client).
|
|
57
58
|
- 🚀 **Zero-setup launch** — `haya-pet run …` auto-starts the overlay; no separate
|
|
58
59
|
daemon to manage.
|
|
59
60
|
- 🖼️ **Codex-compatible pet assets** (1536×1872 sprite atlas, 9 actions).
|
|
@@ -94,8 +95,8 @@ Haya Pet watches all of them and presents one ambient interface:
|
|
|
94
95
|
| **Node ≥ 18** | Runtime + companion (Electron) |
|
|
95
96
|
| **npm** | Install + scripts |
|
|
96
97
|
|
|
97
|
-
> Default status is lifecycle-only and needs no extra modules. Opt-in Claude Code
|
|
98
|
-
> hooks (`
|
|
98
|
+
> Default status is lifecycle-only and needs no extra modules. Opt-in Claude Code /
|
|
99
|
+
> Codex hooks (`haya-pet hooks on`) also need none. The opt-in `--observe` PTY mode uses
|
|
99
100
|
> `node-pty` (installed automatically when it can build; without it, `--observe`
|
|
100
101
|
> degrades to lifecycle-only tracking).
|
|
101
102
|
|
|
@@ -157,11 +158,12 @@ Two **opt-in** ways to get richer *in-session* status (thinking / running tools
|
|
|
157
158
|
editing files / waiting for approval):
|
|
158
159
|
|
|
159
160
|
```bash
|
|
160
|
-
# Claude Code — live status via per-session hooks, NO terminal-fidelity
|
|
161
|
-
# Enable once (persisted); the first run
|
|
162
|
-
# prompt you approve once.
|
|
161
|
+
# Claude Code AND Codex — live status via per-session hooks, NO terminal-fidelity
|
|
162
|
+
# tradeoff. Enable once (persisted, global); the first run for each client shows a
|
|
163
|
+
# one-time "review hooks" prompt you approve once.
|
|
163
164
|
haya-pet hooks on
|
|
164
165
|
haya-pet run --client claude-code -- claude
|
|
166
|
+
haya-pet run --client codex -- codex
|
|
165
167
|
# (per-run override without persisting: HAYA_PET_HOOKS=1 …, or $env:HAYA_PET_HOOKS=1 in PowerShell)
|
|
166
168
|
# (turn back off: haya-pet hooks off · check: haya-pet hooks status)
|
|
167
169
|
|
|
@@ -169,10 +171,25 @@ haya-pet run --client claude-code -- claude
|
|
|
169
171
|
haya-pet run --observe --client codex -- codex
|
|
170
172
|
```
|
|
171
173
|
|
|
174
|
+
> **Codex coverage.** Codex shows `thinking` (working) and `idle` (done) via hooks,
|
|
175
|
+
> plus `running_tool` / `editing_files` via a session-transcript watcher.
|
|
176
|
+
> *Waiting for approval* doesn't arrive yet because of an upstream gap where
|
|
177
|
+
> Codex's `PermissionRequest` hook doesn't fire
|
|
178
|
+
> ([openai/codex#16732](https://github.com/openai/codex/issues/16732)); it'll start
|
|
179
|
+
> working automatically once Codex fixes it. Also: if you pass your own
|
|
180
|
+
> `-p/--profile` to codex, haya-pet skips hook injection (Codex allows one
|
|
181
|
+
> profile) and tells you. Claude Code has full coverage.
|
|
182
|
+
|
|
183
|
+
> **Approval prompts resolve correctly** (Claude Code): deny → the pet returns to
|
|
184
|
+
> idle the moment the denial lands in the session transcript; accept a command →
|
|
185
|
+
> the pet flips to *working* a couple of seconds after the approved command
|
|
186
|
+
> actually starts running (detected from the client's process tree — a real
|
|
187
|
+
> event, never a timeout, so an unanswered prompt keeps warning until you decide).
|
|
188
|
+
|
|
172
189
|
> **Why opt-in?**
|
|
173
|
-
> - **Hooks (Claude Code):** injecting hooks makes
|
|
174
|
-
> *review hooks* trust prompt. We don't disrupt your session by default;
|
|
175
|
-
> on once with `haya-pet hooks on` when you're happy to approve the hooks.
|
|
190
|
+
> - **Hooks (Claude Code / Codex):** injecting hooks makes the client show a
|
|
191
|
+
> one-time *review hooks* trust prompt. We don't disrupt your session by default;
|
|
192
|
+
> turn it on once with `haya-pet hooks on` when you're happy to approve the hooks.
|
|
176
193
|
> - **`--observe` (any client):** PTY observation infers status from output, but on
|
|
177
194
|
> Windows it routes input through ConPTY, which can break **Shift+Tab**, mouse
|
|
178
195
|
> scroll, and word-edit. Use it only for non-interactive runs. See
|
|
@@ -246,8 +263,8 @@ Full list (incl. repairing a broken Electron install): [docs/troubleshooting.md]
|
|
|
246
263
|
| Client | Status | Support level |
|
|
247
264
|
|---|---|---|
|
|
248
265
|
| Generic CLI | ✅ | L1 process wrapper (+ L2 PTY via `--observe`) |
|
|
249
|
-
| Codex | ✅ | L1 wrapper
|
|
250
|
-
| Claude Code | ✅ | L1 wrapper + **L4 live-status hooks** (opt-in `
|
|
266
|
+
| Codex | ✅ | L1 wrapper + **L4 live-status hooks** (opt-in `haya-pet hooks on`; partial — see note) |
|
|
267
|
+
| Claude Code | ✅ | L1 wrapper + **L4 live-status hooks** (opt-in `haya-pet hooks on`) |
|
|
251
268
|
| Antigravity | ✅ | L1 wrapper (+ L2 PTY via `--observe`) |
|
|
252
269
|
| Gemini CLI / Aider / others | 🔜 | via the generic adapter |
|
|
253
270
|
|
package/apps/cli/src/haya-pet.js
CHANGED
|
@@ -6,13 +6,15 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
|
|
7
7
|
import { parseStateArgs, runStateCommand } from "../../../packages/cli-core/src/run-state.js";
|
|
8
8
|
import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages/cli-core/src/claude-hook-injection.js";
|
|
9
|
+
import { injectCodexHooks as defaultInjectCodexHooks } from "../../../packages/cli-core/src/codex-hook-injection.js";
|
|
9
10
|
import { watchClaudeTranscript as defaultWatchClaudeTranscript } from "../../../packages/cli-core/src/claude-transcript-watcher.js";
|
|
11
|
+
import { watchCodexTranscript as defaultWatchCodexTranscript } from "../../../packages/cli-core/src/codex-transcript-watcher.js";
|
|
10
12
|
import { ensureCompanionConnection } from "../../../packages/cli-core/src/companion-launcher.js";
|
|
11
13
|
import { createIpcClient as defaultCreateIpcClient } from "../../../packages/daemon-core/src/ipc-server.js";
|
|
12
14
|
import { getDefaultPaths } from "../../../packages/platform-core/src/paths.js";
|
|
13
15
|
import { discoverPets as defaultDiscoverPets } from "../../../packages/pet-core/src/discovery.js";
|
|
14
16
|
import { createStateFile as defaultCreateStateFile } from "../../../packages/app-state/src/state-file.js";
|
|
15
|
-
import { getSelectedPetId, setSelectedPet,
|
|
17
|
+
import { getSelectedPetId, setSelectedPet, getHooksEnabled, setHooksEnabled } from "../../../packages/app-state/src/state.js";
|
|
16
18
|
import { getAdapterInfo } from "../../../packages/adapters/src/adapter-info.js";
|
|
17
19
|
|
|
18
20
|
const CLIENT_DISPLAY_NAMES = Object.freeze({
|
|
@@ -123,7 +125,10 @@ export async function runStartCommand(_parsed, dependencies = {}) {
|
|
|
123
125
|
async function runRunCommand(parsed, dependencies) {
|
|
124
126
|
const runGenericCommand = dependencies.runGenericCommand ?? defaultRunGenericCommand;
|
|
125
127
|
const injectClaudeHooks = dependencies.injectClaudeHooks ?? defaultInjectClaudeHooks;
|
|
128
|
+
const injectCodexHooks = dependencies.injectCodexHooks ?? defaultInjectCodexHooks;
|
|
126
129
|
const watchClaudeTranscript = dependencies.watchClaudeTranscript ?? defaultWatchClaudeTranscript;
|
|
130
|
+
const watchCodexTranscript = dependencies.watchCodexTranscript ?? defaultWatchCodexTranscript;
|
|
131
|
+
const print = dependencies.print ?? defaultPrint;
|
|
127
132
|
const env = dependencies.env ?? process.env;
|
|
128
133
|
const now = dependencies.now ?? Date.now;
|
|
129
134
|
const cwd = dependencies.cwd ?? process.cwd();
|
|
@@ -135,14 +140,16 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
135
140
|
let cleanup = () => {};
|
|
136
141
|
let stopWatcher = () => {};
|
|
137
142
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// HAYA_PET_HOOKS=1 — because injecting hooks makes
|
|
141
|
-
// hooks" trust prompt; we never disrupt the user's session uninvited.
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
const
|
|
145
|
-
|
|
143
|
+
// Native passthrough is always the default (full terminal fidelity). Live-status
|
|
144
|
+
// hooks are OPT-IN — persisted via `haya-pet hooks on`, or per-run via
|
|
145
|
+
// HAYA_PET_HOOKS=1 — because injecting hooks makes the client show a one-time
|
|
146
|
+
// "review hooks" trust prompt; we never disrupt the user's session uninvited.
|
|
147
|
+
// Both clients report live status via the `haya-pet state` reporter (no PTY, so
|
|
148
|
+
// Shift+Tab works); the session id rides in via HAYA_PET_SESSION_ID.
|
|
149
|
+
const hooksOn = await resolveHooksEnabled(env, dependencies);
|
|
150
|
+
|
|
151
|
+
// Claude Code: inject a stable `--settings` file.
|
|
152
|
+
const claudeHooksOn = hooksOn && parsed.clientId === "claude-code";
|
|
146
153
|
if (claudeHooksOn) {
|
|
147
154
|
const injected = injectClaudeHooks();
|
|
148
155
|
childArgs = [...parsed.childArgs, "--settings", injected.settingsPath];
|
|
@@ -175,6 +182,78 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
175
182
|
stopWatcher = watcher.stop;
|
|
176
183
|
}
|
|
177
184
|
|
|
185
|
+
// Codex: no `--settings` equivalent, so inject a stable profile and add
|
|
186
|
+
// `-p <name>` at the FRONT (a global flag must precede any subcommand). Codex
|
|
187
|
+
// takes only one profile, so if the user already passes their own -p/--profile
|
|
188
|
+
// we skip injection and say so rather than clobber their choice. Codex
|
|
189
|
+
// PreToolUse is not reliable, so a transcript watcher supplies tool activity.
|
|
190
|
+
const codexHooksOn = hooksOn && parsed.clientId === "codex";
|
|
191
|
+
if (codexHooksOn) {
|
|
192
|
+
if (hasProfileArg(parsed.childArgs)) {
|
|
193
|
+
print(
|
|
194
|
+
"haya-pet: Codex live-status hooks skipped — you passed your own -p/--profile (Codex allows only one)."
|
|
195
|
+
);
|
|
196
|
+
} else {
|
|
197
|
+
const injected = injectCodexHooks();
|
|
198
|
+
childArgs = ["-p", injected.profileName, ...parsed.childArgs];
|
|
199
|
+
childEnv = { ...env, HAYA_PET_SESSION_ID: sessionId };
|
|
200
|
+
cleanup = injected.cleanup;
|
|
201
|
+
|
|
202
|
+
const activeToolCalls = new Set();
|
|
203
|
+
const watcher = watchCodexTranscript({
|
|
204
|
+
homeDir: dependencies.homeDir,
|
|
205
|
+
sessionsRoot: dependencies.codexSessionsRoot,
|
|
206
|
+
startedAt: now(),
|
|
207
|
+
onToolEvent: (event) => {
|
|
208
|
+
hookDebugLog(env, now, {
|
|
209
|
+
source: "codex_transcript",
|
|
210
|
+
event: event.type,
|
|
211
|
+
toolCallId: event.toolCallId,
|
|
212
|
+
toolName: event.toolName,
|
|
213
|
+
state: event.state
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
if (event.type === "tool_started") {
|
|
217
|
+
activeToolCalls.add(event.toolCallId);
|
|
218
|
+
messageSender
|
|
219
|
+
.send({
|
|
220
|
+
type: "state",
|
|
221
|
+
sessionId,
|
|
222
|
+
state: event.state,
|
|
223
|
+
summary: event.toolName,
|
|
224
|
+
confidence: 0.85,
|
|
225
|
+
source: "client_log",
|
|
226
|
+
updatedAt: now()
|
|
227
|
+
})
|
|
228
|
+
.catch(() => {});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (event.type === "tool_finished") {
|
|
233
|
+
activeToolCalls.delete(event.toolCallId);
|
|
234
|
+
if (activeToolCalls.size === 0) {
|
|
235
|
+
messageSender
|
|
236
|
+
.send({
|
|
237
|
+
type: "state",
|
|
238
|
+
sessionId,
|
|
239
|
+
state: "thinking",
|
|
240
|
+
confidence: 0.85,
|
|
241
|
+
source: "client_log",
|
|
242
|
+
updatedAt: now()
|
|
243
|
+
})
|
|
244
|
+
.catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
const previousStopWatcher = stopWatcher;
|
|
250
|
+
stopWatcher = () => {
|
|
251
|
+
watcher.stop();
|
|
252
|
+
previousStopWatcher();
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
178
257
|
try {
|
|
179
258
|
return await runGenericCommand({
|
|
180
259
|
command: parsed.childCommand,
|
|
@@ -197,10 +276,11 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
197
276
|
}
|
|
198
277
|
}
|
|
199
278
|
|
|
200
|
-
// Resolve whether
|
|
201
|
-
// Precedence: HAYA_PET_NO_HOOKS forces off, HAYA_PET_HOOKS
|
|
202
|
-
// overrides), otherwise the persisted `haya-pet hooks on/off`
|
|
203
|
-
|
|
279
|
+
// Resolve whether live-status hooks should be injected for this run (any
|
|
280
|
+
// hook-capable client). Precedence: HAYA_PET_NO_HOOKS forces off, HAYA_PET_HOOKS
|
|
281
|
+
// forces on (per-run overrides), otherwise the persisted `haya-pet hooks on/off`
|
|
282
|
+
// preference.
|
|
283
|
+
async function resolveHooksEnabled(env, dependencies) {
|
|
204
284
|
if (isTruthyFlag(env.HAYA_PET_NO_HOOKS)) {
|
|
205
285
|
return false;
|
|
206
286
|
}
|
|
@@ -209,7 +289,7 @@ async function resolveClaudeHooksEnabled(env, dependencies) {
|
|
|
209
289
|
}
|
|
210
290
|
try {
|
|
211
291
|
const state = await createConfigStateFile(dependencies).load();
|
|
212
|
-
return
|
|
292
|
+
return getHooksEnabled(state);
|
|
213
293
|
} catch {
|
|
214
294
|
return false;
|
|
215
295
|
}
|
|
@@ -219,6 +299,14 @@ function isTruthyFlag(value) {
|
|
|
219
299
|
return value === "1" || value === "true";
|
|
220
300
|
}
|
|
221
301
|
|
|
302
|
+
// Detect a user-supplied Codex profile flag so we don't clobber it: -p, --profile,
|
|
303
|
+
// or the `--profile=foo` / `-p=foo` forms.
|
|
304
|
+
function hasProfileArg(args) {
|
|
305
|
+
return args.some(
|
|
306
|
+
(arg) => arg === "-p" || arg === "--profile" || arg.startsWith("--profile=") || arg.startsWith("-p=")
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
222
310
|
function createConfigStateFile(dependencies) {
|
|
223
311
|
const paths = getDefaultPaths({
|
|
224
312
|
platform: dependencies.platform,
|
|
@@ -310,25 +398,26 @@ function parseHooksArgs(args) {
|
|
|
310
398
|
throw new Error(`Unknown hooks action: ${action} (use on, off, or status)`);
|
|
311
399
|
}
|
|
312
400
|
|
|
313
|
-
// Persisted toggle for
|
|
314
|
-
//
|
|
401
|
+
// Persisted GLOBAL toggle for live-status hooks (the convenient alternative to
|
|
402
|
+
// setting HAYA_PET_HOOKS every shell). Covers every hook-capable client — Claude
|
|
403
|
+
// Code and Codex today.
|
|
315
404
|
export async function runHooksCommand(parsed, dependencies = {}) {
|
|
316
405
|
const print = dependencies.print ?? defaultPrint;
|
|
317
406
|
const stateFile = createConfigStateFile(dependencies);
|
|
318
407
|
const state = await stateFile.load();
|
|
319
408
|
|
|
320
409
|
if (parsed.action === "status") {
|
|
321
|
-
const enabled =
|
|
322
|
-
print(`
|
|
410
|
+
const enabled = getHooksEnabled(state);
|
|
411
|
+
print(`Live-status hooks: ${enabled ? "on" : "off"}`);
|
|
323
412
|
return { command: "hooks", action: "status", enabled };
|
|
324
413
|
}
|
|
325
414
|
|
|
326
415
|
const enabled = parsed.action === "on";
|
|
327
|
-
await stateFile.save(
|
|
416
|
+
await stateFile.save(setHooksEnabled(state, enabled));
|
|
328
417
|
print(
|
|
329
418
|
enabled
|
|
330
|
-
? "
|
|
331
|
-
: "
|
|
419
|
+
? "Live-status hooks: on. The first `haya-pet run` for Claude Code or Codex asks the client to review the hooks once — approve it."
|
|
420
|
+
: "Live-status hooks: off."
|
|
332
421
|
);
|
|
333
422
|
return { command: "hooks", action: parsed.action, enabled };
|
|
334
423
|
}
|
|
@@ -335,8 +335,8 @@ test("parses the state command", () => {
|
|
|
335
335
|
});
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
-
const hooksStateFile = (
|
|
339
|
-
load: async () => ({ settings: {
|
|
338
|
+
const hooksStateFile = (hooksEnabled) => () => ({
|
|
339
|
+
load: async () => ({ settings: { hooksEnabled } }),
|
|
340
340
|
save: async (state) => state
|
|
341
341
|
});
|
|
342
342
|
|
|
@@ -409,7 +409,7 @@ test("hooks command parses and persists the toggle", async () => {
|
|
|
409
409
|
let saved;
|
|
410
410
|
const lines = [];
|
|
411
411
|
const store = {
|
|
412
|
-
load: async () => ({ settings: {
|
|
412
|
+
load: async () => ({ settings: { hooksEnabled: false } }),
|
|
413
413
|
save: async (state) => { saved = state; return state; }
|
|
414
414
|
};
|
|
415
415
|
const result = await runAiPet(["hooks", "on"], {
|
|
@@ -419,10 +419,113 @@ test("hooks command parses and persists the toggle", async () => {
|
|
|
419
419
|
});
|
|
420
420
|
|
|
421
421
|
assert.equal(result.enabled, true);
|
|
422
|
-
assert.equal(saved.settings.
|
|
422
|
+
assert.equal(saved.settings.hooksEnabled, true);
|
|
423
423
|
assert.ok(lines.some((l) => l.includes("on")));
|
|
424
424
|
});
|
|
425
425
|
|
|
426
|
+
test("persisted `hooks on` injects a Codex profile via -p at the front of args", async () => {
|
|
427
|
+
const calls = [];
|
|
428
|
+
let injected = 0;
|
|
429
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
430
|
+
cwd: process.cwd(),
|
|
431
|
+
env: { USERPROFILE: "C:\\Users\\A" }, // no HAYA_PET_HOOKS
|
|
432
|
+
heartbeatIntervalMs: 10,
|
|
433
|
+
send: async () => {},
|
|
434
|
+
createStateFile: hooksStateFile(true),
|
|
435
|
+
injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
|
|
436
|
+
runGenericCommand: async (options) => {
|
|
437
|
+
calls.push(options);
|
|
438
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
assert.equal(injected, 1, "config preference enables Codex hooks");
|
|
443
|
+
assert.deepEqual(calls[0].args, ["-p", "haya-pet"], "profile flag goes at the front");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("codex hooks also start a transcript watcher for tool activity", async () => {
|
|
447
|
+
const sent = [];
|
|
448
|
+
let fireToolEvent;
|
|
449
|
+
let stopped = false;
|
|
450
|
+
|
|
451
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
452
|
+
cwd: process.cwd(),
|
|
453
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
454
|
+
now: () => 42,
|
|
455
|
+
heartbeatIntervalMs: 10,
|
|
456
|
+
send: async (message) => sent.push(message),
|
|
457
|
+
createStateFile: hooksStateFile(true),
|
|
458
|
+
injectCodexHooks: () => ({ profileName: "haya-pet", cleanup: () => {} }),
|
|
459
|
+
watchCodexTranscript: ({ onToolEvent }) => {
|
|
460
|
+
fireToolEvent = onToolEvent;
|
|
461
|
+
return { stop: () => { stopped = true; } };
|
|
462
|
+
},
|
|
463
|
+
runGenericCommand: async (options) => {
|
|
464
|
+
fireToolEvent({
|
|
465
|
+
type: "tool_started",
|
|
466
|
+
toolCallId: "call_shell",
|
|
467
|
+
toolName: "shell_command",
|
|
468
|
+
state: "running_tool"
|
|
469
|
+
});
|
|
470
|
+
fireToolEvent({
|
|
471
|
+
type: "tool_finished",
|
|
472
|
+
toolCallId: "call_shell"
|
|
473
|
+
});
|
|
474
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
assert.ok(stopped, "transcript watcher is stopped after the wrapped command exits");
|
|
479
|
+
assert.deepEqual(
|
|
480
|
+
sent.filter((message) => message.type === "state" && message.source === "client_log").map((message) => message.state),
|
|
481
|
+
["running_tool", "thinking"]
|
|
482
|
+
);
|
|
483
|
+
assert.ok(sent.every((message) => message.updatedAt === undefined || message.updatedAt === 42));
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("codex hooks are skipped (with a notice) when the user passes their own -p", async () => {
|
|
487
|
+
const calls = [];
|
|
488
|
+
let injected = 0;
|
|
489
|
+
const lines = [];
|
|
490
|
+
await runAiPet(["run", "--client", "codex", "--", "codex", "-p", "mine"], {
|
|
491
|
+
cwd: process.cwd(),
|
|
492
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
493
|
+
heartbeatIntervalMs: 10,
|
|
494
|
+
send: async () => {},
|
|
495
|
+
createStateFile: hooksStateFile(true),
|
|
496
|
+
print: (line) => lines.push(line),
|
|
497
|
+
injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
|
|
498
|
+
runGenericCommand: async (options) => {
|
|
499
|
+
calls.push(options);
|
|
500
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
assert.equal(injected, 0, "user's profile is respected — no injection");
|
|
505
|
+
assert.deepEqual(calls[0].args, ["-p", "mine"], "user args untouched");
|
|
506
|
+
assert.ok(lines.some((l) => /skipped/i.test(l)), "user is told why");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("codex does NOT inject hooks by default (safe out-of-box)", async () => {
|
|
510
|
+
const calls = [];
|
|
511
|
+
let injected = 0;
|
|
512
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
513
|
+
cwd: process.cwd(),
|
|
514
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
515
|
+
heartbeatIntervalMs: 10,
|
|
516
|
+
send: async () => {},
|
|
517
|
+
createStateFile: hooksStateFile(false),
|
|
518
|
+
injectCodexHooks: () => { injected += 1; return { profileName: "haya-pet", cleanup: () => {} }; },
|
|
519
|
+
runGenericCommand: async (options) => {
|
|
520
|
+
calls.push(options);
|
|
521
|
+
return { sessionId: "s", pid: 1, exitCode: 0 };
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
assert.equal(injected, 0, "no hook injection unless opted in");
|
|
526
|
+
assert.deepEqual(calls[0].args, []);
|
|
527
|
+
});
|
|
528
|
+
|
|
426
529
|
test("HAYA_PET_HOOKS=1 opts claude-code into --settings + HAYA_PET_SESSION_ID", async () => {
|
|
427
530
|
const calls = [];
|
|
428
531
|
let watched = 0;
|
|
@@ -471,14 +574,15 @@ test("a transcript denial clears the stuck approval to idle", async () => {
|
|
|
471
574
|
assert.equal(idle.updatedAt, 42);
|
|
472
575
|
});
|
|
473
576
|
|
|
474
|
-
test("non-
|
|
577
|
+
test("non-hook-capable clients are never injected even with HAYA_PET_HOOKS=1", async () => {
|
|
475
578
|
const calls = [];
|
|
476
|
-
await runAiPet(["run", "--client", "
|
|
579
|
+
await runAiPet(["run", "--client", "generic", "--", "aider"], {
|
|
477
580
|
cwd: process.cwd(),
|
|
478
581
|
env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
|
|
479
582
|
heartbeatIntervalMs: 10,
|
|
480
583
|
send: async () => {},
|
|
481
|
-
injectClaudeHooks: () => { throw new Error("should not inject for
|
|
584
|
+
injectClaudeHooks: () => { throw new Error("should not inject for generic"); },
|
|
585
|
+
injectCodexHooks: () => { throw new Error("should not inject for generic"); },
|
|
482
586
|
runGenericCommand: async (options) => {
|
|
483
587
|
calls.push(options);
|
|
484
588
|
return { sessionId: "s", pid: 1, exitCode: 0 };
|