Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions packages/adapters/claude/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ import { HOOK_SH } from "@middle/core";
* - `SessionStart` → `session.started` carries `session_id`/`transcript_path`
* and triggers the launch→drive transition.
* - `Stop` → `agent.stopped` is the turn boundary the workflow classifies.
* `SubagentStop` also normalizes to `agent.stopped` (per the taxonomy); the
* dispatcher correlates by session, so a subagent turn boundary is treated as a
* stop signal for the session.
* `SubagentStop` maps to its own `agent.subagent-stopped` event — NOT
* `agent.stopped`. A subagent (e.g. an Explore agent the implementer spawned)
* finishing is not the *main* agent's turn boundary; the main agent is still
* working. Conflating the two let the first subagent's completion resolve
* `awaitStop`, which classified a `bare-stop` and tore the workflow down
* mid-research. `agent.subagent-stopped` is recorded but never resolves the
* stop awaiter.
*/
const CLAUDE_EVENT_MAP: ReadonlyArray<[claudeEvent: string, normalized: NormalizedEvent]> = [
["SessionStart", "session.started"],
Expand All @@ -23,7 +27,7 @@ const CLAUDE_EVENT_MAP: ReadonlyArray<[claudeEvent: string, normalized: Normaliz
["PostToolUse", "tool.post"],
["Notification", "agent.notification"],
["Stop", "agent.stopped"],
["SubagentStop", "agent.stopped"],
["SubagentStop", "agent.subagent-stopped"],
["SessionEnd", "session.ended"],
];

Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/claude/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ describe("installHooks", () => {
expect(cmd("PostToolUse")).toBe(`"${abs}" tool.post`);
expect(cmd("Notification")).toBe(`"${abs}" agent.notification`);
expect(cmd("Stop")).toBe(`"${abs}" agent.stopped`);
expect(cmd("SubagentStop")).toBe(`"${abs}" agent.stopped`);
expect(cmd("SubagentStop")).toBe(`"${abs}" agent.subagent-stopped`);
expect(cmd("SessionEnd")).toBe(`"${abs}" session.ended`);
});

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type NormalizedEvent =
| "tool.failed"
| "agent.notification"
| "agent.stopped"
| "agent.subagent-stopped"
| "session.ended"
| "rate-limit.detected";

Expand All @@ -28,6 +29,7 @@ export const NORMALIZED_EVENTS: readonly NormalizedEvent[] = [
"tool.failed",
"agent.notification",
"agent.stopped",
"agent.subagent-stopped",
"session.ended",
"rate-limit.detected",
];
Expand Down
15 changes: 15 additions & 0 deletions packages/dispatcher/test/hook-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ describe("HookServer — Stop", () => {
const payload = await pending;
expect(payload.reason).toBe("turn-end");
});

test("a subagent stop does NOT resolve awaitStop — only the main agent's Stop does", async () => {
// Regression: SubagentStop normalizes to agent.subagent-stopped, not
// agent.stopped. A spawned Explore agent finishing must not be mistaken for
// the main agent's turn boundary (which tore the workflow down mid-research).
const pending = server.awaitStop("middle-6", 300);
await postHook("agent.subagent-stopped", "middle-6", { reason: "subagent-done" });
// the subagent stop is accepted but does not satisfy the stop awaiter
await expect(pending).rejects.toThrow();

// the main agent's real Stop does resolve it
const next = server.awaitStop("middle-6", 1000);
await postHook("agent.stopped", "middle-6", { reason: "turn-end" });
expect((await next).reason).toBe("turn-end");
});
});

describe("HookServer — HMAC auth + event validation (with store)", () => {
Expand Down
5 changes: 3 additions & 2 deletions planning/middle-management-build-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -808,14 +808,15 @@ All adapters emit these. The hook script POSTs `{type, sessionName, payload}` to
| `tool.post` | PostToolUse | command hook (success) |
| `tool.failed` | PostToolUseFailure | command hook (failure) |
| `agent.notification` | Notification | n/a |
| `agent.stopped` | Stop / SubagentStop | turn-end hook |
| `agent.stopped` | Stop | turn-end hook |
| `agent.subagent-stopped` | SubagentStop | (subagent turn-end) |
| `session.ended` | SessionEnd | shutdown hook |
| `rate-limit.detected` | (synthetic from Stop) | (synthetic from Stop) |

Two events are **load-bearing for dispatch**, not merely observational:

- `session.started` carries `session_id` and `transcript_path` in its payload. It is how the dispatcher discovers the on-disk transcript at all, and it triggers the launch→drive transition (enter auto mode, confirm readiness, send the prompt).
- `agent.stopped` is the turn boundary the workflow reacts to. Because the interactive process does not exit between turns, this — not a process exit — is the signal the dispatcher classifies (`classifyStop`).
- `agent.stopped` is the turn boundary the workflow reacts to. Because the interactive process does not exit between turns, this — not a process exit — is the signal the dispatcher classifies (`classifyStop`). It is fired only by the **main** agent's Stop. A `SubagentStop` (e.g. an Explore agent the implementer spawned finishing) is **not** a main-agent turn boundary — the main agent is still working — so it maps to the separate, observational `agent.subagent-stopped` and never resolves the workflow's stop awaiter. Conflating the two would let the first subagent's completion classify a premature `bare-stop` and tear the dispatch down mid-work.

The hook script is uniform across both:

Expand Down