Playbook: Background-agent transcript forensics — where agent actions really live
Read this when you need to attribute a specific action (a settings.json edit, a SOPS touch, a server command, a specific string in a diff) to a specific background agent after the fact — i.e. during a security investigation like #5860.
One-line answer: do not read ~/.claude/tasks/**/*.output — those are empty by design. The
authoritative transcript of a background agent’s reasoning and tool calls is a separate JSONL file
at ~/.claude/projects/<encoded-cwd>/<parent-session-uuid>/subagents/agent-<id>.jsonl, paired with an
agent-<id>.meta.json that links it back to the exact Task tool call in the parent session.
Filed for: #5862. Investigated 2026-08-07 on
bms-4 (Claude Code 2.1.177). Companion reference: docs/session-format.md.
1. What the #5860 investigation hit, and why it looked like a black hole
During the #5860 settings.json investigation, a sys-security agent tried to trace which background
agent wrote an unauthorized change by reading each agent’s task-output file under the session’s
tasks/ directory. It found 17 of ~20 .output files were 0 bytes, the 3 non-empty ones held
unrelated cached API dumps, and no same-day session JSONL was found under the searched
~/.claude/projects/{C,d}--code-2026-p24-infra/ directories.
The conclusion drawn — “transcript capture is silently failing” — is incorrect. Capture is not failing. The investigation read the wrong files in the wrong place. This playbook documents the right ones.
2. The three things under ~/.claude/ that look like transcripts but mostly are not
| Path | What it actually is | Forensic value |
|---|---|---|
~/.claude/tasks/<session-uuid>/N.json | TodoWrite / task-list items — {id, subject, description, status, blocks, blockedBy}. One file per todo item. | None for attribution. It is the agent’s plan, not what it did. |
~/.claude/tasks/<session-uuid>/.lock, .highwatermark | Lock file (always 0 bytes) and the todo high-water counter. | None. |
~/.claude/tasks/**/*.output (seen on the Windows workstation; absent on the Linux workers) | A transient streaming buffer the parent process tails to show a background agent’s live progress. | None post-hoc — see §3. |
~/.claude/projects/<encoded-cwd>/<parent-session-uuid>/subagents/agent-<id>.jsonl | The real subagent transcript — full reasoning, tool calls, tool results. | This is the gold. See §4. |
The
tasks/directory and theprojects/directory are unrelated systems that both happen to be keyed by session UUID.tasks/is the todo list;projects/is the transcript store.
3. Why .output files are empty — and why that is NORMAL, not a bug
The tasks/<id>.output files observed on the Windows workstation are a live IPC/streaming buffer,
not a persistent log. The parent session tails the buffer to render a background agent’s progress
while it runs. When the agent finishes, its final message is returned to the parent as a normal
tool_result (captured in the parent’s own JSONL), and the streaming buffer is consumed/truncated.
A 0-byte .output file is therefore the expected terminal state of a completed background agent,
not evidence of failed capture. A non-empty one is just an in-flight or not-yet-drained buffer — which
is why the 3 non-empty files in #5860 held stale, unrelated content. Never treat .output files as
a forensic source. There is no setting that makes them persist a full transcript, because that is
not their job.
This is general harness behavior, not a Windows-specific defect. On the Linux workers the buffer
mechanism is different enough that no .output files exist at all (verified on bms-4 2.1.177 — the
entire tasks/ tree contains only todo N.json, .lock, and .highwatermark files).
4. Where the transcript actually is — verified layout (Claude Code 2.1.177)
Each background agent spawned via the Task/Agent tool gets its own transcript file under a
subagents/ subdirectory of the parent session’s project directory:
~/.claude/projects/
<encoded-cwd>/ # e.g. -tmp-worker-4815-radieu-p24-infra
<parent-session-uuid>.jsonl # parent transcript (records the Task tool_use + the returned tool_result)
<parent-session-uuid>/
subagents/
agent-<agent-id>.jsonl # <-- THE SUBAGENT TRANSCRIPT (full reasoning + tool calls)
agent-<agent-id>.meta.json # {"agentType","description","toolUseId"}
<encoded-cwd>— the session cwd with/→-(seesession-format.md§1). On Windows the drive letter is folded in too (C--code-2026-p24-infra), which is a lossy encoding — do not trust the directory name; the authoritative cwd is the.cwdfield inside each line.- The subagent transcript’s own lines are marked
"isSidechain": true. agent-<id>.meta.jsoncarriestoolUseId— the id of theTask/Agenttool_useblock in the parent transcript that spawned this agent. This is the join key for attribution (§5).
Verified corpus health on bms-4 (2026-08-07): 103 subagents/ directories, 167 subagent
transcripts, ZERO of them 0-byte (min 39 KB, median 138 KB, max 456 KB). Capture on the Linux
workers is reliable and complete — the opposite of the #5860 impression.
Version skew. On 2.1.177 the parent JSONL does not inline the subagent’s steps; the subagent transcript lives entirely in its own
subagents/*.jsonlfile. Some older sessions embeddedisSidechain:truelines directly in the parent JSONL instead. When investigating, check both: thesubagents/subdir first, then grep the parent JSONL forisSidechainlines as a fallback.
5. Forensic procedure — attribute an action to a background agent
Run on the host where the session executed (transcripts are local and not shipped off-host — see
§6). Substitute <SEARCH> with the string you are tracing (e.g. a permission key, a filename, a
command). This is read-only.
BASE="$HOME/.claude/projects" # on vps-i1 also scan /root/.claude/projects (dual roots)
# 1. Find every subagent transcript that mentions the string — do NOT guess the encoded-cwd dir.
grep -rl '<SEARCH>' --include='agent-*.jsonl' "$BASE"/*/*/subagents/ 2>/dev/null
# 2. For a hit, identify the agent type + which parent tool call spawned it.
HIT=<path from step 1>
META="${HIT%.jsonl}.meta.json"
cat "$META" # {"agentType": "...", "description": "...", "toolUseId": "toolu_..."}
# 3. Pin down WHICH parent session (authoritative cwd + session id live inside the transcript).
head -1 "$HIT" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("sessionId:",d.get("sessionId"),"cwd:",d.get("cwd"))'
# 4. Extract the agent's prose + tool calls for review, stripping token noise
# (use the jq filters in session-format.md §4; add tool_use to see actions, not just prose).
jq -rc 'select(.type=="assistant" and (.message.content|type=="array"))
| .message.content[] | select(.type=="tool_use") | {tool:.name, input:.input}' "$HIT"If a string is genuinely not in any subagents/*.jsonl nor any parent JSONL for the relevant
day, then — and only then — is there a real capture gap. In #5860 the correct next step would have
been step 1 above against all project dirs (not just the two guessed {C,d}--code-2026-p24-infra
names), because the encoded-cwd dir name is lossy and the session may have run under a different cwd.
Secret-safety. These transcripts can contain command output. When grepping/printing them during an investigation, follow the global rule: never echo a matched secret value — narrow with
grep -c/grep -l(count / filename only) when the search term itself is or borders a credential, and reference key names only in your report. See CLAUDE.md §Secrets.
6. Known limitations — plan around these, don’t rely on transcript recovery blindly
- Transcripts are host-local and not backed up off-host. If the workstation/VPS is wiped or the
file is rotated away, the transcript is gone. For anything that must survive an incident, the
durable audit trail is the Supabase
dev_r_agent_sessions/agent_tasksrecords (worker identity, issue, branch, PR) plus GitHub (PRs, commits, issue comments) — not the local JSONL. The Daily Session Audit (DSA, #2637,session-format.md) is the mechanism that reads these transcripts; it reads the parent session files and should be extended to also walksubagents/if subagent-level audit is required. .outputfiles are never a source. Restate: 0-byte is normal (§3).- The encoded-cwd directory name is lossy — always locate transcripts by content
(
grep -rl+ the internal.cwd/.sessionIdfields), never by reconstructing the directory name. - Interactive vs worker sessions differ in cwd. Workstation interactive sessions live under
C--…/d--…; bms-4/vps-i1 workers live under-tmp-worker-<n>-…and-home-claude-runner. vps-i1 additionally has dual roots (/home/claude-runnerand/root) — scan both. - For actions that MUST be attributable (credential-touching settings changes, SOPS writes, production commands): do not depend solely on transcript recovery. Prefer mechanisms that leave a durable, off-host record — a PR/commit, a Supabase row, or an explicit self-report comment on the issue — so attribution survives even if the local transcript does not.
7. TL;DR
- Empty
tasks/**/*.outputfiles are normal (transient buffers), not a capture failure. - Real subagent transcripts:
~/.claude/projects/<encoded-cwd>/<parent-session>/subagents/agent-<id>.jsonl.meta.json(hastoolUseId→ parent tool call). On bms-4: 167 present, 0 empty.
- Find them by content (
grep -rl), never by guessing the directory name. - Durable audit = Supabase + GitHub. Local transcripts are best-effort and host-local.