Summary
When a host embeds @earendil-works/pi-coding-agent as a library and creates multiple AgentSessions sequentially in the same working directory within a single process, the second and every later session throws
This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload().
from runtime.assertActive() for every Extension API / context call (pi.getFlag, pi.sendUserMessage, ctx.isIdle(), ctx.getModel(), …), even though those sessions are brand new and were never replaced, forked, switched, or reloaded.
The non-extension (e.g. Claude) path is unaffected because it never instantiates the extension runtime.
Environment
@earendil-works/pi-coding-agent 0.80.2
- Usage: embedded as a library (not the TUI), extensions enabled, one
AgentSession per workflow node, same cwd reused across nodes.
- Concrete host hitting this: Archon (Pi provider, multi-node DAG workflows in one server process).
Root cause
The extension runtime is a process-wide singleton keyed by cwd, and dispose() permanently poisons it.
dist/core/extensions/loader.js:106 — const extensionCache = new Map() (module-level), keyed via useExtensionCacheCwd(cwd) (lines 114-118). The cached extensionsResult carries a single shared runtime object.
dist/core/extensions/loader.js:124 — createExtensionRuntime() returns a runtime whose assertActive (line 128) and invalidate (line 153) both close over the same state object. That runtime is cached and reused by every session in the same cwd.
dist/core/extensions/runner.js:150 — this.runtime = runtime; the per-session ExtensionRunner holds a reference to the shared cached runtime.
dist/core/extensions/runner.js:323 — invalidate() sets this.staleMessage (per-runner) and calls this.runtime.invalidate(message) (line 326), which sets state.staleMessage on the shared runtime closure.
dist/core/agent-session.js:490 — dispose() unconditionally calls this._extensionRunner.invalidate(...).
The poisoning chain:
Session A.dispose() (agent-session.js:490)
→ runner.invalidate(msg) (runner.js:323)
→ runtime.invalidate(msg) (runner.js:326)
→ state.staleMessage = msg (loader.js:154) ← poisoned FOREVER on the shared runtime
Session B created (same cwd) → reuses cached runtime
→ runtime.assertActive() (loader.js:128) → throws ❌ (pi.getFlag, pi.sendUserMessage, …)
→ runner.assertActive() (runner.js:330) → throws ❌ (ctx.isIdle, ctx.getModel, …)
There are two independent stale guards, both poisoned by the shared runtime:
runtime.assertActive() (loader.js:128) — guards Extension API methods (pi.*). Poisoned via shared state.staleMessage.
runner.assertActive() (runner.js:330) — guards context methods (ctx.isIdle(), ctx.getModel(), …). Poisoned via the per-runner staleMessage set by dispose().
Reproduction
import { createAgentSession } from "@earendil-works/pi-coding-agent";
const opts = { /* piConfig: enableExtensions + at least one extension installed in cwd */ };
const a = createAgentSession(opts);
await a.sendQuery("hi");
a.dispose(); // poisons the shared runtime
const b = createAgentSession(opts); // same cwd → reuses the cached runtime
await b.sendQuery("hi"); // extension session_start fires pi.getFlag(...) → THROWS "stale ctx"
Any extension that calls pi.getFlag(...) / ctx.isIdle() in session_start, or ctx.isIdle() in an agent_end handler (e.g. the Plannotator extension's continueWhenIdle), triggers the throw.
Impact
Pi cannot be used as a library with extensions across more than one session per process per cwd. Hosts that run multi-node workflows (one Pi session per node, same repo) — Archon, any agent orchestrator, any test harness — break on node 2+.
Proposal
The stale guard exists for the replacement scenarios (ctx.newSession() / fork() / switchSession() / reload()). dispose(), by contrast, means "I'm done with this session" — it should not poison siblings or future sessions.
Option A (minimal, targeted) — scope the shared-runtime poisoning to replacement only
Add a dispose-local invalidation that sets the per-runner staleMessage (preserving use-after-dispose protection for that session's ctx) without touching the shared runtime:
// runner.js
invalidate(message = DEFAULT_STALE_MSG) { // full: replacement scenarios
if (!this.staleMessage) { this.staleMessage = message; this.runtime.invalidate(message); }
}
+ invalidateLocal(message = DEFAULT_STALE_MSG) { // dispose: per-session only
+ if (!this.staleMessage) { this.staleMessage = message; }
+ }
// agent-session.js dispose()
- this._extensionRunner.invalidate(DEFAULT_STALE_MSG);
+ this._extensionRunner.invalidateLocal(DEFAULT_STALE_MSG);
invalidate() (full) stays the path for the genuine replacement cases.
Option B (isolating) — give each session its own runtime
Stop caching the mutable runtime in extensionCache; clone (or recreate) the runtime per AgentSession so a disposed session's invalidate() can never reach another session. Higher memory cost but full isolation.
Workaround currently used by Archon (host side, not a real fix)
Archon's Pi provider neutralizes both guards right after session creation:
// shared runtime (Extension API methods)
extResult.runtime.invalidate = () => {};
// per-session runner (ctx methods)
session.extensionRunner.invalidate = () => {};
This works but disables the legitimate use-after-replacement guard, so it's a workaround rather than a fix. A real fix should live in Pi.
Happy to open a PR for Option A if there's interest.
Summary
When a host embeds
@earendil-works/pi-coding-agentas a library and creates multipleAgentSessions sequentially in the same working directory within a single process, the second and every later session throwsfrom
runtime.assertActive()for every Extension API / context call (pi.getFlag,pi.sendUserMessage,ctx.isIdle(),ctx.getModel(), …), even though those sessions are brand new and were never replaced, forked, switched, or reloaded.The non-extension (e.g. Claude) path is unaffected because it never instantiates the extension runtime.
Environment
@earendil-works/pi-coding-agent0.80.2AgentSessionper workflow node, samecwdreused across nodes.Root cause
The extension runtime is a process-wide singleton keyed by
cwd, anddispose()permanently poisons it.dist/core/extensions/loader.js:106—const extensionCache = new Map()(module-level), keyed viauseExtensionCacheCwd(cwd)(lines 114-118). The cachedextensionsResultcarries a single sharedruntimeobject.dist/core/extensions/loader.js:124—createExtensionRuntime()returns aruntimewhoseassertActive(line 128) andinvalidate(line 153) both close over the samestateobject. Thatruntimeis cached and reused by every session in the samecwd.dist/core/extensions/runner.js:150—this.runtime = runtime; the per-sessionExtensionRunnerholds a reference to the shared cached runtime.dist/core/extensions/runner.js:323—invalidate()setsthis.staleMessage(per-runner) and callsthis.runtime.invalidate(message)(line 326), which setsstate.staleMessageon the shared runtime closure.dist/core/agent-session.js:490—dispose()unconditionally callsthis._extensionRunner.invalidate(...).The poisoning chain:
There are two independent stale guards, both poisoned by the shared runtime:
runtime.assertActive()(loader.js:128) — guards Extension API methods (pi.*). Poisoned via sharedstate.staleMessage.runner.assertActive()(runner.js:330) — guards context methods (ctx.isIdle(),ctx.getModel(), …). Poisoned via the per-runnerstaleMessageset bydispose().Reproduction
Any extension that calls
pi.getFlag(...)/ctx.isIdle()insession_start, orctx.isIdle()in anagent_endhandler (e.g. the Plannotator extension'scontinueWhenIdle), triggers the throw.Impact
Pi cannot be used as a library with extensions across more than one session per process per cwd. Hosts that run multi-node workflows (one Pi session per node, same repo) — Archon, any agent orchestrator, any test harness — break on node 2+.
Proposal
The stale guard exists for the replacement scenarios (
ctx.newSession()/fork()/switchSession()/reload()).dispose(), by contrast, means "I'm done with this session" — it should not poison siblings or future sessions.Option A (minimal, targeted) — scope the shared-runtime poisoning to replacement only
Add a
dispose-local invalidation that sets the per-runnerstaleMessage(preserving use-after-dispose protection for that session'sctx) without touching the shared runtime:// runner.js invalidate(message = DEFAULT_STALE_MSG) { // full: replacement scenarios if (!this.staleMessage) { this.staleMessage = message; this.runtime.invalidate(message); } } + invalidateLocal(message = DEFAULT_STALE_MSG) { // dispose: per-session only + if (!this.staleMessage) { this.staleMessage = message; } + }invalidate()(full) stays the path for the genuine replacement cases.Option B (isolating) — give each session its own runtime
Stop caching the mutable runtime in
extensionCache; clone (or recreate) the runtime perAgentSessionso a disposed session'sinvalidate()can never reach another session. Higher memory cost but full isolation.Workaround currently used by Archon (host side, not a real fix)
Archon's Pi provider neutralizes both guards right after session creation:
This works but disables the legitimate use-after-replacement guard, so it's a workaround rather than a fix. A real fix should live in Pi.
Happy to open a PR for Option A if there's interest.