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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions src/integration/agent/claude-code/adapter.spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,71 @@ describe("claudeCodeHookAdapter", () => {
} satisfies Partial<NotifyInput>);
});

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({
Expand Down
19 changes: 17 additions & 2 deletions src/integration/agent/claude-code/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down Expand Up @@ -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,
Expand Down
Loading