Skip to content

feat(coding-agent): add hold condition API for extensions#2138

Closed
tintinweb wants to merge 2 commits intobadlogic:mainfrom
tintinweb:feat/hold-condition
Closed

feat(coding-agent): add hold condition API for extensions#2138
tintinweb wants to merge 2 commits intobadlogic:mainfrom
tintinweb:feat/hold-condition

Conversation

@tintinweb
Copy link
Copy Markdown

@tintinweb tintinweb commented Mar 14, 2026

add setHoldCondition() to the extension API so extensions can keep headless/print mode alive while async work completes. @tintinweb/pi-subagents integrates this so background agents can finish before print mode exits. Callbacks are awaited in a loop - returned strings become follow-up agent turns, empty array signals completion. Errors are caught per-callback and routed through emitError().

closes #2071


Details

  • Add setHoldCondition() API to the extension system, allowing extensions to keep print mode alive after prompts complete for async workflows (e.g. subagent orchestration, MCP approval flows)
  • Hold condition callbacks return follow-up messages that trigger additional agent turns, looping until all conditions return empty arrays
  • Errors in hold condition callbacks are caught and routed through emitError() to prevent print mode crashes
  • Hold conditions registered during extension loading are queued and flushed on bindCore(), matching the registerProvider pattern

Motivation

Print mode (pi -p "do something") is fire-and-forget: it sends prompts, waits for the agent to finish, then exits the process. This is a problem for extensions that kick off async work during a prompt — like spawning subagents or waiting for an external MCP approval — because print mode exits before that work completes. There was no mechanism for an extension to say "don't exit yet, I'm still working."

Interactive mode doesn't have this problem because the session stays alive between prompts, giving async work time to finish naturally.

Design decisions

Why a loop instead of a single await?
Hold conditions can produce follow-up messages (e.g. "subagent X finished with result Y") that need to be fed back into the agent as new user turns. A single await can't express this back-and-forth. The loop awaits all conditions, delivers any messages, lets the agent process them, then checks again — repeating until all conditions return empty arrays.

Why string[] instead of a boolean?
A boolean would only tell us "keep waiting." Returning strings lets conditions inject context back into the conversation, so the agent can react to results (e.g. summarize subagent output, handle approval decisions). Empty array = "I'm done", non-empty = "here's what happened, keep going."

Why .catch() per callback instead of a top-level try/catch?
A single throwing condition shouldn't kill the entire loop or block other conditions. The per-callback .catch() isolates failures: the broken condition returns [] (gracefully completes) while others continue. Errors route through emitError(), matching the pattern used by every other extension callback in runner.ts.

Why on AgentSession instead of ExtensionRunner?
Hold conditions need access to agent.followUp() and agent.continue() to deliver messages and trigger turns. These live on AgentSession, not the runner. The runner only handles event dispatch. Putting the loop on the session keeps it close to the agent lifecycle it controls.

Why queue hold conditions during extension loading?
Extensions call setHoldCondition() in their activate function, which runs before bindCore() wires up the real session actions. Like registerProvider, hold conditions registered pre-bind are queued in pendingHoldConditions and flushed when the runner binds to the session. This avoids forcing extensions to defer registration to an event handler.

Example: Extension registering hold condition

  // Hold print mode open while background agents are still running.
  // The existing onComplete callbacks (sendIndividualNudge / groupJoin) handle
  // delivering results via sendUserMessage — the hold condition just prevents
  // the process from exiting before they finish.
  pi.setHoldCondition(async () => {
    if (!manager.hasRunning()) return [];
    await manager.waitForAll();
    return [];
  });
  • npm run check passes no errors
  • ./test.sh passes (1 error: FAIL test/context-overflow.test.ts but this is unrelated )
  • pi -e ../subagents/src/index.ts -p "run 2 subagents wait for 10 seconds" actually waits 10 seconds

@badlogic
Copy link
Copy Markdown
Owner

Sorry, will take a while until I have time to review this. Like the general direction, but it's a bit tricky.

@tintinweb
Copy link
Copy Markdown
Author

easy, take your time 🤗 ❤️ lmk if I can help evaluating smth

PS:

  • also, if you have thoughts on extension-to-extension communication. pi-tasks and pi-subagents <-> comm via eventbus to turn tasks->subagents. not sure if there is a more elegant/dedicated way to that :)

  Allow extensions to register hold conditions via setHoldCondition() that
  keep print mode alive after prompts complete. Hold condition callbacks
  return follow-up messages that trigger additional agent turns, enabling
  async workflows like MCP approval flows. Errors in callbacks are caught
  and routed through emitError() to prevent crashes.
…loading

  Hold conditions registered in activate() were hitting "not initialized"
  because setHoldCondition was called before bindCore(). Queue them in
  pendingHoldConditions and flush on bind, matching the registerProvider
  pattern. Adds unit tests for waitForPendingWork().
@tintinweb tintinweb force-pushed the feat/hold-condition branch from 3b067a2 to 9e803da Compare March 22, 2026 15:30
@badlogic badlogic added the inprogress Issue is being worked on label Mar 27, 2026
@badlogic
Copy link
Copy Markdown
Owner

Sooo, I'm afraid I need to close this down. Here are the reasons:

  • this introduces an extension point that only applies to a single mode, print mode. it requires a lot of semi-complex machinery that has a subtle bug as well (i can elaborate, but since i'm closing this, I don't want to waste our time)
  • print mode is supposed to be "one prompt, wait for answer, print answer, exit". it is not meant as a long running, steerable mode. instead, use RPC mode, which gives you all of print JSON mode, all the same CLI flags you have in print mode except the prompts (so thinking level, model, etc.) and a simple IPC protocol. (see RPCClient) to steer the agent. That is a much better fit for your subagents extension.

TL;DR: use RPC mode.

re: extension-to-extension, i think this is good!

@badlogic badlogic closed this Mar 27, 2026
@tintinweb
Copy link
Copy Markdown
Author

got it and I understand that it's more complex. Currently the "one prompt, wait for answer, print answer, exit" returns before sub-activities resolve which kinda breaks the contract with the user (finishes before the entirety of the prompt is done - when bg agents are used). I don't think CC in print mode returns before subagents finish.

I'll keep my hack in for now, document it as know-issue with a fix for headless print-mode and explore how other extensions wait for their sub-activities 🙌

thanks for looking into it! 🤗

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

inprogress Issue is being worked on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add hold condition API to prevent agent loop exit while extensions have pending async work

2 participants