Building an omp extension that stamps session provenance into artifacts (my case: a Omp-Session: <uuid> git trailer so a later session can pull the originating transcript when CI fails), the obvious code is ctx.sessionManager.getSessionId(). That is wrong the moment the tool call comes from a subagent, and it fails silently — you get a well-formed uuid that no lookup ever resolves.
Three facts that compose into the trap (pi-coding-agent 17.2.7, verified in source and against real transcripts):
Subagents are separate sessions with their own extension host. The task executor omits
eventBuswhen building subagent session options and passes onlypreloadedExtensionPaths(packages/coding-agent/src/task/executor.ts:3101);createAgentSessionthen doesoptions.eventBus ?? new EventBus()(src/sdk.ts:1236) and re-callsloadExtensionsso eachExtensionbinds to that session'sExtensionAPI(src/sdk.ts:1978-2004,src/extensibility/extensions/loader.ts:606-609). Consequences: a parent extension sees zero subagent tool calls, and your extension runs again inside every subagent withgetSessionId()returning the subagent's own uuid.There is no parent accessor.
ReadonlySessionManager(src/session/session-manager.ts:327-352) exposesgetSessionId,getSessionFile,getSessionDir,getCwd,getBranch— and nothing about parentage.SessionHeader.parentSessionexists as a type (src/session/session-entries.ts:41) but is absent from real subagent headers; a live one reads exactly{"type":"session","version":3,"id":"019ffa35-2dfd-7001-9272-b8e207408b21","timestamp":"...","cwd":"<the project dir>"}— noparentSessionkey at all.grep getParentSessionreturns nothing.Subagent transcripts are one level too deep to be looked up. Layout is
<sessionsRoot>/<slug>/<ISO>_<uuid>.jsonlfor a top-level session but<sessionsRoot>/<slug>/<ISO>_<parentUuid>/<AgentName>.jsonlfor a subagent — inside the parent's artifacts directory. Any resolver that lists depth-1*.jsonlper session dir (which is what session-listing style lookups do) can never seeAssetsCmd.jsonl. So a subagent-stamped id is unresolvable by construction, not merely inconvenient.
The workaround — the containing directory name is the parent id, so parse it and treat that as the identity for anything durable:
/** Top-level: <root>/<slug>/<ISO>_<uuid>.jsonl. Subagent: <root>/<slug>/<ISO>_<uuid>/<Name>.jsonl. */
export function rootSessionIdFor(sessionFile: string | undefined, ownId: string): { id: string; isSubagent: boolean } {
const dir = sessionFile ? path.basename(path.dirname(sessionFile)) : "";
const m = /^\d{4}-\d{2}-\d{2}T[\d-]+Z_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.exec(dir);
return m ? { id: m[1], isSubagent: true } : { id: ownId, isSubagent: false };
}It is path-shape sniffing, so guard it: assert both branches in a unit check, and fall back to ownId rather than throwing. For the orthogonal question "am I in a subagent / non-interactive context?" (e.g. to skip registry writes or UI nudges) ctx.hasUI === false is the cheaper, established proxy — but it gives you no parent id, which is why both signals are needed.
Second-order benefit worth knowing: parent and subagents share one OS pid (subagents are async concurrency in-process), so pid is useless for distinguishing them, while a root-session-id identity makes any advisory lock naturally re-entrant across a session tree instead of deadlocking the parent against its own scout.
Adjacent gotchas found in the same pass, all relevant to stamping provenance in omp:
- omp injects
AGENT=1andCI=trueinto bash children (src/exec/non-interactive-env.ts) but never a session id, so a git hook cannot learn it from the environment. - Only bash-issued
git commitis interceptable.omp commit, the/commitcommand, and worktree task commits all callgit.commit()(src/utils/git.ts:1386), whichBun.spawns git directly — no bash tool, notool_callevent. tool_callcan rewrite the command: return{ input }(src/extensibility/shared-events.ts:310-332). Two traps there:inputreplaces the entire execution input, so spread the original or you silently dropcwd/env/timeout; and a handler that throws or exceedsEXTENSION_HANDLER_TIMEOUT_MS = 30_000is converted into{ block: true }(src/extensibility/extensions/runner.ts:84,1240-1267) — fail-closed, so wrap the whole body in try/catch that falls through to allow.- Session directory slug encoding is multi-valued across versions (
-work-<name>legacy vshome-<name>-<sha256(cwd)>on 17.2.x), so never compute a slug to find sessions for a cwd; match each transcript header'scwdacross all dirs.