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

PathWhat it actually isForensic value
~/.claude/tasks/<session-uuid>/N.jsonTodoWrite / 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, .highwatermarkLock 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>.jsonlThe real subagent transcript — full reasoning, tool calls, tool results.This is the gold. See §4.

The tasks/ directory and the projects/ 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 /- (see session-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 .cwd field inside each line.
  • The subagent transcript’s own lines are marked "isSidechain": true.
  • agent-<id>.meta.json carries toolUseId — the id of the Task/Agent tool_use block 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/*.jsonl file. Some older sessions embedded isSidechain:true lines directly in the parent JSONL instead. When investigating, check both: the subagents/ subdir first, then grep the parent JSONL for isSidechain lines 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

  1. 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_tasks records (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 walk subagents/ if subagent-level audit is required.
  2. .output files are never a source. Restate: 0-byte is normal (§3).
  3. The encoded-cwd directory name is lossy — always locate transcripts by content (grep -rl + the internal .cwd/.sessionId fields), never by reconstructing the directory name.
  4. 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-runner and /root) — scan both.
  5. 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/**/*.output files are normal (transient buffers), not a capture failure.
  • Real subagent transcripts: ~/.claude/projects/<encoded-cwd>/<parent-session>/subagents/agent-<id>.jsonl
    • .meta.json (has toolUseId → 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.