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

112 lines
19 KiB
Markdown

# 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.