Skip to content

Embedded library: shared extension runtime is poisoned across sessions by dispose() ("stale ctx" on every later session) #6101

Description

@wloonis

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:106const 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:124createExtensionRuntime() 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:150this.runtime = runtime; the per-session ExtensionRunner holds a reference to the shared cached runtime.
  • dist/core/extensions/runner.js:323invalidate() 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:490dispose() 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    no-actionThis issue has been rejected after triage

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions