diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a92da..52535b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Claude Code turn notifications no longer arrive while a background subagent or workflow is still running. + ## 0.1.4 ### Fixed diff --git a/README.md b/README.md index 8a57b20..6518568 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,8 @@ Completion notifications are configured by default: | Claude Code | `Stop` | Final response, when available. | | Claude Code | `StopFailure` | Turn stopped with an error. | +For Claude Code, Busy Octopus skips the `Stop` notification while a background subagent or workflow is still running, because its result starts another turn. + The `--enable-attention` setup option adds: | Provider | Event | Message | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 27e3b61..c622bc1 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -26,6 +26,8 @@ Do not share prompts, agent output, hook payloads, queue files, credentials, or - Find the hook error output in the agent transcript or hook log. - A line that starts with `busy-octopus: unable to process the agent hook:` names the failure type and the hook fields that failed validation. - This line contains no hook content. You can include it in a bug report. +- In Claude Code, a turn that ends while a background subagent or workflow is still running sends no notification. The turn that starts after the last of them finishes sends it. +- A background subagent waiting for a permission prompt sends no notification until it continues. Run setup with `--enable-attention` to be notified of approval requests. ## VS Code does not consume notifications diff --git a/src/integration/agent/claude-code/adapter.spec.test.ts b/src/integration/agent/claude-code/adapter.spec.test.ts index 4ec59f0..f454664 100644 --- a/src/integration/agent/claude-code/adapter.spec.test.ts +++ b/src/integration/agent/claude-code/adapter.spec.test.ts @@ -40,6 +40,71 @@ describe("claudeCodeHookAdapter", () => { } satisfies Partial); }); + it.each(["subagent", "workflow"])( + "skips a turn that waits for a %s task", + (type) => { + expect( + claudeCodeHookAdapter.createNotification({ + ...stopHook, + background_tasks: [ + { + id: "task-1", + type, + status: "running", + description: "Synthetic task.", + }, + ], + }), + ).toBeNull(); + }, + ); + + it("skips a turn that waits for an agent task among other tasks", () => { + expect( + claudeCodeHookAdapter.createNotification({ + ...stopHook, + background_tasks: [ + { + id: "task-1", + type: "shell", + status: "running", + description: "Synthetic task.", + }, + { + id: "task-2", + type: "subagent", + status: "pending", + description: "Synthetic task.", + }, + ], + }), + ).toBeNull(); + }); + + it("converts a turn while only open-ended background tasks run", () => { + expect( + claudeCodeHookAdapter.createNotification({ + ...stopHook, + background_tasks: ["shell", "monitor", "teammate", "cloud session"].map( + (type, index) => ({ + id: `task-${index}`, + type, + status: "running", + description: "Synthetic task.", + }), + ), + }), + ).toEqual(claudeCodeHookAdapter.createNotification(stopHook)); + }); + + it("converts a turn without a background task registry", () => { + const { background_tasks: _, ...hook } = stopHook; + + expect(claudeCodeHookAdapter.createNotification(hook)).toEqual( + claudeCodeHookAdapter.createNotification(stopHook), + ); + }); + it("converts a failed turn without exposing error details", () => { expect( claudeCodeHookAdapter.createNotification({ diff --git a/src/integration/agent/claude-code/adapter.ts b/src/integration/agent/claude-code/adapter.ts index 88d3aee..5328e04 100644 --- a/src/integration/agent/claude-code/adapter.ts +++ b/src/integration/agent/claude-code/adapter.ts @@ -20,12 +20,16 @@ const commonShape = { agent_type: z.string().min(1).max(256).optional(), } satisfies z.ZodRawShape; +const ClaudeBackgroundTask = z.object({ + type: z.string().max(256), +}); + const ClaudeStopHook = z.object({ ...commonShape, hook_event_name: z.literal("Stop"), stop_hook_active: z.boolean(), last_assistant_message: z.string().max(65_536).optional(), - background_tasks: z.array(z.unknown()).max(1_024).optional(), + background_tasks: z.array(ClaudeBackgroundTask).max(1_024).optional(), session_crons: z.array(z.unknown()).max(1_024).optional(), }); @@ -80,11 +84,22 @@ const notificationId = ({ .digest("hex") .slice(0, 32)}`; -const createNotification = (input: unknown): NotifyInput => { +// Subagents and workflows report back in a later turn; other tasks, like idle teammates, may never end. +const agentTaskTypeSet = new Set(["subagent", "workflow"]); + +const createNotification = (input: unknown): NotifyInput | null => { const hook = ClaudeCodeHook.parse(input); switch (hook.hook_event_name) { case "Stop": + if ( + (hook.background_tasks ?? []).some(({ type }) => + agentTaskTypeSet.has(type), + ) + ) { + return null; + } + return { body: agentNotificationBody(hook.last_assistant_message ?? null), directory: hook.cwd,