Files
reliquary/docs/workpad/research/2026-10-16-00-claude-headless-fork-sessions.md

19 KiB

Claude Code headless: train one session, fork it per subsection

Model: claude-opus-5-5 (researcher). Date: 2026-10-16. CLI on PATH: claude 2.1.283 (/c/Users/g4b/.local/bin/claude); the host Desktop session runs 2.1.299 (AI_AGENT=claude-code_2-1-299_agent), so the nested CLI is older than the host.

Answer

Yes, it works. claude -p ... --output-format json returns session_id. claude -p --resume <base> --fork-session gives each run a new session id and a copy of the base history. The base stays as it was. Two forks ran in parallel without trouble, and each hit the prompt cache on the base prefix, provided its flags matched the base's. The catches:

  • Permission, tool and model flags are not inherited. Pass them again, identically, on every fork, or the cache misses.
  • The nested process is invisible to the Agent tool's tracking. The orchestrator has to block on Bash or poll for results.
  • --permission-mode plan quietly changed the model.

Claims

Sources: H = https://code.claude.com/docs/en/headless, C = https://code.claude.com/docs/en/cli-reference, S = https://code.claude.com/docs/en/sessions, P = https://code.claude.com/docs/en/permission-modes, E = https://code.claude.com/docs/en/env-vars, T = test log below.

# Claim Status
1a --output-format json prints a single result object with session_id, result, usage (incl. cache_read_input_tokens, cache_creation_input_tokens), modelUsage, total_cost_usd, permission_denials, subagent_stats. With stream-json --verbose, the first event is system/init with session_id, model, tools, agents, skills, plugins, permissionMode. verified (docs H; local test T1, T6)
1b --resume <id> finds the session by id in any project on the machine. It also accepts a .jsonl path. Without --fork-session it appends to the same id. verified (docs H, C, S; local T5: resumed base kept id 433df7b7…)
1c --continue = most recent conversation in the cwd. claude -p --continue includes -p/SDK sessions; interactive --continue skips them. Do not use it for fan-out, because "most recent" is ambiguous. Pass explicit ids instead. verified (docs C, S)
1d --session-id <uuid> lets the caller choose the base id up front, so no output parsing is needed. verified (docs C, --help)
2a --fork-session exists in 2.1.283: "When resuming, create a new session ID instead of reusing the original". verified (--help; docs C)
2b The fork sees the base context. Forks do not see each other. The base is unchanged on disk and in later resumes. verified (docs S "The original is unchanged on disk"; local T2-T5)
2c N forks from one base, run concurrently, work, and each gets its own id and .jsonl. Resuming the same id in two processes without forking interleaves both into one transcript. verified (local T2+T3 run in parallel; docs S for the interleave warning)
2d A fork can be forked again (fork of a fork). verified (local T6 forked fork A)
3a Forks hit the cache on the shared prefix: T2/T3 each had cache_read 14650 and cache_creation ~345, against the base's 8661 creation. verified (local test)
3b A cache hit needs identical tool set, model and system prompt. A fork with different flags (T6: no --tools "", default mode, haiku) got cache_read 0 and creation 17474. A fork that changed only the model (T7) got 4293 read and 7213 created. verified (local test)
3c Cache writes here were ephemeral_1h (1-hour TTL). Forks spaced more than about an hour after the base's last request pay full creation for the base prefix. Docs S: after about 1 h inactive "the session's prompt cache has expired". The 1 h TTL may be plan/account dependent. verified (local usage field; docs S); TTL policy across accounts unverified
3d total_cost_usd on a resumed or forked run is the cumulative conversation total, base spend included. Per-fork cost = fork total minus base total (T2: 0.0403 - 0.0359 = ~$0.0044). verified (docs H "reports the conversation's whole total"; local numbers consistent)
4a A -p run starts in default mode on accounts that fetch feature flags. It cannot prompt: without a host or --permission-prompt-tool, anything that would prompt is denied and the run continues (listed in permission_denials). --permission-prompts none also tells Claude not to retry. dontAsk denies anything not pre-allowed. --dangerously-skip-permissions = bypassPermissions. verified (docs H, P)
4b A fork does not inherit the base's permission mode, --allowedTools or --tools. T6, forked without flags, came up in default with all 43 tools. Docs: -p --resume starts "in the permission mode a new claude -p run would start in" (the plan-mode exception is disabled by --fork-session), and session grants don't carry into a --fork-session process. Pass the flags on every fork. verified (local T6; docs S)
4c Not restored on resume either: --mcp-config, --settings, --plugin-dir, --fallback-model, --add-dir. The system prompt (incl. --append-system-prompt) is recorded at the first request and reused on resume/fork until compaction. Later flag text is ignored. verified (docs S, C)
4d The model is restored on resume. --model on the fork overrides it (T7 ran haiku on a sonnet-5 base). verified (docs S; local T7)
5a Without --bare, -p loads installed plugins, skills, agents, hooks, MCP and CLAUDE.md like an interactive session. T6 init listed plugin spire@reliquary 0.2.2, spire:* agents (athanor, adept, …) and spire:* skills. verified (docs H; local T6)
5b --bare skips all that discovery and requires ANTHROPIC_API_KEY (no OAuth). Context then has to come from --append-system-prompt[-file], --settings, --agents, --plugin-dir, --mcp-config. verified (docs H, --help)
5c --model (alias or full id), --agents <json or file>, --agent, --append-system-prompt[-file], --settings, --max-budget-usd, --max-turns, --json-schema all exist in 2.1.283. verified (--help; docs C)
5d --model haiku --permission-mode plan ran on claude-sonnet-5 (T1-T5). The same flags with dontAsk ran on claude-haiku-4-5-20251001 (T7). User settings.json has no model key. Plan mode apparently swaps the model. The docs fetched don't explain it. observed (local test); cause unverified
6a The nested claude -p inherits CLAUDECODE=1 and CLAUDE_CODE_CHILD_SESSION=1. No recursion guard blocked it. -p sessions launched this way are still persisted (only nested interactive TUIs are excluded from --resume). verified (docs E; local: env shows both, runs succeeded and persisted)
6b It is a separate process with its own Agent-depth budget (subagent_stats.max_depth 0 in its result; CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH default 3 applies per session). It does not count against the parent's Agent depth or concurrency caps. depth separation inferred from per-process subagent_stats and docs E; not tested with a deep parent
6c Parent task notifications, ListAgents and TaskStop don't see it. The orchestrator must run it via Bash in the foreground (Bash timeout max 10 min) or background, then poll the output file or --output-format json result. --bg cannot combine with -p. --bg/-p conflict verified (docs H); notification invisibility assumed (not a tracked Agent)
6d Transcripts land in ~/.claude/projects/<cwd with non-alnum -> '-'>/<session-id>.jsonl, one file per fork. Each fork file holds the copied base history under its own sessionId. 30-day retention. verified (docs S; local listing)
6e The nested process inherited the Desktop host's env (CLAUDE_CODE_MESSAGING_SOCKET, CLAUDE_CODE_ENTRYPOINT=claude-desktop, OAuth-refresh vars). T6's init showed Desktop-only tools (Artifact, ReportFindings, PushNotification) and a messaging_socket_path. Nested sessions may talk to the host app. Side effects unknown. observed (local T6); implications unverified
7 Windows: worked from Git Bash with forward-slash paths. The project dir name is derived from the Windows path (C--Users-g4b-…). CLAUDE_CODE_GIT_BASH_PATH exists if bash isn't on PATH. There's no python on PATH here (use node to parse JSON). The CLI on PATH (2.1.283) lags the host (2.1.299). verified (local; docs E)

Test log

cwd: C:\Users\g4b\AppData\Local\Temp\claude\G--dev-projects-reliquary\42c87f82-…\scratchpad\forktest (empty). Flags unless noted: --model haiku --tools "" --permission-mode plan --output-format json. 7 invocations, total ~$0.13 (cumulative-total arithmetic per 3d).

T Command session_id Result cache_create / cache_read Model
1 base: "Memorize ZEBRA-7731, reply OK" 433df7b7 OK 8661 / 5989 sonnet-5
2 --resume base --fork-session: recall + memorize FORK-A-111 (parallel with T3) c3fbf072 ZEBRA-7731 343 / 14650 sonnet-5
3 same, FORK-B-222 (parallel with T2) 7ccea6b5 ZEBRA-7731 346 / 14650 sonnet-5
4 --resume forkA (no fork): list all tokens, include FORK-B if known c3fbf072 (same) ZEBRA-7731, FORK-A-111 109 / 14993 sonnet-5
5 --resume base (no fork), same question, after T2-T4 433df7b7 (same) ZEBRA-7731 102 / 14650 sonnet-5
6 --resume forkA --fork-session --model haiku --output-format stream-json --verbose, no --tools/mode 20a04357 DONE; init: mode default, 43 tools, spire loaded 17474 / 0 haiku-4-5
7 --resume base --fork-session, --permission-mode dontAsk instead of plan b8e6b9cd ZEBRA-7731 7213 / 4293 haiku-4-5

What it shows: (a) forks see the base (T2, T3, T7); (b) forks are isolated from each other (T4 doesn't know FORK-B); (c) the base is unaffected (T5 knows only ZEBRA); (d) cache reuse depends on matching flags (T2/T3 vs T6/T7).

Caveats and gaps

  • Tiny contexts only. Behavior with a large trained base (100k+ tokens, compaction mid-fork) and with tool-using forks that write files was not tested. Parallel forks writing the same worktree would collide. Use --worktree or separate dirs.
  • The plan-mode model swap (5d) is unexplained. Avoid plan for forks, or check modelUsage on each run.
  • The 6b/6c claims about depth and notifications are inferred, not tested from inside an Agent-spawned subagent with a deep stack.
  • The env inheritance from the Desktop host (6e) needs a look before relying on nested runs. Consider --bare + API key, or scrubbing CLAUDE_CODE_MESSAGING_* vars.
  • Budget: 5 web pages fetched (headless, cli-reference, sessions, permission-modes, env-vars, the last two partially). Not read: Agent SDK sessions/cost-tracking pages and the prompt-caching page (exact TTL rules).

Follow-up 2026-10-16

Model: claude-opus-5-5. Coordinator follow-up on the open points above. The original text is kept; where it was wrong, this section corrects it (see F3c on 6e and F2 on 5d). Same scratch folder. 8 invocations plus 1 nested child, about $0.35 in client-side estimates. Extra sources: PC = https://code.claude.com/docs/en/prompt-caching, MC = https://code.claude.com/docs/en/model-config, CT = https://code.claude.com/docs/en/agent-sdk/cost-tracking, E = env-vars (as above).

# Claim Status
F1a A claude -p launched from this subagent's Bash can spawn subagents with the Agent tool (--tools "Task,Bash" --permission-mode dontAsk). The subagent replied PONG. subagent_stats: spawned 1, max_depth 1, refused 0. verified (local D1)
F1b A second-level nested claude -p (launched by D1's Bash) works too: L2 returned L2 with its own session id. No recursion guard, no error. verified (local D1)
F1c Depth is counted per process, starting from that process's main conversation. With the default limit, the nested run went two subagent layers down (spawned 2, max_depth 2, spawned_by_subagents 1, refused 0). That run was itself nested at least two levels under the Desktop host (host, then this subagent, then Bash). The host's depth is not passed down: there is no depth env var among the inherited ones. verified (local D3; env listing)
F1d Hitting the limit produces no error message. With CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1, the layer-1 subagent simply had no Agent/Task tool. It answered "I don't have a Task/Agent tool available", and refused.depth_limit stayed 0. Docs: default 3, "set 1 to turn nesting off". verified (local D2; docs E)
F2a Reproduced. --model haiku --permission-mode plan: system/init.model reports the haiku model, but the assistant messages and modelUsage show Sonnet. On 2.1.283 it ran on claude-sonnet-5 (init claude-haiku-4-5-20251001). On 2.1.299 it ran on claude-sonnet-5-5 (init claude-haiku-5-5). With dontAsk, haiku runs as asked (T7). The debug log only shows dispatching to firstParty model=claude-sonnet-5, with no reason given. verified (local M1, M2, T7)
F2b Explanation: plan mode upgrades a Haiku session to Sonnet. MC, in the availableModels section: "A Haiku session that would normally upgrade to Sonnet in plan mode likewise uses the newest permitted Sonnet". The behavior is only implied there, not documented as a rule. It is not opusplan, and no env override is involved: there is no ANTHROPIC_MODEL, ANTHROPIC_DEFAULT_*_MODEL, CLAUDE_CODE_SUBAGENT_MODEL or ANTHROPIC_SMALL_FAST_MODEL in the inherited env, and no model in the user settings.json. Model-relevant inherited values: CLAUDE_EFFORT=medium, CLAUDE_CODE_SIMPLE=0. docs (indirect, MC) + local env check; exact rule unverified
F2c Practical point: init.model is not the model that ran. Read modelUsage or assistant.message.model instead. Aliases also depend on the CLI version: haiku is Haiku 4.5 on 2.1.283 and Haiku 5.5 on 2.1.299 (Haiku 5.5 needs v2.1.293 or later, Sonnet 5.5 needs v2.1.284 or later). verified (local; docs MC)
F3a Inherited env vars that point the nested run at the Desktop host (names only): CLAUDE_CODE_MESSAGING_SOCKET, CLAUDE_CODE_MESSAGING_TOKEN, CLAUDE_CODE_HOST_SESSION_ID, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_DESKTOP_APP_VERSION, CLAUDE_CODE_SDK_HAS_HOST_AUTH_REFRESH, CLAUDE_CODE_TERMINAL_MCP_TOOLS, CLAUDE_CODE_REPORT_FINDINGS, CLAUDE_CODE_SESSION_ID, CLAUDE_AGENT_SDK_VERSION, CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL, CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING, CLAUDE_CODE_EXECPATH, CLAUDE_PID, plus CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, AI_AGENT. verified (local env listing); each var's role unverified
F3b Removing the first twelve of these with env -u drops the Artifact, ArtifactComments and ArtifactData tools (35 to 32 non-MCP tools). ReportFindings, PushNotification and the other built-ins stay. The 8 mcp__claude_ai_Claude_Docs__* tools also stay: they are a claude.ai account connector, not inherited. Auth (OAuth) still worked. Which single var gates the Artifact tools was not isolated. verified (local E1 vs T6)
F3c Correction to 6e: messaging_socket_path in system/init is the nested process's own pipe, not the host's. It was different in every run (cc-msg-8e28... in T6, cc-msg-155d... in E1) and never equal to the host's CLAUDE_CODE_MESSAGING_SOCKET (cc-msg-f3c5...). So there is no evidence that the nested run is wired to the host's channel, but there is also no proof that it isn't. Nothing was sent over the channel. verified (local); "not wired" unverified
F3d --bare removes all of it: 4 tools (Bash, Edit, PowerShell, Read), 0 MCP, no socket. It refuses OAuth, though (Not logged in · Please run /login, is_error true), so it needs ANTHROPIC_API_KEY (docs H). plugins was still listed in bare init, which looks odd. verified (local E2; docs H)
F4a The 1 h TTL is Claude Code's default only for the main-conversation bucket (interactive turns and -p runs) on a Claude subscription within plan usage. Usage credits, API key and cloud providers default to 5 m. Over the plan limit, the main conversation drops to 5 m. A claude -p --resume --fork-session run is a main conversation, which matches the 1 h writes we saw. In-process Agent-tool forks and subagents are in the "everything else" bucket and get 5 m by default. verified (docs PC, CT; local ephemeral_1h in T1-T7)
F4b It is configurable: CLAUDE_CODE_PROMPT_CACHE_TTL or the promptCacheTtl setting (main), CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL or subagentPromptCacheTtl (everything else), each 5m or 1h, v2.1.242 or later. ENABLE_PROMPT_CACHING_1H=1 sets both; FORCE_PROMPT_CACHING_5M=1 overrides everything. verified (docs PC)
F4c Cache hits refresh the TTL: "Each request that hits the cache resets the timer". So forks spaced under the TTL keep the base prefix warm for later forks. verified (docs PC); not tested over time
F4d Cache scope: "Sessions you run in parallel in the same directory build matching prefixes and read each other's cache". Different cwd or a different git-status snapshot changes the prefix. Run all forks from the base's cwd. verified (docs PC); cross-cwd fork not tested
F4e Cost accounting: on resume or fork, total_cost_usd/modelUsage include the restored session totals (v2.1.277 or later). Per-fork cost = fork result minus base total. Don't sum results. --max-budget-usd counts only the call's own spend. verified (docs CT, C)
F5a They are separate installs. PATH has C:\Users\g4b\.local\bin\claude.exe (native installer, 2.1.283, dated 2026-09-26). The Desktop host bundles its own C:\Users\g4b\AppData\Local\Packages\Claude_pzs8sxrjxfjjc\LocalCache\Roaming\Claude\claude-code\2.1.299\f0584be0fbaf\claude.exe (2.1.299, dated 2026-10-15), reachable as $CLAUDE_CODE_EXECPATH. verified (local)
F5b With 2.1.299 the results hold, except that the aliases resolve to newer models (F2c). A 2.1.299 fork of the 2.1.283 base worked (recalled ZEBRA-7731, new id). But it had cache_read 0 and 15020 created, because plan mode now ran on claude-sonnet-5-5, while the base was on claude-sonnet-5. A Claude Code upgrade also changes the prompt (PC, "Upgrading Claude Code"). Pin one binary and a full model id for the base and all its forks. verified (local V1; docs PC)

Follow-up test log

T Command (cwd forktest) Result
M1 2.1.283 --model haiku --tools "" --permission-mode plan, stream-json, --debug-file init haiku-4-5, ran sonnet-5
M2 same on 2.1.299 init haiku-5-5, ran sonnet-5-5
D1 --tools "Task,Bash" --allowedTools "Task" "Bash(claude -p *)" --permission-mode dontAsk: spawn subagent, then run nested claude -p SUB=PONG; L2=L2; max_depth 1
D2 CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH=1, subagent asked to spawn a sub-subagent layer-1 has no Agent tool; refused 0
D3 same as D2 with the default depth DEEP; spawned 2, max_depth 2
E1 env -u (12 Desktop vars), default tools, dontAsk 32 built-ins + 8 MCP; Artifact tools gone; own socket
E2 --bare, same 4 tools, no socket; "Not logged in"
V1 2.1.299 --resume base --fork-session, T2 flags ZEBRA-7731; cache 15020 created / 0 read (model changed)

Remaining gaps

  • The single env var that gates the Desktop Artifact tools, and whether the nested pipe can reach the host, were not isolated.
  • The plan-mode Haiku-to-Sonnet upgrade is documented only by implication.
  • TTL refresh and the cross-cwd fork cache behavior come from docs only.