Summary
Every Pi subagent run resolves to the same session ID — the literal string
session — because the ID is parsed from the transcript's basename, and Pi names
every subagent transcript session.jsonl.
Because that ID keys both transcript paths, every subagent run writes over the
previous one, and at commit time the checkpoint reads whichever run happened to
write last:
- Staged copy:
captureTranscript writes <repo>/.entire/tmp/pi/<id>.json
→ one session.json for all runs.
- Shadow-branch archive:
SessionMetadataDirFromSessionID is
.entire/<id>/ → one metadata dir for all runs.
- Condensation prefers the staged copy over the shadow archive
(strategy/manual_commit_condensation.go:1005-1018), so the wrong content wins
even where an archive exists.
Net effect: a checkpoint's stored transcript can be a different subagent's
conversation. There is no uncontaminated copy to fall back to, because both paths
collapse on the same ID.
Reproduces with one Pi session and one subagent. No concurrency required.
What is not a problem
So this isn't mistaken for an attribution bug: the sessions attached to a commit
are the correct ones. A trail shows the Pi parent and the Pi subagent work, both
genuine participants, nothing unrelated. Sessions↔checkpoints is many-to-many and
the subagent session carries the same checkpoint set as its parent, so no
checkpoint linkage is lost.
The damage is to transcript content, not to which sessions or checkpoints appear.
Observed
$ entire session list
Pi · gpt-5.6-sol · session 019fab4c-c147-7b3a-8cc9-9ebd7224663b turns 3
Pi · claude-sonnet-5 · session session turns 9
That second row is nine runs across three distinct subagents:
~/.pi/agent/sessions/<cwd-slug>/2026-07-29T00-36-01-991Z_019fab4c-…/
71e7c5e8/run-0/session.jsonl 71e7c5e8/run-1/session.jsonl
716671d6/run-0/session.jsonl 716671d6/run-1/session.jsonl 716671d6/run-2/session.jsonl
d93a3451/run-0/session.jsonl d93a3451/run-1/session.jsonl d93a3451/run-2/session.jsonl
d93a3451/run-3/session.jsonl
…and a single staged capture for all of them:
$ ls -l .entire/tmp/pi/
2075829 019fab4c-c147-7b3a-8cc9-9ebd7224663b.json ← parent, fine
197526 session.json ← all 9 runs, last writer wins
Two commits were made from this session, at 11:01 and 11:28. session.json has an
mtime of 11:26 — so by the time the 11:28 commit condensed, the staged file held
the 11:26 run's transcript, not the run that produced the commit.
Root cause
Pi writes parent and subagent transcripts in different shapes. The parent's ID is
its filename; a subagent's ID is its directory path, and the leaf is named by
role:
2026-07-29T00-36-01-991Z_019fab4c-….jsonl ← parent: <timestamp>_<uuid>.jsonl
2026-07-29T00-36-01-991Z_019fab4c-…/d93a3451/run-0/session.jsonl ← subagent
Refs at 849421758:
The ID is derived from the basename. agent/pi/lifecycle.go:366-378:
base := filepath.Base(p)
base = strings.TrimSuffix(base, ".jsonl")
if i := strings.LastIndex(base, "_"); i >= 0 {
return base[i+1:]
}
return base
…/d93a3451/run-0/session.jsonl → session.jsonl → session → no underscore →
returns session. The fallback behaves as documented; the problem is that
filepath.Base discards the three segments carrying the identity.
It runs on every event, not just as a fallback. lifecycle.go:156-159 only
parses the path when payload.SessionID is empty — but
agent/pi/entire_extension.ts sends session_file: at lines 87, 97 and 120 and
never sends session_id. So the parse always runs. The comment at
lifecycle.go:173 ("Pi emits before_agent_start with a fully-populated session
ID") doesn't hold for the extension the CLI ships.
Nothing rejects the result. The only gate is validation.ValidateSessionID
(validation/validators.go:20), called at the capture choke point
(lifecycle.go:335). It checks empty, leading dash, path separators, ./..,
:, and glob metacharacters — "is this safe in a path?", not "is this an
identity?". session is a safe path segment and passes. That validator is doing a
different job correctly and shouldn't be extended to cover identity.
Both transcript paths key on the ID. Staging at lifecycle.go:346:
dst := filepath.Join(dir, sessionID+".json")
and the shadow archive via paths.SessionMetadataDirFromSessionID:
return EntireMetadataDir + "/" + sessionID
And the staged copy is preferred at read time.
strategy/manual_commit_condensation.go:1005-1018, reached from
extractSessionData with liveTranscriptPath = state.TranscriptPath:
if liveTranscriptPath != "" {
...
if liveData, readErr := os.ReadFile(liveTranscriptPath); readErr == nil && len(liveData) > 0 {
fullTranscript = string(liveData)
}
}
if fullTranscript == "" {
// Fall back to shadow branch copy
The documented rationale is sound in general — "handles the case where SaveStep
was skipped (no code changes) but the transcript continued growing; the shadow
branch copy would be stale" — i.e. the live file is assumed to be a newer version
of the same session. For Pi subagents that assumption inverts: the live file is a
different subagent's transcript, so the preference actively selects wrong content.
Pi subagents never flow through the subagent mechanism. lifecycle.go:1088
derives SubagentTranscriptPath from event.SubagentID, which is how Claude Code
keeps per-subagent transcripts distinct. Pi's lifecycle events
(SessionStart/TurnStart/TurnEnd/session_shutdown) never populate
SubagentID, so Pi subagent runs arrive as ordinary turns carrying a colliding
session ID rather than as subagent steps. That's the design-level gap underneath
the parse bug.
Suggested fix
The identity is already in payload.SessionFile — parent UUID plus
<subagent>/<run> segments; only filepath.Base throws them away. Renaming the
staged capture alone is not sufficient, since the shadow archive keys on the
same ID.
Two coherent shapes:
- Model Pi subagents as subagents. Populate
event.SubagentID from the path
segments so runs go through the existing SubagentTranscriptPath mechanism, and
let the session ID roll up to the parent UUID. This matches Claude Code, keeps a
trail's session list to the conversation that drove the work, and gets distinct
per-run transcripts for free.
- Give each run its own session ID (
019fab4c-…:d93a3451:run-0). Simpler, but
it adds session rows that duplicate the parent's checkpoint set.
(1) looks right given the mechanism already exists.
Worth doing alongside either: have entire_extension.ts send session_id
explicitly so the path parse stops being load-bearing, and add an identity check
distinct from the path-safety one.
Why tests didn't catch it
agent/pi/lifecycle_test.go:176 TestExtractSessionIDFromPath:
"/tmp/2026-05-09T12-00-00-000Z_abc-123.jsonl": "abc-123",
"abc-123.jsonl": "abc-123",
"/tmp/no-underscore-here.jsonl": "no-underscore-here", // fallback asserted as correct
"/path/with/multiple_under_scores_id.jsonl": "id",
Every case is a flat file in a sessions directory, and the third pins the fallback
as intended. The nested subagent shape isn't represented, so the model under test
is "Pi transcripts are flat <timestamp>_<uuid>.jsonl" — true for parents, false
for subagents.
Scope
Observed within a single repo. Sessions appear repo-scoped and I have no evidence
of one session ID spanning repos — the only other checkout with Pi subagent runs
on disk had no Pi session registered, so that case is untested rather than ruled
out.
Environment
- Entire CLI 0.9.0 (darwin/arm64, go1.26.4)
- Pi, parent model
gpt-5.6-sol, subagent model claude-sonnet-5
Summary
Every Pi subagent run resolves to the same session ID — the literal string
session— because the ID is parsed from the transcript's basename, and Pi namesevery subagent transcript
session.jsonl.Because that ID keys both transcript paths, every subagent run writes over the
previous one, and at commit time the checkpoint reads whichever run happened to
write last:
captureTranscriptwrites<repo>/.entire/tmp/pi/<id>.json→ one
session.jsonfor all runs.SessionMetadataDirFromSessionIDis.entire/<id>/→ one metadata dir for all runs.(
strategy/manual_commit_condensation.go:1005-1018), so the wrong content winseven where an archive exists.
Net effect: a checkpoint's stored transcript can be a different subagent's
conversation. There is no uncontaminated copy to fall back to, because both paths
collapse on the same ID.
Reproduces with one Pi session and one subagent. No concurrency required.
What is not a problem
So this isn't mistaken for an attribution bug: the sessions attached to a commit
are the correct ones. A trail shows the Pi parent and the Pi subagent work, both
genuine participants, nothing unrelated. Sessions↔checkpoints is many-to-many and
the subagent session carries the same checkpoint set as its parent, so no
checkpoint linkage is lost.
The damage is to transcript content, not to which sessions or checkpoints appear.
Observed
That second row is nine runs across three distinct subagents:
…and a single staged capture for all of them:
Two commits were made from this session, at 11:01 and 11:28.
session.jsonhas anmtime of 11:26 — so by the time the 11:28 commit condensed, the staged file held
the 11:26 run's transcript, not the run that produced the commit.
Root cause
Pi writes parent and subagent transcripts in different shapes. The parent's ID is
its filename; a subagent's ID is its directory path, and the leaf is named by
role:
Refs at
849421758:The ID is derived from the basename.
agent/pi/lifecycle.go:366-378:…/d93a3451/run-0/session.jsonl→session.jsonl→session→ no underscore →returns
session. The fallback behaves as documented; the problem is thatfilepath.Basediscards the three segments carrying the identity.It runs on every event, not just as a fallback.
lifecycle.go:156-159onlyparses the path when
payload.SessionIDis empty — butagent/pi/entire_extension.tssendssession_file:at lines 87, 97 and 120 andnever sends
session_id. So the parse always runs. The comment atlifecycle.go:173("Pi emits before_agent_start with a fully-populated sessionID") doesn't hold for the extension the CLI ships.
Nothing rejects the result. The only gate is
validation.ValidateSessionID(
validation/validators.go:20), called at the capture choke point(
lifecycle.go:335). It checks empty, leading dash, path separators,./..,:, and glob metacharacters — "is this safe in a path?", not "is this anidentity?".
sessionis a safe path segment and passes. That validator is doing adifferent job correctly and shouldn't be extended to cover identity.
Both transcript paths key on the ID. Staging at
lifecycle.go:346:and the shadow archive via
paths.SessionMetadataDirFromSessionID:And the staged copy is preferred at read time.
strategy/manual_commit_condensation.go:1005-1018, reached fromextractSessionDatawithliveTranscriptPath = state.TranscriptPath:The documented rationale is sound in general — "handles the case where SaveStep
was skipped (no code changes) but the transcript continued growing; the shadow
branch copy would be stale" — i.e. the live file is assumed to be a newer version
of the same session. For Pi subagents that assumption inverts: the live file is a
different subagent's transcript, so the preference actively selects wrong content.
Pi subagents never flow through the subagent mechanism.
lifecycle.go:1088derives
SubagentTranscriptPathfromevent.SubagentID, which is how Claude Codekeeps per-subagent transcripts distinct. Pi's lifecycle events
(
SessionStart/TurnStart/TurnEnd/session_shutdown) never populateSubagentID, so Pi subagent runs arrive as ordinary turns carrying a collidingsession ID rather than as subagent steps. That's the design-level gap underneath
the parse bug.
Suggested fix
The identity is already in
payload.SessionFile— parent UUID plus<subagent>/<run>segments; onlyfilepath.Basethrows them away. Renaming thestaged capture alone is not sufficient, since the shadow archive keys on the
same ID.
Two coherent shapes:
event.SubagentIDfrom the pathsegments so runs go through the existing
SubagentTranscriptPathmechanism, andlet the session ID roll up to the parent UUID. This matches Claude Code, keeps a
trail's session list to the conversation that drove the work, and gets distinct
per-run transcripts for free.
019fab4c-…:d93a3451:run-0). Simpler, butit adds session rows that duplicate the parent's checkpoint set.
(1) looks right given the mechanism already exists.
Worth doing alongside either: have
entire_extension.tssendsession_idexplicitly so the path parse stops being load-bearing, and add an identity check
distinct from the path-safety one.
Why tests didn't catch it
agent/pi/lifecycle_test.go:176TestExtractSessionIDFromPath:Every case is a flat file in a sessions directory, and the third pins the fallback
as intended. The nested subagent shape isn't represented, so the model under test
is "Pi transcripts are flat
<timestamp>_<uuid>.jsonl" — true for parents, falsefor subagents.
Scope
Observed within a single repo. Sessions appear repo-scoped and I have no evidence
of one
sessionID spanning repos — the only other checkout with Pi subagent runson disk had no Pi session registered, so that case is untested rather than ruled
out.
Environment
gpt-5.6-sol, subagent modelclaude-sonnet-5