@hayasaka7/haya-pet 0.3.9 → 0.3.10

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 CHANGED
@@ -7,6 +7,24 @@ All notable changes to HAYA Pet are documented here. This project adheres to
7
7
  > 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
8
8
  > ships them.
9
9
 
10
+ ## [0.3.10]
11
+
12
+ ### Fixed
13
+ - **A running Claude Code subagent no longer drives the main session's status.**
14
+ With hooks enabled and a multi-agent run, once the main agent had stopped but a
15
+ subagent was still working, two things went wrong: the pet dropped to *idle*
16
+ (even though work was ongoing), and while the subagent ran its tool calls flipped
17
+ the pet between *running tools* / *editing files* / *thinking*. Fix, checked
18
+ **only at the main agent's `Stop`** (no timers, no persisted state): (1) Claude's
19
+ `Stop` payload carries a live `background_tasks` snapshot — when it still lists a
20
+ running **subagent**, the pet shows *running tools* with the message **"Subagent
21
+ running"**, and the follow-up `Stop` (empty `background_tasks`) clears it back to
22
+ *idle*; (2) every subagent-originated hook event carries an `agent_id`, so the
23
+ reporter now **drops any event with an `agent_id`**, and a subagent's activity can
24
+ no longer overwrite the main session's status. Background **shells** are
25
+ deliberately not surfaced (their completion isn't reliably observable). See
26
+ `docs/known-issues.md`.
27
+
10
28
  ## [0.3.9]
11
29
 
12
30
  ### Fixed
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
8
- import { parseStateArgs, runStateCommand, readHookTranscriptPathFromStdin } from "../../../packages/cli-core/src/run-state.js";
8
+ import { parseStateArgs, runStateCommand, readHookPayloadFromStdin } from "../../../packages/cli-core/src/run-state.js";
9
9
  import { removeSessionTranscriptLink } from "../../../packages/cli-core/src/session-transcript-link.js";
10
10
  import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages/cli-core/src/claude-hook-injection.js";
11
11
  import { injectCodexHooks as defaultInjectCodexHooks } from "../../../packages/cli-core/src/codex-hook-injection.js";
@@ -969,20 +969,31 @@ if (isDirectRun(import.meta.url, process.argv[1])) {
969
969
  }
970
970
 
971
971
  // Real-process entry. For a `haya-pet state` invocation — which is ALWAYS a client
972
- // hook child — read the hook payload from stdin once to learn this session's real
973
- // transcript path, and hand it to the reporter so it can record the
974
- // session->transcript link. Done here (not inside main/runStateCommand) so unit
975
- // tests, and every other command that needs stdin passed through to its child
976
- // (e.g. `run`), never touch stdin.
972
+ // hook child — read the hook payload from stdin once to learn: this session's real
973
+ // transcript path (for the session->transcript link); the live background_tasks
974
+ // snapshot (so a Stop with a still-running subagent keeps a working cue instead of
975
+ // idle); and the agent_id (present only for subagent-originated events, which the
976
+ // reporter drops so a subagent's activity never overwrites the main status). All
977
+ // are handed to the reporter via dependencies. Done here (not inside
978
+ // main/runStateCommand) so unit tests, and every other command that needs stdin
979
+ // passed through to its child (e.g. `run`), never touch stdin.
977
980
  async function bootstrap() {
978
981
  const argv = process.argv.slice(2);
979
982
  const dependencies = {};
980
983
  if (argv[0] === "state") {
981
984
  try {
982
- const transcriptPath = await readHookTranscriptPathFromStdin();
985
+ const { transcriptPath, backgroundTasks, agentId } = await readHookPayloadFromStdin();
983
986
  if (transcriptPath) {
984
987
  dependencies.transcriptPath = transcriptPath;
985
988
  }
989
+ if (Array.isArray(backgroundTasks) && backgroundTasks.length > 0) {
990
+ dependencies.backgroundTasks = backgroundTasks;
991
+ }
992
+ // Present only for subagent-originated events — the reporter drops those so a
993
+ // subagent's tool use never overwrites the main session's status.
994
+ if (agentId) {
995
+ dependencies.agentId = agentId;
996
+ }
986
997
  } catch {
987
998
  // a missing/garbled payload just means no binding this time — never fatal
988
999
  }
@@ -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: Claude Code subagent completion changed the main session status
154
-
155
- - **Symptom:** In Claude Code multi-agent runs, the main agent could already be
156
- stopped while a subagent was still finishing. When that late subagent emitted
157
- `SubagentStop`, the pet treated it as a main-session `idle` update and could
158
- show a misleading working/done transition after the main agent had settled.
159
- - **Root cause:** The Claude hook table mapped `SubagentStop` to `idle`. That is
160
- only safe if subagent completion is ordered before the main turn finishes, which
161
- Claude Code does not guarantee.
162
- - **Fix:** Claude `SubagentStop` is now ignored. Main-session idle still comes
163
- from Claude's real `Stop` hook, while late subagent completion cannot override
164
- the current main-agent state. Codex keeps its separate behavior because Codex
165
- uses `Stop` as the only idle signal and treats `SubagentStop` as mid-turn.
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
 
@@ -426,8 +467,12 @@ observation (`--observe`) or L1 lifecycle as the fallback. Current state:
426
467
  `Notification`/`PreCompact`/`PostCompact`/`Stop` events to `haya-pet state <state>`,
427
468
  reported to the daemon over the IPC pipe. `PostCompact` is split by its
428
469
  `manual`/`auto` trigger matcher (manual `/compact` → *idle*, auto compaction →
429
- *thinking*) so the pet never sticks on *compacting*. `SubagentStop` is intentionally ignored because
430
- it is not a main-turn idle signal. `PreToolUse` distinguishes
470
+ *thinking*) so the pet never sticks on *compacting*. Subagent-originated events
471
+ are **dropped** by the reporter (they carry an `agent_id`), so a subagent's tool
472
+ use never drives the main status, and `SubagentStop` is not wired; when `Stop`
473
+ fires while a subagent is still running, its `background_tasks` snapshot surfaces
474
+ as a *running tools* / "Subagent running" cue that the next (empty) `Stop` clears
475
+ — see the resolved subagent entry above. `PreToolUse` distinguishes
431
476
  file-editing tools (`Edit`/`Write`/`MultiEdit`/`NotebookEdit` → *editing files*)
432
477
  from other tools (→ *running tools*) via the hook `matcher`. **Why opt-in:**
433
478
  injecting hooks makes Claude show a one-time *review hooks* trust prompt; the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -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
+ }
@@ -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(env, now, { state: parsed.state, sessionId, summary: parsed.summary });
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: parsed.state,
97
- summary: parsed.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
- // Read the Claude hook payload from stdin and return its transcript_path. Used by
156
- // the real `haya-pet state` process (a Claude hook child) NOT by internal
157
- // callers, so tests and other commands never touch stdin. Bounded and best-effort:
158
- // a TTY (manual invocation) or a slow/absent payload resolves to undefined rather
159
- // than ever hanging the host client's hook.
160
- export function readHookTranscriptPathFromStdin(options = {}) {
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(undefined);
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 = (value) => {
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(value);
254
+ resolve(data);
192
255
  };
193
256
 
194
257
  const onData = (chunk) => {
195
258
  data += chunk;
196
259
  if (data.length > maxBytes) {
197
- finish(extractTranscriptPath(data));
260
+ finish();
198
261
  }
199
262
  };
200
- const onEnd = () => finish(extractTranscriptPath(data));
201
- const onError = () => finish(undefined);
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(() => finish(extractTranscriptPath(data)), timeoutMs);
275
+ timer = setTimeout(finish, timeoutMs);
210
276
  if (timer && typeof timer.unref === "function") {
211
277
  timer.unref();
212
278
  }
213
279
  } catch {
214
- finish(undefined);
280
+ finish();
215
281
  }
216
282
  });
217
283
  }
@@ -0,0 +1,115 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "../../../test/harness.mjs";
3
+ import {
4
+ applySubagentBackgroundTasks,
5
+ extractBackgroundTasks,
6
+ runningSubagentTasks,
7
+ summarizeSubagentTasks
8
+ } from "../src/background-tasks.js";
9
+
10
+ test("extractBackgroundTasks pulls the array out of a Claude Stop payload", () => {
11
+ const raw = JSON.stringify({
12
+ hook_event_name: "Stop",
13
+ background_tasks: [
14
+ { id: "x", type: "subagent", status: "running", description: "Survey repo structure" }
15
+ ]
16
+ });
17
+ assert.deepEqual(extractBackgroundTasks(raw), [
18
+ { id: "x", type: "subagent", status: "running", description: "Survey repo structure" }
19
+ ]);
20
+ });
21
+
22
+ test("extractBackgroundTasks is defensive about junk, missing field, and wrong types", () => {
23
+ assert.deepEqual(extractBackgroundTasks("{not json"), []);
24
+ assert.deepEqual(extractBackgroundTasks(JSON.stringify({ hook_event_name: "Stop" })), []);
25
+ assert.deepEqual(extractBackgroundTasks(JSON.stringify({ background_tasks: "nope" })), []);
26
+ assert.deepEqual(extractBackgroundTasks(""), []);
27
+ assert.deepEqual(extractBackgroundTasks(undefined), []);
28
+ });
29
+
30
+ test("runningSubagentTasks keeps only running subagents (drops shells and finished)", () => {
31
+ const tasks = [
32
+ { id: "a", type: "subagent", status: "running", description: "A" },
33
+ { id: "b", type: "subagent", status: "completed", description: "B" },
34
+ { id: "c", type: "shell", status: "running", command: "sleep 120" },
35
+ null,
36
+ { id: "d", type: "subagent", status: "running", description: "D" }
37
+ ];
38
+ assert.deepEqual(runningSubagentTasks(tasks).map((t) => t.id), ["a", "d"]);
39
+ assert.deepEqual(runningSubagentTasks(undefined), []);
40
+ assert.deepEqual(runningSubagentTasks("nope"), []);
41
+ });
42
+
43
+ test("summarizeSubagentTasks returns a fixed message when any subagent runs (no detail)", () => {
44
+ // Same generic message regardless of description, agent_type, or count.
45
+ assert.equal(
46
+ summarizeSubagentTasks([
47
+ { type: "subagent", status: "running", description: "Survey repo structure" }
48
+ ]),
49
+ "Subagent running"
50
+ );
51
+ assert.equal(
52
+ summarizeSubagentTasks([{ type: "subagent", status: "running", agent_type: "explore" }]),
53
+ "Subagent running"
54
+ );
55
+ assert.equal(
56
+ summarizeSubagentTasks([
57
+ { type: "subagent", status: "running", description: "A" },
58
+ { type: "subagent", status: "running", description: "B" }
59
+ ]),
60
+ "Subagent running"
61
+ );
62
+ });
63
+
64
+ test("summarizeSubagentTasks returns undefined when nothing is running", () => {
65
+ assert.equal(summarizeSubagentTasks([]), undefined);
66
+ assert.equal(summarizeSubagentTasks([{ type: "shell", status: "running" }]), undefined);
67
+ assert.equal(summarizeSubagentTasks([{ type: "subagent", status: "completed" }]), undefined);
68
+ });
69
+
70
+ test("applySubagentBackgroundTasks upgrades idle to a working cue when a subagent runs", () => {
71
+ assert.deepEqual(
72
+ applySubagentBackgroundTasks({
73
+ state: "idle",
74
+ summary: undefined,
75
+ backgroundTasks: [
76
+ { type: "subagent", status: "running", description: "Survey repo structure" }
77
+ ]
78
+ }),
79
+ { state: "running_tool", summary: "Subagent running" }
80
+ );
81
+ });
82
+
83
+ test("applySubagentBackgroundTasks leaves a non-idle state untouched", () => {
84
+ assert.deepEqual(
85
+ applySubagentBackgroundTasks({
86
+ state: "thinking",
87
+ summary: "hi",
88
+ backgroundTasks: [{ type: "subagent", status: "running", description: "A" }]
89
+ }),
90
+ { state: "thinking", summary: "hi" }
91
+ );
92
+ });
93
+
94
+ test("applySubagentBackgroundTasks passes idle through when nothing runs (the retraction case)", () => {
95
+ // The follow-up Stop carries an empty background_tasks — this is what clears the cue.
96
+ assert.deepEqual(
97
+ applySubagentBackgroundTasks({ state: "idle", summary: undefined, backgroundTasks: [] }),
98
+ { state: "idle", summary: undefined }
99
+ );
100
+ assert.deepEqual(
101
+ applySubagentBackgroundTasks({ state: "idle", summary: "idle", backgroundTasks: undefined }),
102
+ { state: "idle", summary: "idle" }
103
+ );
104
+ });
105
+
106
+ test("applySubagentBackgroundTasks ignores background shells (deliberately unsupported)", () => {
107
+ assert.deepEqual(
108
+ applySubagentBackgroundTasks({
109
+ state: "idle",
110
+ summary: undefined,
111
+ backgroundTasks: [{ type: "shell", status: "running", command: "sleep 120" }]
112
+ }),
113
+ { state: "idle", summary: undefined }
114
+ );
115
+ });
@@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { test } from "../../../test/harness.mjs";
6
- import { extractTranscriptPath, parseStateArgs, runStateCommand } from "../src/run-state.js";
6
+ import { extractAgentId, extractTranscriptPath, parseStateArgs, runStateCommand } from "../src/run-state.js";
7
7
  import { readSessionTranscriptLink } from "../src/session-transcript-link.js";
8
8
 
9
9
  test("runStateCommand appends a debug line when HAYA_PET_HOOK_DEBUG is set", async () => {
@@ -35,6 +35,40 @@ test("extractTranscriptPath pulls transcript_path out of a Claude hook payload",
35
35
  assert.equal(extractTranscriptPath(undefined), undefined);
36
36
  });
37
37
 
38
+ test("extractAgentId pulls agent_id out of a subagent hook payload", () => {
39
+ assert.equal(
40
+ extractAgentId(JSON.stringify({ hook_event_name: "PreToolUse", agent_id: "a9a8317d", agent_type: "Explore" })),
41
+ "a9a8317d"
42
+ );
43
+ // Main-agent events carry no agent_id; junk and wrong types yield undefined.
44
+ assert.equal(extractAgentId(JSON.stringify({ hook_event_name: "Stop" })), undefined);
45
+ assert.equal(extractAgentId(JSON.stringify({ agent_id: 42 })), undefined);
46
+ assert.equal(extractAgentId(JSON.stringify({ agent_id: "" })), undefined);
47
+ assert.equal(extractAgentId("{not json"), undefined);
48
+ assert.equal(extractAgentId(undefined), undefined);
49
+ });
50
+
51
+ test("runStateCommand ignores subagent-originated events so they never drive main status", async () => {
52
+ let connected = false;
53
+ const sent = [];
54
+ const result = await runStateCommand(
55
+ { command: "state", state: "running_tool", summary: undefined, session: "sess_main" },
56
+ {
57
+ now: () => 9,
58
+ agentId: "a9a8317d3457d6364",
59
+ createIpcClient: async () => {
60
+ connected = true;
61
+ return { send: async (m) => sent.push(m), close: async () => {} };
62
+ }
63
+ }
64
+ );
65
+
66
+ assert.equal(result.ok, false);
67
+ assert.equal(result.reason, "subagent-event");
68
+ assert.equal(connected, false);
69
+ assert.equal(sent.length, 0);
70
+ });
71
+
38
72
  test("runStateCommand records the session->transcript link when given a transcript path", async () => {
39
73
  const sessionDir = mkdtempSync(join(tmpdir(), "sess-"));
40
74
  await runStateCommand(
@@ -112,6 +146,39 @@ test("runStateCommand sends one official_plugin state message", async () => {
112
146
  });
113
147
  });
114
148
 
149
+ test("runStateCommand upgrades a Stop idle to a working cue when a subagent still runs", async () => {
150
+ const sent = [];
151
+ const result = await runStateCommand(
152
+ { command: "state", state: "idle", summary: undefined, session: "sess_bg" },
153
+ {
154
+ now: () => 42,
155
+ backgroundTasks: [
156
+ { id: "x", type: "subagent", status: "running", description: "Survey repo structure" }
157
+ ],
158
+ createIpcClient: async () => ({ send: async (m) => sent.push(m), close: async () => {} })
159
+ }
160
+ );
161
+
162
+ assert.equal(result.ok, true);
163
+ assert.equal(sent[0].state, "running_tool");
164
+ assert.equal(sent[0].summary, "Subagent running");
165
+ });
166
+
167
+ test("runStateCommand reports plain idle when the follow-up Stop has no running subagents", async () => {
168
+ const sent = [];
169
+ await runStateCommand(
170
+ { command: "state", state: "idle", summary: undefined, session: "sess_done" },
171
+ {
172
+ now: () => 43,
173
+ backgroundTasks: [],
174
+ createIpcClient: async () => ({ send: async (m) => sent.push(m), close: async () => {} })
175
+ }
176
+ );
177
+
178
+ assert.equal(sent[0].state, "idle");
179
+ assert.equal(sent[0].summary, undefined);
180
+ });
181
+
115
182
  test("runStateCommand falls back to HAYA_PET_SESSION_ID", async () => {
116
183
  const sent = [];
117
184
  const result = await runStateCommand(