Skip to content

fix: keep team tasks and coordinator authority across Claude notifications - #23

Merged
SergioTCG merged 1 commit into
mainfrom
fix/team-system-prompt-authority
Oct 6, 2026
Merged

SergioTCG merged 1 commit into
mainfrom
fix/team-system-prompt-authority

Conversation

@SergioTCG

Copy link
Copy Markdown
Owner

Summary

Claude Code's own background-command, subagent, and Monitor notifications broke session teams in two ways:

  • Workers: they failed the worker's delegated task ("A local terminal prompt replaced the delegated worker turn.") while the worker kept working.
  • Coordinators: they revoked the coordinator's owner team turn, so its next sab team send / continue was refused with owner_turn_required.

SAB now recognizes these notifications by the leading envelope Claude Code writes, and they no longer fail the task or revoke the turn. Typed local input still does both, exactly as before.

Root cause

Claude Code submits background-command completions, background-subagent returns and Monitor events as <task-notification> prompts, and fires UserPromptSubmit for each of them. That includes the mid-turn case: while a turn is still running, Claude Code drains a queued notification between tool calls (a queued_command), and fires the hook at drain time.

There is no structural signal to tell these prompts apart from typing:

  • Hook payload: it has no origin field. It carries only session_id, transcript_path, cwd, prompt_id, permission_mode, hook_event_name, prompt and session_title.
  • Transcript: the entry that does carry origin.kind: "task-notification" is written after the hook returns. I verified this at hook time for typed prompts, background commands and subagent returns.

The prompt hook treated every prompt that was not one of the following as local terminal input:

  • a delegated-task acknowledgement;
  • an injected Slack prompt;
  • an automation echo.

That input then hit one of two outcomes:

  • worker with an active task → failTeamTaskForSession(session, 'A local terminal prompt replaced the delegated worker turn.')
  • otherwise → clearTeamTurn(session), which revokes a coordinator's current owner turn.

isSystemPrompt already recognized these prompts, but it was only used to skip mirroring.

Evidence from the failed batch (Claude team, five workers plus a coordinator)

Workers.

  • Each of the five delegated tasks was acknowledged within about 2 s of its claim.
  • Each was then failed between 44 s and 12 min later, running → failed, with no replies or reports.
  • In every worker transcript, a queued <task-notification> (for example a background npm ci completing) was drained 44 ms to 2.6 s before the failure.
  • The worker carried on in the same prompt turn afterwards, so the task was failed while the work continued.
  • In one of them, a coordinator follow-up had been acknowledged normally 27 s earlier; the later notification drain failed the task anyway.

Coordinator.

  • Monitor events (<summary>Monitor event: "…"</summary><event>…</event>) were drained mid-turn during the owner turn.
  • The very next sab team send calls were refused twice: Team delegation is available only during a current owner or authorized automatic-continuation turn.

Merging. An isolated scratch Claude Code 2.1.291 confirmed the hook timing:

  • a typed prompt fires its hook when it is submitted;
  • a notification fires its own hook when it is drained;
  • queued commands are never merged into one hook prompt.

Fix

  • isProviderInternalPrompt(provider, prompt) in daemon/providers.mjs:
    • It applies to Claude only.
    • It is start-anchored and matches only the exact <task-notification> or <system-reminder> envelope, with no attributes.
  • unacknowledgedPromptTeamEffect(…) in daemon/team-provider-turn.mjs is a pure decision: null, 'fail_task' or 'revoke_turn'. Provider notifications, SAB injections and automation echoes return null.
  • The prompt hook classifies the prompt first, then routes both former branches through the helper.
  • A notification's quoted task envelope is never read as a task marker. For example, a Monitor tailing team output is not a delivery, an acknowledgement or an identity mismatch.

Deliberately unchanged

  • reserveTeamInput(session, 'provider') still runs. A notification starts or continues a real provider turn. The worker or coordinator must therefore read as busy, and SAB must not paste new team input into it. The Stop finalizer clears the reservation as before.
  • startPoller still runs. It is idempotent mid-turn, and a standalone notification turn needs the spinner relay and missing-Stop detection.
  • retireTeamProviderTurn and mirroring are unchanged. Mirroring still uses isSystemPrompt, so notifications are still not posted as terminal typing.
  • No authority is granted; it is only no longer revoked.
    • An owner turn still ends at its Stop. A notification that arrives after it finds no team turn, exactly as today.
    • A notification after a worker's turn was reported (awaiting_release) no longer fails the task. Its final becomes another turn report for the same work generation, which is the existing behaviour for later native turns.
  • Still treated as local input (fail/revoke as before):
    • typed prompts, even ones that mention or paste a notification;
    • typed slash commands, which reach the hook as raw /name; local commands and ! bash mode don't fire it at all (verified);
    • <pasted_content>;
    • MCP <channel …> messages;
    • peer and teammate envelopes;
    • plain-text notification variants (ultraplan, ultrareview, MCP-task fallbacks);
    • all Codex and Pi prompts.
  • Not keyed on promptSource. MCP channel messages are promptSource: "system" too, so a source-based exemption could have let a collaborator's Slack turn keep the owner's authority. The envelope test avoids that.
  • SAB's own SYSTEM NOTIFICATION: continuation is unchanged. It is injected and keeps its existing handling; SAB never writes a provider envelope itself.

Tests

New test/team-provider-notifications.test.mjs adds 7 tests. Its fixtures are prompt shapes captured from Claude Code 2.1.291: a background command, a subagent return, a Monitor event, and a reminder.

  • A worker with a running task receives each notification, and the task stays running with its binding intact.
  • A coordinator in an owner turn receives each notification, and consumeCoordinatorDispatch (used by send and continue) and assertCoordinatorTaskControl still succeed.
  • Genuine typed input still fails the task and revokes the turn. This includes:
    • a prompt that merely mentions or pastes a notification;
    • slash commands;
    • a Codex prompt containing the envelope.
  • Bridge injections and automation echoes keep their semantics.
  • A source contract pins the hook wiring:
    • classification happens before marker extraction;
    • markers are blinded for notifications;
    • there is exactly one fail path and one revoke path, both through the helper;
    • the reservation, poller and mirroring are unchanged.

Each mutation below was applied on its own, then the files were restored byte-identical:

Mutation Result
Helper ignores providerInternal (the guard removed) red: worker task and coordinator authority tests
Hook reverted to the old unguarded branches red: hook wiring contract
Hook stops passing providerInternal red: hook wiring contract
Classifier not anchored at the start red: classifier, worker and coordinator typed-input tests
Classifier ignores the provider red: classifier and worker typed-input tests
Task markers no longer blinded for notifications red: hook wiring contract

Validation

  • npm test: 555 pass, 0 fail (7 new).
  • npm run check: pass.
  • node --check on every daemon, channel and scripts .mjs: pass.
  • PI_OFFLINE=1 pi --extension ./pi/sab-extension.ts --list-models: pass.
  • shellcheck: clean.
  • ⚠️ npm run audit fails on a newly published advisory, GHSA-jqcg-44mw-7w3h.
    • It is in proxy-addr 2.0.7 via @modelcontextprotocol/sdk → express, a transitive production dependency.
    • It was published after main's last green CI (Oct 5), so CI's audit step will fail on this branch and on main until the lockfile is refreshed, as in fix: refresh vulnerable transitive bridge dependencies #20.
    • This PR does not touch dependencies.

Rollout notes

  • Daemon restart needed: yes.
    • The fix is daemon code only. hooks/hook.sh, the CLI and the MCP channel are unchanged.
    • There is no state migration and no new persisted field.
  • Merging deploys. The self-updater fast-forwards a clean main and restarts the daemon, so merge only when you want to roll.
  • Running sessions survive a restart. Provider sessions are tmux-owned and re-adopted. Restart with launchctl kickstart -k gui/$(id -u)/si.sergej.claudeslackproxy.
  • Existing restart semantics still apply to an active team (unchanged here):
    • a worker mid-turn is re-adopted with live-turn proof;
    • an idle worker with a running task is released as failed;
    • awaiting_release tasks are kept;
    • a dispatching claim is never retried.
      So roll when no task is dispatching, ideally between batches.
  • Before release, CLAUDE.md asks for a local Claude start/resume smoke test for hook-path changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Xs8AVBVrTGfpzUMiCQp2R

…tions

Claude Code submits background-command, subagent and Monitor results as
prompts of their own, and UserPromptSubmit fires for them, also mid-turn
while it drains queued notifications. The hook payload carries no origin
field, so SAB treated these notifications as local terminal input. A
worker's active delegated task failed with "A local terminal prompt replaced
the delegated worker turn." while the worker kept working. A coordinator
lost its owner team turn, so its next sab team send or continue was refused
with owner_turn_required.

A notification is now recognized by the leading envelope Claude Code writes
(<task-notification> or <system-reminder>). It no longer fails the active
task or revokes the team turn. A task envelope quoted inside it is never
taken as a delivery, an acknowledgement or an identity mismatch.

Notifications still reserve team input and start the Claude poller, because
they begin or continue a real provider turn, and they are still not
mirrored. Typed prompts, typed slash commands, pasted text, Slack channel
messages, SAB's own injections, and Codex and Pi prompts keep their existing
semantics.

Validated:
- Full suite passes (555 tests, 7 new), plus shellcheck, .mjs syntax,
  npm run check and the Pi extension load.
- Each of six guard mutations turns the new tests red.
- npm run audit fails on GHSA-jqcg-44mw-7w3h (proxy-addr via
  @modelcontextprotocol/sdk > express). The advisory was published after
  main's last green CI; this change touches no dependencies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Xs8AVBVrTGfpzUMiCQp2R
@SergioTCG
SergioTCG force-pushed the fix/team-system-prompt-authority branch from 07498d3 to e750d09 Compare October 6, 2026 11:13
@SergioTCG
SergioTCG merged commit 092f98b into main Oct 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants