From 752be37a3dc2f8b4f1b6215f11ac9c7dea7d76c1 Mon Sep 17 00:00:00 2001 From: Miley Date: Wed, 4 Nov 2026 23:20:17 +0200 Subject: [PATCH 01/13] fix(opencode): updating moves the running server onto the new binary `opencode upgrade` replaces the binary on disk, but a server already running keeps executing the old one. With a server URL pointing at OpenCode's background service, T3 Code kept reporting the old version after a successful update. After an update, replace this install's service the way the TUI does on a version mismatch, which hands persistent terminals to the new process. A URL served by anything else is left alone. T3 Code's own idle 1.x helper closes too, so the next status check reads the new version. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/ProviderRegistry.test.ts | 12 ++- apps/server/src/provider/ProviderRegistry.ts | 1 + packages/provider-core/src/server/driver.ts | 2 + .../src/server/OpenCodeServerOwner.test.ts | 22 ++++++ .../src/server/OpenCodeServerOwner.ts | 8 ++ .../provider-opencode/src/server/driver.ts | 19 +++++ .../src/server/v2/OpenCode2Service.test.ts | 60 +++++++++++++++ .../src/server/v2/OpenCode2Service.ts | 75 +++++++++++++++++++ .../src/server/versionProbe.ts | 2 +- 9 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts create mode 100644 packages/provider-opencode/src/server/v2/OpenCode2Service.ts diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index e318a3c79a66e..86565e738b162 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1510,7 +1510,7 @@ it.layer( skills: [], } as const satisfies ServerProvider; const invalidations = yield* Ref.make(0); - const maintenanceReads = yield* Ref.make>([]); + const maintenanceReads = yield* Ref.make>([]); const instance = { instanceId, driverKind: driver, @@ -1536,6 +1536,10 @@ it.layer( applyUsageLimits: () => Effect.void, }, invalidateCaches: Ref.update(invalidations, (count) => count + 1), + afterUpdate: Ref.update(maintenanceReads, (reads) => [ + ...reads, + "afterUpdate" as const, + ]), orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; @@ -1607,6 +1611,12 @@ it.layer( assert.deepStrictEqual(yield* first.takeMaintenanceReads, [true]); yield* registry.invalidateModelCaches({ ...workspace, fresh: true }); assert.deepStrictEqual(yield* first.takeMaintenanceReads, [false]); + + yield* registry.refreshAfterUpdate(first.instance.driverKind, first.instance.instanceId); + assert.deepStrictEqual((yield* first.takeMaintenanceReads).slice(0, 2), [ + "afterUpdate", + true, + ]); }).pipe( Effect.provide( ProviderRegistry.layer.pipe( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index fb6aeaac26b63..2c9a46cff28bb 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -731,6 +731,7 @@ export const layer = Layer.effect( (instance) => Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; + yield* instance.afterUpdate ?? Effect.void; const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); yield* Effect.forEach( latestProviderVersionCacheKeys(maintenance), diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index a1771e2e748e9..19efe84d8864a 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -45,6 +45,8 @@ export interface ProviderInstance { ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; readonly invalidateCaches?: Effect.Effect; + /** Moves long-lived processes onto a newly updated binary before its version is read back. */ + readonly afterUpdate?: Effect.Effect; readonly consumeResetCredit?: () => Effect.Effect< ProviderConsumeResetCreditOutcome, ProviderDriverError diff --git a/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts index a4010a648d2f1..29458565948fc 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts @@ -85,6 +85,28 @@ it.effect("shares concurrent borrowers and closes after the idle TTL", () => }).pipe(Effect.provide(TestClock.layer())), ); +it.effect("closes only an idle server so the next borrower starts the updated binary", () => + Effect.gen(function* () { + const testRuntime = yield* makeRuntime; + yield* Effect.scoped( + Effect.gen(function* () { + const owner = yield* OpenCodeServerOwner.make({ + binaryPath: "opencode", + directory: "/project", + }); + yield* owner.withServer(() => owner.closeIfIdle); + expect(yield* Ref.get(testRuntime.closes)).toBe(0); + + yield* owner.closeIfIdle; + expect(yield* Ref.get(testRuntime.closes)).toBe(1); + expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( + "http://127.0.0.1:2", + ); + }), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); + }), +); + it.effect("retries a failed start and closes on owner scope shutdown", () => Effect.gen(function* () { const testRuntime = yield* makeRuntime; diff --git a/packages/provider-opencode/src/server/OpenCodeServerOwner.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts index 839669a49580d..489f0f2982f4e 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerOwner.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts @@ -23,6 +23,7 @@ export class OpenCodeServerOwner extends Context.Service< readonly withServer: ( use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect, ) => Effect.Effect; + readonly closeIfIdle: Effect.Effect; } >()("@t3tools/provider-opencode/server/OpenCodeServerOwner") {} @@ -175,6 +176,13 @@ export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { ), ); }), + closeIfIdle: mutex.withPermit( + Effect.gen(function* () { + if (state.borrowers > 0) return; + yield* cancelIdleClose(); + yield* closeServer(); + }), + ), }); }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index eb62d44b9aa94..38ebe0fffcca2 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -46,6 +46,7 @@ import { import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; import * as OpenCode2Server from "./v2/OpenCode2Server.ts"; +import { replaceOutdatedService } from "./v2/OpenCode2Service.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -439,6 +440,23 @@ export const OpenCodeDriver: ProviderDriver< ), ); + const serverUrl = effectiveConfig.serverUrl.trim(); + const afterUpdate = + serverUrl.length > 0 + ? replaceOutdatedService({ + binaryPath: effectiveConfig.binaryPath, + serverUrl, + environment: processEnv, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("OpenCode service replacement failed", cause), + ), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + ) + : serverOwner.closeIfIdle; + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ resolveMaintenance, @@ -537,6 +555,7 @@ export const OpenCodeDriver: ProviderDriver< ), ), }), + afterUpdate, orchestrationAdapter, textGeneration, } satisfies ProviderInstance; diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts new file mode 100644 index 0000000000000..4830115f07509 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts @@ -0,0 +1,60 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { expect, vi } from "vite-plus/test"; + +import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; +import { replaceOutdatedService } from "./OpenCode2Service.ts"; + +const service = vi.hoisted(() => ({ incumbent: vi.fn(), ensure: vi.fn() })); +vi.mock("@opencode/client/effect/service", () => ({ Service: service })); + +const runtimeWithBinaryVersion = (stdout: string) => + ({ + runOpenCodeCommand: () => Effect.succeed({ stdout, stderr: "", code: 0 }), + }) as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]; + +it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { + it.effect("replaces only this install's 2.x service, keeping its configured env", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.makeDirectory(path.join(home, ".config", "opencode"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(home, ".config", "opencode", "service.json"), + JSON.stringify({ env: { CONFIGURED: "yes", XDG_STATE_HOME: "/elsewhere" } }), + ); + const file = path.join(home, ".local", "state", "opencode", "service.json"); + const run = (binary: string) => + replaceOutdatedService({ + binaryPath: "/bin/opencode", + serverUrl: "http://127.0.0.1:49374", + environment: { HOME: home }, + }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtimeWithBinaryVersion(binary)), + ); + service.ensure.mockReturnValue(Effect.void); + + service.incumbent.mockReturnValue(Effect.succeed({ state: "ready" })); + yield* run("opencode v1.18.32"); + expect(service.incumbent).not.toHaveBeenCalled(); + + service.incumbent.mockReturnValue(Effect.undefined); + yield* run("opencode v2.2.5"); + expect(service.incumbent).toHaveBeenCalledWith({ file, url: "http://127.0.0.1:49374" }); + expect(service.ensure).not.toHaveBeenCalled(); + + service.incumbent.mockReturnValue(Effect.succeed({ state: "ready" })); + yield* run("opencode v2.2.5"); + expect(service.ensure).toHaveBeenCalledWith({ + file, + version: "2.2.5", + command: ["/bin/opencode", "serve", "--service"], + env: { HOME: home, CONFIGURED: "yes" }, + }); + }).pipe(Effect.scoped), + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts new file mode 100644 index 0000000000000..9d4307ed11b53 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts @@ -0,0 +1,75 @@ +import { Service } from "@opencode/client/effect/service"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { probeOpenCodeBinary } from "../versionProbe.ts"; + +const decodeServiceConfig = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ env: Schema.optional(Schema.Record(Schema.String, Schema.String)) }), + ), +); + +/** + * Replaces the way the TUI does on a version mismatch, which hands persistent terminals to the new + * process. `opencode service restart` shuts them down instead. + */ +export const replaceOutdatedService = Effect.fn("OpenCode2Service.replaceOutdatedService")( + function* (input: { + readonly binaryPath: string; + readonly serverUrl: string; + readonly environment: NodeJS.ProcessEnv; + }) { + const { generation, version } = yield* probeOpenCodeBinary(input.binaryPath, input.environment); + if (generation !== "v2") return; + + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const home = input.environment.HOME ?? (yield* HostProcess.HomeDirectory); + const file = path.join( + input.environment.XDG_STATE_HOME ?? path.join(home, ".local", "state"), + "opencode", + "service.json", + ); + // The configured URL may be another machine's server, which this install must leave alone. + if ((yield* Service.incumbent({ file, url: input.serverUrl })) === undefined) return; + + const command = yield* resolveSpawnCommand(input.binaryPath, ["serve", "--service"], { + env: input.environment, + extendEnv: true, + bypassNpmShim: true, + }); + // The service spawns without a shell; stopping it first would leave nothing running. + if (command.shell) { + return yield* Effect.logWarning("OpenCode service cannot be replaced through a shell shim"); + } + + const config = yield* fileSystem + .readFileString( + path.join( + input.environment.XDG_CONFIG_HOME ?? path.join(home, ".config"), + "opencode", + "service.json", + ), + ) + .pipe(Effect.option, Effect.map(Option.flatMap(decodeServiceConfig))); + // The CLI passes configured variables but never lets them move the registration file. + const { XDG_STATE_HOME: _state, ...configured } = Option.getOrUndefined(config)?.env ?? {}; + const env: Record = {}; + for (const [name, value] of Object.entries({ ...input.environment, ...configured })) { + if (value !== undefined) env[name] = value; + } + + yield* Service.ensure({ + file, + version, + command: [command.command, ...command.args], + env, + }); + }, +); diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts index ab40142de845a..0895c0ba35548 100644 --- a/packages/provider-opencode/src/server/versionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -55,7 +55,7 @@ function classifyOpenCodeProbeResponse( return probed(Option.getOrUndefined(version)); } -const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( +export const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( binaryPath: string, environment: NodeJS.ProcessEnv | undefined, ) { From 464e8faa9cc9698d4864948bc3da56c3240b9f3b Mon Sep 17 00:00:00 2001 From: Miley Date: Wed, 4 Nov 2026 23:31:17 +0200 Subject: [PATCH 02/13] fix(opencode): replace only the updated instance's service Resolve the executable before configured variables can change PATH, read the service config from OPENCODE_CONFIG_DIR when set, and run the update hook only for the instance being updated so an unchanged binary cannot replace a newer service. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/ProviderRegistry.test.ts | 1 + apps/server/src/provider/ProviderRegistry.ts | 4 +++- .../src/server/v2/OpenCode2Service.test.ts | 18 +++++++++++------- .../src/server/v2/OpenCode2Service.ts | 11 +++++++---- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 86565e738b162..a0c6a6391df7e 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1617,6 +1617,7 @@ it.layer( "afterUpdate", true, ]); + assert.notInclude(yield* second.takeMaintenanceReads, "afterUpdate"); }).pipe( Effect.provide( ProviderRegistry.layer.pipe( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 2c9a46cff28bb..b97db6f91b554 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -731,7 +731,9 @@ export const layer = Layer.effect( (instance) => Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; - yield* instance.afterUpdate ?? Effect.void; + if (instance.instanceId === selectedInstanceId) { + yield* instance.afterUpdate ?? Effect.void; + } const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); yield* Effect.forEach( latestProviderVersionCacheKeys(maintenance), diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts index 4830115f07509..c548b38194b59 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts @@ -22,17 +22,21 @@ it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const home = yield* fileSystem.makeTempDirectoryScoped(); - yield* fileSystem.makeDirectory(path.join(home, ".config", "opencode"), { recursive: true }); + const binDirectory = path.join(home, "bin"); + yield* fileSystem.makeDirectory(binDirectory); + yield* fileSystem.writeFileString(path.join(binDirectory, "opencode"), ""); + yield* fileSystem.chmod(path.join(binDirectory, "opencode"), 0o755); + const environment = { HOME: home, PATH: binDirectory, OPENCODE_CONFIG_DIR: home }; yield* fileSystem.writeFileString( - path.join(home, ".config", "opencode", "service.json"), - JSON.stringify({ env: { CONFIGURED: "yes", XDG_STATE_HOME: "/elsewhere" } }), + path.join(home, "service.json"), + JSON.stringify({ env: { PATH: "/nowhere", XDG_STATE_HOME: "/elsewhere" } }), ); const file = path.join(home, ".local", "state", "opencode", "service.json"); const run = (binary: string) => replaceOutdatedService({ - binaryPath: "/bin/opencode", + binaryPath: "opencode", serverUrl: "http://127.0.0.1:49374", - environment: { HOME: home }, + environment, }).pipe( Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtimeWithBinaryVersion(binary)), ); @@ -52,8 +56,8 @@ it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { expect(service.ensure).toHaveBeenCalledWith({ file, version: "2.2.5", - command: ["/bin/opencode", "serve", "--service"], - env: { HOME: home, CONFIGURED: "yes" }, + command: [path.join(binDirectory, "opencode"), "serve", "--service"], + env: { ...environment, PATH: "/nowhere" }, }); }).pipe(Effect.scoped), ); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts index 9d4307ed11b53..08be5cde0bc35 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts @@ -1,6 +1,6 @@ import { Service } from "@opencode/client/effect/service"; import * as HostProcess from "@t3tools/shared/HostProcess"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -39,7 +39,10 @@ export const replaceOutdatedService = Effect.fn("OpenCode2Service.replaceOutdate // The configured URL may be another machine's server, which this install must leave alone. if ((yield* Service.incumbent({ file, url: input.serverUrl })) === undefined) return; - const command = yield* resolveSpawnCommand(input.binaryPath, ["serve", "--service"], { + // Configured variables below can change PATH, which a bare name would then resolve against. + const executable = yield* resolveCommandPath(input.binaryPath, { env: input.environment }); + if (executable === null) return; + const command = yield* resolveSpawnCommand(executable, ["serve", "--service"], { env: input.environment, extendEnv: true, bypassNpmShim: true, @@ -52,8 +55,8 @@ export const replaceOutdatedService = Effect.fn("OpenCode2Service.replaceOutdate const config = yield* fileSystem .readFileString( path.join( - input.environment.XDG_CONFIG_HOME ?? path.join(home, ".config"), - "opencode", + input.environment.OPENCODE_CONFIG_DIR ?? + path.join(input.environment.XDG_CONFIG_HOME ?? path.join(home, ".config"), "opencode"), "service.json", ), ) From a88a8ac88fddc7c76c665da5837865d7426b3dbe Mon Sep 17 00:00:00 2001 From: Miley Date: Wed, 4 Nov 2026 23:57:12 +0200 Subject: [PATCH 03/13] fix(opencode): leave the 1.x helper out of the service fix OpenCode 1.x has no background service. Closing T3 Code's own 1.x helper after an update is a separate change with no real-binary proof, so it does not belong here. Co-Authored-By: Claude Opus 5.5 --- packages/provider-core/src/server/driver.ts | 2 +- .../src/server/OpenCodeServerOwner.test.ts | 22 ------------------- .../src/server/OpenCodeServerOwner.ts | 8 ------- .../provider-opencode/src/server/driver.ts | 2 +- 4 files changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index 19efe84d8864a..5b36db270d429 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -45,7 +45,7 @@ export interface ProviderInstance { ) => Effect.Effect; readonly refreshModels?: () => Effect.Effect; readonly invalidateCaches?: Effect.Effect; - /** Moves long-lived processes onto a newly updated binary before its version is read back. */ + /** Moves a long-lived service onto a newly updated binary before its version is read back. */ readonly afterUpdate?: Effect.Effect; readonly consumeResetCredit?: () => Effect.Effect< ProviderConsumeResetCreditOutcome, diff --git a/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts index 29458565948fc..a4010a648d2f1 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.test.ts @@ -85,28 +85,6 @@ it.effect("shares concurrent borrowers and closes after the idle TTL", () => }).pipe(Effect.provide(TestClock.layer())), ); -it.effect("closes only an idle server so the next borrower starts the updated binary", () => - Effect.gen(function* () { - const testRuntime = yield* makeRuntime; - yield* Effect.scoped( - Effect.gen(function* () { - const owner = yield* OpenCodeServerOwner.make({ - binaryPath: "opencode", - directory: "/project", - }); - yield* owner.withServer(() => owner.closeIfIdle); - expect(yield* Ref.get(testRuntime.closes)).toBe(0); - - yield* owner.closeIfIdle; - expect(yield* Ref.get(testRuntime.closes)).toBe(1); - expect(yield* owner.withServer((server) => Effect.succeed(server.url))).toBe( - "http://127.0.0.1:2", - ); - }), - ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, testRuntime.runtime)); - }), -); - it.effect("retries a failed start and closes on owner scope shutdown", () => Effect.gen(function* () { const testRuntime = yield* makeRuntime; diff --git a/packages/provider-opencode/src/server/OpenCodeServerOwner.ts b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts index 489f0f2982f4e..839669a49580d 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerOwner.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerOwner.ts @@ -23,7 +23,6 @@ export class OpenCodeServerOwner extends Context.Service< readonly withServer: ( use: (server: OpenCodeRuntime.OpenCodeServerProcess) => Effect.Effect, ) => Effect.Effect; - readonly closeIfIdle: Effect.Effect; } >()("@t3tools/provider-opencode/server/OpenCodeServerOwner") {} @@ -176,13 +175,6 @@ export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { ), ); }), - closeIfIdle: mutex.withPermit( - Effect.gen(function* () { - if (state.borrowers > 0) return; - yield* cancelIdleClose(); - yield* closeServer(); - }), - ), }); }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 38ebe0fffcca2..3d9d66bb39367 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -455,7 +455,7 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), ) - : serverOwner.closeIfIdle; + : Effect.void; const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ From 8e9ae743721a06b5a5dd03e78d7540fb8951eaa8 Mon Sep 17 00:00:00 2001 From: Miley Date: Thu, 5 Nov 2026 00:06:22 +0200 Subject: [PATCH 04/13] fix(opencode): never send the local service password to the configured URL Service.incumbent probes the URL it is given using the local registration's password, so a server URL on another machine would receive that password. Use Service.discover, which only contacts the registered address, and match the configured URL against it locally. Co-Authored-By: Claude Opus 5.5 --- .../src/server/v2/OpenCode2Service.test.ts | 21 +++++++++++------- .../src/server/v2/OpenCode2Service.ts | 22 +++++++++++++++++-- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts index c548b38194b59..466f433f066eb 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts @@ -8,7 +8,7 @@ import { expect, vi } from "vite-plus/test"; import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import { replaceOutdatedService } from "./OpenCode2Service.ts"; -const service = vi.hoisted(() => ({ incumbent: vi.fn(), ensure: vi.fn() })); +const service = vi.hoisted(() => ({ discover: vi.fn(), ensure: vi.fn() })); vi.mock("@opencode/client/effect/service", () => ({ Service: service })); const runtimeWithBinaryVersion = (stdout: string) => @@ -32,26 +32,31 @@ it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { JSON.stringify({ env: { PATH: "/nowhere", XDG_STATE_HOME: "/elsewhere" } }), ); const file = path.join(home, ".local", "state", "opencode", "service.json"); - const run = (binary: string) => + const run = (binary: string, serverUrl = "http://127.0.0.1:49374") => replaceOutdatedService({ binaryPath: "opencode", - serverUrl: "http://127.0.0.1:49374", + serverUrl, environment, }).pipe( Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtimeWithBinaryVersion(binary)), ); service.ensure.mockReturnValue(Effect.void); - service.incumbent.mockReturnValue(Effect.succeed({ state: "ready" })); + service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" })); yield* run("opencode v1.18.32"); - expect(service.incumbent).not.toHaveBeenCalled(); + expect(service.discover).not.toHaveBeenCalled(); - service.incumbent.mockReturnValue(Effect.undefined); + for (const elsewhere of ["http://192.168.1.5:49374", "http://127.0.0.1:49999"]) { + yield* run("opencode v2.2.5", elsewhere); + } + expect(service.discover).toHaveBeenCalledWith({ file }); + expect(service.ensure).not.toHaveBeenCalled(); + + service.discover.mockReturnValue(Effect.undefined); yield* run("opencode v2.2.5"); - expect(service.incumbent).toHaveBeenCalledWith({ file, url: "http://127.0.0.1:49374" }); expect(service.ensure).not.toHaveBeenCalled(); - service.incumbent.mockReturnValue(Effect.succeed({ state: "ready" })); + service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" })); yield* run("opencode v2.2.5"); expect(service.ensure).toHaveBeenCalledWith({ file, diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts index 08be5cde0bc35..c418d74e3048c 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts @@ -15,6 +15,22 @@ const decodeServiceConfig = Schema.decodeUnknownOption( ), ); +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); +const WILDCARD_HOSTS = new Set(["0.0.0.0", "[::]"]); + +function isRegisteredService(registeredUrl: string, serverUrl: string): boolean { + const registered = URL.parse(registeredUrl); + const configured = URL.parse(serverUrl); + if (registered === null || configured === null || registered.port !== configured.port) { + return false; + } + return ( + registered.hostname === configured.hostname || + (LOOPBACK_HOSTS.has(configured.hostname) && + (LOOPBACK_HOSTS.has(registered.hostname) || WILDCARD_HOSTS.has(registered.hostname))) + ); +} + /** * Replaces the way the TUI does on a version mismatch, which hands persistent terminals to the new * process. `opencode service restart` shuts them down instead. @@ -36,8 +52,10 @@ export const replaceOutdatedService = Effect.fn("OpenCode2Service.replaceOutdate "opencode", "service.json", ); - // The configured URL may be another machine's server, which this install must leave alone. - if ((yield* Service.incumbent({ file, url: input.serverUrl })) === undefined) return; + // `Service.incumbent` would send the local service's password to the configured URL, which may + // be another machine. `discover` only contacts the address in the local registration. + const service = yield* Service.discover({ file }); + if (service === undefined || !isRegisteredService(service.url, input.serverUrl)) return; // Configured variables below can change PATH, which a bare name would then resolve against. const executable = yield* resolveCommandPath(input.binaryPath, { env: input.environment }); From caede3cdfc1b81e3d574f77a238410773337cac2 Mon Sep 17 00:00:00 2001 From: Miley Date: Thu, 5 Nov 2026 00:09:53 +0200 Subject: [PATCH 05/13] fix(opencode): report a failed service replacement as a failed update check The driver logged and swallowed replacement failures, so the registry could read back a version and report success while the old service kept running. Fail the hook instead, so the registry marks the instance and the update reads as unverified. Co-Authored-By: Claude Opus 5.5 --- packages/provider-core/src/server/driver.ts | 2 +- packages/provider-opencode/src/server/driver.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index 5b36db270d429..f73f98931e432 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -46,7 +46,7 @@ export interface ProviderInstance { readonly refreshModels?: () => Effect.Effect; readonly invalidateCaches?: Effect.Effect; /** Moves a long-lived service onto a newly updated binary before its version is read back. */ - readonly afterUpdate?: Effect.Effect; + readonly afterUpdate?: Effect.Effect; readonly consumeResetCredit?: () => Effect.Effect< ProviderConsumeResetCreditOutcome, ProviderDriverError diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 3d9d66bb39367..5796e98985829 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -448,8 +448,14 @@ export const OpenCodeDriver: ProviderDriver< serverUrl, environment: processEnv, }).pipe( - Effect.catch((cause) => - Effect.logWarning("OpenCode service replacement failed", cause), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to replace the OpenCode service after an update.", + cause, + }), ), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), Effect.provideService(FileSystem.FileSystem, fileSystem), From 3fed040270683e8a9b14f42f9d407c7500452d91 Mon Sep 17 00:00:00 2001 From: Miley Date: Thu, 5 Nov 2026 00:17:06 +0200 Subject: [PATCH 06/13] fix(opencode): match the service URL by address family and keep stderr out of logs A wildcard listener covers only its own family's loopback, so an independent server on the other family or behind localhost is no longer mistaken for the service. Failed replacements no longer pass the service's stderr to the registry's log. Co-Authored-By: Claude Opus 5.5 --- packages/provider-opencode/src/server/driver.ts | 4 ++-- .../src/server/v2/OpenCode2Service.test.ts | 7 ++++++- .../provider-opencode/src/server/v2/OpenCode2Service.ts | 8 +++----- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 5796e98985829..bcef779d955b7 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -448,13 +448,13 @@ export const OpenCodeDriver: ProviderDriver< serverUrl, environment: processEnv, }).pipe( + // Startup errors carry the service's stderr, which the registry would log. Effect.mapError( - (cause) => + () => new ProviderDriverError({ driver: DRIVER_KIND, instanceId, detail: "Failed to replace the OpenCode service after an update.", - cause, }), ), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts index 466f433f066eb..f1be1f86400de 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts @@ -46,7 +46,12 @@ it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { yield* run("opencode v1.18.32"); expect(service.discover).not.toHaveBeenCalled(); - for (const elsewhere of ["http://192.168.1.5:49374", "http://127.0.0.1:49999"]) { + for (const elsewhere of [ + "http://192.168.1.5:49374", + "http://127.0.0.1:49999", + "http://[::1]:49374", + "http://localhost:49374", + ]) { yield* run("opencode v2.2.5", elsewhere); } expect(service.discover).toHaveBeenCalledWith({ file }); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts index c418d74e3048c..4d7794ef23d15 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts @@ -15,19 +15,17 @@ const decodeServiceConfig = Schema.decodeUnknownOption( ), ); -const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); -const WILDCARD_HOSTS = new Set(["0.0.0.0", "[::]"]); - function isRegisteredService(registeredUrl: string, serverUrl: string): boolean { const registered = URL.parse(registeredUrl); const configured = URL.parse(serverUrl); if (registered === null || configured === null || registered.port !== configured.port) { return false; } + // A wildcard listener covers only its own address family's loopback; `localhost` could be either. return ( registered.hostname === configured.hostname || - (LOOPBACK_HOSTS.has(configured.hostname) && - (LOOPBACK_HOSTS.has(registered.hostname) || WILDCARD_HOSTS.has(registered.hostname))) + (registered.hostname === "0.0.0.0" && configured.hostname === "127.0.0.1") || + (registered.hostname === "[::]" && configured.hostname === "[::1]") ); } From fbee88255010db82b43f20a30591f93728e4cee4 Mon Sep 17 00:00:00 2001 From: Miley Date: Thu, 5 Nov 2026 01:01:06 +0200 Subject: [PATCH 07/13] fix(opencode): compare URL protocols and keep the replacement error's cause Two URLs with default ports both parse to an empty port, so http on 80 could match https on 443. Keep the immediate failure as the error's cause, as the service error conventions require. Co-Authored-By: Claude Opus 5.5 --- .../provider-opencode/src/server/driver.ts | 4 +- .../src/server/v2/OpenCode2Service.test.ts | 104 ++++++++++-------- .../src/server/v2/OpenCode2Service.ts | 7 +- 3 files changed, 64 insertions(+), 51 deletions(-) diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index bcef779d955b7..5796e98985829 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -448,13 +448,13 @@ export const OpenCodeDriver: ProviderDriver< serverUrl, environment: processEnv, }).pipe( - // Startup errors carry the service's stderr, which the registry would log. Effect.mapError( - () => + (cause) => new ProviderDriverError({ driver: DRIVER_KIND, instanceId, detail: "Failed to replace the OpenCode service after an update.", + cause, }), ), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts index f1be1f86400de..defb501752d84 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -17,58 +18,65 @@ const runtimeWithBinaryVersion = (stdout: string) => }) as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"]; it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { - it.effect("replaces only this install's 2.x service, keeping its configured env", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = yield* fileSystem.makeTempDirectoryScoped(); - const binDirectory = path.join(home, "bin"); - yield* fileSystem.makeDirectory(binDirectory); - yield* fileSystem.writeFileString(path.join(binDirectory, "opencode"), ""); - yield* fileSystem.chmod(path.join(binDirectory, "opencode"), 0o755); - const environment = { HOME: home, PATH: binDirectory, OPENCODE_CONFIG_DIR: home }; - yield* fileSystem.writeFileString( - path.join(home, "service.json"), - JSON.stringify({ env: { PATH: "/nowhere", XDG_STATE_HOME: "/elsewhere" } }), - ); - const file = path.join(home, ".local", "state", "opencode", "service.json"); - const run = (binary: string, serverUrl = "http://127.0.0.1:49374") => - replaceOutdatedService({ - binaryPath: "opencode", - serverUrl, - environment, - }).pipe( - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtimeWithBinaryVersion(binary)), + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "replaces only this install's 2.x service, keeping its configured env", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped(); + const binDirectory = path.join(home, "bin"); + yield* fileSystem.makeDirectory(binDirectory); + yield* fileSystem.writeFileString(path.join(binDirectory, "opencode"), ""); + yield* fileSystem.chmod(path.join(binDirectory, "opencode"), 0o755); + const environment = { HOME: home, PATH: binDirectory, OPENCODE_CONFIG_DIR: home }; + yield* fileSystem.writeFileString( + path.join(home, "service.json"), + JSON.stringify({ env: { PATH: "/nowhere", XDG_STATE_HOME: "/elsewhere" } }), ); - service.ensure.mockReturnValue(Effect.void); + const file = path.join(home, ".local", "state", "opencode", "service.json"); + const run = (binary: string, serverUrl = "http://127.0.0.1:49374") => + replaceOutdatedService({ + binaryPath: "opencode", + serverUrl, + environment, + }).pipe( + Effect.provideService( + OpenCodeRuntime.OpenCodeRuntime, + runtimeWithBinaryVersion(binary), + ), + ); + service.ensure.mockReturnValue(Effect.void); - service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" })); - yield* run("opencode v1.18.32"); - expect(service.discover).not.toHaveBeenCalled(); + service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" })); + yield* run("opencode v1.18.32"); + expect(service.discover).not.toHaveBeenCalled(); - for (const elsewhere of [ - "http://192.168.1.5:49374", - "http://127.0.0.1:49999", - "http://[::1]:49374", - "http://localhost:49374", - ]) { - yield* run("opencode v2.2.5", elsewhere); - } - expect(service.discover).toHaveBeenCalledWith({ file }); - expect(service.ensure).not.toHaveBeenCalled(); + for (const elsewhere of [ + "http://192.168.1.5:49374", + "http://127.0.0.1:49999", + "http://[::1]:49374", + "http://localhost:49374", + ]) { + yield* run("opencode v2.2.5", elsewhere); + } + expect(service.discover).toHaveBeenCalledWith({ file }); + service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0" })); + yield* run("opencode v2.2.5", "https://127.0.0.1"); + expect(service.ensure).not.toHaveBeenCalled(); - service.discover.mockReturnValue(Effect.undefined); - yield* run("opencode v2.2.5"); - expect(service.ensure).not.toHaveBeenCalled(); + service.discover.mockReturnValue(Effect.undefined); + yield* run("opencode v2.2.5"); + expect(service.ensure).not.toHaveBeenCalled(); - service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" })); - yield* run("opencode v2.2.5"); - expect(service.ensure).toHaveBeenCalledWith({ - file, - version: "2.2.5", - command: [path.join(binDirectory, "opencode"), "serve", "--service"], - env: { ...environment, PATH: "/nowhere" }, - }); - }).pipe(Effect.scoped), + service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" })); + yield* run("opencode v2.2.5"); + expect(service.ensure).toHaveBeenCalledWith({ + file, + version: "2.2.5", + command: [path.join(binDirectory, "opencode"), "serve", "--service"], + env: { ...environment, PATH: "/nowhere" }, + }); + }).pipe(Effect.scoped), ); }); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts index 4d7794ef23d15..dd46d1a9bdc6c 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts @@ -18,7 +18,12 @@ const decodeServiceConfig = Schema.decodeUnknownOption( function isRegisteredService(registeredUrl: string, serverUrl: string): boolean { const registered = URL.parse(registeredUrl); const configured = URL.parse(serverUrl); - if (registered === null || configured === null || registered.port !== configured.port) { + if ( + registered === null || + configured === null || + registered.protocol !== configured.protocol || + registered.port !== configured.port + ) { return false; } // A wildcard listener covers only its own address family's loopback; `localhost` could be either. From 95c6ead6ceccb762a99a3eaadc0b597846de07c0 Mon Sep 17 00:00:00 2001 From: Miley Date: Thu, 5 Nov 2026 01:39:20 +0200 Subject: [PATCH 08/13] fix(opencode): keep the replaced service independent of T3 Code The replacement inherited T3 Desktop's ELECTRON_RUN_AS_NODE and ran in T3 Code's systemd unit, so stopping T3 Code killed it. Run it in its own scope without that variable, make the stop and start uninterruptible, and replace once per update so a version the probe can't match never becomes a kill loop. Document the restart. Co-Authored-By: Claude Opus 5.5 --- docs/user/providers-opencode.md | 5 +++ .../provider-opencode/src/server/driver.ts | 3 ++ .../src/server/v2/OpenCode2Service.test.ts | 24 +++++++++--- .../src/server/v2/OpenCode2Service.ts | 37 ++++++++++++++----- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 9166469791d9f..4917d53bf6dcb 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -43,6 +43,11 @@ settings. T3 Code uses only that configured password for an external server; it does not forward a local `OPENCODE_SERVER_PASSWORD`. If connection or version checks fail, check the URL, credentials, and OpenCode version, then refresh provider status. +If **Server URL** points at this machine's OpenCode background service, updating +OpenCode from T3 Code restarts that service on the new version. Running turns are +interrupted, and the service runs with T3 Code's environment. Set any variables it +needs with `opencode service set env `. + After a lost connection, send another prompt to reconnect to the same OpenCode session. diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 5796e98985829..da7983305f758 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -17,6 +17,7 @@ import { makeOpenCodeTextGeneration } from "./textGeneration.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as OpenCodeAdapterV2 from "./adapter.ts"; import * as OpenCode2AdapterV2 from "./v2/adapter.ts"; @@ -196,6 +197,7 @@ export const OpenCodeDriver: ProviderDriver< const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; + const agentScope = yield* AgentScope; const httpClient = yield* HttpClient.HttpClient; const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const digest = yield* Digest.Digest; @@ -457,6 +459,7 @@ export const OpenCodeDriver: ProviderDriver< cause, }), ), + Effect.provideService(AgentScope, agentScope), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts index defb501752d84..79c9dac2073af 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.test.ts @@ -9,7 +9,7 @@ import { expect, vi } from "vite-plus/test"; import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; import { replaceOutdatedService } from "./OpenCode2Service.ts"; -const service = vi.hoisted(() => ({ discover: vi.fn(), ensure: vi.fn() })); +const service = vi.hoisted(() => ({ discover: vi.fn(), stop: vi.fn(), ensure: vi.fn() })); vi.mock("@opencode/client/effect/service", () => ({ Service: service })); const runtimeWithBinaryVersion = (stdout: string) => @@ -29,7 +29,12 @@ it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { yield* fileSystem.makeDirectory(binDirectory); yield* fileSystem.writeFileString(path.join(binDirectory, "opencode"), ""); yield* fileSystem.chmod(path.join(binDirectory, "opencode"), 0o755); - const environment = { HOME: home, PATH: binDirectory, OPENCODE_CONFIG_DIR: home }; + const environment = { + HOME: home, + PATH: binDirectory, + OPENCODE_CONFIG_DIR: home, + ELECTRON_RUN_AS_NODE: "1", + }; yield* fileSystem.writeFileString( path.join(home, "service.json"), JSON.stringify({ env: { PATH: "/nowhere", XDG_STATE_HOME: "/elsewhere" } }), @@ -46,9 +51,14 @@ it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { runtimeWithBinaryVersion(binary), ), ); + service.stop.mockReturnValue(Effect.void); service.ensure.mockReturnValue(Effect.void); + const outdated = (options: { readonly version?: string }) => + Effect.succeed( + options.version === undefined ? { url: "http://0.0.0.0:49374" } : undefined, + ); - service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" })); + service.discover.mockImplementation(outdated); yield* run("opencode v1.18.32"); expect(service.discover).not.toHaveBeenCalled(); @@ -71,11 +81,15 @@ it.layer(NodeServices.layer)("replaceOutdatedService", (it) => { service.discover.mockReturnValue(Effect.succeed({ url: "http://0.0.0.0:49374" })); yield* run("opencode v2.2.5"); + expect(service.stop).not.toHaveBeenCalled(); + + service.discover.mockImplementation(outdated); + yield* run("opencode v2.2.5"); + expect(service.stop).toHaveBeenCalledWith({ file, pty: "handoff" }); expect(service.ensure).toHaveBeenCalledWith({ file, - version: "2.2.5", command: [path.join(binDirectory, "opencode"), "serve", "--service"], - env: { ...environment, PATH: "/nowhere" }, + env: { ...environment, PATH: "/nowhere", ELECTRON_RUN_AS_NODE: undefined }, }); }).pipe(Effect.scoped), ); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts index dd46d1a9bdc6c..bfbebff52245c 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Service.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Service.ts @@ -1,4 +1,5 @@ import { Service } from "@opencode/client/effect/service"; +import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; @@ -35,8 +36,8 @@ function isRegisteredService(registeredUrl: string, serverUrl: string): boolean } /** - * Replaces the way the TUI does on a version mismatch, which hands persistent terminals to the new - * process. `opencode service restart` shuts them down instead. + * Replaces the way the TUI's restart does, which hands persistent terminals to the new process. + * `opencode service restart` shuts them down instead. */ export const replaceOutdatedService = Effect.fn("OpenCode2Service.replaceOutdatedService")( function* (input: { @@ -63,7 +64,16 @@ export const replaceOutdatedService = Effect.fn("OpenCode2Service.replaceOutdate // Configured variables below can change PATH, which a bare name would then resolve against. const executable = yield* resolveCommandPath(input.binaryPath, { env: input.environment }); if (executable === null) return; - const command = yield* resolveSpawnCommand(executable, ["serve", "--service"], { + // A scope of its own keeps the service alive when T3 Code's systemd unit stops. + const launch = yield* (yield* AgentScope).wrap({ + command: executable, + args: ["serve", "--service"], + name: "opencode-service", + env: input.environment, + shared: true, + survivesRestart: true, + }); + const command = yield* resolveSpawnCommand(launch.command, launch.args, { env: input.environment, extendEnv: true, bypassNpmShim: true, @@ -89,11 +99,20 @@ export const replaceOutdatedService = Effect.fn("OpenCode2Service.replaceOutdate if (value !== undefined) env[name] = value; } - yield* Service.ensure({ - file, - version, - command: [command.command, ...command.args], - env, - }); + if ((yield* Service.discover({ file, version })) !== undefined) return; + + // `ensure` with a version would kill every new service whose version never matches the probe, + // and being interrupted between the stop and the start would leave no service running. + yield* Service.stop({ file, pty: "handoff" }).pipe( + Effect.andThen( + Service.ensure({ + file, + command: [command.command, ...command.args], + // T3 Desktop's backend sets this; undefined overrides the inherited value at spawn. + env: { ...env, ELECTRON_RUN_AS_NODE: undefined } as unknown as Record, + }), + ), + Effect.uninterruptible, + ); }, ); From d9576a59af6a8ba018fe73b2b9f88d76d1ba54bb Mon Sep 17 00:00:00 2001 From: Miley Date: Thu, 5 Nov 2026 16:07:10 +0200 Subject: [PATCH 09/13] docs(opencode): name the Server URLs that restart the service on update Co-Authored-By: Claude Opus 5.5 --- docs/user/providers-opencode.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 4917d53bf6dcb..32535dca861cb 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -44,9 +44,13 @@ does not forward a local `OPENCODE_SERVER_PASSWORD`. If connection or version ch fail, check the URL, credentials, and OpenCode version, then refresh provider status. If **Server URL** points at this machine's OpenCode background service, updating -OpenCode from T3 Code restarts that service on the new version. Running turns are -interrupted, and the service runs with T3 Code's environment. Set any variables it -needs with `opencode service set env `. +OpenCode from T3 Code restarts that service on the new version. The URL must use the +service's own protocol, host, and port. For a service listening on `0.0.0.0` use +`127.0.0.1`, and for `[::]` use `[::1]`. With any other address, including +`localhost`, the service keeps running the old version until you restart it. + +Restarting interrupts running turns, and the service then runs with T3 Code's +environment. Set any variables it needs with `opencode service set env `. After a lost connection, send another prompt to reconnect to the same OpenCode session. From 526e6c8277899d10e30cc5d8d77f3e68f1ef4ec0 Mon Sep 17 00:00:00 2001 From: Miley Date: Tue, 10 Nov 2026 21:44:42 +0200 Subject: [PATCH 10/13] fix(opencode): reconnect after replacing the service so its new version is used A configured server's first verified connection was cached for good, so sessions and reconnects kept the replaced service's old version and features gated on it, such as native goals, stayed off. Forget the connection after an update. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/OpenCode2AdapterV2.testkit.ts | 1 + .../provider-opencode/src/server/driver.ts | 1 + .../src/server/v2/OpenCode2Server.test.ts | 45 +++++++++++++++++++ .../src/server/v2/OpenCode2Server.ts | 6 +++ 4 files changed, 53 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts index e4ad66e76539f..75fc636ddf923 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.testkit.ts @@ -238,6 +238,7 @@ export const replayServer = ( borrowers.current -= 1; }), ), + forget: Effect.void, }); }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index bb4fc4c5b2e2f..54d1763267eea 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -491,6 +491,7 @@ export const OpenCodeDriver: ProviderDriver< serverUrl, environment: processEnv, }).pipe( + Effect.ensuring(openCode2Server.forget), Effect.mapError( (cause) => new ProviderDriverError({ diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts index dec32dc90d006..61ac37d79ceae 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.test.ts @@ -149,6 +149,51 @@ describe("OpenCode2Server error details", () => { ); }); +describe("OpenCode2Server external server", () => { + it.effect("reverifies after forgetting, so a replaced service's version is seen", () => + Effect.gen(function* () { + let version = "2.2.4"; + const layerService = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ version, pid: 1, urls: [], paths: { tmp: "/tmp" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ), + ), + ); + const versions = yield* Effect.gen(function* () { + const server = yield* OpenCode2Server.make({ + binaryPath: "opencode", + serverUrl: "http://127.0.0.1:49374", + serverPassword: "secret", + directory: "/project", + environment: {}, + }); + const read = server.withConnection((connection) => Effect.succeed(connection.version)); + const before = yield* read; + version = "2.2.5"; + const cached = yield* read; + yield* server.forget; + return [before, cached, yield* read]; + }).pipe( + Effect.provide( + Layer.mergeAll( + OpenCode2Client.layer.pipe(Layer.provide(layerService)), + OpenCodeRuntime.layer.pipe(Layer.provide(OpenCodeServerLedger.layerTest)), + ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ), + ); + assert.deepStrictEqual(versions, ["2.2.4", "2.2.4", "2.2.5"]); + }), + ); +}); + describe("OpenCode2Server passwords", () => { it("hands the spawned server only the T3 password", () => { const password = Redacted.make("t3-generated"); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts index 57ba94d3231aa..30758c14872f8 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Server.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Server.ts @@ -26,6 +26,8 @@ export class OpenCode2Server extends Context.Service< readonly withConnection: ( use: (connection: OpenCode2Connection) => Effect.Effect, ) => Effect.Effect; + /** Drops a configured server's verified connection, so the next use reads its version again. */ + readonly forget: Effect.Effect; } >()("@t3tools/provider-opencode/server/v2/OpenCode2Server") {} @@ -144,6 +146,9 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: { Effect.flatMap(use), ); }), + forget: Effect.sync(() => { + latest = undefined; + }), }); } @@ -162,5 +167,6 @@ export const make = Effect.fn("OpenCode2Server.make")(function* (input: { : Effect.die(new Error("OpenCode 2 server was lent before verification.")), ); }), + forget: Effect.void, }); }); From c16df6036d011c7c8beb4e50a7cbd2ff230be3a3 Mon Sep 17 00:00:00 2001 From: Miley Date: Tue, 10 Nov 2026 21:55:46 +0200 Subject: [PATCH 11/13] fix(opencode): forget every instance's service connection when an update starts Sessions reconnecting while the service restarts reused the connection cached before the update, and other instances on the same service never dropped theirs. Forget it in invalidateCaches, which the registry runs for every instance first. Co-Authored-By: Claude Opus 5.5 --- packages/provider-opencode/src/server/driver.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 54d1763267eea..45ff321b1f8cb 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -621,6 +621,7 @@ export const OpenCodeDriver: ProviderDriver< }), ), ), + invalidateCaches: openCode2Server.forget, afterUpdate, orchestrationAdapter, textGeneration, From ae5ebede3b47b306046d99e40815fde30221c8f8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 20:22:41 +0000 Subject: [PATCH 12/13] fix(opencode): replace the shared service before any instance forgets its connection --- .../server/src/provider/ProviderRegistry.test.ts | 16 ++++++++++++++-- apps/server/src/provider/ProviderRegistry.ts | 14 ++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 2632eed306d0b..e083ebf08710a 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1493,6 +1493,7 @@ it.layer( Effect.gen(function* () { const forceRefreshCalls = yield* Ref.make(0); const manifest = yield* Ref.make(ModelManifest.BUNDLED_MODEL_MANIFEST); + const updateEvents = yield* Ref.make>([]); const makeInstance = (name: string) => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); @@ -1537,11 +1538,19 @@ it.layer( streamChanges: Stream.empty, applyUsageLimits: () => Effect.void, }, - invalidateCaches: Ref.update(invalidations, (count) => count + 1), + invalidateCaches: Ref.update(invalidations, (count) => count + 1).pipe( + Effect.andThen( + Ref.update(updateEvents, (events) => [...events, `invalidate:${name}`]), + ), + ), afterUpdate: Ref.update(maintenanceReads, (reads) => [ ...reads, "afterUpdate" as const, - ]), + ]).pipe( + Effect.andThen( + Ref.update(updateEvents, (events) => [...events, `afterUpdate:${name}`]), + ), + ), orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], textGeneration: {} as ProviderInstance["textGeneration"], } satisfies ProviderInstance; @@ -1631,12 +1640,15 @@ it.layer( assert.strictEqual(yield* readCachedVersion("codex_second-package", "3.0.0"), "3.0.0"); yield* first.takeMaintenanceReads; + yield* Ref.set(updateEvents, []); yield* registry.refreshAfterUpdate(first.instance.driverKind, first.instance.instanceId); assert.deepStrictEqual((yield* first.takeMaintenanceReads).slice(0, 2), [ "afterUpdate", true, ]); assert.notInclude(yield* second.takeMaintenanceReads, "afterUpdate"); + // Instances sharing the replaced service must not cache its old version. + assert.strictEqual((yield* Ref.get(updateEvents))[0], "afterUpdate:codex_first"); }).pipe( Effect.provide( ProviderRegistry.layer.pipe( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index da3a305d669c7..75838f5f8fbe0 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -740,16 +740,22 @@ export const layer = Layer.effect( ), ); }); + // Instances can share the replaced service, so none may invalidate before it restarts. + const selectedInstance = instances.find( + (instance) => instance.instanceId === selectedInstanceId, + ); + if (selectedInstance?.afterUpdate !== undefined) { + yield* selectedInstance.afterUpdate.pipe( + Effect.catchCause((cause) => recoverInstanceFailure(selectedInstance.instanceId, cause)), + ); + } // Accounts can share an installation, but discovery must use each // instance's own executable, environment, and credentials. yield* Effect.forEach( - instances, + instances.filter((instance) => !failedInstances.has(instance.instanceId)), (instance) => Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; - if (instance.instanceId === selectedInstanceId) { - yield* instance.afterUpdate ?? Effect.void; - } const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); yield* Effect.forEach( latestProviderVersionCacheKeys(maintenance), From df1a7e5dd2126c233118f05f4402279c7a9ca164 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 11 Nov 2026 14:47:43 -0800 Subject: [PATCH 13/13] fix(opencode): a failed service replacement still reports what is running A failed hook marked the instance as failed and skipped its probe, so the update ended as unverified. The probe now runs, and it shows the restart hint when the old server is still up. Co-Authored-By: Claude Fable 5.2 --- .../src/provider/ProviderRegistry.test.ts | 21 +++++++++++++++++++ apps/server/src/provider/ProviderRegistry.ts | 12 +++++++++-- docs/user/providers-opencode.md | 6 +++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index e083ebf08710a..c05cffe62e7e4 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -74,6 +74,7 @@ import type { import * as ProviderInstanceRegistry from "./ProviderInstanceRegistry.ts"; import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts"; import * as ProviderRegistry from "./ProviderRegistry.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -1494,6 +1495,7 @@ it.layer( const forceRefreshCalls = yield* Ref.make(0); const manifest = yield* Ref.make(ModelManifest.BUNDLED_MODEL_MANIFEST); const updateEvents = yield* Ref.make>([]); + const hookFails = yield* Ref.make(false); const makeInstance = (name: string) => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); @@ -1550,6 +1552,12 @@ it.layer( Effect.andThen( Ref.update(updateEvents, (events) => [...events, `afterUpdate:${name}`]), ), + Effect.andThen(Ref.get(hookFails)), + Effect.flatMap((fails) => + fails + ? Effect.fail(new ProviderDriverError({ driver, instanceId, detail: "hook" })) + : Effect.void, + ), ), orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], textGeneration: {} as ProviderInstance["textGeneration"], @@ -1649,6 +1657,19 @@ it.layer( assert.notInclude(yield* second.takeMaintenanceReads, "afterUpdate"); // Instances sharing the replaced service must not cache its old version. assert.strictEqual((yield* Ref.get(updateEvents))[0], "afterUpdate:codex_first"); + + // A failed hook leaves the probe to report what is running. + yield* Ref.set(hookFails, true); + const probesBeforeFailedHook = yield* Ref.get(first.probes); + const refreshed = yield* registry.refreshAfterUpdate( + first.instance.driverKind, + first.instance.instanceId, + ); + assert.strictEqual(yield* Ref.get(first.probes), probesBeforeFailedHook + 1); + assert.include( + refreshed.map((provider) => provider.instanceId), + first.instance.instanceId, + ); }).pipe( Effect.provide( ProviderRegistry.layer.pipe( diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 75838f5f8fbe0..d37d33201866f 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -745,14 +745,22 @@ export const layer = Layer.effect( (instance) => instance.instanceId === selectedInstanceId, ); if (selectedInstance?.afterUpdate !== undefined) { + // The probe below reports what is running, so a failed hook needs no status of its own. yield* selectedInstance.afterUpdate.pipe( - Effect.catchCause((cause) => recoverInstanceFailure(selectedInstance.instanceId, cause)), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logError("provider post-update hook failed", { + instanceId: selectedInstance.instanceId, + cause: Cause.pretty(cause), + }), + ), ); } // Accounts can share an installation, but discovery must use each // instance's own executable, environment, and credentials. yield* Effect.forEach( - instances.filter((instance) => !failedInstances.has(instance.instanceId)), + instances, (instance) => Effect.gen(function* () { yield* instance.invalidateCaches ?? Effect.void; diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index eab27a15a0c6c..7c9b463b3553d 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -50,10 +50,10 @@ must use the service's own protocol, host, and port. For a service listening on Restarting interrupts running turns, and the service then runs with T3 Code's environment. Set any variables it needs with `opencode service set env `. -T3 Code does not restart any other local server, including the background service +T3 Code does not restart any other server, including the background service reached through `localhost`. That server can still report the previous version -until you restart it. T3 Code shows that restart requirement instead of treating -the CLI update as a failure. +until you restart it. For a loopback URL, T3 Code shows that restart requirement +instead of treating the CLI update as a failure. After a lost connection, send another prompt to reconnect to the same OpenCode session.