session: resolve the session running the command, not the one that moved last

main

Commit

Stefan Haubold1mo ago

entire session current answered "which session state moved most recently, preferring this worktree" and described it as "the active session for the current worktree". Worktrees share one session store, so in a worktree with no sessions of its own it silently fell back across the whole store and returned a live session belonging to somewhere else — that worktree's path right there in the JSON, indistinguishable from a real answer.

That is not a cosmetic mislabel. Agents reach for this command to find their own session id, read the id back, and pass it to entire session adopt, which moves the named session into the current worktree and resets its checkpoint bookkeeping. A wrong id there mutates a third party's running session.

Five agents already publish their session id into the environment of the processes they spawn, and Entire read none of them. strategy.ResolveCallerSession now answers the question directly, in tiers, each reported back so a caller can tell an identification from a guess: caller-env (the agent named it), ancestry (a session's recorded Owner is our ancestor — the same match commit linking performs, which is what covers the agents that publish nothing), worktree, then other-worktree. The weak tiers stay, because "what has been happening in this repo" is a real question; they just have to say that is what they answered. SessionResolution.IsCaller() is the gate for acting on a session rather than displaying it.

Three call sites, all of which were asking the caller question: session current, a bare session tokens, and the hook log-stamp in withHookSession — an agent-triggered git hook inherits the agent's variable, so log lines now name the session that ran the commit instead of whichever one moved last. FindMostRecentSession is untouched and serves as the bottom tiers.

The per-agent half is declarative: an agent implements CallerSessionIdentifier by naming its variable, and the agent package does the read, so the id is validated with validation.ValidateAgentSessionID in exactly one place. That is load-bearing rather than tidy — the id becomes a path component in ResolveSessionFile, so an unvalidated one out of the environment is a traversal sink. Declaring the name instead of the value also makes the set enumerable, which is what lets tests clear it.

Every variable was established against the shipped agent rather than inferred: Claude Code CLAUDE_CODE_SESSION_ID, Codex CODEX_SESSION_ID (the root-session identity — not CODEX_THREAD_ID, which follows forks and subagent threads), Cursor CURSOR_CONVERSATION_ID, Copilot CLI COPILOT_AGENT_SESSION_ID, pi PI_SESSION_ID. Gemini CLI and opencode publish nothing, which is a finding rather than a gap: Gemini passes its session id only to its own background-process bookkeeping, and opencode's shell tool augments no environment at all. That is why the ancestry tier is not optional.

Two states are kept distinct on the caller tier. An id the agent named but Entire holds no state for is reported as a diagnosis — "you are in session X and Entire is not recording it" — rather than falling through and answering with an unrelated session. And several claims at once is the normal nested case (a codex exec under Claude Code sees both variables), resolved by ancestry depth, then by recency.

The test harnesses now clear these variables, and that is not incidental: three existing tests failed while writing this because the developer's own agent session leaked into a spawned binary, which is the same class of failure the config/cache isolation in both TestMains exists to prevent. Derived from the registry so it cannot rot when an agent gains the capability.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com Entire-Checkpoint: 01M20MVC7XPS7EPSHR637S4EVX