Skip to content

fix(opencode): 0.28.1 — remote URL toasts on OpenCode 1, one turn per feedback, notice ordering - #1697

Merged
backnotprop merged 9 commits into
mainfrom
fix/opencode-028-1
Oct 5, 2026
Merged

backnotprop merged 9 commits into
mainfrom
fix/opencode-028-1

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Four OpenCode bugs found by the 0.28.0 smoke tests. None of them are regressions. There is one commit per fix.

1. OpenCode 1: slash commands now show the session URL (remote mode)

On the embedded runtime, /plannotator-review, /plannotator-annotate and /plannotator-last wrote the URL only to app.log (OpenCode's log file) and stderr. A remote or SSH reviewer never saw it. Plan review already showed the URL in a toast. The three commands now use the same toast, through a shared helper (announceSessionUrl in session-url.ts). As with plan review, the toast shows in local and remote mode. The CLI runtime already showed a toast and is unchanged. OpenCode 2 already shows the URL as a transcript notice and is unchanged.

Tests: commands.test.ts, "embedded commands toast the session URL". The test calls each command's ready hook in remote mode. All three cases fail without the fix.
Live (1.18.34, PLANNOTATOR_REMOTE=1): for annotate, last and review, the /event stream carries tui.toast.show "Open annotation UI: http://localhost:19432" and "Open code review: …".

2. OpenCode 1: one model turn per command, not two

After command.execute.before, OpenCode 1 always runs a turn on the command's own message, and the hook cannot stop it (SessionPrompt.command @ v1.18.32). The plugin emptied that message and sent the feedback as a separate session.prompt. The result was two turns: one for the feedback, then one on the empty message, where the model answered the same review again.

The command's own message now carries the feedback:

  • While a command runs, its handlers get a client whose session.prompt to the invoking session is recorded, not sent (command-turn.ts).
  • The hook pushes that text into output.parts, so OpenCode runs exactly one turn.
  • The hook's parts cannot name an agent. The routed agent (the review UI's agent switch, or the writer of the annotated message, /plannotator-last feedback uses Build instead of the annotated message agent #1612) is applied to that same message in chat.message, together with that agent's configured model, as the separate prompt did.
  • This covers the embedded and CLI runtimes. OpenCode 2 is untouched.

Tests: command-turn.test.ts covers recording, pass-through and retargeting. command-interception.test.ts runs a fake CLI end to end: the parts carry the feedback, session.prompt is never called, and chat.message retargets once. It fails without the fix.
Live (1.18.34, fake model): with the fix, annotate, review and last each make exactly 1 model request after the feedback, in local, remote and runtime: "cli". The 0.28.0 smoke made 2. The transcript shows one user message with the feedback and one reply, with no empty message. /plannotator-last on a message written by plan, run while the command defaulted to build, was answered by plan (agent: "plan", mode: "plan").

Known limit: a command that ends with nothing to send (Close, or Done without notes) still runs one turn on an empty message, as before. OpenCode 1 gives a command hook no way to skip that turn.

3. OpenCode 2: the URL notice is no longer the last thing the model reads after a plan decision

The Plannotator session ready: <url> notice is a pending steer (#1515). Pending steers are promoted at the next step boundary, and during plan review that boundary comes right after the submit_plan tool result. In the 0.28.0 smoke the model answered ECHO: Plannotator session ready: … instead of the denial. The plugin API at 2.0.22 has no inbox member (SessionDomain is a fixed Pick), so the plugin cannot cancel the pending row. Every model request does pass through the context hook, so the plugin now drops these notices there, for every workflow (dropSessionUrlNotices). The transcript row and its description stay visible to the user. One exception: when a notice is the only message after the model's own reply, it is kept, so a request never ends on an assistant message. The hook is registered only when ctx.session.hook exists. OpenCode 1.18 also runs this setup, with a context that has no session hooks.

Tests: server.test.ts, "session-URL notices stay out of the model context". This covers the unit cases and checks that the plugin filters requests in plan-agent and manual. The plugin cases fail without the fix.
Live (2.0.22, fake model): after a denial the request ends [tool: YOUR PLAN WAS NOT APPROVED…, user: <plan-mode reminder>] with no notice. Approval works the same way. The synthetic notice row is still in the transcript. A native /plannotator-annotate still runs one turn, with the feedback last and the notice still shown to the user.

4. Dev environment: bun install no longer writes into the real ~/.config/opencode

A workspace bun install ran the plugin's postinstall, which copied the command stubs and the skill into the developer's real config. The script now exits 0 unless it runs from a path under node_modules, where npm and pnpm put an installed package. OpenCode itself installs plugins without running scripts, and the install script writes the stubs.

Tests: package-boundary.test.ts runs the real script with a sandboxed HOME/XDG_CONFIG_HOME, from a workspace layout and from a node_modules layout. It fails without the fix.
Live: bun run --cwd apps/opencode-plugin postinstall left ~/.config/opencode mtimes unchanged. From the installed package in the smoke cache, the same script still wrote the stubs and the skill into a sandbox.

Checks

  • bun test apps/opencode-plugin: 242 pass
  • bun run typecheck: clean
  • bun run --cwd apps/review build && bun run build:hook && bun run build:opencode: ok

Live runs used OpenCode 1.18.34 and 2.0.22 from temp prefixes, a fake OpenAI-compatible model and a throwaway registry serving this branch packed as 0.28.1-dev. Every run used a sandboxed HOME, XDG and PLANNOTATOR_DATA_DIR.

Side observation, not changed here: OpenCode 1.18.34 also runs the V2 server.js setup, with a partial context.

🤖 Generated with Claude Code

backnotprop and others added 6 commits October 4, 2026 18:14
On the embedded runtime /plannotator-review, /plannotator-annotate and
/plannotator-last only wrote the session URL to app.log (OpenCode's log
file) and stderr, so a remote/SSH reviewer never saw it. Route their ready
hooks through the same toast plan review uses, now a shared
announceSessionUrl helper, in local and remote mode alike.

Co-Authored-By: Claude <noreply@anthropic.com>
OpenCode 1 always runs a model turn for a command's own message after
command.execute.before returns, and the hook cannot ask it not to. The
plugin emptied that message and sent the feedback as a separate
session.prompt, so the feedback ran one turn and the empty command message
ran a second, in which the model answered the same review again.

Record the feedback the handlers would have prompted with and put it in the
command's own parts instead, so OpenCode runs exactly one turn for it. The
agent the feedback names (the review UI's agent switch, or the writer of an
annotated message, #1612) is applied to that message in chat.message,
together with that agent's configured model, as the separate prompt did.

Co-Authored-By: Claude <noreply@anthropic.com>
…n OpenCode 2

The Plannotator session ready notice is a pending steer, and steers are
promoted at the next step boundary. During plan review that boundary is
right after the submit_plan tool result, so the next request ended with the
notice and a model could answer the URL line instead of the decision (seen
live on 2.0.22). The plugin API cannot withdraw a pending inbox row, but
every model request passes through the session context hook, so the
notices are dropped there for every workflow. A lone trailing notice after
the model's own reply is kept so a request never ends on an assistant
message.

Co-Authored-By: Claude <noreply@anthropic.com>
bun install in this monorepo runs workspace postinstalls, and the OpenCode
plugin's copied its command stubs and knowledge skill into the developer's
real ~/.config/opencode. Run it only when the package sits under a
node_modules directory, which is where npm and pnpm install it for end
users; anywhere else it exits 0 without writing.

Co-Authored-By: Claude <noreply@anthropic.com>
…prompt did

Moving the feedback into the command's own message must change only the
turn count. The retarget now reproduces what createUserMessage did for the
old separate prompt (agent named or not, no model, no variant):

- agent: the named one, else OpenCode's default agent (default_agent, else
  the first primary non-hidden agent) instead of the command's agent;
- model: the agent's configured model, else the session's model read
  before the command's message exists, also when the routed agent is the
  command's own agent (the TUI-picked model no longer wins);
- variant: the agent's variant when its model offers it (/config/providers),
  never the TUI's.

Tests pin new-vs-old equivalence against a transcription of
createUserMessage for the review agent switch, the #1612 writer, an agent
without a model, no agent, default_agent, and the routed agent being the
current one.

Co-Authored-By: Claude <noreply@anthropic.com>
OpenCode 1.18.34 also runs the V2 server.js setup, with a context that has
no session or tool domain, so the planning context hook (and the submit_plan
tool transform) threw during setup. Probe both like the notice filter does.

Co-Authored-By: Claude <noreply@anthropic.com>
@backnotprop

Copy link
Copy Markdown
Owner Author

Review follow-ups, pushed as two commits:

  • 29dc929: the feedback reply now uses exactly the agent, model and variant the old separate prompt would have used (a transcription of createUserMessage @ v1.18.32):
    • Agent: the named one. With no agent named, OpenCode's default agent (default_agent from config, else the first primary non-hidden agent), not the command's agent.
    • Model: the agent's configured model, else the session's model, read before the command's message exists. This also applies when the routed agent is the current agent, so the TUI-picked model no longer wins.
    • Variant: the agent's variant, only when its model offers it (checked via /config/providers). The TUI's variant is never kept.
    • Tests: command-turn.test.ts asserts new == old for the review agent switch, the /plannotator-last feedback uses Build instead of the annotated message agent #1612 writer, an agent without a model, no agent, default_agent, and a routed agent equal to the current one. command-interception.test.ts covers the agent-switch and no-agent cases end to end.
  • 9b6efad: server.ts probes ctx.session.hook and ctx.tool.transform before using them, so setup completes on OpenCode 1.18's context. Test added. It fails without the guard for plan-agent and all-agents.

Results:

  • bun test apps/opencode-plugin: 253 pass. build:opencode: ok.
  • Live, OpenCode 1.18.34, packed as 0.28.1-dev.3:
    • Setup logs no errors.
    • /plannotator-review invoked with agent: plan and no agent switch: 1 model request, answered by build (the default agent), on the session model.
    • /plannotator-last on a message written by plan: 1 model request, answered by plan.

…order

- OpenCode 1.1.31 to 1.3.17 keep a user message's variant top-level
  (info.variant, the TUI pick for a command). The retarget now replaces or
  deletes that key instead of writing model.variant, and picks the variant
  by the running version's rule (scanned over every v1.x tag): none before
  1.1.54, the agent's variant when the message's model offers it up to
  1.3.13, and only on the agent's own model from 1.3.14. The version comes
  from /global/health when the client has it, else the session's.
- With no default_agent, OpenCode takes the first visible primary agent in
  its registration order (build, plan, general, explore, then config
  agents), not GET /agent's name order. Prefer that order.

Co-Authored-By: Claude <noreply@anthropic.com>
@backnotprop

Copy link
Copy Markdown
Owner Author

Re-review fixes, pushed as bcef447:

  1. Top-level variant (OpenCode 1.1.31–1.3.17). When the command's message has its own variant key, the retarget now sets it to the old prompt's variant or deletes it, and leaves model.variant unset. The variant rule now follows the running version. I scanned every v1.x tag:

    • before 1.1.54: no variant;
    • 1.1.54 to 1.3.13: the agent's variant when the message's model offers it;
    • from 1.3.14: only on the agent's own model (the && same check).

    The version comes from client.global.health() when the host's client has it; otherwise from the session's version. The 1.18.34 plugin client has no health, so there the session's version is used. That is the version that created the session.

    Tests: a transcription of that era's createUserMessage, checked at 1.1.31, 1.1.53, 1.1.54, 1.2.10, 1.3.13, 1.3.14 and 1.3.17, for an agent switch, an agent without a model, and no agent. It asserts the TUI variant never survives.

  2. Default agent. With no default_agent, the plugin now picks the first visible primary agent in OpenCode's registration order (build, plan, general, explore), then falls back to the listing order. Tests: build hidden with a custom agent sorted first by name, and no visible built-in.

Results: bun test apps/opencode-plugin: 277 pass. build:opencode: ok. typecheck: clean.

…rsion from the session

OpenCode 1.1.49 to 1.1.53 applied the agent's variant whenever the message
ran on the agent's own configured model, without checking that the model
offers it; agentVariantRule returned "none" there. Add that band, with
tests at 1.1.49 and 1.1.53.

OpenCode 1 plugins get the v1 SDK, whose global domain has only event(), so
the /global/health probe could never answer: drop it and say plainly that
the version is the session's own.

Co-Authored-By: Claude <noreply@anthropic.com>
@backnotprop

Copy link
Copy Markdown
Owner Author

Final fix, pushed as 6be125c:

  • Variant rule. agentVariantRule now covers OpenCode 1.1.49 to 1.1.53: the agent's variant is applied when the message runs on the agent's own model, without checking that the model offers it (prompt.ts @ 1.1.53). Before 1.1.49 is still "none".
  • Tests. The equivalence tests now cover 1.1.48, 1.1.49, 1.1.53 and the later versions. A new case uses an agent whose variant its own model doesn't offer; only 1.1.49 to 1.1.53 apply it. There are also direct boundary assertions.
  • Version source. The /global/health probe is removed. OpenCode 1 plugins get the v1 SDK, whose global has only event(). The comment now says the version is the session's own version, i.e. the OpenCode that created the session.

Results: bun test apps/opencode-plugin: 293 pass. build:opencode: ok.

…script shell can't run case/esac and it broke Windows dependency installs
@backnotprop backnotprop changed the title fix(opencode): 0.28.1 — remote URL toasts on OpenCode 1, one turn per feedback, notice ordering, dev postinstall fix(opencode): 0.28.1 — remote URL toasts on OpenCode 1, one turn per feedback, notice ordering Oct 5, 2026
@backnotprop
backnotprop merged commit f880667 into main Oct 5, 2026
28 checks passed
@backnotprop

Copy link
Copy Markdown
Owner Author

Note: the postinstall monorepo guard (fix 4) was dropped before merge (commit a723641): Bun's Windows script shell can't run case … esac and it broke Windows dependency installs in CI. The original postinstall is unchanged in this PR; the dev-environment fix will come back as a cross-platform script.

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