Files
hapi/cli/src/opencode/opencodeRemoteLauncher.ts
T

1228 lines
62 KiB
TypeScript

import React from 'react';
import { randomUUID } from 'node:crypto';
import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle';
import { logger } from '@/ui/logger';
import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
import type { AcpStderrError } from '@/agent/backends/acp/AcpStdioTransport';
import { isAcpStallStderrError } from '@/agent/backends/acp/acpStderrErrors';
import { convertAgentMessage } from '@/agent/messageConverter';
import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types';
import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase';
import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay';
import type { OpencodeSession } from './session';
import type { OpencodeMode, PermissionMode } from './types';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
import { allocateFreePort, createOpencodeBackend } from './utils/opencodeBackend';
import { captureCompactionMarkerSnapshot, fetchCompactionResult, splitProviderModel, triggerOpencodeCompact } from './utils/opencodeCompactBridge';
import { formatOpencodePromptError } from './utils/opencodeErrorText';
import {
formatOpencodeRetryStatus,
subscribeToOpencodeEvents,
type OpencodeEventSubscription,
type OpencodeRetryStatus
} from './utils/opencodeEventStream';
import { OpencodePermissionHandler } from './utils/permissionHandler';
import { getOpencodeNativeToolInstruction, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt';
import { resolveThoughtLevelEffort } from './thoughtLevelEffort';
import { fetchOpenCodeReasoningEffortState, type OpenCodeReasoningEffortState } from './utils/opencodeVariants';
import type { AgentSessionConfigOptionDescriptor } from '@/agent/types';
type OpencodeRemoteLauncherOptions = {
onReasoningEffortRollback?: (effort: string | null) => void;
// Called with `true` once the ACP backend + internal HTTP baseUrl are
// ready (so /compact can actually run) and with `false` whenever this
// session leaves remote mode. runOpencode.ts uses this to decide whether
// a `/compact` message should be queued or immediately answered with a
// "not yet supported" reply — see its `slash.kind === 'compact'` branch.
onCompactAvailabilityChange?: (available: boolean) => void;
// Consumes (delete-and-return) whether the queued item with this localId
// was cancelled via runOpencode.ts's `onCancelQueuedMessage` fallback
// branch (see the comment on `cancelledDequeuedLocalIds` there for what
// that actually covers — in practice a narrow ack-vs-hub-DB-write race,
// not "cancel while the REST call is running"). Checked once the REST
// call (and summary lookup) settles, so a cancelled request's result
// doesn't surface for an action the user no longer expects a reply from.
isLocalIdCancelled?: (localId: string) => boolean;
// Called only after /clear reaches its FIFO position *and* this
// launcher has disconnected its OpenCode backend. The caller then performs
// the source lifecycle cleanup before requesting the fresh process.
onClearRequested?: () => Promise<void>;
onClearCleanupComplete?: () => Promise<void>;
onClearCleanupFailed?: () => Promise<void>;
};
export type AbortStatusDecision = {
message: string;
shouldClearThinking: boolean;
};
type CompactOperationPhase = 'idle' | 'snapshot' | 'summarize' | 'post-summarize' | 'verification';
/**
* Pure decision logic for handleAbort()'s final step: which status message
* to show, and whether `thinking` should be cleared. Extracted out of the
* method itself (which calls this with freshly re-read state, not a
* snapshot from before its awaits — see the call site) so it's unit
* testable without needing to observe `MessageBuffer`/Ink rendering, which
* this file's test harness (`opencodeRemoteLauncher.test.ts`) has no
* infrastructure for.
*
* A compact operation left deliberately running after a plain Stop (see
* `compactResultSuppressed`'s field doc comment on the class) is the one
* case where nothing has actually stopped yet — Stop alone cannot leave
* this remote session, only switch-to-local/exit can, so the message says
* so explicitly rather than leaving the user wondering why the UI still
* looks busy.
*/
export function selectAbortStatusMessage(opts: {
hasCompactInFlight: boolean;
leavingRemote: boolean;
compactAborted: boolean;
}): AbortStatusDecision {
const compactStillWaiting = opts.hasCompactInFlight && !opts.leavingRemote && !opts.compactAborted;
if (compactStillWaiting) {
return {
message: 'Stop requested — waiting for the in-progress compaction to finish on the server. Switch to local or exit to leave immediately.',
shouldClearThinking: false
};
}
return { message: 'Turn aborted', shouldClearThinking: true };
}
class OpencodeRemoteLauncher extends RemoteLauncherBase {
private readonly session: OpencodeSession;
private backend: ReturnType<typeof createOpencodeBackend> | null = null;
/** Loopback base URL of the OpenCode ACP subprocess's internal HTTP API, set once the backend is spawned with an explicit --port/--hostname. */
private baseUrl: string | null = null;
private permissionHandler: OpencodePermissionHandler | null = null;
private happyServer: { stop: () => void } | null = null;
// Becomes true when the FIFO loop reaches /clear. Its callback is deferred
// until cleanup() completes so a failed OpenCode disconnect cannot create a
// replacement while the source backend may still be live.
private clearRequested = false;
private abortController = new AbortController();
// Set by the dequeue loop as soon as a batch is identified as a
// `operation:'compact'` one — deliberately *before* that batch's inline
// model/effort switch runs, not only once runCompactOperation()'s
// triggerOpencodeCompact() REST call actually starts (a hostile-review
// sweep found that creating it any later left a window during that
// switch — a real async ACP round-trip — where an abort had nothing to
// act on yet). Null whenever no compact batch is in flight. Unlike
// `abortController` above (which governs the dequeue loop's
// wait-for-next-message signal), `handleAbort()` needs this to actually
// interrupt the compact's HTTP call(s) — without it, Stop/switch-to-local
// has no way to unblock a dequeued /compact whose REST call is
// deliberately unbounded (see triggerOpencodeCompact's doc comment) and
// the launcher stays wedged until it eventually settles on its own.
private compactAbortController: AbortController | null = null;
// A plain Stop must keep waiting only while the summarize POST is
// actually in flight. That POST can outlive a client-side abort while
// continuing to mutate the shared OpenCode session, so advancing to a
// prompt would violate FIFO. The pre-POST marker snapshot and post-POST
// result verification are read-only GETs; Stop aborts those immediately.
// `compactOperationPhase` makes that distinction explicit for
// handleAbort(), while this flag suppresses every eventual compact result.
private compactResultSuppressed = false;
private compactOperationPhase: CompactOperationPhase = 'idle';
private displayPermissionMode: PermissionMode | null = null;
private instructionsSent = false;
private currentBackendModel: string | null = null;
private defaultBackendModel: string | null = null;
private currentBackendEffort: string | null = null;
private defaultBackendEffort: string | null = null;
private nativeReasoningEffortState: OpenCodeReasoningEffortState | null = null;
private setModelSupported: boolean | undefined = undefined;
private setEffortSupported: boolean | undefined = undefined;
private activeAcpSessionId: string | null = null;
/** Subscription to the agent's own server event stream; null until the ACP session id is known, closed in cleanup(). */
private eventStream: OpencodeEventSubscription | null = null;
private stallErrorReportedForPrompt = false;
constructor(
session: OpencodeSession,
private readonly options: OpencodeRemoteLauncherOptions = {}
) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
}
public async launch(): Promise<RemoteLauncherExitReason> {
return this.start({
onExit: () => this.handleExitFromUi(),
onSwitchToLocal: () => this.handleSwitchFromUi()
});
}
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
return React.createElement(OpencodeDisplay, context);
}
protected async runMainLoop(): Promise<void> {
const session = this.session;
const messageBuffer = this.messageBuffer;
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, {
enableChangeTitle: false,
skillLookup: { workingDirectory: session.path, flavor: 'opencode' }
});
this.happyServer = happyServer;
// Pre-select a loopback port for the ACP subprocess's internal HTTP
// API and pass it explicitly via --port/--hostname. opencode does not
// announce the bound port anywhere (stdout/stderr/ACP responses) when
// launched with --port 0, so HAPI must choose it up front to be able
// to reach that HTTP API later (e.g. for /compact — see
// opencodeCompactBridge.ts).
const hostname = '127.0.0.1';
const port = await allocateFreePort(hostname);
const baseUrl = `http://${hostname}:${port}`;
this.baseUrl = baseUrl;
const backend = createOpencodeBackend({
cwd: session.path,
port,
hostname
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
backend.onStderrError((error) => {
this.handleAcpStderrError(error);
});
await backend.initialize();
const resumeSessionId = session.sessionId;
const mcpServerList = toAcpMcpServers(mcpServers);
let acpSessionId: string;
if (resumeSessionId) {
try {
acpSessionId = await backend.loadSession({
sessionId: resumeSessionId,
cwd: session.path,
mcpServers: mcpServerList
});
} catch (error) {
logger.warn('[opencode-remote] resume failed, starting new session', error);
session.sendSessionEvent({
type: 'message',
message: 'OpenCode resume failed; starting a new session.'
});
acpSessionId = await backend.newSession({
cwd: session.path,
mcpServers: mcpServerList
});
}
} else {
acpSessionId = await backend.newSession({
cwd: session.path,
mcpServers: mcpServerList
});
}
session.onSessionFound(acpSessionId);
this.activeAcpSessionId = acpSessionId;
// Upstream retries are announced only on the agent's own event
// stream — not as ACP notifications and not on stderr. Measured
// against a provider stubbed to answer 429: 40 minutes, 85 retries,
// zero ACP updates, zero stderr bytes, and a prompt that never
// settled. Without this the user watches a session that looks like
// it is thinking and never learns it is rate limited.
//
// Attached here rather than at spawn time because the stream is
// filtered by ACP session id, which only exists once new/loadSession
// has answered. Failing to subscribe degrades that visibility and
// nothing else, so it is deliberately neither awaited nor
// error-checked; the subscription reports its own failures at debug
// level and keeps the session running.
this.eventStream = subscribeToOpencodeEvents({
baseUrl,
// Required. Omitting it yields heartbeats and no session events
// at all, with no error to notice — see the subscription's doc.
directory: session.path,
sessionId: acpSessionId,
onRetry: (retry) => this.surfaceUpstreamRetry(retry)
});
// Seed currentBackendModel from the ACP session metadata so the first
// batch — whose model the hub mirrors from the just-discovered session —
// does not trigger a redundant setModel on the very first turn.
const initialMetadata = backend.getSessionModelsMetadata?.(acpSessionId);
this.currentBackendModel = initialMetadata?.currentModelId ?? null;
this.defaultBackendModel = this.currentBackendModel;
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
this.currentBackendEffort = thoughtLevelOption?.currentValue ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
if (!thoughtLevelOption) {
await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel);
}
// Let the caller (runOpencode.ts) know native /compact can actually
// run now that the ACP backend + internal HTTP baseUrl exist. The
// dequeue loop below (not an externally-invoked trigger) is what
// executes it, in its actual FIFO queue position.
//
// A 9th PR-review round found a race here: a terminal
// switch-to-local/exit can land *during* the newSession/loadSession
// await above (setupTerminal() wires up onExit/onSwitchToLocal
// before runMainLoop() even starts, so this is reachable well
// before setupAbortHandlers() below registers the RPC
// 'abort'/'switch' handlers). RemoteLauncherBase.requestExit()
// already fired onLeavingRemote() (availability(false)) and set
// `this.shouldExit = true` synchronously for that switch/exit,
// before awaiting its handler — but this line used to run
// regardless once initialization finished, resurrecting
// availability(true) even though the session is already on its way
// out. runOpencode.ts's compactSupported/compactTeardownInProgress
// gate treats compactSupported flipping true as reason enough to
// ignore compactTeardownInProgress entirely (see that gate's doc
// comment), so this stray true could let a /compact arriving right
// after slip into the queue mid-teardown. Checking `shouldExit`
// here — the same flag requestExit() already set — keeps
// availability from ever un-flipping once a switch/exit is
// underway.
if (!this.shouldExit) {
this.options.onCompactAvailabilityChange?.(true);
}
// Expose the cached models metadata via per-session RPC so the hub can
// forward it to the web UI's model selector without round-tripping ACP.
session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeModels, async () => {
const metadata = backend.getSessionModelsMetadata?.(acpSessionId);
if (!metadata) {
return { success: false, error: 'OpenCode model metadata is not available' };
}
return {
success: true,
availableModels: metadata.availableModels,
currentModelId: metadata.currentModelId
};
});
session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListOpencodeReasoningEffortOptions, async () => {
const effortOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
const currentModelId = backend.getSessionModelsMetadata?.(acpSessionId)?.currentModelId ?? null;
const requestedModel = this.session.getModel?.();
const targetModelId = requestedModel === null
? this.defaultBackendModel
: requestedModel ?? currentModelId;
if (effortOption) {
return {
success: true,
options: effortOption.options,
currentValue: effortOption.currentValue ?? null,
currentModelId,
targetModelId
};
}
await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel);
const nativeOption = this.nativeReasoningEffortState?.option;
if (!nativeOption) {
return {
success: false,
error: 'OpenCode reasoning effort options are not available',
currentModelId,
targetModelId
};
}
return {
success: true,
options: nativeOption.options,
currentValue: this.currentBackendEffort ?? nativeOption.currentValue ?? null,
currentModelId,
targetModelId
};
});
this.permissionHandler = new OpencodePermissionHandler(
session.client,
backend,
() => session.getPermissionMode() as PermissionMode | undefined
);
this.applyDisplayMode(session.getPermissionMode() as PermissionMode);
this.setupAbortHandlers(session.client.rpcHandlerManager, {
// Explicit `false`: plain Stop stays in this remote session, so
// an in-flight compact must not be aborted client-side — see
// handleAbort's `leavingRemote` doc comment.
onAbort: () => this.handleAbort(false),
onSwitch: () => this.handleSwitchRequest()
});
const sendReady = () => {
session.sendSessionEvent({ type: 'ready' });
};
while (!this.shouldExit) {
const waitSignal = this.abortController.signal;
const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal);
if (!batch) {
if (waitSignal.aborted && !this.shouldExit) {
continue;
}
break;
}
// /clear is deliberately a queue operation rather than a direct
// slash side effect: every prompt and /compact ahead of it has
// completed before this point. In particular, do not route this
// through handleAbort(true): that method exists to interrupt an
// in-flight compact, while clear can only run after one finishes.
if (batch.mode.operation === 'clear') {
await this.options.onClearRequested?.();
this.clearRequested = true;
await this.requestExit('exit', async () => {})
break;
}
// Created here — before the model/effort switch below — rather
// than inside runCompactOperation(), so it already exists for
// handleAbort() to act on during that switch. backend.setModel()/
// setConfigOption() are real async ACP round-trips that yield to
// the event loop; a hostile-review whole-feature sweep found
// that an abort landing in that window used to hit a still-null
// compactAbortController (a no-op) and then get silently
// forgotten once runCompactOperation() created a *fresh*
// controller afterward — the compact's unbounded REST call would
// then run to completion with no way to interrupt it, despite
// the user having already pressed Stop/switch/exit.
const isCompactBatch = batch.mode.operation === 'compact';
const compactAbortController = isCompactBatch ? new AbortController() : null;
if (compactAbortController) {
this.compactAbortController = compactAbortController;
this.compactOperationPhase = 'idle';
// Reset here (as early as the controller itself — see its
// sibling field's doc comment for why that timing matters)
// rather than inside runCompactOperation(), so a plain Stop
// landing during the model/effort switch below already has
// something to suppress.
this.compactResultSuppressed = false;
}
// Inline model change via ACP RPC (session/set_model — see ACP SDK
// schema `x-method: session/set_model`). Mirrors the Gemini pattern
// from PR #543: if the running OpenCode build does not implement the
// RPC, we learn that from the first method-not-found response and stop
// attempting it for the rest of this session.
//
// `batch.mode.model` semantics: a string is a specific model id;
// `null` means "reset to whatever model the backend launched with"
// (emitted by `/model default`); `undefined` means "no change".
const requestedModel = batch.mode.model === null
? this.defaultBackendModel
: batch.mode.model;
// The very first batch seeds currentBackendModel — the OpenCode CLI was
// launched with that model via --model and there is nothing to switch yet.
if (requestedModel && this.currentBackendModel === null) {
this.currentBackendModel = requestedModel;
} else if (requestedModel && requestedModel !== this.currentBackendModel) {
if (!backend.setModel || this.setModelSupported === false) {
batch.mode.model = this.currentBackendModel ?? undefined;
} else {
logger.debug(`[opencode-remote] Switching model inline: ${this.currentBackendModel} -> ${requestedModel}`);
try {
await backend.setModel(acpSessionId, requestedModel, { flavor: 'opencode' });
this.currentBackendModel = requestedModel;
this.setModelSupported = true;
// Reflect the resolved model back into the batch so
// downstream display logic sees the concrete id rather
// than a `null` placeholder.
batch.mode.model = requestedModel;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const methodNotFound = /method not found/i.test(message);
if (methodNotFound && this.setModelSupported === undefined) {
this.setModelSupported = false;
logger.warn('[opencode-remote] OpenCode build does not support session/set_model; inline switching disabled for this session');
session.sendSessionEvent({
type: 'message',
message: 'This OpenCode build does not support inline model switching. Restart the session to apply a different model.'
});
} else {
logger.warn('[opencode-remote] Inline model switch failed', error);
session.sendSessionEvent({
type: 'message',
message: `Failed to switch model to ${requestedModel}. Continuing with ${this.currentBackendModel ?? '(default)'}.`
});
}
batch.mode.model = this.currentBackendModel ?? undefined;
}
}
if (requestedModel === this.currentBackendModel) {
await this.refreshNativeReasoningEffortState(acpSessionId, this.currentBackendModel);
}
}
const requestedEffort = batch.mode.modelReasoningEffort ?? this.defaultBackendEffort;
if (requestedEffort && requestedEffort !== this.currentBackendEffort) {
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
const nativeEffortOption = this.nativeReasoningEffortState
&& this.nativeReasoningEffortState.modelId === this.currentBackendModel
? this.nativeReasoningEffortState.option
: undefined;
const effortOption: AgentSessionConfigOptionDescriptor | undefined = thoughtLevelOption ?? nativeEffortOption;
const nativeVariantSupported = Boolean(nativeEffortOption && backend.promptWithVariant);
if (!effortOption || (!backend.setConfigOption && !nativeVariantSupported) || this.setEffortSupported === false) {
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
} else {
const resolvedEffort = resolveThoughtLevelEffort(
requestedEffort,
effortOption,
this.currentBackendEffort ?? this.defaultBackendEffort
);
if (!resolvedEffort || resolvedEffort === this.currentBackendEffort) {
if (requestedEffort !== resolvedEffort) {
logger.warn(
`[opencode-remote] Unsupported reasoning effort "${requestedEffort}"; continuing with ${resolvedEffort ?? this.currentBackendEffort ?? '(default)'}`
);
this.rollbackReasoningEffort(batch, resolvedEffort ?? this.currentBackendEffort);
}
} else {
logger.debug(`[opencode-remote] Switching effort inline: ${this.currentBackendEffort ?? '(default)'} -> ${resolvedEffort}`);
try {
if (nativeVariantSupported) {
this.currentBackendEffort = resolvedEffort;
this.nativeReasoningEffortState = {
...this.nativeReasoningEffortState!,
currentValue: resolvedEffort,
option: { ...nativeEffortOption!, currentValue: resolvedEffort }
};
} else {
await backend.setConfigOption!(acpSessionId, effortOption.id, resolvedEffort);
this.currentBackendEffort = resolvedEffort;
}
this.setEffortSupported = true;
if (requestedEffort !== resolvedEffort) {
this.rollbackReasoningEffort(batch, resolvedEffort);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const methodNotFound = /method not found/i.test(message);
if (methodNotFound && this.setEffortSupported === undefined) {
this.setEffortSupported = false;
logger.warn('[opencode-remote] OpenCode build does not support session/set_config_option; inline effort switching disabled for this session');
session.sendSessionEvent({
type: 'message',
message: 'This OpenCode build does not support inline reasoning effort switching.'
});
} else {
logger.warn('[opencode-remote] Inline effort switch failed', error);
session.sendSessionEvent({
type: 'message',
message: `Failed to switch reasoning effort to ${resolvedEffort}. Continuing with ${this.currentBackendEffort ?? '(default)'}.`
});
}
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
}
}
}
}
this.applyDisplayMode(batch.mode.permissionMode);
messageBuffer.addMessage(batch.message, 'user');
// /compact reaches here through the exact same dequeue loop as
// any prompt — it was pushed via messageQueue.pushIsolated(...)
// in runOpencode.ts, so it occupies its real FIFO position
// relative to prompts queued before or after it (fixes a prior
// design where /compact ran via an externally-invoked trigger
// and could execute ahead of an already-queued prompt). The
// model/effort switch above already ran for this batch just like
// any other, so compaction runs under whatever model this batch
// resolved to.
if (isCompactBatch && compactAbortController) {
// A compact batch is always a single isolated item (pushed
// via pushIsolated), so its own localId is exactly
// batch.items[0]?.localId.
const compactLocalId = batch.items[0]?.localId;
// A 7th PR-review round found that a plain Stop landing
// *during the model/effort switch above* — before this
// compact's REST request has ever actually been sent — was
// silently ignored here. Plain Stop's handleAbort(false) only
// sets `compactResultSuppressed = true`; it deliberately
// leaves compactAbortController.signal alone (see that
// field's doc comment — Round 6 needs the real HTTP request
// to keep running so the dequeue loop can wait for genuine
// server-side completion). But that logic assumed a request
// was already in flight to wait for. Here, mid-switch, none
// has been sent yet — so this branch used to call
// runCompactOperation() unconditionally once the switch
// resolved anyway, starting a brand new REST request the
// instant a cancelled compact's turn came up and blocking
// the dequeue loop for however long that takes.
//
// The fix is narrow on purpose: skip starting the operation
// only when a plain Stop landed (compactResultSuppressed)
// AND the controller was never actually aborted. If the
// controller WAS aborted, that means switch/exit's
// handleAbort(true) ran instead — and Round 5's test
// (below) established that runCompactOperation() must still
// be called in that case, threading the pre-aborted signal
// through so the fetch call rejects immediately without any
// network I/O, rather than being skipped here.
//
// An 8th PR-review round found this same reasoning also
// applies to isLocalIdCancelled, which round 7 had
// deliberately left out of this check (see runOpencode.ts's
// preparingLocalIds/cancelledBeforeEnqueue doc comment for
// the full mechanism): the localId-keyed cancel Set it reads
// can *only* ever be populated during the brief network
// round trip between the CLI emitting the /compact item's
// "invoked" ack and the hub recording it — never while a
// compact REST call is actually running. So if
// isLocalIdCancelled(compactLocalId) is already true here,
// that unconditionally means this compact was cancelled
// before its REST request was ever sent, exactly like the
// compactResultSuppressed case above — there's no
// in-flight server-side work to preserve by starting the
// operation anyway. (isLocalIdCancelled is a delete-and-
// return, one-shot callback, so checking it here consumes
// the same entry runCompactOperation()'s own isCancelled()
// would otherwise have consumed — it isn't checked twice.)
const compactCancelledByLocalId = compactLocalId
? (this.options.isLocalIdCancelled?.(compactLocalId) ?? false)
: false;
const cancelledBeforeStart =
(this.compactResultSuppressed && !compactAbortController.signal.aborted)
|| compactCancelledByLocalId;
if (cancelledBeforeStart) {
if (this.compactAbortController === compactAbortController) {
this.compactAbortController = null;
this.compactOperationPhase = 'idle';
}
// A 10th PR-review round found this skip path never
// calls session.onThinkingChange(true) (that's the
// whole point of skipping) but also never told the hub
// this queued item is done, leaving the web UI spinner
// stuck: markMessageQueued's 15s "queued thinking"
// grace (hub/src/sync/sessionCache.ts) keeps thinking
// pinned true regardless of keepalives until either the
// grace expires or a messages-consumed ack with
// `clearQueuedThinkingGrace` arrives. Same situation,
// same fix, as the synchronous slash.kind === 'handled'
// path in runOpencode.ts (e.g. /model — see its
// `clearQueuedThinkingGrace` comment there): ack with
// the grace-clearing flag, then push an immediate
// thinking=false keepalive so the spinner clears
// without waiting on the grace. (This is on top of, not
// instead of, the queue's own unflagged
// onBatchConsumed ack — a second ack for an
// already-invoked localId is a no-op on the hub's
// first-write-wins queued-message protocol, and
// clearQueuedThinkingGrace itself is keyed by session,
// not by localId, so it's idempotent too.)
if (compactLocalId) {
session.client.emitMessagesConsumed([compactLocalId], { clearQueuedThinkingGrace: true });
}
// Plain Stop before summarize already emitted this
// keepalive in handleAbort(); localId cancellation did not.
if (!this.compactResultSuppressed) {
session.onThinkingChange(false);
}
if (session.queue.size() === 0 && !this.shouldExit) {
sendReady();
}
continue;
}
session.onThinkingChange(true);
try {
await this.runCompactOperation(acpSessionId, compactAbortController, compactLocalId);
} finally {
session.onThinkingChange(false);
if (session.queue.size() === 0 && !this.shouldExit) {
sendReady();
}
}
continue;
}
// Inject title instructions on first prompt
let messageText = batch.message;
if (batch.mode.permissionMode === 'plan') {
messageText = `${PLAN_MODE_INSTRUCTION}\n\n${messageText}`;
}
if (!this.instructionsSent) {
messageText = `${getOpencodeNativeToolInstruction()}\n\n${messageText}`;
this.instructionsSent = true;
}
const promptContent: PromptContent[] = [{
type: 'text',
text: messageText,
}];
this.stallErrorReportedForPrompt = false;
session.onThinkingChange(true);
try {
const nativeVariant = this.nativeReasoningEffortState
&& this.nativeReasoningEffortState.modelId === this.currentBackendModel
? this.currentBackendEffort
: null;
const nativeModel = splitProviderModel(this.currentBackendModel);
if (nativeVariant && nativeModel && backend.promptWithVariant && this.baseUrl) {
await backend.promptWithVariant(acpSessionId, promptContent, (message: AgentMessage) => {
this.handleAgentMessage(message);
}, {
baseUrl: this.baseUrl,
directory: session.path,
providerId: nativeModel.providerId,
modelId: nativeModel.modelId,
variant: nativeVariant
});
} else {
await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => {
this.handleAgentMessage(message);
});
}
void backend.refreshSessionInfo(acpSessionId, session.path);
} catch (error) {
logger.warn('[opencode-remote] prompt failed', error);
this.reportPromptFailure(error);
} finally {
session.onThinkingChange(false);
await this.permissionHandler?.cancelAll('Prompt finished');
if (session.queue.size() === 0 && !this.shouldExit) {
sendReady();
}
}
}
}
/**
* /compact must stop being offered the instant remote mode starts
* leaving — not merely by the time it's actually torn down, and
* critically not only on the *next* local-mode entry (the previous
* mechanism, in loop.ts's `runLocal:` callback). That gap between "a
* switch/exit was requested" and "the next runLocal() call reset this"
* is exactly the window a PR-review round found: a /compact slash
* command arriving in it still queues normally (runOpencode.ts's
* `compactSupported` flag hadn't flipped yet), and since local mode
* immediately hands back to remote when it finds a non-empty queue, that
* queued compact can end up running anyway — despite the user having
* already asked to leave remote mode. See onLeavingRemote()'s doc
* comment on RemoteLauncherBase for exactly when this fires.
*/
protected onLeavingRemote(): void {
this.options.onCompactAvailabilityChange?.(false);
}
protected async cleanup(): Promise<void> {
// Before anything that can fail: a stream left open would keep
// reconnecting to a subprocess that is on its way out.
this.eventStream?.close();
this.eventStream = null;
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
const failures: unknown[] = [];
if (this.permissionHandler) {
try {
await this.permissionHandler.cancelAll('Session ended');
} catch (error) {
failures.push(error);
} finally {
this.permissionHandler = null;
}
}
if (this.backend) {
try {
await this.backend.disconnect();
} catch (error) {
failures.push(error);
} finally {
this.backend = null;
}
}
if (this.happyServer) {
try {
this.happyServer.stop();
} catch (error) {
failures.push(error);
} finally {
this.happyServer = null;
}
}
if (failures.length > 0) {
if (this.clearRequested) await this.options.onClearCleanupFailed?.();
throw failures.length === 1 ? failures[0] : new AggregateError(failures, 'OpenCode cleanup failed');
}
if (this.clearRequested) await this.options.onClearCleanupComplete?.();
// Signal the runner only after the native backend is gone. If an
// awaited teardown above fails, RemoteLauncherBase propagates that
// failure and this callback never runs; runOpencode then archives the
// source as an error rather than spawning a potentially concurrent
// replacement.
}
private handleAcpStderrError(error: AcpStderrError): void {
logger.debug('[opencode-remote] stderr error', error);
const isStall = isAcpStallStderrError(error)
&& this.backend?.isPromptRequestInFlight() === true;
if (isStall && this.stallErrorReportedForPrompt) {
return;
}
if (isStall) {
this.stallErrorReportedForPrompt = true;
}
this.surfaceAgentError(error.message);
if (isStall) {
void this.clearStalledPrompt();
}
}
/**
* Reports an upstream retry as progress, not as a failure: the agent is
* still working and will try again on its own.
*
* Sent as the `api_error` system message Claude sessions already use for
* the same situation, rather than as a new message type, so the web
* timeline folds consecutive retries into a single block instead of
* stacking one warning per attempt (`foldApiErrorEvents` in
* web/src/chat/reducerEvents.ts). `maxRetries` is 0 because OpenCode
* announces no ceiling — it retries until the provider lets it through
* (its own issue tracker has the missing circuit breaker open) — and the
* presentation already has a branch for exactly that. The provider's own
* text rides in `error` so the reader learns *why* the session is
* waiting, which is the entire point; without it the timeline says only
* "Retrying...".
*
* `thinking` is deliberately untouched. During a retry the session
* really is busy, and the hub composes the flag it shows from what this
* session reports plus its own queue grace
* (`hub/src/sync/sessionCache.ts`) — this side reports what is true and
* does not reach across that seam.
*/
private surfaceUpstreamRetry(retry: OpencodeRetryStatus): void {
const text = formatOpencodeRetryStatus(retry);
this.session.client.sendClaudeSessionMessage({
type: 'system',
uuid: randomUUID(),
subtype: 'api_error',
retryAttempt: retry.attempt,
maxRetries: 0,
error: { message: text }
});
this.messageBuffer.addMessage(text, 'status');
}
/**
* Reports a rejected `session/prompt` to the user in the provider's own
* words.
*
* Nothing had to be detected to make this possible: `AcpStdioTransport`
* already rejects the pending request with the JSON-RPC `error.message`
* verbatim, and this catch used to log it and hand the user a fixed
* "check logs for details" line instead. A remote user is by definition
* not at the machine holding those logs.
*
* This reports unconditionally, and that is a decision rather than an
* omission. On a hard error OpenCode also dumps the same JSON-RPC error
* onto stderr, which the shared ACP reader reports a line at a time, so
* the user sees three raw fragments and then this one sentence. An
* earlier revision tried to suppress the sentence as a duplicate; do
* not add that back:
*
* - The fragments are a JSON dump split at newlines. This sentence is
* the only line of the four a person can read. It follows them as a
* summary, which is not the same thing as a repeat.
* - Suppressing on containment deleted it in every measured hard error,
* because a fragment embeds it as a substring; suppressing on exact
* equivalence never fired at all, because the fragment and this
* sentence are differently worded. There was no observed case where
* the check both fired and was right.
* - Doing it at all needs a per-turn ledger of what has been shown, and
* the stderr path has no turn boundary of its own to reset it on: a
* /compact batch never reaches this loop's per-prompt reset, so the
* ledger leaked across batches and silently muted the stderr channel
* for the rest of such a session. That channel discarded nothing
* before this branch existed and should keep discarding nothing.
*/
private reportPromptFailure(error: unknown): void {
this.surfaceAgentError(formatOpencodePromptError(error));
}
private async refreshNativeReasoningEffortState(
acpSessionId: string,
modelId: string | null
): Promise<void> {
const baseUrl = this.baseUrl;
if (!baseUrl || !modelId) {
this.nativeReasoningEffortState = null;
return;
}
const state = await fetchOpenCodeReasoningEffortState({
baseUrl,
directory: this.session.path,
sessionId: acpSessionId,
modelId
});
this.nativeReasoningEffortState = state;
if (!state) return;
// The native session is authoritative. A variant may have been
// changed in OpenCode itself between HAPI polls, so refresh both the
// current and launch-time fallback values from the live session.
this.currentBackendEffort = state.currentValue ?? state.option.options[0]?.value ?? null;
this.defaultBackendEffort = this.currentBackendEffort;
// A model switch can move from an ACP-only effort option to native
// variants (or vice versa); do not retain a previous capability probe.
this.setEffortSupported = undefined;
}
private surfaceAgentError(message: string): void {
this.session.sendAgentMessage({ type: 'error', message });
this.messageBuffer.addMessage(message, 'status');
}
private async clearStalledPrompt(): Promise<void> {
const backend = this.backend;
const sessionId = this.activeAcpSessionId;
if (!backend || !sessionId) {
return;
}
this.session.onThinkingChange(false);
try {
await backend.cancelPrompt(sessionId);
} catch (error) {
logger.debug('[opencode-remote] cancelPrompt after stderr failed', error);
}
}
private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void {
batch.mode.modelReasoningEffort = effort;
this.session.setModelReasoningEffort(effort);
this.session.pushKeepAlive();
this.options.onReasoningEffortRollback?.(effort);
}
/**
* Executes the /compact operation for a queued `operation:'compact'`
* batch. Reached only through the main dequeue loop (so it never runs
* concurrently with a prompt turn — see the loop's doc comment), which
* is also why this needs no timeout/mutex of its own despite the REST
* call it makes potentially taking several minutes.
*
* `localId` is used to detect a cancel that runOpencode.ts's
* `isLocalIdCancelled` reports for this item (see its declaration there
* for the real — and narrow — race window that covers) — checked at each
* point below right before a result would be shown, same as the
* pre-redesign behavior where this was a single `wasCancelled()` check
* after one combined async trigger(). "Compaction started" itself is
* never suppressed (it wasn't before either).
*
* Separately, `compactAbortController`/`compactResultSuppressed` cover a
* different case: Stop/switch-to-local firing *while the REST call is
* actually in flight*, which `isLocalIdCancelled` cannot — that
* mechanism only ever observes a cancel for this item's *queue message*,
* and by this point the item has already been dequeued. `isCancelled()`
* below checks all three, so any kind of cancellation suppresses the
* eventual result the same way. A plain Stop aborts only the read-only
* snapshot/verification GET phases; while the summarize POST itself is
* in flight it leaves the signal alone and waits for real server-side
* completion, preserving the shared-session FIFO invariant. Switch/exit
* aborts every phase because teardown disconnects the session.
*
* `compactAbortController` is created by the caller (the dequeue loop),
* not here, and passed in — deliberately, before the loop's model/effort
* switch for this batch runs, not after. A hostile-review whole-feature
* sweep found that creating it in here (i.e. only once this function was
* actually entered) left a window during that switch — a real async ACP
* round-trip — where an abort had nothing to act on yet (`this
* .compactAbortController` was still null) and was silently lost by the
* time this function created a *fresh* controller afterward.
*/
private async runCompactOperation(
acpSessionId: string,
compactAbortController: AbortController,
localId?: string
): Promise<void> {
const session = this.session;
session.sendSessionEvent({ type: 'message', message: '📦 Compaction started' });
try {
const isCancelled = (): boolean =>
(localId ? (this.options.isLocalIdCancelled?.(localId) ?? false) : false)
|| compactAbortController.signal.aborted
|| this.compactResultSuppressed;
const backend = this.backend;
const baseUrl = this.baseUrl;
if (!baseUrl || !backend) {
if (!isCancelled()) {
session.sendSessionEvent({
type: 'message',
message: '📦 Compaction failed: OpenCode internal HTTP API base URL is not available.'
});
}
return;
}
const metadata = backend.getSessionModelsMetadata?.(acpSessionId);
const split = splitProviderModel(metadata?.currentModelId ?? this.currentBackendModel);
if (!split) {
if (!isCancelled()) {
session.sendSessionEvent({
type: 'message',
message: '📦 Compaction failed: OpenCode model metadata is not available; cannot determine provider/model for compaction.'
});
}
return;
}
if (isCancelled()) {
logger.debug('[opencode-remote] /compact skipped before marker snapshot: cancelled or aborted');
return;
}
// The pre-POST marker snapshot is a read-only GET. A plain Stop
// aborts it because no summarize request has started yet.
this.compactOperationPhase = 'snapshot';
const markerSnapshot = await captureCompactionMarkerSnapshot({
baseUrl,
sessionId: acpSessionId,
signal: compactAbortController.signal
});
if (isCancelled()) {
logger.debug('[opencode-remote] /compact skipped after marker snapshot: cancelled or aborted');
return;
}
// Suppressed: OpenCode keeps streaming session/update notifications
// (agent_thought_chunk etc.) over the ACP transport while this raw
// HTTP call runs — with no prompt() turn in flight to own them, they
// would otherwise leak into the previous turn's still-installed
// onUpdate and render as a duplicate assistant message alongside the
// explicit summary we show below (from fetchCompactionResult).
// See AcpSdkBackend.suppressUpdatesDuring's doc comment.
//
// The summarize POST can keep mutating the shared session after a
// client abort, so a plain Stop deliberately waits only in this
// phase. It has no deadline because real compaction can take minutes.
this.compactOperationPhase = 'summarize';
const requestResult = await backend.suppressUpdatesDuring(async () => {
try {
return await triggerOpencodeCompact({
baseUrl,
sessionId: acpSessionId,
providerId: split.providerId,
modelId: split.modelId,
signal: compactAbortController.signal
});
} finally {
// suppressUpdatesDuring may still quiet-drain after the
// POST settles. It is no longer server-side compaction.
this.compactOperationPhase = 'post-summarize';
}
});
if (!requestResult.ok) {
if (!isCancelled()) {
session.sendSessionEvent({ type: 'message', message: `📦 Compaction failed: ${requestResult.error}` });
} else {
logger.debug('[opencode-remote] /compact failure suppressed: cancelled or aborted before it resolved');
}
return;
}
if (isCancelled()) {
logger.debug('[opencode-remote] /compact verification skipped: cancelled or aborted after summarize');
return;
}
// The persisted-result lookup is also read-only: once summarize
// returned, a plain Stop can abort it without concurrent session work.
this.compactOperationPhase = 'verification';
const result = await fetchCompactionResult({
baseUrl,
sessionId: acpSessionId,
markerIdsBefore: markerSnapshot?.markerIds ?? null,
signal: compactAbortController.signal
});
if (isCancelled()) {
logger.debug('[opencode-remote] /compact result suppressed: cancelled or aborted before it resolved');
return;
}
switch (result.status) {
case 'success': {
session.sendSessionEvent({ type: 'message', message: '📦 Compaction completed' });
const converted = convertAgentMessage({ type: 'reasoning', text: result.text, id: randomUUID() });
if (converted) {
session.sendAgentMessage(converted);
}
return;
}
case 'failed':
session.sendSessionEvent({ type: 'message', message: `📦 Compaction failed: ${result.reason}` });
return;
case 'unverified':
session.sendSessionEvent({ type: 'message', message: '📦 Compaction result could not be verified.' });
return;
}
} finally {
// Defensive: only clear if this is still the controller we set —
// mirrors the same "don't clobber a newer value" guard as
// AcpSdkBackend.suppressUpdatesDuring's restore. In practice this
// is always still the same instance, since compact runs
// serialized through the single dequeue loop (never concurrently
// with another runCompactOperation call).
if (this.compactAbortController === compactAbortController) {
this.compactAbortController = null;
this.compactOperationPhase = 'idle';
}
}
}
private handleAgentMessage(message: AgentMessage): void {
const converted = convertAgentMessage(message, this.currentBackendModel);
if (converted) {
this.session.sendAgentMessage(converted);
}
switch (message.type) {
case 'text':
this.messageBuffer.addMessage(message.text, 'assistant');
break;
case 'reasoning':
if (message.live) {
break;
}
this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system');
break;
case 'tool_call':
this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool');
break;
case 'tool_result':
this.messageBuffer.addMessage('Tool result received', 'result');
break;
case 'usage':
break;
case 'plan':
this.messageBuffer.addMessage('Plan updated', 'status');
break;
case 'error':
this.messageBuffer.addMessage(message.message, 'status');
break;
case 'generated_image':
this.messageBuffer.addMessage(`Generated image: ${message.fileName}`, 'assistant');
break;
case 'turn_complete':
this.messageBuffer.addMessage('Turn complete', 'status');
break;
default: {
const _exhaustive: never = message;
return _exhaustive;
}
}
}
private applyDisplayMode(permissionMode: PermissionMode | undefined): void {
if (permissionMode && permissionMode !== this.displayPermissionMode) {
this.displayPermissionMode = permissionMode;
this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system');
}
}
/**
* `leavingRemote` distinguishes plain Stop (`false`, the default — stays
* in the same remote session) from switch-to-local/exit (`true` — the
* session is being torn down). A 6th PR-review round rejected an earlier
* fix (always aborting `compactAbortController` here) because it broke
* this feature's core invariant: compact and a prompt must never touch
* the same OpenCode session concurrently (see
* `compactResultSuppressed`'s field doc comment for the full
* reasoning). Plain Stop aborts only read-only marker/result GETs. It
* leaves an in-flight summarize POST running so the dequeue loop waits
* for its real server-side completion; only that phase can still mutate
* the shared session. Switch/exit aborts every phase because cleanup()
* disconnects the ACP subprocess right after.
*/
private async handleAbort(leavingRemote = false): Promise<void> {
// A hostile-review sweep found that a plain Stop during an in-flight
// compact — which deliberately leaves the operation running for real
// (see compactResultSuppressed's doc comment) — still unconditionally
// flipped `thinking` off and reported "Turn aborted" below, telling
// the user the turn had stopped while the dequeue loop was actually
// still blocked inside runCompactOperation() for however long the
// real server-side compaction takes (potentially minutes). Track
// that specific case so the messaging stays honest: nothing has
// actually stopped yet from the user's perspective, and the dequeue
// loop's own `finally` (once runCompactOperation() genuinely
// returns) remains the sole source of truth for when this turn is
// done.
const compactAbortController = this.compactAbortController;
if (compactAbortController) {
this.compactResultSuppressed = true;
// Only summarize can still mutate the shared OpenCode session.
// Plain Stop aborts snapshot/verification reads, but deliberately
// waits for an in-flight POST to complete server-side.
if (
leavingRemote
|| this.compactOperationPhase === 'snapshot'
|| this.compactOperationPhase === 'post-summarize'
|| this.compactOperationPhase === 'verification'
) {
compactAbortController.abort();
}
}
const backend = this.backend;
if (backend && this.session.sessionId) {
await backend.cancelPrompt(this.session.sessionId);
}
await this.permissionHandler?.cancelAll('User aborted');
this.session.queue.reset();
this.abortController.abort();
this.abortController = new AbortController();
// Re-read here (not the snapshot taken above, before the awaits) in
// case a concurrent leavingRemote=true call for the same compact
// interleaved with this one and already aborted it — RPC dispatch
// doesn't serialize handleAbort() calls against each other, so a
// Stop immediately followed by a switch-to-local can genuinely
// overlap. Without this, the (now-stale) plain-Stop continuation
// could append its "still waiting" message after the switch's
// "Turn aborted" already ran, showing the two in a confusing order.
const activeCompactAbortController = this.compactAbortController;
const decision = selectAbortStatusMessage({
hasCompactInFlight: activeCompactAbortController !== null && this.compactOperationPhase === 'summarize',
leavingRemote,
compactAborted: activeCompactAbortController?.signal.aborted ?? false
});
if (decision.shouldClearThinking) {
this.session.onThinkingChange(false);
}
this.messageBuffer.addMessage(decision.message, 'status');
}
private async handleExitFromUi(): Promise<void> {
await this.requestExit('exit', () => this.handleAbort(true));
}
private async handleSwitchFromUi(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort(true));
}
private async handleSwitchRequest(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort(true));
}
}
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
return Object.entries(config).map(([name, entry]) => ({
name,
command: entry.command,
args: entry.args,
env: []
}));
}
export async function opencodeRemoteLauncher(
session: OpencodeSession,
options: OpencodeRemoteLauncherOptions = {}
): Promise<'switch' | 'exit'> {
const launcher = new OpencodeRemoteLauncher(session, options);
return launcher.launch();
}