Repository navigation
Desktop SSH: a bare opencode silently replaces a Desktop-deployed service with an older CLI, causing a reconnect loop #55429
Description
Summary
When the OpenCode Desktop app manages a remote host over SSH, it deploys its own CLI copy to
~/.opencode/desktop-ssh/<version>/opencode and starts the background service from that binary. It never
touches the CLI already on PATH (~/.opencode/bin/opencode).
If those two versions differ, the next bare opencode launch in a terminal silently kills the
Desktop-started service and replaces it with its own (possibly older) binary — dropping the Desktop
connection. Reconnecting from Desktop then puts the newer service back, so the two sides keep
overwriting each other.
No confirmation is requested, and the TUI splash renders the downgrade as OpenCode updated to 2.0.28.
Environment
OpenCode CLI on PATH |
2.0.28 (~/.opencode/bin/opencode) |
| Desktop-deployed CLI | 2.0.29 (~/.opencode/desktop-ssh/2.0.29/opencode) |
| Desktop app | connects over SSH ("Add SSH server") |
| Remote host | Linux x86_64, glibc 2.39 (resolved target linux-x64-baseline) |
| Service | http://127.0.0.1:4096 |
No binary was ever downloaded or replaced in this scenario — only the service was swapped. Verified by
checksum: ~/.opencode/bin/opencode had md5 8666510adccd739beb743468625efb2e and reported v2.0.28
both before and after the sequence below.
Steps to reproduce
- From the Desktop app, add the host as an SSH server and let it bootstrap. It deploys
2.0.29into~/.opencode/desktop-ssh/2.0.29/and starts the service with it. - Open a terminal on the remote host and run
opencode(no arguments → TUI). - The TUI splash shows:
OpenCode updated to 2.0.28 ✓ Ready 3/3 - The Desktop session disconnects at the same moment.
- Reconnect from Desktop → it reports
Server update required, "Update and reconnect" restores2.0.29.
Evidence
From ~/.local/share/opencode/log/opencode.log (UTC). args=[] is the TUI; args=["serve","--service"]
is the spawned service.
09:41:49.560 cli starting version=2.0.29 args=["--version"] ← Desktop probes its staged copy
09:41:50.319 cli starting version=2.0.29 args=["service","restart"] ← replace=true path
09:41:50.517 cli starting version=2.0.29 args=["serve","--service"] ← service now 2.0.29
09:57:39.225 cli starting version=2.0.28 args=[] ← user runs bare `opencode`
09:57:39.308 background service starting reason=version-mismatch previousVersion=2.0.29
09:57:39.528 cli starting version=2.0.28 args=["serve","--service"] ← 2.0.29 killed, replaced by 2.0.28
09:57:40.450 update check current=2.0.28 latest=2.0.29 ← direction is UP, unrelated
09:58:04.778 cli starting version=2.0.29 args=["service","restart"] ← Desktop reconnects
09:58:04.984 cli starting version=2.0.29 args=["serve","--service"]
reason=version-mismatch previousVersion=2.0.29 immediately followed by a 2.0.28 service start is the
whole bug in two lines.
This is not a one-off: the same pattern appears in earlier history on this host —
2026-10-13T17:28:17 reason=version-mismatch previousVersion=2.0.28,
2026-10-13T17:52:39 reason=version-mismatch previousVersion=2.0.27.
Root cause
Three independent behaviours compose into the loop.
1. Desktop deploys to a private directory and never syncs the PATH CLI
packages/gui-extensions/src/ssh/bootstrap.ts
export function binaryPath(version: string) {
return `$HOME/.opencode/desktop-ssh/${RemoteCli.requireVersion(version)}/opencode`
}
export function startScript(version: string, replace = false) {
return `set -eu
cli="${binaryPath(version)}"
"$cli" service ${replace ? "restart" : "start"}
...Discovery prefers the PATH CLI (packages/gui-extensions/src/ssh/remote-cli.ts):
if [ -z "$cli" ] && [ -x "$HOME/.opencode/bin/opencode" ]; then cli="$HOME/.opencode/bin/opencode"; fiThe result is two independent installs on the same host, with nothing keeping them in sync — and a single
shared registration file (~/.local/state/opencode/service.json) recording whichever one started last.
2. The TUI replaces any version-mismatched service, with no direction check
packages/cli/src/commands/handlers/default.ts:38 hardcodes it:
const server = yield* ServerConnection.resolve({
server: requestedServer,
standalone: input.standalone,
mismatch: "replace",
onStart: (reason, previousVersion) => { ... },
})packages/cli/src/services/server-connection.ts:45
const mismatch = args.mismatch ?? "ignore"
const options = yield* ServiceConfig.options({ checkVersion: mismatch !== "ignore" })→ options.version = OPENCODE_VERSION → packages/client/src/service-probe.ts:
export function decide(service: LocalService, options: DiscoverOptions) {
if (!matchesVersion(service.version, options))
return { _tag: "replace", pty: service.state === "ready" ? "handoff" : "clear" } as constpackages/client/src/service-version.ts compares by string equality only. There is no ordering, so
"service is newer" and "service is older" both take the replace branch. The replacement service is
spawned via selfCommand() → [process.execPath]
(packages/cli/src/util/process.ts:5), i.e. whatever binary the user just launched.
Net effect: whoever starts last wins, regardless of direction.
opencode run (handlers/run.ts) omits mismatch, so it defaults to "ignore" and is unaffected — the
bare TUI is the only entry point that force-takes over the service.
3. The splash renders the downgrade as an update
packages/cli/src/services/update-preflight.tsx
const completedHeader = phrase(
["OpenCode", colors.muted, true],
["updated to", colors.muted],
[OPENCODE_VERSION, colors.accent],
)
const stages = ["Keeping your session safe", "Starting the new background service", "Loading OpenCode"]Stage 2 is literally "Starting the new background service" — this screen is a service swap, never a
binary update. But the completed header only says "updated to", so 2.0.29 → 2.0.28 reads as a normal
upgrade. The in-progress header does show from 2.0.29 to 2.0.28, but it flashes by.
Why it is not caught
handlers/service/{start,restart}.ts call ServiceConfig.options() without checkVersion, so
options.version === undefined and matchesVersion returns true unconditionally. Only the TUI enables
the version requirement. The two paths therefore disagree about whether the running service is
acceptable, and the TUI silently wins.
Impact
- Desktop sessions drop whenever the TUI is launched while versions differ.
- Silent version rollback of the managed service, with no prompt.
- Persistent ping-pong until the two installs happen to match.
- Unbounded disk growth:
~/.opencode/desktop-ssh/retains every deployed version
(2.0.12, 2.0.27, 2.0.28, 2.0.29 observed — ~200 MB each, ~800 MB total) and is never pruned.
Suggested fixes
Any one of A/B breaks the loop; C removes the root cause.
A. Make decide() direction-aware — packages/client/src/service-probe.ts
if (!matchesVersion(service.version, options)) {
if (older(service.version, options.version)) // service is newer than this client
return { _tag: "fail", error: new Error(
"Background server is newer than this client. Run `opencode upgrade`, or launch " + options.version + " directly.") }
return { _tag: "replace", pty: ... } as const
}The old client steps aside instead of dragging the newer service down.
B. Stop hardcoding mismatch: "replace" in handlers/default.ts
Either drop the field (falling back to "ignore", matching handlers/run.ts), or gate it behind an
explicit opt-in. A client should not kill a shared service it did not start.
C. Have Desktop keep the PATH CLI in sync — packages/gui-extensions/src/ssh/bootstrap.ts
After a successful service restart, mirror the staged binary to an existing PATH CLI if one is present
and older. Desktop already has remote code execution over the SSH channel and already ships this exact
binary from registry.npmjs.org, so the marginal cost is one local file copy (~10 lines). This is what
makes "upgrade once" true for users.
D. Fix the preflight wording so a downgrade is labelled as one, e.g.
Background server downgraded 2.0.29 → 2.0.28 (client is older).
Adjacent issues found while diagnosing (not the cause here)
-
packages/cli/src/services/updater-action.tshas no version ordering.export function action(current: string, latest: string, policy: Policy): Action { if (policy === "disable") return "none" const currentVersion = parseReleaseVersion(current) const latestVersion = parseReleaseVersion(latest) if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none" return policy // any inequality triggers "update", newer or older }
If the release endpoint ever reports a version older than
OPENCODE_VERSION(a revert, or a lagged
channel), this performs a real binary downgrade. Adding a comparison would close it. -
?current=is ignored by the update endpoint. Verified:
/update/api/latest/cli/npm?current=2.0.28and?current=2.0.29both return
{"version":"2.0.29", ...}. The parameter suggests server-side suppression of older versions;
honouring it would remove the downgrade vector in (1). -
No prerelease awareness in the comparison. A
0.0.0-beta-Nbuild and a release build would need
channel-aware ordering; naive semver ordering ranks the beta after the release.
Activity
opencode-agent commentedon Oct 14, 2026
Thanks for the detailed report. I reproduced this on 2.0.28 without the Desktop app, on Linux:
opencode service start).service.jsonrecords version 2.0.29.opencodefrom a 2.0.28 CLI.background service starting reason=version-mismatch previousVersion=2.0.29, and the 2.0.28 binary starts its ownserve --service.service.jsonnow shows 2.0.28 with a new pid: the newer service was replaced by the older one. The splash says "from 2.0.29 to 2.0.28" and then "OpenCode updated to 2.0.28".The same thing happens on the latest
dev(f44c40b) when it runs as a 2.0.28 release-channel client. The code that decides to replace the service hasn't changed since 2.0.28. The script below reproduces it with two CLI versions. I didn't test the Desktop SSH flow itself, or the growth of~/.opencode/desktop-ssh/.Related issues: #53375 (Desktop SSH version-mismatch message), #47714 (update prompt recommending an older version), #54181 and #38567 (background service restart and race problems).
Reproduction script (repro.sh)
Run it:
bash repro.shvladkosinov commentedon Oct 26, 2026
Same bug without SSH: the local Desktop app plus the terminal CLI on Windows.
The Desktop app updated itself to 2.0.32; the terminal
opencode(~/.opencode/bin) stayed on 2.0.27. Runningopencodein a terminal replaces the app's service, the app starts its own again, and the two keep killing each other. The app shows "Failed to fetch", the TUI "Unable to connect". Reproduced on a clean Windows 11 ARM64 VM with the vendor installers only:App log, same second:
v2 CLI background service starting { reason: 'missing' }.Control: with the terminal on 2.0.32, the TUI reuses the app's service and both work.
On Windows the gap does not close by itself:
opencode upgradeneeds Git Bash, so the terminal CLI stays on its install version while the app keeps updating.mismatch: "replace"is still indefault.ts:38on v2.1.4.#55434 adds the missing direction check. Could it be reopened and reviewed?
0xYiliu commentedon Oct 27, 2026
I believe this issue also covers a broader SSH bootstrap behavior. The SSH bootstrap can discover an
opencodeon the remotePATH, but when no service is currently registered it discards that candidate and starts the private$HOME/.opencode/desktop-ssh/<version>/opencodecopy instead.Expected behavior should be:
PATH(or an explicitly supported remote binary setting).desktop-sshcopy when no usable remote OpenCode is available.I am willing to implement this once the expected behavior is confirmed. If maintainers agree this is the right direction, please assign this issue to me (
0xYiliu).0xYiliu commentedon Oct 27, 2026
@Hona @Brendonovich I noticed that PR #55434 is linked to this issue. It addresses the client-side version-direction guard so an older CLI does not replace a newer running service, but it does not change the SSH bootstrap's remote CLI discovery/start selection.
Could you confirm whether the intended SSH behavior is to prefer an existing, usable and version-compatible
opencodeon the remote host, and only fall back to$HOME/.opencode/desktop-ssh/<version>/opencodewhen no such CLI is available? If confirmed, I would be happy to implement this; please assign the issue to@0xYiliu.