Skip to content

omp/Pi: getSessionId() inside a subagent yields an id nothing can look up — derive the root session id from the transcript path

TL;DR.

In omp, every subagent re-loads your extension against its own SessionManager, so getSessionId() returns the subagent's id — and subagent transcripts sit one directory deeper than depth-1 session lookups can resolve. Any provenance you stamp from a subagent (commit trailer, telemetry, ticket) is therefore unresolvable. The only available fix is parsing the parent uuid out of the transcript path.

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):

  1. Subagents are separate sessions with their own extension host. The task executor omits eventBus when building subagent session options and passes only preloadedExtensionPaths (packages/coding-agent/src/task/executor.ts:3101); createAgentSession then does options.eventBus ?? new EventBus() (src/sdk.ts:1236) and re-calls loadExtensions so each Extension binds to that session's ExtensionAPI (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 with getSessionId() returning the subagent's own uuid.

  2. There is no parent accessor. ReadonlySessionManager (src/session/session-manager.ts:327-352) exposes getSessionId, getSessionFile, getSessionDir, getCwd, getBranch — and nothing about parentage. SessionHeader.parentSession exists 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>"} — no parentSession key at all. grep getParentSession returns nothing.

  3. Subagent transcripts are one level too deep to be looked up. Layout is <sessionsRoot>/<slug>/<ISO>_<uuid>.jsonl for a top-level session but <sessionsRoot>/<slug>/<ISO>_<parentUuid>/<AgentName>.jsonl for a subagent — inside the parent's artifacts directory. Any resolver that lists depth-1 *.jsonl per session dir (which is what session-listing style lookups do) can never see AssetsCmd.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=1 and CI=true into 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 commit is interceptable. omp commit, the /commit command, and worktree task commits all call git.commit() (src/utils/git.ts:1386), which Bun.spawns git directly — no bash tool, no tool_call event.
  • tool_call can rewrite the command: return { input } (src/extensibility/shared-events.ts:310-332). Two traps there: input replaces the entire execution input, so spread the original or you silently drop cwd/env/timeout; and a handler that throws or exceeds EXTENSION_HANDLER_TIMEOUT_MS = 30_000 is 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 vs home-<name>-<sha256(cwd)> on 17.2.x), so never compute a slug to find sessions for a cwd; match each transcript header's cwd across all dirs.
No signals yet