Skip to content

feat(ai): opt-in Ask AI forking of the invoking agent session - #1523

Draft
ruaridhw wants to merge 5 commits into
backnotprop:mainfrom
ruaridhw:ask-ai-fork-origin-session
Draft

ruaridhw wants to merge 5 commits into
backnotprop:mainfrom
ruaridhw:ask-ai-fork-origin-session

Conversation

@ruaridhw

@ruaridhw ruaridhw commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Closes #1519

Summary

The fork machinery merged in #363 was never reachable: no client ever populated AIContext.parent, so shouldFork in /api/ai/session was always false. This PR wires the dormant path end to end and exposes it as an opt-in toggle in the Ask AI panel — off by default, since a fresh session is often what you want.

When a review/annotate surface was invoked from an agent session and the selected provider can fork that harness, the Ask AI panel shows a "Fork the <agent> session" checkbox. Toggling it on makes the next question fork the invoking session (full conversation history — "why did you choose X?", "what else did you consider?") instead of starting fresh with just the document snapshot.

How it works

The origin session never crosses the wire. It is resolved server-side at launch and merged into context.parent inside /api/ai/session; the browser's only say in the matter is a single boolean.

  1. Capture — each launch path already knows the session that invoked it; buildOriginSession (packages/server/origin-session.ts) is the one place that identity becomes a ParentSession:
    • Claude Code plan hook: session_id + cwd straight off the hook payload
    • /plannotator-last (Claude): session id from the resolved transcript path
    • plannotator review / annotate (Claude): ancestor-PID session-log walk, rooted at PLANNOTATOR_CWD || process.cwd() (no-op for a human running the CLI in a terminal)
    • OpenCode submit_plan, /plannotator-review, /plannotator-annotate, /plannotator-last: sessionID threaded through the embedded runtime, the CLI bridge, and v2's native commands
  2. Server-side hand-offcreateAIRuntime({ originSession }) carries it into AIEndpointDeps. It is never echoed into /api/plan or /api/diff; those payloads are untouched.
  3. Advertisement/api/ai/capabilities (already fetched on load) gains originFork: { agent, providerIds } | null. providerIds is computed server-side: providers that declare capabilities.fork and natively own the origin harness, matched on registry id or type name, so a custom instance id can't silently miss.
  4. Request — the client posts forkOrigin: true on /api/ai/session. The endpoint merges its own originSession into context.parent. The toggle's armed state is derived (wantsFork && available), so switching to a non-forking provider disarms it rather than silently answering fresh.

Deliberate scope cuts

Test plan

  • bunx tsc --noEmit clean across all nine projects in the typecheck script
  • packages/ai + packages/core — 302 pass, incl. new endpoint tests: forks when forkOrigin is set, doesn't without it, doesn't when the resolved provider can't fork, originFork reports the right ids (and matches a provider registered under a custom instance id), null with no origin session
  • apps/opencode-plugin — 179 pass, incl. --session-id emission from the CLI bridge
  • apps/hook — 248 pass
  • packages/review-editor — 549 pass
  • packages/ui — 1407 pass (one unrelated pre-existing failure, Viewer consumer props > readOnly fenced code…, identical on main)
  • bun run build + the pi-extension vendor step
  • packages/server suite green per-file; agent-terminal* / ai-disabled panic on node-pty under bun 1.4.2 locally, identically on unmodified upstream

Not manually verified

A real fork against a live Claude Code / OpenCode session (needs an interactive agent session plus a browser). The provider fork paths are unchanged from #363; what's new is that context.parent finally reaches them.

Issue backnotprop#1519: Ask AI always starts a fresh provider session, even though the
agent session that produced the plan/diff/document usually has the exploration
and reasoning that led to it. A prior attempt (reverted) had the browser hand
back a ParentSession on /api/ai/session, but no client ever built one and the
plumbing to get there touched 29 files across the hook CLI, both editors, and
five OpenCode plugin layers.

The browser never needs the session id, cwd, or harness — the server already
knows all three at launch. So /api/ai/session now takes a `forkOrigin: true`
boolean instead, and the endpoint merges deps.originSession (server-side only)
into the request's AIContext before the existing fork-or-fresh check runs.
/api/ai/capabilities advertises which providers can fork it as
`originFork: { agent, providerIds }`.

originForkProviderIds matches registry entries against the origin's provider
types by id OR name (`matchesAgentProvider`, packages/core/agents.ts) rather
than id alone: a registry instance id can be custom (registered with its own
instanceId), so id-only matching silently drops a provider registered that
way. This is the same rule `findOriginAIProvider` already used client-side
(packages/ui/utils/aiProvider.ts) to pick a default provider for an origin —
extracting it into one shared predicate keeps the two in sync instead of
re-implementing the match with different vocabulary on each side.

ParentSession.agent is now typed as Origin (packages/core/agents.ts) instead
of a bare string, removing the `as Origin` casts the reverted version needed.
Pi vendors ai-context.ts/endpoints.ts verbatim into apps/pi-extension/generated,
so vendor.sh also vendors agents.ts alongside them and rewrites the new
`@plannotator/core/agents` import the same way it already does for ai-context.
createAIRuntime() and the three server bootstraps (plan, annotate, review) now
accept an optional originSession, forwarded straight into createAIEndpoints'
deps. It rides no bootstrap payload (/api/plan, /api/diff are untouched) —
only the AI runtime sees it, so /api/ai/session and /api/ai/capabilities can
act on it server-side per the previous commit.

The {sessionId, cwd, agent} ParentSession object and the `originSession` doc
comment both get one home instead of being restated per call site:
origin-session.ts's `buildOriginSession` is the single builder apps/hook and
apps/opencode-plugin will share (next two commits), and `OriginSessionOption`
is the one place `ServerOptions`/`AnnotateServerOptions`/`ReviewServerOptions`
document the field, via `extends` instead of copying the same four-line
comment three times. The package.json `exports` map gains an `./origin-session`
subpath so a Node-targeted bundle (apps/opencode-plugin's CLI entry points)
can import the builder without pulling in the package root's Bun-only modules.
Wires originSession into the seven hook-CLI launch sites that can genuinely
name their invoking session: the direct `review`/`annotate` CLI (ancestor-PID
transcript resolution), `annotate-last` (the transcript the rendered message
was actually read from), the three OpenCode JSON-stdin bridge subcommands
(opencode-plan/opencode-review/opencode-annotate-last, which already carry a
sessionId/directory pair), and the ExitPlanMode hook-event branch (the event
names its session id directly).

Two small helpers replace what would otherwise be seven ad-hoc reconstructions:
resolveInvokingClaudeSession() for the ancestor-PID cases, and
toOpenCodeOriginSession() for the three bridge subcommands. Both delegate to
the shared builder (buildOriginSession, packages/server/origin-session.ts) —
apps/hook is Bun-only, so it can depend on @plannotator/server directly, the
same builder apps/opencode-plugin's own toOriginSession wraps (next commit).
Both return null outside their one supported harness — Codex's
CODEX_THREAD_ID is deliberately never captured (no provider can fork a Codex
thread), and neither Gemini nor any other origin is guessed at, since only
claude-code and opencode ever appear in AIEndpointDeps.originSession.agent.

resolveInvokingClaudeSession() resolves cwd via the PLANNOTATOR_CWD idiom used
everywhere else in this file (the original working directory before a
launcher shim `cd`s), not a bare process.cwd() — claude-agent-sdk.ts forks
with `cwd: parent.cwd`, so a wrong cwd here would root the forked session in
the wrong directory.

The direct `annotate` CLI also gains a `--session-id <id>` value flag,
following the same indexOf/splice idiom as the existing `--browser <name>`
flag. It closes the one named surface from backnotprop#1519 ("plan review via hook,
/plannotator-last, /plannotator-annotate") that the CLI-bridge fallback leg of
/plannotator-annotate couldn't cover: that leg shells out to the plain
`annotate` subcommand (no stdin-JSON channel the way opencode-plan/
opencode-annotate-last have), so this flag is the only way it can name the
invoking OpenCode session. buildAnnotateCliArgs (apps/opencode-plugin) emits
it next commit.
… commands

submit_plan (V1 and V2), the native /plannotator-review, /plannotator-annotate,
and /plannotator-last command handlers, and the embedded/CLI-bridge plan-review
runners all now carry the OpenCode session id (and cwd) one hop further, into
originSession on the server bootstrap call.

toOriginSession() wraps the shared builder (buildOriginSession,
packages/server/origin-session.ts) that apps/hook's own helpers use — the one
place the {sessionId, cwd, agent} object is actually constructed, replacing
what was otherwise the same construction repeated across the CLI bridge, the
native-command handlers, and the embedded runtime. It imports the
`@plannotator/server/origin-session` subpath rather than the package root:
index.ts and server.ts are bundled with `--target node` (they run under
Node.js-hosted OpenCode, not Bun), and the package root barrel pulls in
Bun-only modules (browser.ts, repo.ts, project.ts, integrations.ts) that a
Node-targeted bundle can't import — the origin-session submodule itself has
no such dependency.

sessionId and cwd previously traveled as separate parameters through
runPlanReview, runCliPlanReview and runEmbeddedPlanReview, joined into a
ParentSession only at the leaf (inside embedded.ts, and again — across the
process boundary — inside the hook CLI). runPlanReview (both the V1 and V2
copies) now joins them once, itself, into `originSession`, and threads that
one value down; runCliPlanReview still takes `cwd` separately since the CLI
bridge also needs it to spawn the child process, but the JSON payload's
sessionId/directory fields now read from originSession rather than raw
fields, so the two paths can't drift.

/plannotator-annotate's CLI-bridge fallback leg (used only when the embedded
runtime is unavailable) previously had no way to name the invoking session —
it shells out to the plain `annotate` CLI subcommand, and unlike
opencode-plan/opencode-annotate-last it has no stdin-JSON channel. The
existing value-flag parsing (--result-file <path>, the global --browser
<name>) shows this is a solved problem for the annotate CLI, so
buildAnnotateCliArgs now emits the --session-id <id> flag the previous commit
added, closing the one named backnotprop#1519 surface (plan review, /plannotator-last,
/plannotator-annotate) that OpenCode v2's always-CLI-bridge routing couldn't
reach.
The toggle is shown only when /api/ai/capabilities reports an originFork and
the *effective* AI provider — the resolved selection if it names a real,
currently known provider, else the server's own defaultProvider, never
providers[0] array order — is in its providerIds. useOriginFork() (packages/ui)
owns that gate once; posting `forkOrigin: true` on /api/ai/session is the only
new thing useAIChat does.

Because useAIChat's session depends on useOriginFork's `forkOrigin`, the app
can't compose a reset inside the gate hook itself (the same ordering problem
useAIProviderConfig already documents for provider switches) — so the emitted
`forkOrigin` is *derived* from the gate instead of held as independent state.
Switching to a non-forking provider clears it for free, with no explicit
reset and no stale "still forking" flag left armed. `toggleProps` is memoized
so its identity is stable across renders that don't change the gate inputs,
and `onToggle` hands the toggle's own `setWantsFork` straight through instead
of behind a needless wrapper — without that, a caller's `useCallback` around
`toggleProps.onToggle` (composing the reset) recomputes on every render
regardless of whether anything the toggle cares about changed.

OriginForkToggle (packages/ui/components/ai) is the one checkbox row, used by
both DocumentAIChatPanel and the review AIConfigBar/AITab/ReviewSidebar chain,
replacing what would otherwise be the same prop shape declared four times.
packages/editor/App.tsx and packages/review-editor/App.tsx each wire it with
the same few lines: a useOriginFork() call ahead of useAIChat, and a
handleForkOriginToggle that composes the toggle with resetSession — the editor
additionally withholds availability while the agent-terminal surface is
active, since forking into a provider chat isn't meaningful for the origin
agent's own TUI.

useAIChat.forkOrigin.test.tsx and useOriginFork.test.tsx share one DOM mount
harness (hookTestHarness.tsx) instead of each defining their own copy, and
the former keeps only the one test that asserts real behavior — that
forkOrigin: true actually reaches the wire body and no client-built
ParentSession rides with it — dropping two tests that only asserted key
absence on the `...(forkOrigin && { forkOrigin: true })` spread.
@ruaridhw
ruaridhw force-pushed the ask-ai-fork-origin-session branch from a6e1c9b to b0810ab Compare September 14, 2026 11:37
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.

[Feature Request] Ask AI: optionally fork the invoking agent session so the sidebar inherits its conversation history

1 participant