Summary
The continueWhenIdle callback registered in the agent_end handler (apps/pi-extension/index.ts, plannotator_submit_plan flow) throws an uncaught "stale extension ctx" error inside a setTimeout after a plan is approved, which crashes the host process when the planner session is torn down before the deferred callback runs.
This does not happen in plannotator's primary single-session flow (where plan + execution stay in the same pi session). It only happens in an embedded, multi-node host (Archon) that disposes of the planner session right after the plan is approved and the node completes. The crash orphans the whole workflow run.
Version
@plannotator/pi-extension 0.23.1, loaded from source (index.ts) via bun.
Reproduction
Host: Archon running a multi-node workflow where plannotator_submit_plan is used in a dedicated "planner" node and implementation is a separate node (a fresh pi session).
- Planner node calls
plannotator_submit_plan → plan review browser opens on 127.0.0.1:19432.
- User approves the plan in the browser.
plannotator_submit_plan returns the approved prompt with terminate: true; phase = "executing", justApprovedPlan = true.
- The planner prints its "plan approved" message and the turn ends →
agent_end fires.
- In
agent_end, because phase === "executing" && justApprovedPlan, the code schedules setTimeout(continueWhenIdle, 0), where continueWhenIdle polls the captured ctx.isIdle() and then calls pi.sendUserMessage("Continue with the approved plan.").
- The host disposes of the planner session (the node completed) — this invalidates the captured
ctx's runner (runner.staleMessage is set by runner.invalidate(), called from session.dispose()).
- The deferred
continueWhenIdle runs, calls ctx.isIdle() → runner.assertActive() → throws because runner.staleMessage is set.
- The error is uncaught inside
setTimeout → the host's Bun process crashes → the workflow run is orphaned (marked running in the DB, but no live process; it can never advance to the next node).
Stack trace (exact)
333 | this.runtime.invalidate(message);
334 | }
335 | }
336 | assertActive() {
337 | if (this.staleMessage) {
338 | throw new Error(this.staleMessage);
^
error: 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(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().
at assertActive (.../@earendil-works/pi-coding-agent/dist/core/extensions/runner.js:335:46)
at isIdle (.../@earendil-works/pi-coding-agent/dist/core/extensions/runner.js:461:24)
at continueWhenIdle (.../@plannotator/pi-extension/index.ts:1160:24)
Bun v1.3.14 (Linux x64)
Root cause
In apps/pi-extension/index.ts (around the agent_end handler):
pi.on("agent_end", async (_event, ctx) => {
if (phase === "executing" && justApprovedPlan) {
justApprovedPlan = false;
let attempts = 0;
const continueWhenIdle = (): void => {
if (!ctx.isIdle()) { // <-- ctx captured here, used after session disposal
attempts += 1;
if (attempts <= 200) setTimeout(continueWhenIdle, 50);
return;
}
pi.sendUserMessage("Continue with the approved plan.");
};
setTimeout(continueWhenIdle, 0); // <-- deferred; session may be disposed by then
return;
}
...
ctx is captured in the agent_end handler and then used in a deferred setTimeout callback. pi's extension-ctx contract explicitly says: "Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload()." An embedded host that disposes of the planner session after the node completes (entirely reasonable for a multi-node architecture) invalidates that captured ctx's runner, so the deferred ctx.isIdle() throws. Because the throw happens inside setTimeout, it is uncaught and takes down the host process.
This is fine in plannotator's interactive single-session flow (the session stays alive, so the captured ctx never goes stale), but it is not safe for embedded hosts that manage the session lifecycle per-node.
Proposed fix (plannotator-side, defensive)
Make continueWhenIdle resilient to a disposed/stale ctx so an embedded host is not taken down. Minimal change:
const continueWhenIdle = (): void => {
try {
if (!ctx.isIdle()) {
attempts += 1;
if (attempts <= 200) setTimeout(continueWhenIdle, 50);
return;
}
pi.sendUserMessage("Continue with the approved plan.");
} catch {
// The planner session was replaced/disposed by the host after the plan
// was approved and the node completed. There is nothing to continue —
// stop the loop so the host can advance to its next node instead of
// crashing the process with an uncaught "stale extension ctx" error
// from a captured ctx.
}
};
Alternative (stronger): instead of capturing ctx, re-acquire a live reference inside the callback, or guard with a "session still active" check before polling.
Workaround (tested locally)
We applied the try/catch above locally and confirmed the crash no longer occurs: the workflow advances cleanly from the planner node to the next node (verify-plan → implement) after plan approval, with 0 stale-ctx errors in the run log. In our host (Archon) the "Continue with the approved plan" continuation is unwanted anyway — implementation is a separate node — so silently stopping the loop is exactly the desired behavior.
Environment
@plannotator/pi-extension 0.23.1 (loaded from index.ts)
- pi-coding-agent 0.80.7 (bun hoisted cache copy)
- Bun 1.3.14, Linux x64
- Host: Archon (multi-node workflow, planner session disposed after node completes)
Happy to provide more detail or a minimal host repro if useful. Thanks!
Summary
The
continueWhenIdlecallback registered in theagent_endhandler (apps/pi-extension/index.ts,plannotator_submit_planflow) throws an uncaught "stale extension ctx" error inside asetTimeoutafter a plan is approved, which crashes the host process when the planner session is torn down before the deferred callback runs.This does not happen in plannotator's primary single-session flow (where plan + execution stay in the same
pisession). It only happens in an embedded, multi-node host (Archon) that disposes of the planner session right after the plan is approved and the node completes. The crash orphans the whole workflow run.Version
@plannotator/pi-extension0.23.1, loaded from source (index.ts) viabun.Reproduction
Host: Archon running a multi-node workflow where
plannotator_submit_planis used in a dedicated "planner" node and implementation is a separate node (a fresh pi session).plannotator_submit_plan→ plan review browser opens on127.0.0.1:19432.plannotator_submit_planreturns the approved prompt withterminate: true;phase = "executing",justApprovedPlan = true.agent_endfires.agent_end, becausephase === "executing" && justApprovedPlan, the code schedulessetTimeout(continueWhenIdle, 0), wherecontinueWhenIdlepolls the capturedctx.isIdle()and then callspi.sendUserMessage("Continue with the approved plan.").ctx's runner (runner.staleMessageis set byrunner.invalidate(), called fromsession.dispose()).continueWhenIdleruns, callsctx.isIdle()→runner.assertActive()→ throws becauserunner.staleMessageis set.setTimeout→ the host's Bun process crashes → the workflow run is orphaned (markedrunningin the DB, but no live process; it can never advance to the next node).Stack trace (exact)
Root cause
In
apps/pi-extension/index.ts(around theagent_endhandler):ctxis captured in theagent_endhandler and then used in a deferredsetTimeoutcallback.pi's extension-ctx contract explicitly says: "Do not use a captured pi or command ctx afterctx.newSession(),ctx.fork(),ctx.switchSession(), orctx.reload()." An embedded host that disposes of the planner session after the node completes (entirely reasonable for a multi-node architecture) invalidates that capturedctx's runner, so the deferredctx.isIdle()throws. Because the throw happens insidesetTimeout, it is uncaught and takes down the host process.This is fine in plannotator's interactive single-session flow (the session stays alive, so the captured
ctxnever goes stale), but it is not safe for embedded hosts that manage the session lifecycle per-node.Proposed fix (plannotator-side, defensive)
Make
continueWhenIdleresilient to a disposed/stale ctx so an embedded host is not taken down. Minimal change:Alternative (stronger): instead of capturing
ctx, re-acquire a live reference inside the callback, or guard with a "session still active" check before polling.Workaround (tested locally)
We applied the
try/catchabove locally and confirmed the crash no longer occurs: the workflow advances cleanly from the planner node to the next node (verify-plan→implement) after plan approval, with0stale-ctx errors in the run log. In our host (Archon) the "Continue with the approved plan" continuation is unwanted anyway — implementation is a separate node — so silently stopping the loop is exactly the desired behavior.Environment
@plannotator/pi-extension0.23.1 (loaded fromindex.ts)Happy to provide more detail or a minimal host repro if useful. Thanks!