@hayasaka7/haya-pet 0.3.9 → 0.3.11
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/CHANGELOG.md +29 -0
- package/README.md +6 -0
- package/apps/cli/src/haya-pet.js +190 -155
- package/apps/cli/test/haya-pet.test.mjs +33 -7
- package/docs/architecture.md +4 -5
- package/docs/known-issues.md +74 -32
- package/docs/troubleshooting.md +2 -2
- package/package.json +1 -1
- package/packages/adapters/src/codex-hooks.js +6 -8
- package/packages/cli-core/src/background-tasks.js +63 -0
- package/packages/cli-core/src/codex-hook-injection.js +99 -54
- package/packages/cli-core/src/run-state.js +83 -17
- package/packages/cli-core/test/background-tasks.test.mjs +115 -0
- package/packages/cli-core/test/codex-hook-injection.test.mjs +29 -26
- package/packages/cli-core/test/run-state.test.mjs +68 -1
package/docs/known-issues.md
CHANGED
|
@@ -150,19 +150,60 @@ Issues found in live use, with their current status.
|
|
|
150
150
|
to *thinking* (the agent continues the turn) and the next real event refines it.
|
|
151
151
|
Verified live on the manual path; auto uses the identical matcher mechanism.
|
|
152
152
|
|
|
153
|
-
## ✅ Resolved:
|
|
154
|
-
|
|
155
|
-
- **Symptom:**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
153
|
+
## ✅ Resolved: subagent activity drove the main session status (Claude Code)
|
|
154
|
+
|
|
155
|
+
- **Symptom:** With Claude Code hooks enabled and a multi-agent run, two things
|
|
156
|
+
went wrong once the **main agent had stopped but a subagent was still working**:
|
|
157
|
+
(1) the pet dropped to *idle* even though real work was ongoing in the
|
|
158
|
+
background; and (2) while the subagent ran, its own tool calls flipped the pet
|
|
159
|
+
between *running tools* / *editing files* / *thinking* — the subagent's activity
|
|
160
|
+
was driving the main session's status.
|
|
161
|
+
- **Root cause:** Two gaps. (a) The hook table mapped `Stop` → *idle*
|
|
162
|
+
unconditionally, with no awareness that a backgrounded subagent can outlive the
|
|
163
|
+
main turn. (b) A backgrounded subagent's tool calls fire the **parent session's**
|
|
164
|
+
`PreToolUse` / `PostToolUse` hooks, which ran `haya-pet state running_tool`
|
|
165
|
+
(etc.) under the main session id and overwrote its status. (An earlier fix only
|
|
166
|
+
stopped `SubagentStop` from reporting *idle*; it addressed neither of these.)
|
|
167
|
+
- **Fix — only ever decided at the main agent's `Stop`; no timers, no persisted
|
|
168
|
+
state:**
|
|
169
|
+
- **The "Subagent running" cue.** Claude's `Stop` payload carries an
|
|
170
|
+
(undocumented) **`background_tasks`** array: a live snapshot of work still
|
|
171
|
+
running at that instant. When `Stop` would report *idle* but `background_tasks`
|
|
172
|
+
still lists a running **subagent**, the reporter instead reports *running
|
|
173
|
+
tools* with the summary **"Subagent running"**
|
|
174
|
+
(`packages/cli-core/src/background-tasks.js`). When that subagent finishes,
|
|
175
|
+
Claude fires `Stop` **again** with an empty `background_tasks`, which clears the
|
|
176
|
+
cue back to *idle* — self-retracting, no timer. (Verified against live hook
|
|
177
|
+
traces: a backgrounded subagent appears in `Stop`'s `background_tasks` as
|
|
178
|
+
`type:"subagent", status:"running"`, and a second `Stop` arrives with `[]` once
|
|
179
|
+
it completes.)
|
|
180
|
+
- **Subagent events are dropped.** Every hook payload from a subagent context
|
|
181
|
+
carries an **`agent_id`** — the documented field that distinguishes subagent
|
|
182
|
+
hook calls from main-thread calls. The reporter now drops any event with an
|
|
183
|
+
`agent_id` (`extractAgentId` in `run-state.js`), so a subagent's tool use can
|
|
184
|
+
never overwrite the main session's status. Main-agent events have no `agent_id`
|
|
185
|
+
and report as before; the main `Stop` (also no `agent_id`) still carries the
|
|
186
|
+
`background_tasks` snapshot used for the cue above. `SubagentStop` is likewise
|
|
187
|
+
not wired.
|
|
188
|
+
- **Known limitations (accepted):**
|
|
189
|
+
- **Only subagents, never background shells.** A `background_tasks` entry can
|
|
190
|
+
also be `type:"shell"` (e.g. a `sleep 120` the agent backgrounded). These are
|
|
191
|
+
deliberately **not** surfaced: their completion isn't reliably observable here,
|
|
192
|
+
and a "working" cue we can't retract is worse than showing *idle*. So a
|
|
193
|
+
backgrounded shell still running after the main agent stops shows *idle*.
|
|
194
|
+
- **The `agent_id` discriminator is documented but not yet captured live on
|
|
195
|
+
`PreToolUse` / `PostToolUse`.** The Claude hooks reference lists `agent_id` /
|
|
196
|
+
`agent_type` as optional fields delivered to all hooks to distinguish subagent
|
|
197
|
+
calls, and the observed flicker confirms subagent tool calls reach the parent
|
|
198
|
+
hooks — but a subagent `PreToolUse` payload hasn't been captured on disk to
|
|
199
|
+
100% confirm `agent_id` is present there. If a future Claude build omits it the
|
|
200
|
+
flicker could recur; the marker would then be widened to also match
|
|
201
|
+
`agent_type` / `agent_transcript_path`.
|
|
202
|
+
- **How to diagnose if it recurs:** with `HAYA_PET_HOOK_DEBUG=<path>` set, the
|
|
203
|
+
reporter logs an `agentId` field on subagent-sourced events (which it then
|
|
204
|
+
drops). A subagent event logged with **no** `agentId` is the signal to widen the
|
|
205
|
+
marker. Codex keeps its separate behavior: it uses `Stop` as the only idle signal
|
|
206
|
+
and treats `SubagentStop` as mid-turn.
|
|
166
207
|
|
|
167
208
|
## ✅ Resolved: false "waiting for approval" while Codex auto-reviews an approval (Approve for me)
|
|
168
209
|
|
|
@@ -218,15 +259,13 @@ Issues found in live use, with their current status.
|
|
|
218
259
|
|
|
219
260
|
- **Symptom:** Even after approving HAYA Pet's Codex hooks once, every new
|
|
220
261
|
`haya-pet run --client codex` showed Codex's hook review prompt again.
|
|
221
|
-
- **Root cause:**
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
-
|
|
227
|
-
|
|
228
|
-
profile. Users may need to approve once after updating; after that, unchanged
|
|
229
|
-
hook commands should stay trusted.
|
|
262
|
+
- **Root cause:** Older builds wrote a stable `$CODEX_HOME/haya-pet.config.toml`
|
|
263
|
+
profile, but Codex stored hook trust decisions back into that same profile
|
|
264
|
+
under `[hooks.state]`. Rewriting the profile on launch deleted that trust cache.
|
|
265
|
+
- **Fix:** The Codex hook injector now writes stable commands into
|
|
266
|
+
`$CODEX_HOME/hooks.json` and preserves existing user hooks while refreshing
|
|
267
|
+
HAYA-managed entries. Users may need to approve once after updating; after
|
|
268
|
+
that, unchanged hook commands should stay trusted.
|
|
230
269
|
|
|
231
270
|
## ✅ Resolved: Codex pet looked busy immediately after startup
|
|
232
271
|
|
|
@@ -426,8 +465,12 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
|
|
|
426
465
|
`Notification`/`PreCompact`/`PostCompact`/`Stop` events to `haya-pet state <state>`,
|
|
427
466
|
reported to the daemon over the IPC pipe. `PostCompact` is split by its
|
|
428
467
|
`manual`/`auto` trigger matcher (manual `/compact` → *idle*, auto compaction →
|
|
429
|
-
*thinking*) so the pet never sticks on *compacting*.
|
|
430
|
-
|
|
468
|
+
*thinking*) so the pet never sticks on *compacting*. Subagent-originated events
|
|
469
|
+
are **dropped** by the reporter (they carry an `agent_id`), so a subagent's tool
|
|
470
|
+
use never drives the main status, and `SubagentStop` is not wired; when `Stop`
|
|
471
|
+
fires while a subagent is still running, its `background_tasks` snapshot surfaces
|
|
472
|
+
as a *running tools* / "Subagent running" cue that the next (empty) `Stop` clears
|
|
473
|
+
— see the resolved subagent entry above. `PreToolUse` distinguishes
|
|
431
474
|
file-editing tools (`Edit`/`Write`/`MultiEdit`/`NotebookEdit` → *editing files*)
|
|
432
475
|
from other tools (→ *running tools*) via the hook `matcher`. **Why opt-in:**
|
|
433
476
|
injecting hooks makes Claude show a one-time *review hooks* trust prompt; the
|
|
@@ -436,9 +479,9 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
|
|
|
436
479
|
launch — see the resolved note below). `--observe` is a separate PTY opt-in for
|
|
437
480
|
non-interactive runs (terminal-fidelity tradeoff).
|
|
438
481
|
- **Codex** — **implemented (partial).** Opt-in via the global `haya-pet hooks on`;
|
|
439
|
-
the wrapper injects `packages/adapters/src/codex-hooks.js` as
|
|
440
|
-
`$CODEX_HOME/
|
|
441
|
-
|
|
482
|
+
the wrapper injects `packages/adapters/src/codex-hooks.js` as stable user-level
|
|
483
|
+
hooks in `$CODEX_HOME/hooks.json` (`packages/cli-core/src/codex-hook-injection.js`).
|
|
484
|
+
Custom Codex `-p/--profile` args remain untouched. Falls back to `--observe` / L1
|
|
442
485
|
when not enabled. Findings (verified against `codex-cli` 0.137.0 on Windows):
|
|
443
486
|
- **Mechanism fits.** Codex has a lifecycle-hooks system (`[[hooks.<Event>]]` in
|
|
444
487
|
`config.toml` or a `hooks.json`), with the `hooks` feature flag `stable` and ON
|
|
@@ -452,11 +495,10 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
|
|
|
452
495
|
signal (`SubagentStop` is mid-turn → stays *thinking*). `PermissionRequest`
|
|
453
496
|
exists, so the approval cue is reachable.
|
|
454
497
|
- **Injection differs** — Codex has no `claude --settings <file>` equivalent.
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
trust UX as Claude applies.
|
|
498
|
+
HAYA Pet uses `$CODEX_HOME/hooks.json`, merging HAYA-managed entries with any
|
|
499
|
+
existing user hooks. Codex loads that hook source alongside selected profiles,
|
|
500
|
+
and has its own *review hooks* trust prompt (bypass:
|
|
501
|
+
`--dangerously-bypass-hook-trust`), so the same one-time trust UX as Claude applies.
|
|
460
502
|
- **Windows command quoting (fixed in the adapter):** Codex runs a hook `command`
|
|
461
503
|
via `cmd /c "<cmd>"`, which strips a **leading** quote — so Claude's
|
|
462
504
|
`"<node>" "<cli>" …` form dies with *"hook exited with code 1"*. The Codex
|
package/docs/troubleshooting.md
CHANGED
|
@@ -18,10 +18,10 @@ deferred problems with known root causes.
|
|
|
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
19
|
| Pet changes status after a **Claude Code** subagent finishes, even though the main agent already stopped | Fixed — Claude `SubagentStop` is ignored because it is not a reliable main-turn state. Update to the latest version and restart the wrapped Claude session so the new hook settings are used. |
|
|
20
20
|
| 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, `running_tool`/`editing_files` from a transcript watcher, and approval states from the `PermissionRequest` hook plus a guardian-review watcher. |
|
|
21
|
-
| **Codex** asks to review HAYA hooks on every launch | Fixed — update to the latest version, then approve once more.
|
|
21
|
+
| **Codex** asks to review HAYA hooks on every launch | Fixed — update to the latest version, then approve once more. HAYA Pet now writes stable hook commands into `$CODEX_HOME/hooks.json` and preserves existing user hooks when refreshing that file. |
|
|
22
22
|
| Pet showed **waiting for approval** while **Codex** auto-reviewed the request ("Approve for me") | Fixed — with `approvals_reviewer = auto_review` (legacy `guardian_subagent`) Codex's guardian decides without asking you; the pet now reports **reviewing** from the permission hook itself, then **working** on an allow verdict or **thinking** on a deny. *Waiting for approval* still shows when Codex actually asks you (`approvals_reviewer = "user"`). Restart the wrapped Codex session after updating so Codex reloads the changed hook command. |
|
|
23
23
|
| Pet shows **shell_command** or **thinking** right after starting Codex, before you prompt it | Fixed — the Codex transcript and guardian watchers now ignore rollouts whose `session_meta.timestamp` predates the current wrapper launch, so another active Codex session cannot drive this pet's status. Restart the wrapped Codex session after updating. |
|
|
24
|
-
| **Codex** live status didn't turn on
|
|
24
|
+
| **Codex** live status didn't turn on with a custom `-p`/`--profile` | Fixed — HAYA Pet installs Codex hooks through `$CODEX_HOME/hooks.json`, so profile args are preserved. Run `haya-pet hooks on`, restart the wrapped Codex session, and approve the one-time hook review if Codex asks. |
|
|
25
25
|
| 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. |
|
|
26
26
|
| 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. |
|
|
27
27
|
| Pet shows only **idle** for a generic / unknown CLI | Expected without a hook adapter — add `--observe` for PTY observation, otherwise lifecycle only. |
|
package/package.json
CHANGED
|
@@ -49,14 +49,12 @@
|
|
|
49
49
|
// - UNTESTED: PreCompact / SubagentStart|Stop live firing (no compaction /
|
|
50
50
|
// subagent occurred in the probe).
|
|
51
51
|
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// without HAYA_PET_SESSION_ID).
|
|
59
|
-
// Both still trip Codex's one-time hook-trust prompt, exactly like the Claude path.
|
|
52
|
+
// Injection: unlike `claude --settings <file>`, Codex has no per-invocation
|
|
53
|
+
// settings-file flag. The wrapper writes stable user-level hooks to
|
|
54
|
+
// $CODEX_HOME/hooks.json, merging HAYA-managed entries with any existing user
|
|
55
|
+
// hooks. This avoids consuming Codex's single -p/--profile slot; the reporter
|
|
56
|
+
// still no-ops without HAYA_PET_SESSION_ID.
|
|
57
|
+
// This still trips Codex's one-time hook-trust prompt, exactly like the Claude path.
|
|
60
58
|
|
|
61
59
|
// Codex's file-editing tool(s) vs. its command tool.
|
|
62
60
|
const EDIT_TOOLS = ["apply_patch"];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Pure helpers for the Claude "subagent still running at Stop" cue.
|
|
2
|
+
//
|
|
3
|
+
// When the main Claude agent ends its turn it fires `Stop`, whose payload carries
|
|
4
|
+
// a `background_tasks` array — a live snapshot of work still running at that
|
|
5
|
+
// instant (the hooks docs omit this field; it was verified against live traces).
|
|
6
|
+
// If a backgrounded *subagent* is still running, the main agent is paused but real
|
|
7
|
+
// work continues, so the pet keeps a "working" cue with a message rather than
|
|
8
|
+
// dropping to idle. When that subagent finishes, Claude fires `Stop` AGAIN with an
|
|
9
|
+
// empty `background_tasks`, which naturally clears the cue — so this needs no
|
|
10
|
+
// timers, no persisted state, and no subagent-event wiring. The only check is at
|
|
11
|
+
// the main agent's Stop, exactly as scoped.
|
|
12
|
+
//
|
|
13
|
+
// Scoped to `type: "subagent"` ONLY. Background *shells* are intentionally
|
|
14
|
+
// excluded: their completion isn't reliably observable here, and a status we
|
|
15
|
+
// cannot retract is worse than none.
|
|
16
|
+
|
|
17
|
+
// A single, fixed cue message. The user only wants to know that a subagent is
|
|
18
|
+
// still working after the main agent paused — NOT which one or how many.
|
|
19
|
+
const SUBAGENT_RUNNING_SUMMARY = "Subagent running";
|
|
20
|
+
|
|
21
|
+
// Parse a Claude hook payload (JSON on stdin) and return its background_tasks
|
|
22
|
+
// array. Defensive: any non-JSON / missing / wrong-typed input yields [].
|
|
23
|
+
export function extractBackgroundTasks(raw) {
|
|
24
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
return Array.isArray(parsed?.background_tasks) ? parsed.background_tasks : [];
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The still-running *subagent* tasks. Shells and finished tasks are dropped.
|
|
36
|
+
export function runningSubagentTasks(tasks) {
|
|
37
|
+
if (!Array.isArray(tasks)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return tasks.filter((task) => task && task.type === "subagent" && task.status === "running");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A fixed cue message when any subagent is still running, or undefined when none
|
|
44
|
+
// are (the caller then leaves the reported state untouched). Intentionally not
|
|
45
|
+
// detailed — just "a subagent is still working".
|
|
46
|
+
export function summarizeSubagentTasks(tasks) {
|
|
47
|
+
return runningSubagentTasks(tasks).length > 0 ? SUBAGENT_RUNNING_SUMMARY : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Decide the effective state/summary for a reported state given the Stop payload's
|
|
51
|
+
// background_tasks. Only an `idle` report with a running subagent is upgraded to a
|
|
52
|
+
// working cue; everything else passes through unchanged — including the follow-up
|
|
53
|
+
// Stop whose background_tasks is empty, which is what retracts the cue.
|
|
54
|
+
export function applySubagentBackgroundTasks({ state, summary, backgroundTasks }) {
|
|
55
|
+
if (state !== "idle") {
|
|
56
|
+
return { state, summary };
|
|
57
|
+
}
|
|
58
|
+
const message = summarizeSubagentTasks(backgroundTasks);
|
|
59
|
+
if (!message) {
|
|
60
|
+
return { state, summary };
|
|
61
|
+
}
|
|
62
|
+
return { state: "running_tool", summary: message };
|
|
63
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
// Resolves stable paths, builds the Codex hook settings,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// that doesn't pass `-p <profileName>`.
|
|
1
|
+
// Resolves stable paths, builds the Codex hook settings, and writes them to the
|
|
2
|
+
// user-level hooks.json inside CODEX_HOME. Codex loads user-level hooks alongside
|
|
3
|
+
// any selected profile, so HAYA Pet does not consume Codex's single -p/--profile
|
|
4
|
+
// slot and custom profiles keep working.
|
|
6
5
|
//
|
|
7
6
|
// Like the Claude injector, the file path and command strings are kept identical
|
|
8
7
|
// across sessions so Codex's hook-trust review only needs approving once. fnm hands
|
|
@@ -12,33 +11,31 @@ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
|
12
11
|
import { homedir } from "node:os";
|
|
13
12
|
import { join } from "node:path";
|
|
14
13
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { buildCodexHookSettings
|
|
14
|
+
import { buildCodexHookSettings } from "../../adapters/src/codex-hooks.js";
|
|
16
15
|
|
|
17
16
|
const DEFAULT_CLI_PATH = fileURLToPath(new URL("../../../apps/cli/src/haya-pet.js", import.meta.url));
|
|
18
|
-
const
|
|
19
|
-
const
|
|
17
|
+
const HOOKS_FILE = "hooks.json";
|
|
18
|
+
const HAYA_HOOK_STATUS = "HAYA Pet live status";
|
|
20
19
|
|
|
21
20
|
export function injectCodexHooks({ nodePath, cliPath, codexHome, env = process.env } = {}) {
|
|
22
21
|
const resolvedNode = nodePath ?? safeRealpath(process.execPath);
|
|
23
22
|
const resolvedCli = cliPath ?? safeRealpath(DEFAULT_CLI_PATH);
|
|
24
23
|
const home = codexHome ?? env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
25
24
|
|
|
26
|
-
const settings = buildCodexHookSettings({ nodePath: resolvedNode, cliPath: resolvedCli });
|
|
27
|
-
const toml = serializeCodexHooksToml(settings, {
|
|
28
|
-
header: "haya-pet live-status hooks profile. Managed by haya-pet; safe to delete."
|
|
29
|
-
});
|
|
25
|
+
const settings = markManagedHooks(buildCodexHookSettings({ nodePath: resolvedNode, cliPath: resolvedCli }));
|
|
30
26
|
|
|
31
|
-
// A fixed
|
|
32
|
-
//
|
|
27
|
+
// A fixed user-level hook source works with every Codex profile. We merge rather
|
|
28
|
+
// than overwrite because hooks.json may already contain user hooks.
|
|
33
29
|
mkdirSync(home, { recursive: true });
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
30
|
+
const hooksPath = join(home, HOOKS_FILE);
|
|
31
|
+
const existing = readHooksJson(hooksPath);
|
|
32
|
+
const next = mergeHooksJson(existing, settings);
|
|
33
|
+
writeFileSync(hooksPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
34
|
+
|
|
35
|
+
// The hook file is stable and reusable on purpose — leaving it in place is what
|
|
36
|
+
// lets Codex remember the hooks are trusted. cleanup is a no-op kept for API
|
|
37
|
+
// symmetry with the caller's finally block.
|
|
38
|
+
return { hooksPath, cleanup: () => {} };
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
function safeRealpath(target) {
|
|
@@ -49,50 +46,98 @@ function safeRealpath(target) {
|
|
|
49
46
|
}
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
function
|
|
49
|
+
function readHooksJson(hooksPath) {
|
|
53
50
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
const parsed = JSON.parse(readFileSync(hooksPath, "utf8"));
|
|
52
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error?.code === "ENOENT") {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`haya-pet: could not update Codex ${HOOKS_FILE} (${error.message})`, { cause: error });
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
function markManagedHooks(settings) {
|
|
62
|
+
const hooks = {};
|
|
63
|
+
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
64
|
+
hooks[event] = entries.map((entry) => ({
|
|
65
|
+
...entry,
|
|
66
|
+
hooks: (entry.hooks ?? []).map((hook) => ({
|
|
67
|
+
...hook,
|
|
68
|
+
statusMessage: HAYA_HOOK_STATUS
|
|
69
|
+
}))
|
|
70
|
+
}));
|
|
63
71
|
}
|
|
64
|
-
return
|
|
72
|
+
return { hooks };
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
function
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
inHookState = true;
|
|
78
|
-
} else if (inHookState) {
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
75
|
+
function mergeHooksJson(existing, managed) {
|
|
76
|
+
const output = isPlainObject(existing) ? { ...existing } : {};
|
|
77
|
+
const existingHooks = isPlainObject(output.hooks) ? output.hooks : {};
|
|
78
|
+
const managedHooks = managed.hooks ?? {};
|
|
79
|
+
const hooks = {};
|
|
80
|
+
const events = new Set([...Object.keys(existingHooks), ...Object.keys(managedHooks)]);
|
|
81
|
+
|
|
82
|
+
for (const event of events) {
|
|
83
|
+
const preserved = removeManagedEntries(existingHooks[event]);
|
|
84
|
+
const nextEntries = managedHooks[event] ?? [];
|
|
82
85
|
|
|
83
|
-
if (
|
|
84
|
-
|
|
86
|
+
if (Array.isArray(preserved)) {
|
|
87
|
+
hooks[event] = [...preserved, ...nextEntries];
|
|
88
|
+
} else if (nextEntries.length > 0) {
|
|
89
|
+
hooks[event] = nextEntries;
|
|
90
|
+
} else if (preserved !== undefined) {
|
|
91
|
+
hooks[event] = preserved;
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
|
|
88
|
-
|
|
95
|
+
output.hooks = hooks;
|
|
96
|
+
return output;
|
|
89
97
|
}
|
|
90
98
|
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
function removeManagedEntries(entries) {
|
|
100
|
+
if (!Array.isArray(entries)) {
|
|
101
|
+
return entries;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return entries
|
|
105
|
+
.map((entry) => removeManagedHooksFromEntry(entry))
|
|
106
|
+
.filter((entry) => entry !== undefined);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function removeManagedHooksFromEntry(entry) {
|
|
110
|
+
if (!isPlainObject(entry) || !Array.isArray(entry.hooks)) {
|
|
111
|
+
return entry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const hooks = entry.hooks.filter((hook) => !isManagedHook(hook));
|
|
115
|
+
if (hooks.length === 0) {
|
|
116
|
+
return undefined;
|
|
95
117
|
}
|
|
96
|
-
|
|
97
|
-
|
|
118
|
+
if (hooks.length === entry.hooks.length) {
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
return { ...entry, hooks };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isManagedHook(hook) {
|
|
125
|
+
if (!isPlainObject(hook)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
if (hook.statusMessage === HAYA_HOOK_STATUS) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return isLegacyHayaPetCommand(hook.command);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isLegacyHayaPetCommand(command) {
|
|
135
|
+
if (typeof command !== "string") {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return /haya-pet\.js"?\s+(state|codex-permission-request)\b/.test(command);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isPlainObject(value) {
|
|
142
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
98
143
|
}
|
|
@@ -7,6 +7,7 @@ import { getDefaultPaths } from "../../platform-core/src/paths.js";
|
|
|
7
7
|
import { isAiClientState } from "../../protocol/src/messages.js";
|
|
8
8
|
import { DEADLINE, raceDeadline } from "./deadline.js";
|
|
9
9
|
import { writeSessionTranscriptLink } from "./session-transcript-link.js";
|
|
10
|
+
import { applySubagentBackgroundTasks, extractBackgroundTasks } from "./background-tasks.js";
|
|
10
11
|
|
|
11
12
|
// Hard ceiling on the whole connect→send→close interaction. The reporter is a
|
|
12
13
|
// child process of the wrapped AI client, and the client may wait for its hook
|
|
@@ -60,8 +61,28 @@ export async function runStateCommand(parsed, dependencies = {}) {
|
|
|
60
61
|
const env = dependencies.env ?? process.env;
|
|
61
62
|
const now = dependencies.now ?? Date.now;
|
|
62
63
|
const sessionId = parsed.session ?? env.HAYA_PET_SESSION_ID;
|
|
64
|
+
const agentId = dependencies.agentId;
|
|
63
65
|
|
|
64
|
-
debugLog(
|
|
66
|
+
debugLog(
|
|
67
|
+
env,
|
|
68
|
+
now,
|
|
69
|
+
agentId
|
|
70
|
+
? { state: parsed.state, sessionId, summary: parsed.summary, agentId }
|
|
71
|
+
: { state: parsed.state, sessionId, summary: parsed.summary }
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// A subagent's own activity must NEVER drive the main session's status. A
|
|
75
|
+
// backgrounded subagent's tool calls fire the PARENT session's PreToolUse/
|
|
76
|
+
// PostToolUse hooks, which would otherwise overwrite the main agent's status
|
|
77
|
+
// (and the "Subagent running" cue) with running_tool/thinking/editing_files as
|
|
78
|
+
// the subagent works. The payload's `agent_id` is the documented field that
|
|
79
|
+
// distinguishes subagent hook calls from main-thread calls — when it's present
|
|
80
|
+
// the event came from a subagent, so we drop it entirely. The ONE place a
|
|
81
|
+
// subagent surfaces is the main agent's own Stop (no agent_id), via
|
|
82
|
+
// background_tasks; see applySubagentBackgroundTasks below.
|
|
83
|
+
if (agentId) {
|
|
84
|
+
return { command: "state", ok: false, reason: "subagent-event" };
|
|
85
|
+
}
|
|
65
86
|
|
|
66
87
|
if (!sessionId) {
|
|
67
88
|
return { command: "state", ok: false, reason: "no-session" };
|
|
@@ -77,6 +98,17 @@ export async function runStateCommand(parsed, dependencies = {}) {
|
|
|
77
98
|
// session's interrupt/denial. Best-effort and synchronous; never blocks the hook.
|
|
78
99
|
recordTranscriptLink(sessionId, env, dependencies);
|
|
79
100
|
|
|
101
|
+
// When the main agent's Stop reports idle but its background_tasks (from the hook
|
|
102
|
+
// payload, passed in via dependencies) still lists a running subagent, keep a
|
|
103
|
+
// working cue with a message instead — the main agent is paused but real work
|
|
104
|
+
// continues. The follow-up Stop carries an empty list and clears it. Scoped to
|
|
105
|
+
// subagents only; see background-tasks.js.
|
|
106
|
+
const effective = applySubagentBackgroundTasks({
|
|
107
|
+
state: parsed.state,
|
|
108
|
+
summary: parsed.summary,
|
|
109
|
+
backgroundTasks: dependencies.backgroundTasks
|
|
110
|
+
});
|
|
111
|
+
|
|
80
112
|
const createIpcClient = dependencies.createIpcClient ?? defaultCreateIpcClient;
|
|
81
113
|
const deadlineMs = dependencies.reportDeadlineMs ?? REPORT_DEADLINE_MS;
|
|
82
114
|
|
|
@@ -93,8 +125,8 @@ export async function runStateCommand(parsed, dependencies = {}) {
|
|
|
93
125
|
await client.send({
|
|
94
126
|
type: "state",
|
|
95
127
|
sessionId,
|
|
96
|
-
state:
|
|
97
|
-
summary:
|
|
128
|
+
state: effective.state,
|
|
129
|
+
summary: effective.summary,
|
|
98
130
|
confidence: 0.9,
|
|
99
131
|
source: "official_plugin",
|
|
100
132
|
updatedAt: now()
|
|
@@ -152,19 +184,50 @@ export function extractTranscriptPath(raw) {
|
|
|
152
184
|
}
|
|
153
185
|
}
|
|
154
186
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
187
|
+
// Pull `agent_id` out of a Claude hook payload (JSON on stdin). Present only for
|
|
188
|
+
// subagent-originated events (the documented field distinguishing subagent hook
|
|
189
|
+
// calls from main-thread calls); absent for main-agent events. Pure and
|
|
190
|
+
// defensive: any non-JSON, missing-field, or wrong-type input yields undefined.
|
|
191
|
+
export function extractAgentId(raw) {
|
|
192
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(raw);
|
|
197
|
+
const value = parsed?.agent_id;
|
|
198
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Read the Claude hook payload from stdin and return everything the reporter
|
|
205
|
+
// needs from it in one read (stdin can only be consumed once): the session's
|
|
206
|
+
// transcript_path (for the watcher binding) and the live background_tasks snapshot
|
|
207
|
+
// (for the subagent-at-Stop cue). Used by the real `haya-pet state` process (a
|
|
208
|
+
// Claude hook child) — NOT by internal callers, so tests and other commands never
|
|
209
|
+
// touch stdin. Bounded and best-effort: a TTY (manual invocation) or a
|
|
210
|
+
// slow/absent payload resolves to empty results rather than ever hanging the host
|
|
211
|
+
// client's hook.
|
|
212
|
+
export async function readHookPayloadFromStdin(options = {}) {
|
|
213
|
+
const raw = await readHookPayloadRaw(options);
|
|
214
|
+
return {
|
|
215
|
+
transcriptPath: extractTranscriptPath(raw),
|
|
216
|
+
backgroundTasks: extractBackgroundTasks(raw),
|
|
217
|
+
agentId: extractAgentId(raw)
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Accumulate the raw payload string from stdin under a hard deadline and byte cap.
|
|
222
|
+
// Always resolves (never rejects); an error or no payload yields "".
|
|
223
|
+
function readHookPayloadRaw(options = {}) {
|
|
161
224
|
const stdin = options.stdin ?? process.stdin;
|
|
162
225
|
const timeoutMs = options.timeoutMs ?? 400;
|
|
163
226
|
const maxBytes = options.maxBytes ?? 1_000_000;
|
|
164
227
|
|
|
165
228
|
return new Promise((resolve) => {
|
|
166
229
|
if (!stdin || stdin.isTTY) {
|
|
167
|
-
resolve(
|
|
230
|
+
resolve("");
|
|
168
231
|
return;
|
|
169
232
|
}
|
|
170
233
|
|
|
@@ -172,7 +235,7 @@ export function readHookTranscriptPathFromStdin(options = {}) {
|
|
|
172
235
|
let settled = false;
|
|
173
236
|
let timer;
|
|
174
237
|
|
|
175
|
-
const finish = (
|
|
238
|
+
const finish = () => {
|
|
176
239
|
if (settled) {
|
|
177
240
|
return;
|
|
178
241
|
}
|
|
@@ -188,17 +251,20 @@ export function readHookTranscriptPathFromStdin(options = {}) {
|
|
|
188
251
|
} catch {
|
|
189
252
|
// detaching is best-effort
|
|
190
253
|
}
|
|
191
|
-
resolve(
|
|
254
|
+
resolve(data);
|
|
192
255
|
};
|
|
193
256
|
|
|
194
257
|
const onData = (chunk) => {
|
|
195
258
|
data += chunk;
|
|
196
259
|
if (data.length > maxBytes) {
|
|
197
|
-
finish(
|
|
260
|
+
finish();
|
|
198
261
|
}
|
|
199
262
|
};
|
|
200
|
-
const onEnd = () => finish(
|
|
201
|
-
const onError = () =>
|
|
263
|
+
const onEnd = () => finish();
|
|
264
|
+
const onError = () => {
|
|
265
|
+
data = "";
|
|
266
|
+
finish();
|
|
267
|
+
};
|
|
202
268
|
|
|
203
269
|
try {
|
|
204
270
|
stdin.setEncoding("utf8");
|
|
@@ -206,12 +272,12 @@ export function readHookTranscriptPathFromStdin(options = {}) {
|
|
|
206
272
|
stdin.on("end", onEnd);
|
|
207
273
|
stdin.on("error", onError);
|
|
208
274
|
stdin.resume();
|
|
209
|
-
timer = setTimeout(
|
|
275
|
+
timer = setTimeout(finish, timeoutMs);
|
|
210
276
|
if (timer && typeof timer.unref === "function") {
|
|
211
277
|
timer.unref();
|
|
212
278
|
}
|
|
213
279
|
} catch {
|
|
214
|
-
finish(
|
|
280
|
+
finish();
|
|
215
281
|
}
|
|
216
282
|
});
|
|
217
283
|
}
|