Fork-Subagent recursion guard false-positives on substring match of `<fork-boilerplate` in user messages (v2.1.177)
Summary
In Claude Code v2.1.177, every Agent({subagent_type: "fork", ...}) dispatch from a clean top-level session fails with:
Fork is not available inside a forked worker. Complete your task directly using your tools.
…as soon as any prior user-message in the session contains the literal substring ‹fork-boilerplate — even in documentation, skill text, memory dumps, or pasted transcripts. The recursion-guard is structurally too loose.
Verification that the affected session is not actually a fork:
$ echo $CLAUDE_CODE_FORK_SUBAGENT
1
$ head -1 ~/.claude/projects/<project>/<session-id>.jsonl
{"type":"last-prompt", ...} # NOT "fork-context-ref"
Reproduction
- Start a top-level Claude Code session with
CLAUDE_CODE_FORK_SUBAGENT=1. - In any user message, include the literal text
‹fork-boilerplate›(e.g., via a skill whoseSKILL.mddescribes the tag literally, or by pasting documentation). - Dispatch a fork:
``json``
{"name":"Agent","input":{"subagent_type":"fork","description":"t","prompt":"echo alive"}}
- Observe the error above. The session is not a fork — the first JSONL line is
last-prompt, notfork-context-ref.
A real-world trigger: any plugin/skill that documents the boilerplate mechanism. We hit this in Kirchlive/Claude-Full-Context-Agent v1.0.2 — the Self-Check section of both skill files explained the tag literally, and every invocation of those skills poisoned the session.
Root cause (from the v2.1.177 binary)
// z7H = "fork-boilerplate"
function $m_(messages) {
return messages.some(m => {
if (m.type !== "user") return false;
const content = m.message.content;
if (!Array.isArray(content)) return false;
return content.some(c =>
c.type === "text" && c.text.includes(`<${z7H}`)
);
});
}
// Agent-tool dispatch guard:
if (z.options.querySource === `agent:builtin:${Zx.agentType}` || $m_(z.messages)) {
throw new gTH(
"Fork is not available inside a forked worker. Complete your task directly using your tools."
);
}
text.includes("‹fork-boilerplate") is a blind substring match across all user-messages. Any text — skill docs, memory entries, CLAUDE.md, pasted snippets, this very issue body — that mentions the tag literally triggers the guard. The querySource === "agent:builtin:fork" check is correct on its own; the substring fallback is what introduces the bug.
Suggested fix
The runtime already injects the boilerplate at a known structural position (first sidechain user turn of a fork). The guard should match that structure, not the substring:
- Position-based: check only the first sidechain user turn for the marker, not every user message in the conversation.
- Marker-uniqueness: inject a per-session token (
‹fork-boilerplate session="<uuid>">) and match on the full pattern, not the bare opening tag. - Trust the metadata:
querySource === "agent:builtin:fork"is already a definitive signal — promote that to authoritative, drop the substring fallback (or keep it as a debug-mode assertion).
Any of the three (or a combination) closes this bug class entirely.
Workaround (for plugin/skill authors)
Until the runtime fix lands, anyone writing documentation about the fork mechanism should avoid the literal tag in any text that ends up in user-messages. We use Unicode angle brackets (‹fork-boilerplate›, U+2039 / U+203A) — semantically identical for human readers, doesn't match text.includes("‹fork-boilerplate").
Full incident report including binary-string evidence and a session-JSONL recovery procedure for already-poisoned sessions: <https://github.com/Kirchlive/Claude-Full-Context-Agent/blob/main/UPDATE.md>
Environment
- Claude Code: 2.1.177 (macOS, Mach-O x86_64)
- OS: macOS Darwin 25.5.0
CLAUDE_CODE_FORK_SUBAGENT=1set in~/.claude/settings.json- Verified empirically; binary spelunked with
strings+grepfor the symbols above