Files
hapi/cli/src/opencode/opencodeRemoteLauncher.ts
T
1761b696f7 feat: add cache-aware token usage dashboard (#1338)
* feat: add cache-aware token usage dashboard

Track normalized Claude, Codex, and ACP usage with incremental SQLite backfill. Exclude imported transcript history, rebuild usage after history rewrites, and expose an owner-only dashboard with cache-aware totals and breakdowns.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: preserve usage model and local dates

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix: normalize cached usage and timezone buckets

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-08-03 18:02:26 +08:00

908 lines
47 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 { 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 { fetchCompactionSummary, splitProviderModel, triggerOpencodeCompact } from './utils/opencodeCompactBridge';
import { OpencodePermissionHandler } from './utils/permissionHandler';
import { OPENCODE_NATIVE_TOOL_INSTRUCTION, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt';
import { resolveThoughtLevelEffort } from './thoughtLevelEffort';
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;
};
export type AbortStatusDecision = {
message: string;
shouldClearThinking: boolean;
};
/**
* 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;
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;
// True from the moment handleAbort() observes a compact operation in
// flight until the dequeue loop creates the next one. A 6th PR-review
// round found that unconditionally aborting `compactAbortController` on
// *plain* Stop (not just switch/exit) broke a core invariant this
// feature's whole redesign (see the FIFO-queue comment on the dequeue
// loop) depends on: compact and a prompt must never touch the same
// OpenCode session at once. Aborting only unblocks the *client's* fetch
// — `session/update` notifications are a separate channel from that
// HTTP request's lifecycle (see AcpSdkBackend.suppressUpdatesDuring's
// doc comment), so the agent can still be compacting server-side well
// after the client gives up, and the quiet-drain there (bounded at
// ~1.2s) is not a real guarantee that a multi-minute server-side
// compaction has actually finished. If the dequeue loop moved on to a
// prompt as soon as the client-side abort settled, that prompt could
// run concurrently with a compaction still touching the same session.
//
// The fix: plain Stop only sets this flag (suppressing the eventual
// result) and leaves `compactAbortController` alone, so
// runCompactOperation()'s own awaits keep blocking the dequeue loop
// until the *real* HTTP response arrives — i.e. until the server
// actually finishes. Switch-to-local/exit still abort the controller for
// real (see handleAbort's `leavingRemote` parameter) because cleanup()
// is about to disconnect the whole ACP subprocess regardless, so there's
// no session left to protect.
private compactResultSuppressed = false;
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 setModelSupported: boolean | undefined = undefined;
private setEffortSupported: boolean | undefined = undefined;
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);
this.baseUrl = `http://${hostname}:${port}`;
const backend = createOpencodeBackend({
cwd: session.path,
port,
hostname
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
backend.onStderrError((error) => {
logger.debug('[opencode-remote] stderr error', error);
session.sendSessionEvent({ type: 'message', message: error.message });
messageBuffer.addMessage(error.message, 'status');
});
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);
// 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;
// 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);
if (!effortOption) {
return { success: false, error: 'OpenCode reasoning effort options are not available' };
}
return {
success: true,
options: effortOption.options,
currentValue: effortOption.currentValue ?? null
};
});
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;
}
// 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;
// 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;
}
}
}
const requestedEffort = batch.mode.modelReasoningEffort ?? this.defaultBackendEffort;
if (requestedEffort && requestedEffort !== this.currentBackendEffort) {
const thoughtLevelOption = backend.getThoughtLevelConfigOption?.(acpSessionId);
if (!backend.setConfigOption || !thoughtLevelOption || this.setEffortSupported === false) {
this.rollbackReasoningEffort(batch, this.currentBackendEffort);
} else {
const resolvedEffort = resolveThoughtLevelEffort(
requestedEffort,
thoughtLevelOption,
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 {
await backend.setConfigOption(acpSessionId, thoughtLevelOption.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;
}
// 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 });
}
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 = `${OPENCODE_NATIVE_TOOL_INSTRUCTION}\n\n${messageText}`;
this.instructionsSent = true;
}
const promptContent: PromptContent[] = [{
type: 'text',
text: messageText
}];
session.onThinkingChange(true);
try {
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);
session.sendSessionEvent({
type: 'message',
message: 'OpenCode prompt failed. Check logs for details.'
});
messageBuffer.addMessage('OpenCode prompt failed', 'status');
} 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> {
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
if (this.permissionHandler) {
await this.permissionHandler.cancelAll('Session ended');
this.permissionHandler = null;
}
if (this.backend) {
await this.backend.disconnect();
this.backend = null;
}
if (this.happyServer) {
this.happyServer.stop();
this.happyServer = null;
}
}
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 — but only switch/exit (`leavingRemote`
* in handleAbort()) actually aborts `compactAbortController.signal`; a
* plain Stop sets `compactResultSuppressed` alone and deliberately
* leaves the signal un-aborted, so this function's own awaits below keep
* blocking the dequeue loop until the operation *really* finishes
* server-side — see `compactResultSuppressed`'s field doc comment for
* why that invariant matters.
*
* `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;
}
// 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 fetchCompactionSummary).
// See AcpSdkBackend.suppressUpdatesDuring's doc comment.
//
// `signal` lets handleAbort() interrupt this specific call (see
// compactAbortController's field doc comment) — triggerOpencodeCompact
// otherwise has no deadline by design, since a real compaction can
// legitimately take minutes.
const result = await backend.suppressUpdatesDuring(() => triggerOpencodeCompact({
baseUrl,
sessionId: acpSessionId,
providerId: split.providerId,
modelId: split.modelId,
signal: compactAbortController.signal
}));
if (!result.ok) {
if (!isCancelled()) {
session.sendSessionEvent({ type: 'message', message: `📦 Compaction failed: ${result.error}` });
} else {
logger.debug('[opencode-remote] /compact failure suppressed: cancelled or aborted before it resolved');
}
return;
}
// Best-effort: fetch the actual summary text OpenCode generated
// before the final cancellation check, so a cancel landing anywhere
// during this whole operation (REST call or summary lookup)
// suppresses "Compaction completed" and the Reasoning block
// together — this mirrors the pre-redesign behavior, where both were
// produced by one combined async step checked once. `signal` is
// required on this call (see OpencodeCompactCallOpts) for exactly
// the reason a prior PR-review round flagged as missing here: the
// POST above being interruptible isn't enough on its own if this
// GET can still block Stop/switch-to-local for as long as it takes.
const summary = await fetchCompactionSummary({ baseUrl, sessionId: acpSessionId, signal: compactAbortController.signal });
if (isCancelled()) {
logger.debug('[opencode-remote] /compact result suppressed: cancelled or aborted before it resolved');
return;
}
session.sendSessionEvent({ type: 'message', message: '📦 Compaction completed' });
if (summary.found) {
const converted = convertAgentMessage({ type: 'reasoning', text: summary.text, id: randomUUID() });
if (converted) {
session.sendAgentMessage(converted);
}
}
} 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;
}
}
}
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 '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 now only suppresses the eventual result and
* leaves the compact operation's REST call running for real — the
* dequeue loop stays blocked on it until the server actually finishes,
* exactly as it does for an un-aborted turn. Switch/exit still abort it
* for real: `cleanup()` disconnects the whole ACP subprocess right
* after, so there is no shared-session invariant left to protect and
* responsiveness (fixed in an earlier round) matters more.
*/
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;
if (leavingRemote) {
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 decision = selectAbortStatusMessage({
hasCompactInFlight: compactAbortController !== null,
leavingRemote,
compactAborted: compactAbortController?.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();
}