Skip to content

Keep the event path honest: background subagents, subagent models, big events, shared logs, --no-claude decks, control markers - #1940

Merged
BarganConstantin merged 6 commits into
developmentfrom
fix/event-path
Oct 5, 2026
Merged

BarganConstantin merged 6 commits into
developmentfrom
fix/event-path

Conversation

@BarganConstantin

Copy link
Copy Markdown
Owner

What changes

  • Background subagents no longer take the main thread's calls. Claude Code puts an agent_id on every call a subagent makes and none on the main thread's, but the board handed every call naming nobody to the newest live subagent. With a background Task running, the root's own Read, Edit and Bash were drawn and counted on the subagent's card, and a root call still running when the subagent finished stayed in flight on a done card. Once a subagent of a session names itself on its own traffic, a call naming nobody is the root's, and the calls that reached a subagent before that are handed back. Sessions whose subagents never name themselves (older Claude Code) keep the old rule.
  • A subagent keeps its own model. The server stamped the session's root model on every event without one, the subagent's own events included, and the board wrote it over the model the transcript named for that subagent, so a Haiku subagent under an Opus session read and was priced as Opus. A subagent's events now get its own model when it is known and nothing otherwise, and the board only takes a payload's model onto the agent the payload names.
  • An event too large for the deck still arrives. Every deck refuses an ingest body over 5,000,000 characters, so an Edit of a file of a few megabytes (its result carries the whole file) reached no deck, and the turn's end then drew the successful call red. The hook now replaces the largest fields of such an event with a marker saying how big they were, until it fits, and keeps the event name, session, tool, call id and cwd.
  • A shared log is rotated while its owner is idle. Only the owner of a shared events.jsonl may rotate it, and the owner only looked at the file after writing a line of its own, so a quiet scoped deck owning the log beside a busy machine-wide deck let it grow past 50 MB without bound. The owner now also looks on the existing 30-second floor.
  • A --no-claude deck is not handed Claude sessions. --no-claude removes no hooks an earlier run installed, and the hook never read the record's claude flag, so a Codex-only deck drew every Claude session live and lost them at its next boot. The hook now skips such a deck before the writer election, and the ingest refuses a Claude-stamped event on it so an older hook hands the log on instead of dropping it.
  • Control markers are accepted only from the deck itself. Event names beginning with __ are the deck's own control markers. The hook ingest now refuses a reserved name, and the boot replay honours a marker only when the server itself recorded it.

Verification

  • npm run typecheck clean.
  • Full suite (vitest run --maxWorkers=3 --minWorkers=1): 903 files, 11,777 tests passed, exit 0.
  • New regression tests, each run before its fix and seen failing for the reason described:
    • background-subagent-root-calls.test.ts — before: the root's Read/Bash landed on the subagent (['Bash','Grep'] on a done subagent, root held only Task).
    • subagent-model-stamp.test.ts — before: the subagent's stamped events carried claude-opus-5 and its card read Opus after ModelObserved said Haiku.
    • hook-oversized-event.test.ts — runs the real hook against the server's real ingest handler; before: the deck answered 413 and no PostToolUse arrived.
    • event-log-idle-rotation.test.ts — before: a 51 MB log owned by an idle deck was never rotated.
    • no-claude-deck-ingest.test.ts — before: the claude: false deck was posted the Claude event and its ingest answered 200.
    • event-ingest-reserved-names.test.ts — before: the ingest answered 200 for a reserved name and the replay honoured a marker not recorded by the server.
  • Existing tests repointed, no guard removed: posttooluse-idempotent.test.ts (its last outcome now names nobody, like the call's own PreToolUse — naming a subagent there would now mean the session's subagents name themselves), and replay-scope-696.test.ts (seeds __clear with the internal source the deck has always written it with).
  • Browser, isolated deck on :4414 (own HOME/XDG/CLAUDE_CONFIG_DIR, AGENTS_DECK_NO_*), a session with a background subagent and transcripts on disk (root on Opus, subagent on Haiku), the root's Bash posted before the subagent's first own call: the subagent's detail reads DONE · Haiku 4.5 · 1 call (Grep) with nothing in flight; the root's reads Opus 4.5 · 4 calls (Task, Bash in flight, Read, Edit); when the Bash finishes it settles on the root.

…und subagent

Claude Code puts an agent_id on every call a subagent makes and none on the main thread's, but the board handed every call naming nobody to the newest live subagent. With a background Task running, the root's own Read, Edit and Bash were drawn and counted on the subagent's card, and a root call still running when the subagent finished stayed in flight on a done card.

Once a subagent of a session names itself on its own traffic, a call naming nobody is the root's, and the calls that reached a subagent before that are handed back. Sessions whose subagents never name themselves keep the old rule.
The server stamped the session's root model on every event that arrived without one, the subagent's own events included, and the board wrote it over the model the transcript named for that subagent. Nothing sent the right one again, so a Haiku subagent under an Opus session read and was priced as Opus.

A subagent's events are now stamped with its own model when it is known and with nothing otherwise, and the board only takes a payload's model onto the agent the payload names.
Every deck refuses an ingest body over 5,000,000 characters, so an Edit of a file of a few megabytes, whose result carries the whole file, reached no deck at all and the turn's end settled the successful call as failed.

The hook now cuts the largest fields of such an event down to a marker saying how big they were, until it fits under the cap, and keeps the event, session, tool, call id and cwd the deck settles the call by.
Only the owner of a shared events.jsonl may rotate it, and the owner only checked the file after writing a line of its own. A scoped deck on a quiet tree owns the default log and writes nothing, while the machine-wide deck beside it writes every line and may not rotate, so the 50 MB cap held for nobody.

The owner now also looks on the 30-second floor the rotation already describes, whether or not it writes.
Starting a deck with --no-claude removes no hooks an earlier run installed, and the hook posted every Claude event to every deck whose workspace matched without reading the record's claude flag. The Codex-only deck drew every Claude session live, could be elected to log them, and lost them again at its next boot because its replay does honour the flag.

The hook now skips a deck whose record says claude: false before the writer election, and the ingest answers a Claude-stamped event with a refusal on such a deck, so an older hook hands the log on rather than dropping the line.
Event names beginning with __ are the deck's own control markers, such as the one its Clear sends to every page. The hook ingest passed any name through, so such a marker could also arrive from outside the server and be written to the log.

The ingest now refuses a reserved event name, and the boot replay honours a marker only when the server itself recorded it.
@BarganConstantin
BarganConstantin merged commit d2929af into development Oct 5, 2026
10 checks passed
@BarganConstantin BarganConstantin mentioned this pull request Oct 5, 2026
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.

1 participant