@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
|
@@ -3,6 +3,11 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { createDaemonRuntime } from "../../../../packages/daemon-core/src/daemon-runtime.js";
|
|
5
5
|
import { createIpcServer } from "../../../../packages/daemon-core/src/ipc-server.js";
|
|
6
|
+
import {
|
|
7
|
+
createApprovalWatchCoordinator,
|
|
8
|
+
watchForApprovedProcess
|
|
9
|
+
} from "../../../../packages/daemon-core/src/approval-process-watcher.js";
|
|
10
|
+
import { createProcessSnapshotLister } from "../../../../packages/platform-core/src/process-snapshot.js";
|
|
6
11
|
import { getDefaultPaths } from "../../../../packages/platform-core/src/paths.js";
|
|
7
12
|
import { getPlatformCapabilities } from "../../../../packages/platform-core/src/capabilities.js";
|
|
8
13
|
import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
|
|
@@ -38,6 +43,7 @@ let runtime;
|
|
|
38
43
|
let currentWorkArea;
|
|
39
44
|
let currentDisplayId;
|
|
40
45
|
let petLocal = { x: 0, y: 0 };
|
|
46
|
+
let approvalWatch;
|
|
41
47
|
|
|
42
48
|
// Electron singleton: a second launch forwards to the running instance.
|
|
43
49
|
if (!app.requestSingleInstanceLock()) {
|
|
@@ -54,8 +60,40 @@ async function bootstrap() {
|
|
|
54
60
|
positionState = await stateFile.load();
|
|
55
61
|
pets = await discoverPets(paths.petSearchPaths);
|
|
56
62
|
|
|
63
|
+
// Clients fire no event at the moment the user ACCEPTS a permission prompt
|
|
64
|
+
// (only denial/finish are observable), so a waiting_approval session would
|
|
65
|
+
// otherwise look stuck until its tool completed. The approval watcher flips
|
|
66
|
+
// it to running_tool when the approved command verifiably starts — a new
|
|
67
|
+
// persistent process under the client — and never on a timer, so a genuinely
|
|
68
|
+
// unanswered prompt keeps warning. Unsupported platforms simply skip this.
|
|
69
|
+
const processLister = createProcessSnapshotLister();
|
|
70
|
+
approvalWatch = processLister
|
|
71
|
+
? createApprovalWatchCoordinator({
|
|
72
|
+
createWatcher: ({ rootPid, onApproved }) =>
|
|
73
|
+
watchForApprovedProcess({ rootPid, listProcesses: processLister, onApproved }),
|
|
74
|
+
onApproved: (sessionId) => {
|
|
75
|
+
try {
|
|
76
|
+
runtime.handleMessage({
|
|
77
|
+
type: "state",
|
|
78
|
+
sessionId,
|
|
79
|
+
state: "running_tool",
|
|
80
|
+
summary: "approved",
|
|
81
|
+
confidence: 0.6,
|
|
82
|
+
source: "client_log",
|
|
83
|
+
updatedAt: Date.now()
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
// The session may have unregistered between detection and report.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
: undefined;
|
|
91
|
+
|
|
57
92
|
runtime = createDaemonRuntime({
|
|
58
|
-
onSessionChanged: () =>
|
|
93
|
+
onSessionChanged: (session) => {
|
|
94
|
+
approvalWatch?.onSessionChanged(session);
|
|
95
|
+
pushSessions();
|
|
96
|
+
}
|
|
59
97
|
});
|
|
60
98
|
|
|
61
99
|
ipcServer = await createIpcServer({
|
|
@@ -85,6 +123,7 @@ async function bootstrap() {
|
|
|
85
123
|
|
|
86
124
|
app.on("before-quit", async () => {
|
|
87
125
|
clearInterval(sweep);
|
|
126
|
+
approvalWatch?.stopAll();
|
|
88
127
|
await ipcServer?.close();
|
|
89
128
|
});
|
|
90
129
|
}
|
|
@@ -121,7 +121,7 @@ function renderComposer(bubble, replyMode, bridge) {
|
|
|
121
121
|
return composer;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
function renderControls(controlsPromise,
|
|
124
|
+
function renderControls(controlsPromise, _bubble, _bridge) {
|
|
125
125
|
const wrap = document.createElement("div");
|
|
126
126
|
wrap.className = "controls";
|
|
127
127
|
|
package/docs/architecture.md
CHANGED
|
@@ -49,17 +49,39 @@ as each client allows:
|
|
|
49
49
|
|---|---|---|
|
|
50
50
|
| L1 | Process wrapper (lifecycle only) | session exists / exit code |
|
|
51
51
|
| L2 | PTY output observation (`--observe`, opt-in) | activity-based working/idle |
|
|
52
|
-
| L3 | Client logs / state files |
|
|
53
|
-
| L4 | Client hooks | richest — implemented for Claude Code |
|
|
52
|
+
| L3 | Client logs / state files / process tree | transcript watchers (Claude denial, Codex tools) + approval-accept detection |
|
|
53
|
+
| L4 | Client hooks | richest — implemented for Claude Code (full) and Codex (partial) |
|
|
54
54
|
|
|
55
55
|
The **default** is native passthrough (`stdio: "inherit"`) for full terminal
|
|
56
56
|
fidelity, with **L1 lifecycle** status for every client. Richer status is opt-in:
|
|
57
|
-
**Claude Code**
|
|
58
|
-
(persisted; or per-run via `HAYA_PET_HOOKS=1`)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
**Claude Code** and **Codex** gain **L4 hooks** when enabled with the global
|
|
58
|
+
`haya-pet hooks on` (persisted; or per-run via `HAYA_PET_HOOKS=1`). Both report
|
|
59
|
+
in-session activity through the shared, client-agnostic `haya-pet state` command
|
|
60
|
+
(lifecycle still comes from the wrapper's exit code); any client gains **L2** with
|
|
61
|
+
`--observe`. Hooks are opt-in because injecting them triggers the client's one-time
|
|
62
|
+
*review hooks* trust prompt.
|
|
63
|
+
|
|
64
|
+
The injection mechanism differs per client. **Claude Code** takes a stable
|
|
65
|
+
`claude --settings <file>`. **Codex** has no per-invocation settings flag, so the
|
|
66
|
+
wrapper writes a stable `$CODEX_HOME/haya-pet.config.toml` profile and prepends
|
|
67
|
+
`-p haya-pet` to the codex args (a profile layers on top of the user's base config,
|
|
68
|
+
leaving auth/model/MCP intact, and is inert otherwise). Codex allows only one
|
|
69
|
+
profile, so if the user already passes `-p/--profile`, injection is skipped with a
|
|
70
|
+
notice. Codex's hook command must be unquoted at the program position (it runs via
|
|
71
|
+
`cmd /c`, which strips a leading quote) and its matchers can't use look-around
|
|
72
|
+
(Rust regex) — see [known-issues.md](known-issues.md). Codex's L4 is **partial**:
|
|
73
|
+
`PreToolUse`/`PermissionRequest` don't fire upstream yet, so only `thinking`/`idle`
|
|
74
|
+
arrive today.
|
|
75
|
+
|
|
76
|
+
Hooks alone can't see one moment: clients emit **no event when the user accepts a
|
|
77
|
+
permission prompt** (denial and completion are observable; the accept click is
|
|
78
|
+
not). The companion bridges it with **L3 process-tree observation**: while a
|
|
79
|
+
session sits in `waiting_approval`, it polls the client's process subtree (the
|
|
80
|
+
wrapper reported the pid at register), and when a new descendant process appears
|
|
81
|
+
and persists across two polls — the approved command verifiably running — the
|
|
82
|
+
session flips to `running_tool`. No timers: an unanswered prompt keeps warning
|
|
83
|
+
until a real event resolves it (`approval-process-watcher.js`,
|
|
84
|
+
`process-snapshot.js`; Windows/macOS/Linux listers).
|
|
63
85
|
|
|
64
86
|
L2 is **activity-based**: any visible output → *working*; a short quiet window →
|
|
65
87
|
*idle*; success/failure come from the real exit code, never from scraping output
|
|
@@ -95,8 +117,9 @@ packages/
|
|
|
95
117
|
session-core/ registry, priority, summaries, bubble views, linger, pet-state
|
|
96
118
|
task-core/ task status, events, store, approvals, replies, controls
|
|
97
119
|
adapters/ client info, heuristics, capabilities, output observer, routing
|
|
98
|
-
daemon-core/ IPC server/transport, runtime bridge, singleton
|
|
99
|
-
|
|
120
|
+
daemon-core/ IPC server/transport, runtime bridge, singleton,
|
|
121
|
+
approval process watcher (waiting_approval -> running_tool)
|
|
122
|
+
platform-core/ platform, paths, capabilities, process snapshots (win/mac/linux)
|
|
100
123
|
apps/
|
|
101
124
|
cli/ haya-pet entrypoint + parser (run / start / stop / pets)
|
|
102
125
|
companion/ Electron overlay app (main + renderer)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Cross-OS QA Matrix
|
|
2
|
+
|
|
3
|
+
Use this checklist before release candidates and after changes to IPC, windowing, display handling, terminal attachment, or CLI process wrapping.
|
|
4
|
+
|
|
5
|
+
## Automated Gates
|
|
6
|
+
|
|
7
|
+
- [ ] `npm test` passes on the target branch.
|
|
8
|
+
- [ ] Generic command lifecycle emits register, running state, heartbeat, final state, and unregister.
|
|
9
|
+
- [ ] Daemon accepts local IPC messages and updates the session registry.
|
|
10
|
+
- [ ] CLI can run through daemon IPC without an injected sender.
|
|
11
|
+
- [ ] Pet preview loads a Codex-compatible `1536x1872` spritesheet.
|
|
12
|
+
- [ ] Pet manifest parsing and atlas/action validation pass (`pet-core`).
|
|
13
|
+
- [ ] Generic regex heuristics map sample output to normalized states (`adapters`).
|
|
14
|
+
- [ ] Task status mapping, event normalization, and control gating pass (`task-core`).
|
|
15
|
+
- [ ] Session bubble view models build with status label, summary, action, and elapsed (`session-core`).
|
|
16
|
+
- [ ] PTY output observer infers debounced `pty_output` states from sample client output (`adapters`).
|
|
17
|
+
- [ ] Reply/approval routing dispatches to injectors and safely refuses unsupported adapters (`adapters`).
|
|
18
|
+
|
|
19
|
+
## Manual Platform Matrix
|
|
20
|
+
|
|
21
|
+
| Platform | Shell/Terminal | Display Setup | Required Checks |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| Windows 11 | PowerShell | 100% DPI | `haya-pet run --client generic -- node -e "setTimeout(() => process.exit(0), 1000)"`; daemon sees session exit; overlay opens without focus stealing. |
|
|
24
|
+
| Windows 11 | Windows Terminal | 125% DPI | Pet drag/click/double-click; position persists after restart; terminal attachment helper reports best-effort capability. |
|
|
25
|
+
| Windows 11 | Windows Terminal | 150% or mixed DPI | Saved offscreen position clamps to visible work area; session bubbles remain visible. |
|
|
26
|
+
| macOS current stable | Terminal.app | Retina display | Unix socket IPC works; transparent overlay opens; click/drag behavior works; position persists. |
|
|
27
|
+
| macOS current stable | iTerm2 | External display | Moving terminal across displays does not lose session bubble fallback; permission denial produces best-effort/fallback state. |
|
|
28
|
+
| Ubuntu Linux X11 | GNOME Terminal | Single display | Unix socket IPC works; transparent overlay opens; X11 terminal strategy reports best-effort. |
|
|
29
|
+
| Ubuntu or Fedora Linux Wayland | Default terminal | Single display | Overlay fallback mode works; terminal attachment reports manual fallback; global pet plus cluster/session bubbles remain usable. |
|
|
30
|
+
|
|
31
|
+
## Release Acceptance Gates
|
|
32
|
+
|
|
33
|
+
- [ ] No platform stores prompts, screenshots, or raw terminal logs by default.
|
|
34
|
+
- [ ] Windows uses `\\.\pipe\haya-petd` for local IPC.
|
|
35
|
+
- [ ] macOS/Linux use `~/.haya-pet/haya-petd.sock` for local IPC.
|
|
36
|
+
- [ ] Windows state path is under `%LOCALAPPDATA%\haya-pet\state.json`.
|
|
37
|
+
- [ ] macOS/Linux state path is under `~/.haya-pet/state.json`.
|
|
38
|
+
- [ ] If transparent overlay fails, a normal companion window is available.
|
|
39
|
+
- [ ] If terminal attachment fails, global pet plus manual/cluster bubbles remain available.
|
|
40
|
+
- [ ] Wayland does not use unsupported global positioning assumptions.
|
|
41
|
+
- [ ] Saved display IDs are validated; missing displays fall back to primary visible work area.
|
|
42
|
+
- [ ] Task talk controls are hidden or disabled when adapter capability is unsupported.
|
|
43
|
+
- [ ] Reply composer shows "Open terminal to reply" for wrapper-only adapters (no blind injection).
|
|
44
|
+
- [ ] Approvals require explicit approve/deny and are never auto-approved.
|
|
45
|
+
- [ ] Companion runs as a single instance; a second launch focuses the existing pet.
|
|
46
|
+
- [ ] A stale daemon lock (dead PID) is reclaimed; a live one forwards.
|
|
47
|
+
|
|
48
|
+
## Companion App Smoke Test (per OS)
|
|
49
|
+
|
|
50
|
+
Run from `apps/companion` after `npm install`:
|
|
51
|
+
|
|
52
|
+
- [ ] `npm start` opens the overlay; empty space stays click-through.
|
|
53
|
+
- [ ] Single click → waving; double click → jumping; drag moves and persists.
|
|
54
|
+
- [ ] Running `haya-pet run --client generic -- sleep 10` shows a session bubble.
|
|
55
|
+
- [ ] Two concurrent sessions show two bubbles without renderer conflicts.
|
|
56
|
+
- [ ] Selecting a bubble opens the task talk window (peek mode).
|
|
57
|
+
- [ ] Tray menu can show/hide the pet and reset its position.
|
|
58
|
+
|
|
59
|
+
## Current Implementation Status
|
|
60
|
+
|
|
61
|
+
- Shared protocol/session/pet core: automated coverage exists.
|
|
62
|
+
- Pet asset manifest + validation + frame animator: automated coverage exists.
|
|
63
|
+
- Client adapters (info, generic/PTY heuristics, capabilities): automated coverage exists.
|
|
64
|
+
- Task talk core (status, events, store, approvals, replies, controls): automated coverage exists.
|
|
65
|
+
- Session summaries + bubble view models: automated coverage exists.
|
|
66
|
+
- Daemon singleton decision logic + tray menu model + position state file: automated coverage exists.
|
|
67
|
+
- Cross-OS platform paths and capabilities: automated coverage exists.
|
|
68
|
+
- Test-mode IPC server/client: automated coverage exists.
|
|
69
|
+
- Electron overlay app: implemented as glue (`apps/companion`) consuming the pure cores; requires `electron` install and manual run/QA per OS (not unit-tested).
|
|
70
|
+
- Production OS endpoint behavior: needs manual validation on Windows, macOS, and Linux.
|
|
71
|
+
- Terminal attachment: facade + documented IPC contract (`native/README.md`). Windows helper is implemented in .NET and compiles + runs; macOS/Linux helper binaries are still TODO. Node helper client (`terminal-helper-client.js`) is unit-tested.
|
|
72
|
+
- PTY output observer + reply/approval routing: implemented and unit-tested as pure cores. Live wiring (real PTY via node-pty; bidirectional IPC + real injectors) is a later phase.
|
package/docs/known-issues.md
CHANGED
|
@@ -24,6 +24,44 @@ Issues found in live use, with their current status.
|
|
|
24
24
|
`Notification` hook by type (`permission_prompt`→approval, `idle_prompt`→idle) so
|
|
25
25
|
non-approval notifications no longer masquerade as approvals.
|
|
26
26
|
|
|
27
|
+
## ✅ Resolved: pet stuck on "waiting for approval" after the user ACCEPTS
|
|
28
|
+
|
|
29
|
+
- **Symptom:** The denial fix above covered "deny", but **accepting** a prompt
|
|
30
|
+
still left the pet on *waiting for approval* for the whole run of the approved
|
|
31
|
+
tool — often the user approves a command (build/test) and Claude immediately
|
|
32
|
+
starts working, with no further `PreToolUse`/`PostToolUse` until the tool
|
|
33
|
+
*finishes*. For a long command that was minutes of misleading "waiting"
|
|
34
|
+
(observed: 240 s in a `HAYA_PET_HOOK_DEBUG` trace).
|
|
35
|
+
- **Root cause:** Claude Code emits **nothing at the moment of a manual accept** —
|
|
36
|
+
verified three ways: the official hooks lifecycle (`PreToolUse` → dialog →
|
|
37
|
+
`PermissionRequest` → … → `PostToolUse` only *after* the tool completes; there
|
|
38
|
+
is no "permission granted" event), a live hook trace, and the session
|
|
39
|
+
transcript (the `tool_use`/`tool_result`/hook records are flushed in one batch
|
|
40
|
+
at completion; nothing is written at approval time). So between *prompt shown*
|
|
41
|
+
and *tool finished*, outside observers get zero signal. Timers were rejected
|
|
42
|
+
again: flipping the state on a delay would hide a genuinely unanswered prompt.
|
|
43
|
+
- **Fix:** **Process-tree observation** (`approval-process-watcher.js` +
|
|
44
|
+
`process-snapshot.js`). The wrapper already reports the client's `pid` on
|
|
45
|
+
register. While a session sits in `waiting_approval`, the companion polls the
|
|
46
|
+
client's process subtree (~1.5 s; only during the waiting window): when a
|
|
47
|
+
**new descendant process appears and is still alive on the next poll**, the
|
|
48
|
+
approved command is verifiably running → the session flips to `running_tool`
|
|
49
|
+
(summary `approved`, source `client_log`). The two-poll persistence filter
|
|
50
|
+
keeps short-lived blips (our own hook reporter, the user's hooks) from
|
|
51
|
+
triggering it; the subtree walk covers shim layers (`cmd.exe` → `claude.exe` →
|
|
52
|
+
command). Every transition is event-based: a real process appeared, the tool
|
|
53
|
+
finished (`PostToolUse`), or the user denied (transcript). If nothing happens,
|
|
54
|
+
the warning stays up — by design.
|
|
55
|
+
- **Known limitations (accepted):** (1) In-process approvals (Edit/Write file
|
|
56
|
+
edits) spawn no OS process and aren't detected — but they complete in
|
|
57
|
+
milliseconds after approval, so `PostToolUse` resolves them near-instantly
|
|
58
|
+
anyway. (2) Detection lags the accept by ~2–3 s (two polls + snapshot cost).
|
|
59
|
+
(3) An unrelated *persistent* process spawned by the client mid-wait (e.g. an
|
|
60
|
+
MCP server starting) would read as an approval — considered acceptable since
|
|
61
|
+
the client is in fact actively doing something then. Cross-OS: Windows via a
|
|
62
|
+
PowerShell CIM snapshot, macOS via `ps`, Linux via `/proc` (macOS/Linux listers
|
|
63
|
+
are implemented but need live verification on real hardware).
|
|
64
|
+
|
|
27
65
|
## ✅ Resolved: Claude Code TUI accepted no keyboard input when hooks were injected
|
|
28
66
|
|
|
29
67
|
- **Symptom:** With per-session hooks injected by default (`claude --settings
|
|
@@ -94,25 +132,70 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
|
|
|
94
132
|
approving once (a volatile per-session argument would re-trigger it every
|
|
95
133
|
launch — see the resolved note below). `--observe` is a separate PTY opt-in for
|
|
96
134
|
non-interactive runs (terminal-fidelity tradeoff).
|
|
97
|
-
- **Codex** — **
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
(
|
|
135
|
+
- **Codex** — **implemented (partial).** Opt-in via the global `haya-pet hooks on`;
|
|
136
|
+
the wrapper injects `packages/adapters/src/codex-hooks.js` as a stable
|
|
137
|
+
`$CODEX_HOME/haya-pet.config.toml` profile and launches `codex -p haya-pet`
|
|
138
|
+
(`packages/cli-core/src/codex-hook-injection.js`). Falls back to `--observe` / L1
|
|
139
|
+
when not enabled. Findings (verified against `codex-cli` 0.137.0 on Windows):
|
|
140
|
+
- **Mechanism fits.** Codex has a lifecycle-hooks system (`[[hooks.<Event>]]` in
|
|
141
|
+
`config.toml` or a `hooks.json`), with the `hooks` feature flag `stable` and ON
|
|
142
|
+
by default. Command hooks receive a JSON payload on **stdin**
|
|
143
|
+
(`session_id`, `hook_event_name`, `tool_name`, `cwd`) and treat *exit 0 with no
|
|
144
|
+
output* as continue — so our existing client-agnostic `haya-pet state` reporter
|
|
145
|
+
works unchanged. The hooks.json shape is identical to Claude's settings block;
|
|
146
|
+
**only the hook table differs**, which is all `codex-hooks.js` adds.
|
|
147
|
+
- **Event vocabulary differs** from Claude: no `Notification` / `PermissionDenied`
|
|
148
|
+
/ `*Failure`; adds `PostCompact` / `SubagentStart`. `Stop` is the *only* idle
|
|
149
|
+
signal (`SubagentStop` is mid-turn → stays *thinking*). `PermissionRequest`
|
|
150
|
+
exists, so the approval cue is reachable.
|
|
151
|
+
- **Injection differs** — Codex has no `claude --settings <file>` equivalent.
|
|
152
|
+
Candidate non-mutating paths: `codex -p haya-pet` layering a generated
|
|
153
|
+
`$CODEX_HOME/haya-pet.config.toml` profile on top of the user's base config, or a
|
|
154
|
+
`hooks.json` next to the active config layer. Codex has its own *review hooks*
|
|
155
|
+
trust prompt (bypass: `--dangerously-bypass-hook-trust`), so the same one-time
|
|
156
|
+
trust UX as Claude applies.
|
|
157
|
+
- **Windows command quoting (fixed in the adapter):** Codex runs a hook `command`
|
|
158
|
+
via `cmd /c "<cmd>"`, which strips a **leading** quote — so Claude's
|
|
159
|
+
`"<node>" "<cli>" …` form dies with *"hook exited with code 1"*. The Codex
|
|
160
|
+
builder leaves the **program unquoted** (`<node> "<cli>" …`); args may be quoted.
|
|
161
|
+
Caveat: an unquoted program breaks if `node`'s path contains spaces (fine for
|
|
162
|
+
fnm/scoop/nvm layouts; a `command_windows` / short-path fallback is a follow-up).
|
|
163
|
+
- **No look-around in matchers:** Codex compiles matchers with the Rust `regex`
|
|
164
|
+
crate, which rejects `(?!…)` — Claude's negative-lookahead catch-all is a hard
|
|
165
|
+
parse error. Matchers are **anchored full matches** against the tool name, so
|
|
166
|
+
they must name tools exactly (`apply_patch`, `shell_command`).
|
|
167
|
+
- **Verified end-to-end** against `codex-cli` 0.137.0 (interactive TUI, Windows,
|
|
168
|
+
real `haya-pet state` reporter, `HAYA_PET_HOOK_DEBUG`): **`UserPromptSubmit`→
|
|
169
|
+
thinking, `PostToolUse`→thinking, `Stop`→idle all fire**, the parent env is
|
|
170
|
+
forwarded to hooks (session via `HAYA_PET_SESSION_ID`), and the reporter exits 0
|
|
171
|
+
cleanly. Note `codex exec` can't be used to test this — it forces
|
|
172
|
+
`approval_policy=never` + `sandbox=read`, a posture that disables hooks entirely.
|
|
173
|
+
- **`PreToolUse` does not fire** in 0.137 for tool calls (the entries are kept as
|
|
174
|
+
harmless no-ops for when it's fixed —
|
|
175
|
+
[openai/codex#16732](https://github.com/openai/codex/issues/16732)). Tool
|
|
176
|
+
activity is covered by an L3 Codex transcript watcher that tails
|
|
177
|
+
`~/.codex/sessions` JSONL: normal tools report `running_tool`, `apply_patch`
|
|
178
|
+
reports `editing_files`, and Haya Pet returns to `thinking` after active tool
|
|
179
|
+
calls drain. `PermissionRequest` (the *waiting for approval* cue — the
|
|
180
|
+
highest-value state) is **unconfirmed**; it likely depends on an
|
|
181
|
+
approval-required flow and needs a dedicated test before the feature is worth
|
|
182
|
+
wiring in.
|
|
102
183
|
- **Antigravity (`agy`)** — **not yet implemented** (no hook injection). Uses
|
|
103
184
|
`--observe` or L1 lifecycle. A Gemini-schema hook adapter is a planned follow-up.
|
|
104
185
|
- **Generic / unknown** — no hooks; PTY observation (`--observe`) or L1 lifecycle.
|
|
105
186
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
187
|
+
The Codex **L3 client-log adapter** now covers tool activity without a PTY or
|
|
188
|
+
working `PreToolUse` hook. A similar adapter for Antigravity's `transcript.jsonl`
|
|
189
|
+
remains a possible follow-up.
|
|
109
190
|
|
|
110
191
|
## Status sources, by fidelity
|
|
111
192
|
|
|
112
193
|
| Tier | Source | How |
|
|
113
194
|
|---|---|---|
|
|
114
195
|
| L1 | process wrapper | default; session lifecycle + exit code |
|
|
115
|
-
| L4 | client hooks | opt-in via `
|
|
196
|
+
| L4 | client hooks | opt-in via `haya-pet hooks on` (Claude Code full, Codex partial); reports through `haya-pet state …` |
|
|
197
|
+
| L3 | client logs | Codex session JSONL watcher for tool activity; Claude denial recovery; future clients can add similar transcript adapters |
|
|
198
|
+
| L3 | process tree | approval-accept detection: a `waiting_approval` session flips to `running_tool` when the approved command verifiably starts under the client's pid |
|
|
116
199
|
| L2 | PTY output scraping | opt-in via `--observe` (terminal-fidelity tradeoff) |
|
|
117
200
|
|
|
118
201
|
Native passthrough (L1) + opt-in hooks (L4) is the recommended setup for interactive
|
package/docs/troubleshooting.md
CHANGED
|
@@ -16,11 +16,13 @@ deferred problems with known root causes.
|
|
|
16
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
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
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 |
|
|
19
|
+
| Pet shows only **idle/lifecycle** while **Codex** works | Live status is opt-in: run `haya-pet hooks on` once (persisted, global), then `haya-pet run --client codex -- codex`; approve Codex's one-time *review hooks* prompt. `thinking`/`idle` come from hooks and `running_tool`/`editing_files` from a transcript watcher; *waiting for approval* doesn't fire yet (upstream [openai/codex#16732](https://github.com/openai/codex/issues/16732)). |
|
|
20
|
+
| **Codex** live status didn't turn on / you pass your own `-p`/`--profile` | Codex allows only one profile, so haya-pet skips hook injection when you supply your own and prints a notice. Drop your `-p` for that run to get live status, or accept lifecycle-only. |
|
|
20
21
|
| 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
22
|
| 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
23
|
| Pet shows only **idle** for a generic / unknown CLI | Expected without a hook adapter — add `--observe` for PTY observation, otherwise lifecycle only. |
|
|
23
24
|
| 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. |
|
|
25
|
+
| Pet stayed on **waiting for approval** after I *approved* a command | Fixed — Claude also fires no hook at the accept moment, so the companion watches the client's process tree while a session waits: when the approved command verifiably starts (a new persistent process under the client), the pet flips to **working**. Expect a ~2–3s lag after your click. File-edit approvals (no process) resolve at completion, which is near-instant. |
|
|
24
26
|
| 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
27
|
| 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
28
|
| 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. |
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import js from "@eslint/js";
|
|
2
|
+
import globals from "globals";
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
{
|
|
6
|
+
ignores: [
|
|
7
|
+
"node_modules/**",
|
|
8
|
+
"native/**",
|
|
9
|
+
"tmp/**",
|
|
10
|
+
"docs/**",
|
|
11
|
+
".gax/**",
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
js.configs.recommended,
|
|
15
|
+
{
|
|
16
|
+
files: ["**/*.js", "**/*.mjs", "**/*.cjs"],
|
|
17
|
+
languageOptions: {
|
|
18
|
+
ecmaVersion: "latest",
|
|
19
|
+
sourceType: "module",
|
|
20
|
+
globals: {
|
|
21
|
+
...globals.node,
|
|
22
|
+
...globals.browser,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
rules: {
|
|
26
|
+
"no-unused-vars": [
|
|
27
|
+
"error",
|
|
28
|
+
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hayasaka7/haya-pet",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Generic AI CLI pet runtime foundation.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"haya-pet": "apps/cli/src/haya-pet.js"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
+
"lint": "eslint .",
|
|
20
21
|
"test": "node test/run-tests.mjs"
|
|
21
22
|
},
|
|
22
23
|
"workspaces": [
|
|
@@ -31,5 +32,10 @@
|
|
|
31
32
|
},
|
|
32
33
|
"engines": {
|
|
33
34
|
"node": ">=16.20.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@eslint/js": "^10.0.1",
|
|
38
|
+
"eslint": "^10.4.1",
|
|
39
|
+
"globals": "^17.6.0"
|
|
34
40
|
}
|
|
35
41
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Codex CLI hook adapter. Wired into `haya-pet run --client codex` (opt-in via
|
|
2
|
+
// `haya-pet hooks on`); injected through codex-hook-injection.js as a profile.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors claude-hooks.js: pure builders that map Codex lifecycle events onto the
|
|
5
|
+
// shared pet-state vocabulary and emit a hooks config. No I/O here — callers
|
|
6
|
+
// resolve paths and write the file. Each hook entry runs the SAME client-agnostic
|
|
7
|
+
// `haya-pet state` reporter with a fixed state baked into the command; the session
|
|
8
|
+
// id rides in via HAYA_PET_SESSION_ID (injected by the wrapper at spawn), so Codex
|
|
9
|
+
// never needs to pass it on stdin for our purposes.
|
|
10
|
+
//
|
|
11
|
+
// Why a Codex hook adapter is feasible (verified against codex-cli 0.137.0):
|
|
12
|
+
// - Codex command hooks receive a JSON payload on stdin and treat `exit 0 with no
|
|
13
|
+
// output` as success/continue — so our reporter, which prints nothing, is safe.
|
|
14
|
+
// - The hooks.json / [hooks] shape is identical to Claude's settings.json hooks
|
|
15
|
+
// block (per-event matcher groups of { type:"command", command }).
|
|
16
|
+
// - Codex hooks are gated behind `features.hooks = true` and carry their own
|
|
17
|
+
// Claude-style hook-trust prompt (bypassable with --dangerously-bypass-hook-trust).
|
|
18
|
+
//
|
|
19
|
+
// Differences from Claude that this file encodes:
|
|
20
|
+
// - Event set: no Notification / PermissionDenied / *Failure; adds PostCompact /
|
|
21
|
+
// SubagentStart. Turn-end is `Stop` (the only idle signal); SubagentStop is
|
|
22
|
+
// mid-turn so it returns to `thinking`, not idle.
|
|
23
|
+
// - Tool names: Codex uses `apply_patch` for edits and `shell_command` for
|
|
24
|
+
// commands, not Claude's Edit|Write|Bash. Matchers are ANCHORED full matches
|
|
25
|
+
// (a bare `shell` did NOT match `shell_command`), and Codex's Rust regex crate
|
|
26
|
+
// rejects look-around — so no negative-lookahead catch-all.
|
|
27
|
+
//
|
|
28
|
+
// VERIFIED end-to-end against codex-cli 0.137.0 (interactive TUI, Windows), with
|
|
29
|
+
// the real `haya-pet state` reporter and HAYA_PET_HOOK_DEBUG:
|
|
30
|
+
// - FIRING: UserPromptSubmit→thinking, PostToolUse→thinking, Stop→idle. Codex
|
|
31
|
+
// forwards the parent env to hooks, so the session rides in via
|
|
32
|
+
// HAYA_PET_SESSION_ID and the reporter exits 0 cleanly (no "hook exited 1").
|
|
33
|
+
// - NOT FIRING (0.137): PreToolUse — so `running_tool` / `editing_files` never
|
|
34
|
+
// arrive in practice yet (upstream coverage gap, openai/codex#16732). The
|
|
35
|
+
// entries are kept (harmless no-ops) so they light up once Codex fixes it.
|
|
36
|
+
// - UNTESTED: PermissionRequest / PreCompact / SubagentStart|Stop (no approval /
|
|
37
|
+
// compaction / subagent occurred in the probe).
|
|
38
|
+
//
|
|
39
|
+
// OPEN QUESTION (injection): unlike `claude --settings <file>`, Codex has no
|
|
40
|
+
// per-invocation settings-file flag. Candidate non-mutating paths, best first:
|
|
41
|
+
// 1. `codex -p haya-pet` + a generated `$CODEX_HOME/haya-pet.config.toml` profile
|
|
42
|
+
// that layers ON TOP of the user's base config (auth/config preserved).
|
|
43
|
+
// 2. a `hooks.json` next to the active config layer ($CODEX_HOME/hooks.json) —
|
|
44
|
+
// simple but global to every codex session (harmless: the reporter no-ops
|
|
45
|
+
// without HAYA_PET_SESSION_ID).
|
|
46
|
+
// Both still trip Codex's one-time hook-trust prompt, exactly like the Claude path.
|
|
47
|
+
|
|
48
|
+
// Codex's file-editing tool(s) vs. its command tool.
|
|
49
|
+
const EDIT_TOOLS = ["apply_patch"];
|
|
50
|
+
const EDIT_TOOLS_MATCHER = EDIT_TOOLS.join("|");
|
|
51
|
+
// IMPORTANT: Codex compiles matchers with the Rust `regex` crate, which does NOT
|
|
52
|
+
// support look-around. Claude's negative-lookahead catch-all (`^(?!…).*`) is a
|
|
53
|
+
// hard parse error in Codex ("look-around … is not supported"), so we match the
|
|
54
|
+
// command tool EXPLICITLY instead of "everything that isn't an edit". Tools other
|
|
55
|
+
// than the command/edit tools simply don't update state on PreToolUse —
|
|
56
|
+
// acceptable, as PostToolUse resets to `thinking` right after.
|
|
57
|
+
//
|
|
58
|
+
// The matcher is an ANCHORED full match against the tool name (verified: a bare
|
|
59
|
+
// `shell` did NOT match the real tool `shell_command`). So name the tool exactly.
|
|
60
|
+
const COMMAND_TOOLS_MATCHER = "shell_command";
|
|
61
|
+
|
|
62
|
+
// The hook table. Each entry → one Codex hook that reports a fixed pet state.
|
|
63
|
+
// `matcher` (when present) filters PreToolUse by tool name. `summary` is an
|
|
64
|
+
// optional short label shown in the bubble and in HAYA_PET_HOOK_DEBUG logs.
|
|
65
|
+
const HOOK_TABLE = Object.freeze([
|
|
66
|
+
{ event: "UserPromptSubmit", state: "thinking" },
|
|
67
|
+
{ event: "PreToolUse", matcher: EDIT_TOOLS_MATCHER, state: "editing_files" },
|
|
68
|
+
{ event: "PreToolUse", matcher: COMMAND_TOOLS_MATCHER, state: "running_tool" },
|
|
69
|
+
{ event: "PostToolUse", state: "thinking" },
|
|
70
|
+
{ event: "PermissionRequest", state: "waiting_approval" },
|
|
71
|
+
{ event: "PreCompact", state: "compacting" },
|
|
72
|
+
{ event: "PostCompact", state: "thinking", summary: "compacted" },
|
|
73
|
+
// A subagent finishing is mid-turn — the main agent keeps working, so this is
|
|
74
|
+
// NOT idle. Turn-end is the dedicated `Stop` event below.
|
|
75
|
+
{ event: "SubagentStart", state: "running_tool", summary: "subagent" },
|
|
76
|
+
{ event: "SubagentStop", state: "thinking", summary: "subagent-done" },
|
|
77
|
+
{ event: "Stop", state: "idle" }
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// Resolve the pet state for a Codex event. `toolName` is the tool for PreToolUse.
|
|
81
|
+
// Exposed for testing and to keep the mapping in one place.
|
|
82
|
+
export function mapCodexEventToState(event, toolName) {
|
|
83
|
+
if (event === "PreToolUse") {
|
|
84
|
+
return EDIT_TOOLS.includes(toolName) ? "editing_files" : "running_tool";
|
|
85
|
+
}
|
|
86
|
+
const entry = HOOK_TABLE.find((row) => row.event === event && row.matcher === undefined);
|
|
87
|
+
return entry?.state;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build the hooks config object (hooks.json shape; also valid as the [hooks] table
|
|
91
|
+
// once serialized to TOML). Same structure as buildClaudeHookSettings, with one
|
|
92
|
+
// Windows-critical difference in command quoting (see below).
|
|
93
|
+
//
|
|
94
|
+
// CRITICAL (verified on Windows): Codex runs a hook `command` via `cmd /c "<cmd>"`,
|
|
95
|
+
// which strips the first and last quote if the string STARTS with a quote. So the
|
|
96
|
+
// Claude builder's `"<node>" "<cli>" …` form (leading quote on the program) gets
|
|
97
|
+
// mangled and the hook dies with "exited with code 1". The program (first token)
|
|
98
|
+
// must therefore be UNQUOTED; interior argument quotes are preserved fine.
|
|
99
|
+
//
|
|
100
|
+
// Caveat: an unquoted program breaks if nodePath itself contains spaces (e.g.
|
|
101
|
+
// "C:\Program Files\nodejs\node.exe"). The wrapper bakes the realpath-resolved
|
|
102
|
+
// node path, which is space-free for fnm/scoop/nvm layouts; a space-tolerant
|
|
103
|
+
// path (short 8.3 name, or `command_windows`) is a follow-up before shipping.
|
|
104
|
+
export function buildCodexHookSettings({ nodePath, cliPath }) {
|
|
105
|
+
const command = (state, summary) => {
|
|
106
|
+
// nodePath unquoted (must not lead with a quote); cliPath quoted for spaces.
|
|
107
|
+
const base = `${nodePath} ${quote(cliPath)} state ${state}`;
|
|
108
|
+
return summary ? `${base} --summary ${summary}` : base;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const hooks = {};
|
|
112
|
+
for (const row of HOOK_TABLE) {
|
|
113
|
+
const hookEntry = { hooks: [{ type: "command", command: command(row.state, row.summary) }] };
|
|
114
|
+
if (row.matcher !== undefined) {
|
|
115
|
+
hookEntry.matcher = row.matcher;
|
|
116
|
+
}
|
|
117
|
+
(hooks[row.event] ??= []).push(hookEntry);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { hooks };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function quote(value) {
|
|
124
|
+
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Serialize a hook settings object (from buildCodexHookSettings) to the TOML form
|
|
128
|
+
// Codex loads from a config layer: `[[hooks.<Event>]]` (with optional `matcher`)
|
|
129
|
+
// each containing `[[hooks.<Event>.hooks]]` command entries. Pure (no I/O).
|
|
130
|
+
export function serializeCodexHooksToml(settings, { header } = {}) {
|
|
131
|
+
let toml = header ? `# ${header}\n` : "";
|
|
132
|
+
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
toml += `[[hooks.${event}]]\n`;
|
|
135
|
+
if (entry.matcher !== undefined) {
|
|
136
|
+
toml += `matcher = ${tomlString(entry.matcher)}\n`;
|
|
137
|
+
}
|
|
138
|
+
for (const hook of entry.hooks ?? []) {
|
|
139
|
+
toml += `[[hooks.${event}.hooks]]\n`;
|
|
140
|
+
toml += `type = ${tomlString(hook.type)}\n`;
|
|
141
|
+
toml += `command = ${tomlString(hook.command)}\n`;
|
|
142
|
+
}
|
|
143
|
+
toml += "\n";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return toml;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// TOML basic-string escaping: backslash and double-quote must be escaped.
|
|
150
|
+
function tomlString(value) {
|
|
151
|
+
return `"${String(value).split("\\").join("\\\\").split('"').join('\\"')}"`;
|
|
152
|
+
}
|