feat(cli): bridge OpenCode native compaction via internal REST API (#1252)

* feat(cli): allocate a loopback port for the OpenCode ACP subprocess

opencode does not announce which port it bound when launched with
--port 0 (verified: not present in stdout/stderr even at DEBUG level),
so pick a free loopback port ourselves and pass it explicitly via
--port/--hostname. This makes the ACP subprocess's internal HTTP API
reachable at a known baseUrl for follow-up work (native /compact
bridging).

* feat(cli): add REST bridge for OpenCode native session compaction

opencode's ACP method table has no session/compact RPC, but the
opencode acp subprocess also runs an internal HTTP API. That API's
POST /api/session/:id/compact route is an unimplemented v2 stub
(always 503); the route that actually performs native AI compaction
is the legacy POST /session/:id/summarize, which requires providerID
and modelID in its payload. This adds a small client for that route
plus a helper to split ACP's combined "provider/model" wire id.

Not wired into the slash-command flow yet.

* feat(cli): trigger native OpenCode compaction from /compact

Wires the REST bridge into the OpenCode slash-command flow so /compact
performs real context compaction instead of returning a "not yet
supported" message.

- slashCommands.ts: /compact now resolves to its own `kind: 'compact'`
  (the synchronous 'handled' shape can't carry an async REST round
  trip). /clear is unchanged.
- opencodeRemoteLauncher.ts: registers a compact trigger callback once
  the ACP backend + internal HTTP baseUrl are ready, reading the
  session's current provider/model on every call so it reflects
  inline model switches. A `runExclusive` promise-chain mutex
  serializes the compact trigger against `backend.prompt()` so a
  still-in-flight prompt and a `/compact` sent moments later can't
  race against the same OpenCode session concurrently, in either
  arrival order.
- runOpencode.ts: on /compact, emits "Compaction started" as a session
  event (the same `sendSessionEvent({ type: 'message', ... })` status-
  line channel Claude/Codex already use for compaction and other
  transient state, rather than a chat message), awaits the bridge with
  no artificial timeout (compaction on a reasoning model can take
  90s+), then emits "Compaction completed" or "Compaction failed:
  <reason>" the same way. Falls back to the previous not-yet-supported
  chat message when no trigger is registered (local mode has no ACP
  backend, so this is unreachable there today). If the user cancels
  the message while compaction is queued or in flight, the eventual
  result is suppressed instead of surfacing a stray status message
  for an action the user already considered cancelled (aborting the
  in-flight HTTP call itself is left as follow-up scope).
- help text now notes /compact is remote-sessions only, since local
  mode has no ACP backend to bridge through.

Also fixes a runOpencode.test.ts fixture gap: the mocked
OpencodeSession was missing onThinkingChange, so any exception in that
call path was silently swallowed by the handler's outer catch instead
of failing the test.

* feat(cli): show compaction summary as a reasoning block

After a native OpenCode compaction succeeds, OpenCode's session
history gains a `role: "user"` marker message (a `{type:"compaction"}`
part with no text) followed by a normal assistant message holding the
actual generated summary in a `text` part. Left alone, neither is
visible in HAPI — the bridge only checked success/failure and never
looked at the resulting messages.

- opencodeCompactBridge.ts: `fetchCompactionSummary()` does one more
  GET against the session's message list after a successful compact,
  finds the most recent `type:"compaction"` marker, and extracts the
  following assistant message's text — preferring a match via the
  marker's `parentID` (order-safe) and falling back to simple array
  adjacency, both gated on `role === 'assistant'` so an unrelated
  same-shaped sibling can't be silently misattributed as the summary.
  Concatenates every `text` part in case a summary spans more than
  one. Never throws: any unexpected shape or request failure just
  yields "not found" so the caller can skip showing anything.
- opencodeRemoteLauncher.ts / runOpencode.ts: on a successful compact,
  forward the extracted summary as a `{ type: 'reasoning', text, id }`
  AgentMessage through the same `handleAgentMessage()` /
  `convertAgentMessage()` path OpenCode's own live thought-chunk
  streaming already uses, so it renders in the existing collapsible
  "Reasoning" block — no new UI component or schema field. The
  `role:"user"` marker is never constructed or forwarded at all, so
  there's nothing to filter (unlike Claude's compact summary, which
  is hidden after the fact via an `isCompactSummary` flag).

* fix(cli): disable Bun's hardcoded fetch timeout for OpenCode compaction

Isolated E2E against a real OpenCode session showed compaction always
failing with "Compaction failed: The operation timed out." after
~250s, even though session/prompt-style unlimited waits were intended.
Root cause: Bun's global fetch() hardcodes an internal ~5 minute
timeout that fires independently of any AbortSignal (or its absence) —
see oven-sh/bun#16682. The only documented workaround is passing the
non-standard `timeout: false` fetch option that Bun itself recognizes.

Cast through a local `BunFetchInit = RequestInit & { timeout?: false }`
type alias rather than `Record<string, unknown>`, so the option stays
structurally checked against the rest of the fetch call's shape.

* fix(cli): serialize /compact through the message queue instead of a mutex

HAPI Bot flagged two Major correctness issues on the PR:

- `/compact` bypassed `MessageQueue2` and entered a `runExclusive` mutex
  directly from the user-message handler. If prompt A was in flight and
  prompt B already queued behind it, `/compact` sent afterward could
  still reach the mutex before B did, running compaction ahead of an
  earlier-queued user prompt.
- `compactTriggerRef` (a closure capturing the remote backend) was
  never cleared on a remote→local handoff, so `/compact` typed after
  switching to local mode could call a stale closure targeting an
  already-disposed backend instead of the intended local-mode fallback.

Removes the mutex entirely and routes `/compact` through the same
`messageQueue` regular prompts use (a new `operation?: 'compact'` field
on `OpencodeMode`, dequeued by the launcher's single sequential
consumer loop, which branches to a new `runCompactOperation()` instead
of `backend.prompt()`). Ordering is now enforced by construction (one
queue item in flight at a time) rather than a second bolted-on lock.

Replaces the closure-based `onCompactTriggerReady` callback with a
boolean-flag `onCompactAvailabilityChange`, reset to `false` on every
entry into local mode (cold-start and handoff alike) before the remote
backend is even disposed, so there is no window where the flag says
available but the backend underneath it is gone.

* fix(cli): let /compact's cancel ack fire at dequeue time, not on queue

A follow-up HAPI Bot review caught a Major issue this PR's queue-based
/compact serialization (previous commit) left open: runOpencode.ts
still called session.emitMessagesConsumed manually and synchronously
the instant /compact was pushed onto the queue -- a leftover from
before /compact was routed through the queue at all. That beat
MessageQueue2's automatic dequeue-time ack (onBatchConsumed, wired in
sessionBase.ts, already used by every regular prompt) to the hub, so a
/compact sitting behind other queued prompts was marked "invoked"
immediately and could never actually be cancelled from the UI.

Removes the manual ack; the queue's own dequeue-time ack now covers
/compact exactly like any other queued operation. Adds a deterministic
test for the reported scenario: prompt A generating, /compact queued
behind it, cancelled before A finishes -- the compact REST bridge must
never be called.

* fix(cli): suppress live ACP updates while a compact REST call runs

Found via live use: OpenCode keeps streaming session/update
notifications (thought chunks, etc.) over the ACP transport while
/compact's REST call runs outside prompt(), and
AcpSdkBackend.handleSessionUpdate forwarded them unconditionally to
whatever messageHandler was still installed from the last real prompt
turn -- rendering the compaction summary a second time as a plain
assistant message, alongside the explicit Reasoning block this PR
already sends for the same content.

Adds a narrow, opt-in AcpSdkBackend.suppressUpdatesDuring() (shared
with Gemini, but a pure addition with no behavior change for existing
prompt() callers) that temporarily swaps out the message handler for
the duration of an async callback, restoring it afterward. Wraps the
compact bridge's REST call with it so the live stream produces no
output during that window -- the Reasoning block built from the
explicit GET response becomes the only place the summary appears.

* fix(cli): let Stop/switch-to-local interrupt an in-flight /compact REST call

triggerOpencodeCompact's HTTP request is intentionally unbounded (a
real compaction can take minutes), but the launcher awaited it with no
way to interrupt it once dequeued and running. handleAbort() only
cancels the ACP prompt() turn and resets the queue -- neither touches
this raw HTTP call -- so Stop (and switch-to-local, which routes
through the same handler) stayed blocked until the request settled on
its own: a stale "Compaction completed/failed" could still surface
afterward, queued prompts behind it were delayed, and remote->local
handoff couldn't proceed.

Add a per-call AbortController (compactAbortController) that
handleAbort() aborts before cancelling the prompt, and thread it
through triggerOpencodeCompact's new optional `signal` (kept separate
from the existing timeout:false Bun workaround, which stays
unconditional). An aborted call now folds into the same isCancelled()
check that already suppresses a stale result for the existing
queue-cancel race, so either kind of interruption behaves the same
way.

* fix(cli): structurally close remaining /compact abort/lifecycle races

Two more narrow races surfaced in review, both symptoms of ad hoc
per-site fixes rather than a shared mechanism:

1. The signal threaded through triggerOpencodeCompact (the POST) did
   not also reach fetchCompactionSummary (the GET runCompactOperation
   makes right after it), so Stop/switch-to-local could still block on
   that call alone. Introduce OpencodeCompactCallOpts with `signal`
   required (not optional) so every HTTP step opencodeCompactBridge.ts
   makes on behalf of one /compact operation is forced by the compiler
   to accept it, not left to remembering to wire it in per call site.

2. /compact availability (runOpencode.ts's compactSupported flag) was
   only reset to false on the *next* local-mode entry (loop.ts's
   runLocal callback), leaving a window between "switch/exit was
   requested" and "local mode actually started" where a /compact
   arriving mid-transition could still queue, and -- since local mode
   hands straight back to remote when it finds a non-empty queue --
   run anyway despite the user having already left remote mode. Add an
   onLeavingRemote() hook to RemoteLauncherBase (no-op default, so the
   other six flavors built on it are unaffected) called synchronously
   as the very first action of requestExit() -- closing the race at
   its source -- and again unconditionally in start()'s finally block
   as a backstop for exit paths that never call requestExit() at all
   (an exception thrown from runMainLoop, for instance).
   OpencodeRemoteLauncher overrides it to flip availability false;
   loop.ts's local-entry reset is removed as redundant now that
   leaving remote is what's authoritative.

* fix(cli): create compactAbortController before the inline model/effort switch

A hostile-review whole-feature sweep of the /compact abort/lifecycle
surface (round 5) found that the dequeue loop applied the per-batch
inline model/effort switch (real async ACP round-trips that yield to
the event loop) *before* branching into runCompactOperation(), which
is where compactAbortController used to get created. An abort firing
during that switch hit a still-null controller (a no-op), and by the
time the switch resolved and runCompactOperation() created a fresh
one, the abort was forgotten -- the compact's unbounded REST call
would then run to completion despite the user having already pressed
Stop/switch/exit.

Move controller creation to the top of the loop iteration, as soon as
the batch is known to be a compact operation, and pass it into
runCompactOperation() rather than having that function create its
own. Also: soften an overstated doc comment about what the required
`signal` field actually guarantees, and add a regression test locking
in that two sequential compact operations each get an independent
controller (no leak or cross-clearing).

* fix(cli): drain quietly before restoring the handler in suppressUpdatesDuring

A 5th PR-review round found that suppressUpdatesDuring restored the
suppressed messageHandler the instant its callback settled, but
aborting the client-side HTTP call (e.g. OpenCode's compact bridge
aborting via compactAbortController) does not necessarily stop the
agent from continuing that operation server-side -- session/update is
a separate notification channel from the HTTP request's lifecycle.
A late straggler notification from a still-running server-side
operation could leak straight into the restored handler (or into a
new one prompt() installs right after).

Reuse the same quiet-drain prompt() already runs before installing a
new handler for the next turn (waitForSessionUpdateQuiet with the
PRE_PROMPT_* constants) -- same class of race, same validated
mechanism, just on the way back in instead of the way out. No new
state or Stop-vs-switch branching: the wait is internal to
suppressUpdatesDuring and the handler stays suppressed throughout it.

* fix(cli): queue /compact during remote-mode initialization instead of rejecting it

compactSupported alone conflated two different situations: a
genuinely local-mode session (compact fundamentally can't run) versus
a session that's already in remote mode but hasn't finished ACP
initialize + session load/new yet (onCompactAvailabilityChange(true)
hasn't fired yet, but will shortly). A regular prompt sent in that
same startup window queues normally and just waits its turn; /compact
sent in the identical window instead got an immediate
not-yet-supported reply.

Check sessionWrapperRef.current?.mode (the actual OpencodeSession
instance's mode, synced synchronously by onModeChange before either
launcher starts) alongside compactSupported: only genuinely local mode
now gets the not-yet-supported reply. A session already in remote mode
but still initializing queues /compact exactly like a prompt and lets
it settle into its real FIFO position once the launcher is ready.

* fix(cli): don't let the remote-init /compact queuing fix reopen the teardown race

A hostile-review whole-feature sweep found that gating /compact
queuing on sessionWrapperRef.current?.mode alone (26344118) fixed the
startup window but silently reopened the exact race
OpencodeRemoteLauncher's onLeavingRemote() exists to close: mode stays
'remote' for the entire teardown window too (it only flips back to
'local' once runMainLoop() fully unwinds), so a /compact arriving
after switch/exit was requested -- while compactSupported has already
gone false -- queued anyway instead of getting rejected, and could
still run once local mode bounced back to remote to drain a non-empty
queue.

Add compactTeardownInProgress, true from the moment
onCompactAvailabilityChange(false) fires (which -- since the old
reset-on-local-entry was removed -- only ever means "leaving remote",
never "not ready yet") until the session next re-enters remote mode.
Wrap the onModeChange callback opencodeLoop already receives to reset
it back to false on that re-entry. The existing "stops queuing /compact
once availability is reset to false" test didn't catch this because
its mock never set mode to 'remote' during the simulated teardown,
unlike real production timing -- fixed to match.

* fix(cli): keep the dequeue loop blocked on a compact until it really finishes on plain Stop

A 6th PR-review round rejected the quiet-drain mitigation from the
previous round (bounded at ~1.2s) and asked for the originally
proposed fix instead: quiet-drain cannot guarantee a multi-minute
server-side compaction has actually finished, since session/update is
a separate notification channel from the aborted HTTP request's
lifecycle. Unconditionally aborting compactAbortController on any
abort (the previous fix) let the dequeue loop move on to the next
queued prompt while the agent could still be compacting the same
OpenCode session server-side -- breaking the core invariant this
feature's whole queue-based redesign depends on: compact and a prompt
must never touch the same session concurrently.

handleAbort() now takes a leavingRemote parameter. Plain Stop
(leavingRemote=false, the default) only sets compactResultSuppressed
-- the eventual result gets hidden, but compactAbortController is left
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 (leavingRemote=true) still
abort the controller for real, since cleanup() disconnects the whole
ACP subprocess right after regardless -- there's no shared session left
to protect there, and the responsiveness fixed in an earlier round
still matters for that path.

The quiet-drain from the previous round (AcpSdkBackend.suppressUpdatesDuring)
is left in place -- it still helps for a compaction that finishes
quickly and for trailing session/update stragglers right after a real
completion, it's just no longer the thing plain Stop relies on for
correctness.

* fix(cli): close 3 gaps a hostile-review pass expected the next bot round to flag

Pre-emptively addresses findings a hostile-review final pass on
65729bb7 judged likely for the next external bot round, since a
communication round-trip costs more than fixing them now:

1. No dedicated test existed for the Stop/switch-to-local RPC-overlap
   message-ordering fix (handleAbort() re-reading compactAbortController's
   abort state instead of a stale snapshot). The test harness has no way
   to observe MessageBuffer/Ink content, so the decision logic that
   picks the status message and whether to clear `thinking` is extracted
   into a pure, exported selectAbortStatusMessage() function and unit
   tested directly against all four (hasCompactInFlight, leavingRemote,
   compactAborted) combinations, including the exact overlap case.

2. No test verified compactResultSuppressed doesn't leak between two
   sequential compact operations. Added a regression test: a Stop-suppressed
   compact #1 finishing for real must not silence a normally-completed
   compact #2 queued after it.

3. The "Stop requested — waiting..." status message didn't tell the user
   how to actually leave the session (switch-to-local/exit) while a
   compaction is deliberately left running. Appended that guidance.

* fix(cli): skip a compact cancelled before its request ever went out

A plain Stop landing during the inline model/effort switch that
precedes a compact batch only set compactResultSuppressed; it never
stopped the dequeue loop from calling runCompactOperation()
unconditionally once that switch resolved, so a cancelled-before-start
compact would still fire a brand new REST request and block the loop
for however long that call takes.

Skip starting the operation when compactResultSuppressed is set and
the controller was never actually aborted (plain Stop leaves the
signal alone by design). Deliberately excludes the
compactAbortController.signal.aborted case (switch/exit) and
isLocalIdCancelled: both must keep falling through to
runCompactOperation() as before, per Round 5's and the
isLocalIdCancelled suite's existing coverage.

* fix(cli): also skip a compact cancelled-before-start via isLocalIdCancelled

Round 7's pre-start skip check only covered compactResultSuppressed
(plain Stop), deliberately leaving isLocalIdCancelled out so as not
to disturb the "Compaction started is never suppressed" tests that
predated that check. But isLocalIdCancelled's backing Set can only
ever be populated during the brief ack-vs-hub-DB-write race before a
queued item's REST call is sent, never while it's actually running
(see runOpencode.ts's cancelledBeforeEnqueue doc comment) — so a true
result here unconditionally means the same "cancelled before it ever
went out" situation Round 7 already handles for plain Stop, and
deserves the same treatment: skip starting the operation instead of
sending "Compaction started" for a request already known to be
discarded.

Updates the two tests that had encoded the old "started is never
suppressed" behavior for this specific signal to their corrected
expectation, and removes a no-longer-consumed mockImplementationOnce
that would otherwise have leaked its failure response into the next
test that actually calls the REST bridge.

* fix(cli): don't resurrect /compact availability after a startup-time switch/exit

onCompactAvailabilityChange(true) fired unconditionally right after
newSession/loadSession resolved, with no way to know a terminal
switch-to-local/exit had already run during that pending ACP round
trip (RemoteLauncherBase.requestExit() sets shouldExit synchronously
before awaiting its handler, so the flag is already accurate at that
point). runOpencode.ts's compactSupported/compactTeardownInProgress
gate treats compactSupported flipping true as reason enough to ignore
compactTeardownInProgress entirely, so this belated true could let a
/compact arriving right after slip into the queue mid-teardown.

Guard the call with the same shouldExit flag requestExit() already
set. The race can only be reached via the terminal UI's onExit/
onSwitchToLocal callbacks (wired up before runMainLoop starts, well
before the RPC 'abort'/'switch' handlers exist), so the regression
test forces isTTY and captures ink's render() props to invoke them
directly.

* fix(cli): clear the hub's queued-thinking grace when a compact is skipped pre-start

The cancelledBeforeStart skip path never calls session.onThinkingChange(true)
(the whole point of skipping), but also never told the hub the queued
item was done. The hub's 15s queued-thinking grace (markMessageQueued,
sessionCache.ts) keeps thinking pinned true regardless of keepalives
until a messages-consumed ack with clearQueuedThinkingGrace arrives,
so the web UI spinner could sit stuck for the full grace window.

Applies the same pattern already used by runOpencode.ts's synchronous
slash.kind === 'handled' path (e.g. /model): an emitMessagesConsumed
ack with clearQueuedThinkingGrace, then an immediate thinking=false
keepalive. This is additive to, not a replacement for, 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 protocol, and
clearQueuedThinkingGrace is keyed by session, not localId, so both are
idempotent.
This commit is contained in:
Junmo Kim
2026-08-01 17:20:07 +08:00
committed by GitHub
parent 084d3462cf
commit 08fbea5311
16 changed files with 3323 additions and 36 deletions
+489 -9
View File
@@ -1,4 +1,5 @@
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';
@@ -9,21 +10,113 @@ 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 { createOpencodeBackend } from './utils/opencodeBackend';
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;
@@ -62,8 +155,20 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
});
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
cwd: session.path,
port,
hostname
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
@@ -115,6 +220,34 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
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 () => {
@@ -149,7 +282,10 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
this.applyDisplayMode(session.getPermissionMode() as PermissionMode);
this.setupAbortHandlers(session.client.rpcHandlerManager, {
onAbort: () => this.handleAbort(),
// 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()
});
@@ -167,6 +303,29 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
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
@@ -271,6 +430,118 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
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') {
@@ -310,6 +581,24 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
}
}
/**
* /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);
@@ -336,6 +625,147 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
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);
if (converted) {
@@ -383,29 +813,79 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
}
}
private async handleAbort(): Promise<void> {
/**
* `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.session.onThinkingChange(false);
this.abortController.abort();
this.abortController = new AbortController();
this.messageBuffer.addMessage('Turn aborted', 'status');
// 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());
await this.requestExit('exit', () => this.handleAbort(true));
}
private async handleSwitchFromUi(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
await this.requestExit('switch', () => this.handleAbort(true));
}
private async handleSwitchRequest(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
await this.requestExit('switch', () => this.handleAbort(true));
}
}