Fork-Subagent recursion guard false-positives on substring match of `<fork-boilerplate` in user messages (v2.1.177)

Open 💬 0 comments Opened Jun 13, 2026 by Kirchlive

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

  1. Start a top-level Claude Code session with CLAUDE_CODE_FORK_SUBAGENT=1.
  2. In any user message, include the literal text ‹fork-boilerplate› (e.g., via a skill whose SKILL.md describes the tag literally, or by pasting documentation).
  3. Dispatch a fork:

``json
{"name":"Agent","input":{"subagent_type":"fork","description":"t","prompt":"echo alive"}}
``

  1. Observe the error above. The session is not a fork — the first JSONL line is last-prompt, not fork-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:

  1. Position-based: check only the first sidechain user turn for the marker, not every user message in the conversation.
  2. Marker-uniqueness: inject a per-session token (‹fork-boilerplate session="<uuid>">) and match on the full pattern, not the bare opening tag.
  3. 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=1 set in ~/.claude/settings.json
  • Verified empirically; binary spelunked with strings + grep for the symbols above

View original on GitHub ↗