Skip to content

feat(ai): Ask this session for OpenCode 2 (host-neutral pull bridge) - #1671

Merged
backnotprop merged 5 commits into
mainfrom
feat/opencode-ask-this-session
Oct 3, 2026
Merged

backnotprop merged 5 commits into
mainfrom
feat/opencode-ask-this-session

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Follow-up to #1668 (core + Pi). Ask AI in OpenCode 2 can now be answered by the OpenCode session that opened Plannotator, and the transport it uses is host-neutral so the Claude Code mod can reuse it.

What works

Flow Mode How it was checked
OpenCode 2 /plannotator-annotate Real turn, streamed, in the transcript Live (OpenCode 2.0.22, openai/gpt-5.5) and tests
OpenCode 2 /plannotator-review Real turn, streamed, with tools Live and tests
OpenCode 2 /plannotator-last Real turn, streamed Live and tests
Busy session: no policy / "Ask when it finishes" / "Interrupt and ask now" agent_busy, then wait or session.interrupt Live and tests
Stop during our own turn session.interrupt on our turn only; the next question then runs Live and tests
OpenCode 2 plan review, embedded runtime (default) "Quick answer from this session" (session.generate, no tools, nothing written to the transcript) Live: 3 of 3 answers during a pending submit_plan, transcript unchanged, then approved normally
OpenCode 2 plan review, CLI fallback (runtime: "cli" or a Node host) Same quick answer, over the pull bridge Tests only
OpenCode 1 Not covered See below
Pi, Claude Code and the other hosts Unchanged Full test suite

The pull bridge (host-neutral)

Server half: packages/ai/session-bridge-pull.ts (vendored to Pi). Host half: packages/ai/session-bridge-pull-client.ts. The full contract is documented in CLAUDE.md under "Ask AI Provider Defaults".

  • Launch. The host makes a per-launch secret and starts the server with PLANNOTATOR_SESSION_BRIDGE_TOKEN (32 or more characters), PLANNOTATOR_SESSION_BRIDGE_HOST and an optional PLANNOTATOR_SESSION_BRIDGE_MODES (turn,transient).
    • The Bun runtime reads these once per process and deletes them from process.env, so agent jobs and terminals never inherit the token.
    • The token is never sent to the browser.
    • The bridge is off in remote mode and under --tailscale.
  • Endpoints. Both are POST to http://127.0.0.1:<port> with Authorization: Bearer <token>.
    • /api/ai/bridge/poll takes { status?, modes?, waitMs? }. It long-polls for up to 25 s, answers early when there is work, and returns { commands: [ask | cancel | interrupt], closing?, superseded? }.
    • /api/ai/bridge/event takes one event or { events }. Events: started, delta, tool, done, error, status, interrupted.
    • If a question is no longer running, the server answers 409 ask_not_active, which tells the host to stop it.
  • Delivery. Commands are re-sent every ~5 s until the host acknowledges them, and hosts dedupe by id.
  • Guards, in order:
    1. The Host header must be a loopback name with the server's own port (the existing DNS-rebinding guard): 403.
    2. The request must carry no Origin header: 403.
    3. The bearer token must match: 401.
    4. A server with no bridge answers 404. The plugin treats that as "older binary", so there is no version-skew breakage.
  • Liveness. The bridge reads ready until the host's first poll, so a question asked early waits for that poll. No first request within 30 s, or 30 s with no open poll after that, means gone, and a running question then fails with session_gone.
  • Abort. A question the host never confirmed is dropped. A confirmed one gets a cancel. On a decision or shutdown, detach() drops only unconfirmed questions, and a running turn is never stopped.
  • Both runtimes serve the endpoints through createAIEndpoints. Bun lifts the idle timeout for the poll.

OpenCode 2 adapter (apps/opencode-plugin/opencode-session-bridge.ts)

  • Asking. The question goes in with session.prompt({ id, text, delivery: "steer", metadata: { source: "plannotator-ask" } }). The id is generated in OpenCode's own ascending msg_ format, so it sorts correctly in the transcript.
  • Reading the answer. The answer starts at session.inbox.delivered for our id, streams from text.delta, and falls back to text.ended for blocks that arrive whole. Tool names come from tool.input.started, and execution.* ends the answer. Without an event stream (#44788) it uses session.wait plus session.context.
  • Why "steer" and not "queue" (found live). Every OpenCode 2 command leaves its session-URL notice as a pending steer row. With "queue", the question woke the session and the notice was promoted alone, as its own model turn: the model went off to act on the notice while the question waited behind it. With "steer", the notice and the question are promoted together as one turn. The bridge only asks an idle session, so this cannot merge into someone else's turn.
  • Busy detection. Busy comes from the execution events, cross-checked by one outstanding session.wait probe. The plugin domain has no active call.
  • Plan review. markPlanReviewPending makes every bridge on that session report blocked while submit_plan waits. In that state, interrupt is refused and the review flows fall back to quick answers.

Live check of the pending tool call (task item 3)

  • A real turn is not possible while submit_plan is pending. A prompt sent during the tool call stays in the inbox until the tool returns (verified live).
  • session.generate does work during the pending call. @opencode/ai's normalizeToolHistory fills the unfinished call with an error result for every provider protocol (source: packages/ai/src/tool-history.ts).
  • generate still offers the model its tools. In a first live probe, a question without guidance came back empty because the model chose a tool call. So the transient question now carries SESSION_ASK_TRANSIENT_NOTE ("plain text only, tools unavailable…"). With it, 3 of 3 live answers came back as text. An empty answer is reported as a failure, never shown blank.

Why OpenCode 1 is not included

It is not a small lift:

  • OpenCode 1 has a different message and event model: client.session.prompt blocks for the whole turn, streaming comes from message.part.updated through the plugin event hook, and status and abort have their own endpoints. It would be a second adapter, not a reuse of this one.
  • OpenCode 1 runs /plannotator-* inside command.execute.before, mostly on the embedded runtime. Whether that hook holds the session while the review is open needs its own live testing.

The installed OpenCode 1.18.23 could do that testing, but it is a separate piece of work. The V1 bundle compiles the shared CLI code unchanged and never passes a bridge.

Risks

  • Providers covered live. Only OpenAI was available in the sandbox: the zen and Go credentials were unfunded or region-locked. Anthropic and other providers are covered by the source reading above, not by a live run.
  • Quick answers can still fail. generate offers the session's tools. A model that calls a tool anyway yields a "quick answers cannot run tools" failure the reviewer can retry. Quick answers also cannot be cancelled upstream; we only drop the result.
  • Steer delivery. If the user types into OpenCode at the same instant, our question can share a turn with theirs. This is the same race the Pi bridge has.
  • Stream reliability. If the event stream is unreliable (#44788), busy detection falls back to session.wait probing (about 1 s granularity), and answers arrive whole instead of streamed.
  • Claude Code mod. The protocol is in place, but nothing in this PR exercises a polling host with multi-second gaps beyond the unit tests (resend, gone timeout).

Tests

  • New test files:
    • packages/ai/session-bridge-pull.test.ts: a fake host over the real client. Covers streaming, a question asked before the host connects, busy wait and interrupt, Stop before and after pickup, a host that disappears or never connects, transient questions, wrong token / Origin / rebinding Host / no bridge, supersede and dispose, detach, and the env scrub.
    • apps/opencode-plugin/opencode-session-bridge.test.ts: the adapter over a fake ctx.session and ctx.event.
    • apps/opencode-plugin/session-bridge-cli.test.ts: plugin ↔ a stub CLI child that is a real pull-bridge server. Covers the token hand-off, the ready-file port, the answer round trip, and dispose.
  • Extended: packages/server/ai-runtime.sessionBridge.test.ts (env takeover, scrub, guards, remote mode off).
  • Commands run:
    • bun run typecheck: passes.
    • Full bun test: 5837 pass, 0 fail.
    • bun run --cwd apps/review build && bun run build:hook && bun run build:opencode && bun run build:pi: all pass.
  • No DOM tests were added, so the CI DOM list is unchanged.
  • Live smoke: OpenCode 2.0.22 in a sandboxed XDG and data dir, with the built plugin and a source-run CLI, driven through the plugin API (session.command, prompt), with the Plannotator HTTP API acting as the browser.

No version bumps.

…ridge

Adds the "pull" transport for Ask this session: a host that runs the
Plannotator server as a separate process (the OpenCode plugin's CLI child
today, the Claude Code mod next) long-polls POST /api/ai/bridge/poll for
questions and posts answer deltas, done, error, status and interrupt
results to POST /api/ai/bridge/event. Token from the host's environment
(scrubbed after reading), loopback Host only, no Origin, bounded 25s
long-poll, re-send until acknowledged, gone after 30s of silence. Both
runtimes serve it through createAIEndpoints; Bun takes the env config,
Pi accepts the same config as an option.

OpenCode 2: /plannotator-review, -annotate and -last answer as a real
turn (session.prompt with our own message id, streamed from the event
stream, session.wait + session.context fallback), with busy wait /
interrupt and Stop mapped to session.interrupt on our own turn only.
Plan review answers from context (session.generate, "Quick answer from
this session") because submit_plan is a pending tool call; every bridge
on that session reports blocked so nothing interrupts the review.

OpenCode 1 is not covered (a second adapter over a different event
model). No version bumps.
…at CLI start

The --tailscale path called takeEnvPullSessionBridgeConfig to discard the
host's config, but the take caches it, so createAIRuntime served it anyway
and tailnet peers could type into the agent session. Add an explicit
discard. Also take the config at the top of the CLI, before git/gh/sem,
the auto-update wrapper, or an agent terminal (AI disabled, archive) can
inherit the token.
…he last status report

The server asks only when the host last reported ready, but that report can
be a status tick old. If the user started a run in between, a steered
question would land inside it, and Stop would then interrupt the user's run.
Refuse with busy; the reviewer gets the usual wait / interrupt choice.
@backnotprop
backnotprop merged commit 161b839 into main Oct 3, 2026
24 checks passed
@backnotprop
backnotprop deleted the feat/opencode-ask-this-session branch October 3, 2026 14:44
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