Skip to content

fix(extensions): ask session_before_fork handlers before a message's fork - #1540

Open
agegr wants to merge 4 commits into
mainfrom
fix/fork-before-hook
Open

agegr wants to merge 4 commits into
mainfrom
fix/fork-before-hook

Conversation

@agegr

@agegr agegr commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

Problem

pi's AgentSessionRuntime.fork() emits session_before_fork before it copies anything. A handler can return { cancel: true }, or ask the user something first.

  • pi's git-checkpoint example offers to restore the code to that point.
  • pi's dirty-repo-guard example refuses a fork while the repository has uncommitted changes.

Pi Web's New session on a user message (send({ type: "fork" })) copied the branch on disk without asking. Those extensions never ran.

Repro on main: an extension with pi.on("session_before_fork", async (e, ctx) => (await ctx.ui.confirm("Fork this session?", "…")) ? undefined : { cancel: true }). Click New session on a message: it forks at once, with no dialog.

Fix

  • The wrapper's fork emits session_before_fork ({ entryId, position: "before" }), but only when a handler listens, exactly as emitBeforeFork() does.
  • { cancel: true } returns { cancelled: true }, and nothing is written.
  • A handler's dialog shows in the page while the fork waits.
  • The hook waits for the extensions to be bound first, as pi only forks a bound session. Before binding, a handler sees ctx.hasUI as false and takes its non-interactive branch; dirty-repo-guard, for example, cancels silently there.
  • The hook runs before the copy's session-replacement lock, so while its dialog waits, a running source still takes Stop and steers. The entry and a running ! command are checked again afterwards.
  • Unlike pi, the source is not torn down after the hook, because a running source keeps its run in Pi Web. A handler that restores code therefore does so beside it.
  • The sidebar's Fork stays a runtime-free copy on disk, as documented.
  • session_fork has no counterpart here: the copy gets its runtime later, when it is opened.

Verification

  • New test in lib/rpc-manager.test.mjs: a cancelled fork writes no file, and the next one goes through.
  • npm test, tsc --noEmit, npm run lint.
  • Headless Chrome on an isolated dev server with the extension above:
    • New session showed the "Fork this session?" dialog;
    • Cancel kept the page on the source session;
    • Confirm opened the copy;
    • on a freshly started server (fork right after the session's wrapper started), the dialog still appeared.

🤖 Generated with Claude Code

agegr and others added 2 commits November 4, 2026 02:54
…fork

pi's fork emits session_before_fork first, so an extension can cancel
it or ask whether to restore the code to that point (pi's
git-checkpoint and dirty-repo-guard examples). Pi Web's "New session" on
a message copied the branch without asking, so those extensions never
ran.

The wrapper now emits it when a handler listens: `{ cancel: true }`
leaves everything as it was, and a handler's dialog shows in the page
while the fork waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s lock

Before the extensions were bound a handler saw no UI and took its
non-interactive branch (dirty-repo-guard cancels silently there), and
the hook ran under the session-replacement lock, so while its dialog
waited a running source refused Stop and steers. The fork now waits for
binding, asks the handlers before taking the lock, and checks the entry
and a running ! command again after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@agegr

agegr commented Nov 4, 2026

Copy link
Copy Markdown
Owner Author

Review

The finding below was checked twice: once by tracing the code and once in a wrapper test.

Must fix: the guard fails open after a Stop

forkCancelledByExtension() emits while extensionUiAbortController may still be aborted: Stop aborts it, and only prompt and reload renew it. Two cases follow:

  • After a Stop with no later prompt (stop a bad answer, then "New session" on an earlier message):
    1. The handler's ctx.ui.confirm / select rejects at once with "Extension UI cancelled by Stop".
    2. ExtensionRunner.emit swallows the error, so the result is undefined.
    3. The fork is written with no dialog, and an error toast shows.
  • Stop pressed while the fork's dialog waits approves the fork the same way.

pi treats a closed dialog as undefined, so guards such as confirm-destructive and dirty-repo-guard cancel the fork there.

Fix: renew the controller if it is aborted before emitting. Capture its signal, and treat an abort during the hook as cancelled.

The same root cause already affects session_before_tree (navigate_tree) on main, so one helper could cover both.

Follow-up, not a defect of this PR

  • /clone still skips the hook. pi's /clone, in both the TUI and RPC mode, emits session_before_fork with position: "at". pi-web's clone still doesn't, so a guard can now block a message fork but not /clone. A follow-up should:
    • ask the hook before the clone lock;
    • let the client tell an extension's cancel apart from "Cannot clone an empty or unsaved session".

Checked, fine

agegr and others added 2 commits November 4, 2026 16:06
…for /clone

Stop aborts the extension UI controller and only a prompt or reload renewed
it, so after a stopped run a handler's dialog rejected at once; pi's runner
swallows that, and the fork went through without asking. A Stop while the
dialog waited approved it the same way. The hook now renews the controller
first and counts a Stop during it as a cancel, as pi's TUI reads a closed
dialog. navigate_tree had the same cause for session_before_tree and renews
the controller too (a Stop during that hook still cannot undo the move, which
pi makes inside navigateTree()).

clone now asks session_before_fork at the leaf (position "at"), as pi's
/clone does, before the copy's lock; its cancel carries byExtension so the
composer says nothing, instead of "Cannot clone an empty or unsaved session".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tree hook's gap

A manual /compact after a Stop ran session_before_compact with the
controller still aborted, so a handler's dialog rejected at once, as the
fork and tree hooks did. The tree comment and note said a Stop cannot
land during session_before_tree; it can when a prompt was sent while the
dialog waited, and the move still goes through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@agegr

agegr commented Nov 4, 2026

Copy link
Copy Markdown
Owner Author

Pushed 0f419a2 and bd7ac55 for the review above:

  1. The hook fails closed on Stop.
    • renewExtensionUiStop() renews a controller an earlier Stop left aborted.
    • A Stop while the hook runs counts as cancelled.
    • The reviewers' repro passes in all three cases: baseline, after Stop, and during the dialog.
  2. The same renew now runs before session_before_tree (navigate_tree) and session_before_compact (manual /compact). Both had the same root cause on main.
  3. /clone asks session_before_fork with position: "at", as pi's /clone does. The client tells an extension's cancel apart from "Cannot clone an empty or unsaved session".

tsc, eslint and npm test pass (3277 pass, 1 skipped).

Documented, not changed:

  • A Stop during the tree hook still lets the move through: pi moves inside navigateTree() right after the hook. Stop shows then only if a prompt was sent while the dialog waited.
  • A Stop that lands after the fork dialog was answered, while the handler finishes, also drops the copy. The window is narrow.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant