Files
Michael Ramos 7ee366d8a1 fix(opencode): show the session URL on OpenCode 2's native command path (#1435)
* fix(opencode): show the session URL on OpenCode 2's native command path

On OpenCode 2 a remote session's URL was invisible. runNativeCommand builds
its bridge client with createV2BridgeClient, which deliberately has no tui
domain, so toastPlannotatorUrl optional-chained to a no-op; both URL delivery
paths (the CLI stderr forwarder and the ready-file poller) route through it.
The V2 client's app.log is console.error, and OpenCode discards a server
plugin's stderr under both default launch modes (packages/cli/src/services/
standalone.ts uses stderr: "ignore" unless OPENCODE_PRINT_LOGS=1). Remote mode
also suppresses the browser, so /plannotator-review showed the user nothing at
all and presented as a hang.

Deliver the URL as a visible transcript notice instead. createSessionUrlNotifier
duck-types ctx.session.synthetic and exposes it to cli-bridge as notifyUrl, a
seam toastPlannotatorUrl prefers over the toast when present; OpenCode 1 clients
carry no notifyUrl and keep their real toast unchanged. The notice is posted
with resume: false, which upstream skips the wake for, so nothing starts a model
turn, and it carries the URL in both text and description because the TUI drops
a synthetic row whose description is empty and renders the description rather
than the text. Everything is guarded: a host without session.synthetic, or a
call with no session, gets no notifier and falls back to today's log-only
behavior, and a rejecting synthetic is caught and leaves the URL retryable by
the other delivery path.

The README's remedy line claimed remote sessions should read the URL from the
OpenCode log, which was never true; it now describes the transcript notice and
names OPENCODE_PRINT_LOGS=1 for older hosts.

Also fixes two bugs in the OpenCode 2 native-command smoke:

- scripts/opencode2-native-commands-smoke.sh looked for a node_modules/.bin/
  opencode binary. @opencode-ai/cli publishes opencode2 on every dist-tag, so
  the script failed before it started a server. It now tries both names and
  reports which it looked for.
- The command-ownership check read /api/command once, immediately after
  activation, racing the reclaim schedule whose last tick lands about 15.5s
  later. Under PLANNOTATOR_SMOKE_EXPECT_NATIVE=1 that reported a shadowing bug
  the reclaim had simply not reached yet. It now polls to a 30s deadline
  (PLANNOTATOR_SMOKE_COMMAND_TIMEOUT_MS), still only after /api/plugin reports
  the plugin loaded.

AI-assisted (Claude) under maintainer direction.

* fix(opencode): deliver the session URL on OpenCode 2's plan review path too

The first commit fixed only the native command path. The plan path builds its
own client (createV2Client, typed as { app: { agents, log } } with no notifier),
so a remote OpenCode 2 user who reached a review through submit_plan still never
saw the URL: no browser is opened for them and the plugin's console output is
discarded by the host.

The plan path now builds the same bridge client the command path uses, with
toolContext.sessionID, so it carries notifyUrl whenever the host exposes
session.synthetic. That covers both runtimes: the CLI runtime already prefers
notifyUrl inside toastPlannotatorUrl, and the embedded runtime's previously
empty logReady hook is now createPlanReadyNotifier.

That hook still does not log. app.log is console.error, the same stderr
handleServerReady already printed the URL to, so logging there would duplicate
the line in remote mode and add a stray one locally, which is why the hook was
empty. The transcript notice is a different surface, and it is the only one a
remote reviewer can see. Without session.synthetic the hook stays silent exactly
as before.

createV2Client is gone: it duplicated the bridge client's URL-deduped app.log
verbatim, and nothing else used it.

Three tests on the plan path (delivers the notice; stays silent and does not
re-log without synthetic; catches a rejecting notice) plus one that pins the two
wiring seams at source level, since the notifier tests all pass while the plan
path is wired to nothing, which is the shape the bug had.

Also from review: console.error is stubbed across the V2 URL delivery block, so
those tests no longer print URL lines into the suite output. The README bullet
now says the notice covers every way a session opens rather than slash commands
alone.

AI-assisted (Claude) under maintainer direction.
2026-08-31 13:12:23 -07:00

281 lines
11 KiB
TypeScript

/**
* Native slash commands for OpenCode 2.
*
* OpenCode's V2 plugin API gained command EXECUTION in anomalyco/opencode
* PR #44765 (issue #2185): the command draft grew an `add({ name, description,
* execute })` method whose callback fully owns the invocation, so nothing
* reaches the model unless it says so. That shape currently ships only on the
* `beta` and `dev` dist-tags of `@opencode-ai/plugin`; `next` and `latest`
* still carry a draft of `{ list, get, update, remove }` with no `add`.
*
* `ctx.command.transform` therefore proves NOTHING: it exists on both. The only
* honest probe is the draft handed to the callback, which is what this module
* checks. On an older host it adds nothing and the markdown command stubs stay
* the (model-mediated) fallback.
*
* Execution reuses the exact V1 machinery, `handleCliCommand`, over a
* translation client, so the two hosts cannot drift.
*/
import { handleCliCommand, type OpenCodeBridgeAgent, type OpenCodeBridgeContext } from "./cli-bridge";
import {
createV2BridgeClient,
readListPayload,
type V2CommandDraft,
type V2CommandInvocation,
type V2ContextLike,
} from "./v2-client";
/**
* Descriptions are deliberately NOT copies of the markdown stubs' frontmatter.
* They are the provenance signal the reclaim below reads back out of
* `ctx.command.list()` to tell our definition from the config-loaded stub, and
* `native-commands.test.ts` pins that they stay distinct.
*/
export const NATIVE_COMMANDS: ReadonlyArray<{ name: string; description: string }> = [
{
name: "plannotator-review",
description:
"Open the Plannotator code review UI for current changes or a PR URL; pass --git or --gitbutler to force that provider",
},
{
name: "plannotator-annotate",
description: "Open the Plannotator annotation UI for a file, folder, or URL",
},
{
name: "plannotator-last",
description: "Annotate the last assistant message in Plannotator",
},
];
/**
* Delay BEFORE each ownership re-check, in milliseconds. The loop awaits these
* one after another, so the ticks land at roughly 0.3s, 1.5s, 5.5s and 15.5s
* after setup.
*
* Four bounded ticks, then it stops for good. Plugin activation and the config
* command scan both finish well inside that window; a host slower than that
* keeps the fallback, which still works.
*/
const RECLAIM_SCHEDULE_MS = [300, 1_200, 4_000, 10_000] as const;
export interface CliCommandRequest {
command: string;
client: unknown;
sessionId?: string;
rawArgs: string;
cwd?: string;
bridge?: OpenCodeBridgeContext;
}
export interface NativeCommandDeps {
ctx: V2ContextLike;
getAgents: () => Promise<OpenCodeBridgeAgent[]>;
getBridgeContext: () => Promise<OpenCodeBridgeContext>;
/**
* Injection seam for tests only; production always uses `handleCliCommand`.
* Bun's `mock.module` is process-global and cannot be unset, so a module mock
* of `cli-bridge` here would leak into every other suite.
*/
runCommand?: (request: CliCommandRequest) => Promise<void>;
/** Test seam for the reclaim schedule; production uses real timers. */
wait?: (ms: number) => Promise<void>;
}
/** Resolve the invocation's working directory, session location first. */
async function resolveDirectory(ctx: V2ContextLike, sessionID: string): Promise<string> {
try {
const session = await ctx.session?.get?.({ sessionID });
const directory = session?.location?.directory;
if (typeof directory === "string" && directory) return directory;
} catch {
// Fall through to the plugin location, then the process cwd.
}
return ctx.location?.directory || process.cwd();
}
export async function runNativeCommand(
command: string,
invocation: V2CommandInvocation,
deps: NativeCommandDeps,
): Promise<void> {
const sessionID = invocation.sessionID;
// The raw argument tail, exactly as OpenCode 1 forwards it. The CLI's own
// tolerant argument resolution takes it from here: nothing is parsed or
// rewritten on the way through.
const rawArgs = typeof invocation.prompt?.text === "string" ? invocation.prompt.text : "";
// `sessionID` is what lets the bridge put the session URL where the user can
// actually see it. Without it (and on a host with no `session.synthetic`) a
// remote review would only print its URL into a stream OpenCode discards.
const client = createV2BridgeClient({ ctx: deps.ctx, getAgents: deps.getAgents, sessionID });
const run = deps.runCommand ?? ((request: CliCommandRequest) => handleCliCommand(request as never));
await run({
command,
client,
sessionId: sessionID,
rawArgs,
cwd: await resolveDirectory(deps.ctx, sessionID),
bridge: await deps.getBridgeContext(),
});
}
function defaultWait(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
// Never hold the host process open for a background reconciliation.
(timer as { unref?: () => void }).unref?.();
});
}
/**
* Do our definitions currently own all three names?
*
* `ctx.command.list()` returns the MATERIALIZED command map, so a description
* that is not ours means another transform (in practice OpenCode's own
* ConfigCommandPlugin, replaying the installed markdown stubs) added the name
* after us and won.
*/
async function ownsNativeCommands(ctx: V2ContextLike): Promise<boolean> {
const list = await ctx.command?.list?.();
const commands = readListPayload(list);
return NATIVE_COMMANDS.every((command) => commands.some((entry) =>
entry.name === command.name && entry.description === command.description));
}
/**
* Take the three names back from the config-loaded markdown stubs.
*
* Mechanism, verified against anomalyco/opencode `origin/v2`:
* - Command definitions live in a name-keyed Map and `draft.add` is a
* `Map.set` (`packages/core/src/command.ts`), so the LAST transform to add a
* name wins.
* - Transforms replay in registration order: `transforms = [...transforms,
* transform]`, and `materialize` walks that array
* (`packages/core/src/state.ts`).
* - Activation order is `pre` -> packages -> `post`, and OpenCode's own
* ConfigCommandPlugin, which scans `~/.config/opencode/{command,commands}/
* **\/*.md`, sits in `post` (`packages/core/src/plugin/internal.ts:265-269`,
* `packages/core/src/plugin/supervisor.ts`).
* So a setup-time transform ALWAYS replays before config's, and the stubs the
* installer writes shadow the native definitions on every normal install. The
* fix is to register the same transform once more after activation settles, so
* ours is last in the replay order; from then on it stays last, because config
* only ever calls `reload()` and never re-registers.
*
* The explicit `reload()` is belt and braces, not a requirement: each plugin's
* effect runs inside `State.batch` (`packages/core/src/plugin.ts`), but the
* batch clears its active flag before flushing, so a registration arriving
* after that takes the direct path and materializes on its own. Calling
* `reload()` anyway costs one recompute and removes any dependence on that
* ordering detail holding in a future OpenCode.
*
* Deliberately not driven by `ctx.event.subscribe()`: upstream #44788 reports
* that stream as unreliable on some V2 nightlies, and `command.list()` is a
* direct read of committed state with no bus involved.
*
* Failure mode: if this never gets to run, or the host has no `list`/`reload`,
* or a future OpenCode reorders activation, the markdown stubs keep winning and
* the three commands still work through their model-mediated fallback bodies.
* Degraded, never broken.
*/
export async function reclaimNativeCommands(input: {
ctx: V2ContextLike;
apply: () => Promise<void>;
isSupported: () => boolean;
wait?: (ms: number) => Promise<void>;
}): Promise<void> {
const wait = input.wait ?? defaultWait;
const list = input.ctx.command?.list;
const reload = input.ctx.command?.reload;
if (typeof list !== "function" || typeof reload !== "function") return;
let reclaimed = false;
for (const delay of RECLAIM_SCHEDULE_MS) {
await wait(delay);
// The draft probe only runs when the transform REPLAYS, which under boot
// batching is at the flush after every plugin has loaded. Plannotator loads
// before the post-group config plugins, so an early tick can legitimately
// see this false: skip the tick, never end the loop, or the reclaim would
// be inert in exactly the shape production has.
if (!input.isSupported()) continue;
let owned: boolean;
try {
owned = await ownsNativeCommands(input.ctx);
} catch {
return;
}
// Owning the names on an early tick can simply mean config has not loaded
// yet, so ownership alone is not an exit condition: only ownership that
// outlives a reclaim is.
if (owned) {
if (reclaimed) return;
continue;
}
try {
await input.apply();
await reload();
reclaimed = true;
} catch {
return;
}
}
}
/**
* Register the three Plannotator commands when the host's draft supports it.
*
* Returns whether the draft accepted them. Note the callback may not have run
* by the time `transform` resolves: during boot the host coalesces transforms
* into one batched reload, so the honest answer arrives a tick later. The
* reclaim loop re-reads the same flag rather than trusting this snapshot.
*/
export async function registerNativeCommands(deps: NativeCommandDeps): Promise<boolean> {
const transform = deps.ctx.command?.transform;
if (typeof transform !== "function") return false;
let supported = false;
const apply = async () => {
await transform((draft: V2CommandDraft) => {
// The ONLY honest capability probe: `transform` exists on hosts whose
// draft is `{ list, get, update, remove }`, where `add` is undefined and
// calling it would throw inside the batched reload flush, aborting it
// before commit and taking every command registration down with it.
if (typeof draft?.add !== "function") return;
supported = true;
for (const command of NATIVE_COMMANDS) {
draft.add({
name: command.name,
description: command.description,
execute: async (invocation) => {
try {
await runNativeCommand(command.name, invocation, deps);
} catch (error) {
// handleCliCommand already logs and swallows everything except a
// prompt-delivery failure. Report that one and stop: rethrowing
// would surface an OpenCode command execution error for feedback
// the reviewer has already given.
console.error(
`[Plannotator] /${command.name} failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
});
}
});
};
await apply();
void reclaimNativeCommands({
ctx: deps.ctx,
apply,
isSupported: () => supported,
wait: deps.wait,
});
return supported;
}