feat(cli): apply managed updates when idle and wire up ui (#46485)

This commit is contained in:
James Long 2026-09-02 12:08:41 -04:00 committed by GitHub
parent 955fcad647
commit 33dd4e3ba8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 770 additions and 236 deletions

View file

@ -47,7 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@ -83,6 +83,11 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
updater: service
? {
apply: (version) => runPromise(updater.apply(version)),
}
: undefined,
packages: {
resolve: (spec, install = true) =>
runPromise(

View file

@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { spawn } from "node:child_process"
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
@ -53,7 +54,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
const next = yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
@ -64,7 +66,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@ -161,19 +163,62 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
}),
)
if (server === undefined) return
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
yield* updater
.monitor({
url,
password,
managed: options.mode === "service",
notify: server.updateAvailable,
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
})
.pipe(Effect.forkScoped)
return yield* options.mode === "service"
? server.shutdown
? Effect.raceFirst(
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
Deferred.await(replacement).pipe(Effect.map(Option.some)),
)
: options.mode === "stdio"
? waitForStdinClose()
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
: Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
if (Option.isNone(next)) return
yield* spawnReplacement(next.value)
})
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
const options = yield* ServiceConfig.options()
const [command, ...args] = options.command
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
// We do not monitor the replacement after spawn. A managed TUI
// recovers with Service.ensure if startup fails; a future client
// restart signal could coordinate that recovery instead.
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
...options.env,
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
},
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => new Error("Failed to start replacement server", { cause }),
})
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {

View file

@ -56,7 +56,7 @@ function managedService(options: EnsureOptions) {
reconnect: () => Service.ensure(reconnectOptions),
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.stop({ file: options.file, pty: "handoff" })
yield* Service.ensure(reconnectOptions)
}),
}

View file

@ -1,4 +1,4 @@
export type Policy = boolean | "notify"
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "upgrade"
const maximumComponent = "9007199254740991"
@ -6,13 +6,14 @@ const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
if (policy === "disable") return "none"
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
if (policy === "notify") return "notify"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "none"
return policy === "notify" ? "notify" : "upgrade"
if (currentVersion.major !== latestVersion.major) return "notify"
return "upgrade"
}
export function parseReleaseVersion(input: string) {

View file

@ -3,46 +3,54 @@ import { action } from "./updater-action"
import { decodePolicy } from "./updater"
describe("updater", () => {
test("reads autoupdate from JSONC", () => {
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
test("reads update policy from JSONC", () => {
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
})
test("maps the v1 update policy", () => {
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
})
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
})
test("reports patches and minors without automatically installing them", () => {
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("skips when autoupdate is disabled", () => {
expect(action("1.2.3", "1.2.4", false)).toBe("none")
test("skips when updates are disabled", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
test("never automatically updates majors", () => {
expect(action("1.2.3", "2.0.0", true)).toBe("none")
test("reports majors instead of automatically installing them", () => {
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
})
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", true)).toBe("none")
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
})
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
})
test("preserves strict validity", () => {
@ -63,21 +71,21 @@ describe("updater", () => {
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
})
})

View file

@ -1,11 +1,13 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OpenCode } from "@opencode-ai/client"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
@ -17,11 +19,143 @@ const packageName =
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly monitor: (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) => Effect.Effect<void>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export type Inspection =
| { readonly action: "none" }
| { readonly action: Exclude<Action, "none">; readonly version: string }
type State =
| { readonly type: "current" }
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
| { readonly type: "ready-to-restart"; readonly version: string }
export interface MonitorInput {
readonly url: string
readonly password: string
readonly managed: boolean
readonly inspect: () => Effect.Effect<Inspection, Error>
readonly install: (version: string) => Effect.Effect<boolean, Error>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
readonly interval?: Duration.Input
readonly notificationThreshold?: Duration.Input
readonly notify: (version: string) => Effect.Effect<void>
}
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
const state = yield* Ref.make<State>({ type: "current" })
const applyLock = yield* Semaphore.make(1)
const client = OpenCode.make({
baseUrl: input.url,
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
})
const applyIfIdle = () =>
applyLock.withPermit(
Effect.gen(function* () {
const pending = yield* Ref.get(state)
if (pending.type !== "available") return
const active = yield* Effect.tryPromise({
try: () => client.session.active(),
catch: (cause) => new Error("Failed to read active sessions", { cause }),
})
if (Object.keys(active).length > 0) return
const latest = yield* input.inspect()
if (latest.action !== "upgrade") {
yield* Ref.set(state, { type: "current" })
return
}
const installed = yield* input
.install(latest.version)
.pipe(
Effect.catch((error) =>
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
),
)
if (!installed) return
const handoff = input.managed
? yield* Effect.tryPromise({
try: () => client.experimental.persistentPty.handoff(),
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
})
: undefined
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
if (handoff) yield* input.restart(handoff.handoff)
}),
)
const checkServer = Effect.gen(function* () {
const result = yield* input.inspect()
if (result.action === "notify") {
yield* input.notify(result.version)
return
}
if (result.action !== "upgrade") {
yield* Ref.update(
state,
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
)
return
}
yield* Ref.update(state, (current): State => {
if (current.type === "ready-to-restart" && current.version === result.version) return current
return {
type: "available",
version: result.version,
availableSince: current.type === "available" ? current.availableSince : Date.now(),
}
})
yield* applyIfIdle()
const pending = yield* Ref.get(state)
if (
pending.type === "available" &&
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
)
yield* input.notify(pending.version)
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
const subscribe = Effect.suspend(() =>
Stream.fromAsyncIterable(
client.event.subscribe(),
(cause) => new Error("Update event stream failed", { cause }),
).pipe(
Stream.runForEach((event) => {
if (event.type === "server.connected") return applyIfIdle()
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.failed" &&
event.type !== "session.execution.interrupted"
)
return Effect.void
return Effect.tryPromise({
try: () => client.session.wait({ sessionID: event.data.sessionID }),
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
}).pipe(Effect.andThen(applyIfIdle()))
}),
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
),
).pipe(Effect.repeat(Schedule.spaced("1 second")))
return yield* Effect.all(
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
{
concurrency: "unbounded",
discard: true,
},
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
export function decodePolicy(text: string): Policy | undefined {
@ -29,157 +163,196 @@ export function decodePolicy(text: string): Policy | undefined {
// the location-scoped server configuration graph.
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
const value = input.autoupdate
if (typeof value === "boolean" || value === "notify") return value
if (errors.length || typeof input !== "object" || input === null) return
if ("update" in input) {
const value = input.update
if (value === "disable" || value === "notify" || value === "auto") return value
return
}
if (!("autoupdate" in input)) return
if (input.autoupdate === false) return "disable"
if (input.autoupdate === "notify") return "notify"
if (input.autoupdate === true) return "auto"
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs.readFileString(path.join(global.config, name)).pipe(
Effect.map(decodePolicy),
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? true
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
.pipe(
Effect.map((result) => ({
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
})
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
})
const latest = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, {
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
}),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
return yield* Effect.fail(new Error("Update information did not include a version"))
}
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const check = Effect.fn("cli.updater.check")(
function* () {
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? ""))
return yield* Effect.logInfo("update check skipped", {
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
const policy = yield* readPolicy()
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return yield* Effect.gen(function* () {
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
if (next === "notify")
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
})
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs.readFileString(path.join(global.config, name)).pipe(
Effect.map(decodePolicy),
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? "auto"
})
return Service.of({ check, method, latest, upgrade })
}),
)
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
.pipe(
Effect.map((result) => ({
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
})
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
})
const latest = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, {
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
}),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
return yield* Effect.fail(new Error("Update information did not include a version"))
}
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
yield* Effect.logInfo("update check skipped", {
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
return { action: "none" }
}
const policy = yield* readPolicy()
if (policy === "disable") {
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return { action: "none" }
}
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return { action: "none" }
}
if (next === "notify") {
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return { action: next, version }
}
return { action: next, version }
})
const install = Effect.fnUntraced(function* (version: string) {
const detected = yield* method()
if (!detected) {
yield* Effect.logWarning("automatic update skipped: installation method not found")
return false
}
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
return true
})
const apply = Effect.fn("cli.updater.apply")(function* (version: string) {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
const check = Effect.fn("cli.updater.check")(
function* () {
const result = yield* inspect()
if (result.action !== "upgrade") return
yield* install(result.version)
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) {
return yield* monitorServer({ ...input, inspect, install })
})
return Service.of({ check, monitor, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
export * as Updater from "./updater"
export { action, type Action, type Policy } from "./updater-action"

View file

@ -179,7 +179,7 @@ function verifierConfig(llmUrl: string, skills?: string) {
limit: { context: 100_000, output: 10_000 },
}
return {
autoupdate: false,
update: "disable",
model: "test/test-model",
...(skills ? { skills: [skills] } : {}),
providers: {

View file

@ -13,6 +13,8 @@ await Effect.runPromise(
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
method: () =>
Effect.sync(() => {
record("method")

View file

@ -0,0 +1,107 @@
import { expect } from "bun:test"
import { Deferred, Effect, Layer, Option } from "effect"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.live("installs and restarts after the final Session settles", () =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
const installed = yield* Deferred.make<string>()
const restarted = yield* Deferred.make<void>()
yield* Updater.monitorServer({
url: fixture.url,
password: "test",
managed: true,
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
notify: () => Effect.void,
}).pipe(Effect.forkScoped)
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
fixture.settle()
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
expect(
yield* Effect.raceFirst(
Deferred.await(installed),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
),
).toBe("1.1.0")
yield* Effect.raceFirst(
Deferred.await(restarted),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
)
}),
)
const wait = (promise: Promise<unknown>, message: () => string) =>
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
function makeServer() {
const encoder = new TextEncoder()
const activeRead = Promise.withResolvers<void>()
const eventOpened = Promise.withResolvers<void>()
const waited = Promise.withResolvers<void>()
let active = true
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/session/active") {
activeRead.resolve()
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
}
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
waited.resolve()
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
return Response.json({ handoff: null })
}
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
eventOpened.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return new Response("Not found", { status: 404 })
},
})
return {
url: server.url.origin,
activeRead: activeRead.promise,
eventOpened: eventOpened.promise,
waited: waited.promise,
settle() {
active = false
events?.enqueue(
encoder.encode(
`data: ${JSON.stringify({
id: "evt_settled",
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
data: { sessionID: "ses_test" },
})}\n\n`,
),
)
events?.close()
events = undefined
},
stop() {
server.stop(true)
},
}
}

View file

@ -1892,7 +1892,7 @@ export type ConfigEntry =
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
autoupdate?: boolean | "notify"
update?: "disable" | "notify" | "auto"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string

View file

@ -24,6 +24,7 @@ import { ConfigMCPV1 } from "../v1/config/mcp.js"
import { ConfigPermissionV1 } from "../v1/config/permission.js"
import { ConfigPluginV1 } from "../v1/config/plugin.js"
import { ConfigProviderV1 } from "../v1/config/provider.js"
import { ConfigV1 } from "../v1/config/config.js"
import { ConfigMigrateV1 } from "../v1/config/migrate.js"
import { PositiveInt } from "../schema.js"
@ -69,6 +70,9 @@ export function normalize(input: unknown): Result {
const legacySnapshots = own(input, "snapshot")
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
: undefined
const legacyUpdate = own(input, "autoupdate")
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
? "auto"
@ -82,6 +86,7 @@ export function normalize(input: unknown): Result {
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
@ -191,7 +196,7 @@ export function normalize(input: unknown): Result {
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
autoupdate: Info.fields.autoupdate,
update: Info.fields.update,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,

View file

@ -26,7 +26,14 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
update:
info.autoupdate === false
? "disable"
: info.autoupdate === "notify"
? "notify"
: info.autoupdate === true
? "auto"
: undefined,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,

View file

@ -586,6 +586,13 @@ describe("Config", () => {
})
})
test("migrates the v1 update policy", () => {
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
})
test("migrates v1 provider lists to policies", () => {
expect(
ConfigMigrateV1.migrate({
@ -1026,7 +1033,7 @@ describe("Config", () => {
shell: "/bin/bash",
model: "anthropic/claude",
default_agent: "reviewer",
autoupdate: "notify",
update: "notify",
share: "disabled",
enterprise: { url: "https://share.example.com" },
username: "test-user",
@ -1109,7 +1116,7 @@ describe("Config", () => {
expect(documents[0]?.info.shell).toBe("/bin/bash")
expect(documents[0]?.info.model).toEqual(selection("anthropic/claude"))
expect(documents[0]?.info.default_agent).toBe("reviewer")
expect(documents[0]?.info.autoupdate).toBe("notify")
expect(documents[0]?.info.update).toBe("notify")
expect(documents[0]?.info.share).toBe("disabled")
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
expect(documents[0]?.info.username).toBe("test-user")
@ -1236,6 +1243,7 @@ describe("Config", () => {
JSON.stringify({
shell: "/bin/zsh",
default_agent: "reviewer",
autoupdate: false,
snapshot: false,
autoshare: true,
permission: {
@ -1312,6 +1320,7 @@ describe("Config", () => {
expect(documents[0]?.info).toBeInstanceOf(Info)
expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.default_agent).toBe("reviewer")
expect(documents[0]?.info.update).toBe("disable")
expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.share).toBe("auto")
expect(documents[0]?.info.permissions).toEqual([

View file

@ -13897,16 +13897,9 @@
"default_agent": {
"type": "string"
},
"autoupdate": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"enum": ["notify"]
}
]
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",

View file

@ -34,11 +34,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
default_agent: Schema.String.pipe(optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
description: "Disable updates, notify when one is available, or install automatically",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),

View file

@ -1,9 +1,11 @@
export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { Bus } from "@opencode-ai/core/bus"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import {
HttpMiddleware,
@ -114,7 +116,12 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
)
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
return { address: bound.http.address, shutdown: shutdown.await }
return {
address: bound.http.address,
shutdown: shutdown.await,
updateAvailable: (version: string) =>
Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
}
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)

View file

@ -1,4 +1,5 @@
import { expect } from "bun:test"
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
import { it } from "../../core/test/lib/effect"
@ -99,7 +100,12 @@ it.live("allows browser preflight requests without credentials", () =>
)
expect(event.status).toBe(200)
expect(event.headers.get("content-encoding")).toBeNull()
yield* Effect.promise(() => event.body?.cancel() ?? Promise.resolve())
if (!event.body) return yield* Effect.die(new Error("Event response has no body"))
const reader = event.body.getReader()
yield* Effect.promise(() => readUntil(reader, "server.connected"))
yield* server.updateAvailable("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
yield* Effect.promise(() => reader.cancel())
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
@ -124,3 +130,11 @@ it.live("allows browser preflight requests without credentials", () =>
)
}),
)
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
while (true) {
const next = await reader.read()
if (next.done) throw new Error(`Event stream ended before ${expected}`)
if (new TextDecoder().decode(next.value).includes(expected)) return
}
}

View file

@ -64,6 +64,7 @@ import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogUpdate } from "./component/dialog-update"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@ -184,6 +185,9 @@ export type TuiInput = {
}
args: Args
config: Config.Interface
updater?: {
apply: (version: string) => Promise<void>
}
packages: PackageResolver
environment?: Readonly<Record<string, string>>
terminalHandoff?: () => Promise<
@ -216,6 +220,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const service = managed
? {
reconnect: async (signal: AbortSignal) => {
// Give the server a chance to respawn itself before starting client-side recovery.
await new Promise((resolve) => setTimeout(resolve, 50))
if (signal.aborted) throw signal.reason ?? new Error("Server reconnect cancelled")
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next), url: endpoint.url }
@ -397,6 +404,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
directories={pluginDirectories}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
@ -456,7 +464,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials }) {
function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@ -495,6 +503,10 @@ function App(props: { pair?: DialogPairCredentials }) {
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
})
const [updateNotifications, markUpdateNotification] = useStorage().store<{ versions: string[] }>(
"update-notifications",
{ initial: { versions: [] } },
)
const tabsResize = createPaneResize({
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
defaultValue: () => SESSION_SIDEBAR_WIDTH,
@ -1203,6 +1215,19 @@ function App(props: { pair?: DialogPairCredentials }) {
})
})
event.on("installation.update-available", (evt) => {
const updater = props.updater
const restart = client.restart
if (!updater || !restart) return
const version = evt.data.version
if (updateNotifications.versions.includes(version)) return
void markUpdateNotification((draft) => {
draft.versions = [...draft.versions, version].slice(-100)
}).catch((error) => log.error("failed to persist update notification", { error }))
dialog.replace(() => <DialogUpdate version={version} install={() => updater.apply(version)} restart={restart} />)
dialog.setCentered(true)
})
event.on("tui.session.select", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
route.navigate({

View file

@ -0,0 +1,147 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes } from "@opentui/core"
import { createSignal, For, Match, Show, Switch } from "solid-js"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
import { useDialog } from "../ui/dialog"
import { Spinner } from "./spinner"
type State =
| { type: "ready"; active: "update" | "ignore" }
| { type: "installing" }
| { type: "restarting" }
| { type: "failed"; message: string }
export function DialogUpdate(props: { version: string; install: () => Promise<void>; restart: () => Promise<void> }) {
const dialog = useDialog()
const theme = useTheme("elevated")
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
const install = async () => {
setState({ type: "installing" })
await props.install()
setState({ type: "restarting" })
await props.restart()
dialog.clear()
}
const beginInstall = () => {
if (state().type !== "ready") return
void install().catch((error) => setState({ type: "failed", message: errorMessage(error) }))
}
const run = () => {
const current = state()
if (current.type !== "ready") return
if (current.active === "ignore") return dialog.clear()
beginInstall()
}
const toggle = () =>
setState((current) =>
current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current,
)
const selected = (action: "update" | "ignore") => {
const current = state()
return current.type === "ready" && current.active === action
}
const failure = () => {
const current = state()
return current.type === "failed" ? current.message : ""
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{
bind: "return",
title: "Confirm update action",
group: "Dialog",
run: () => (state().type === "failed" ? dialog.clear() : run()),
},
{
bind: "left",
title: "Previous update action",
group: "Dialog",
run: toggle,
},
{
bind: "right",
title: "Next update action",
group: "Dialog",
run: toggle,
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Update
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box paddingBottom={1}>
<Switch>
<Match when={state().type === "ready"}>
<text fg={theme.text.subdued}>
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
</text>
</Match>
<Match when={state().type === "installing"}>
<Spinner>Installing OpenCode {props.version}</Spinner>
</Match>
<Match when={state().type === "restarting"}>
<Spinner>Restarting the background service</Spinner>
</Match>
<Match when={state().type === "failed"}>
<text fg={theme.text.feedback.error.default}>{failure()}</text>
</Match>
</Switch>
</box>
<Show
when={state().type === "ready"}
fallback={
<Show when={state().type === "failed"}>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => dialog.clear()}
>
<text fg={theme.text.action.primary.focused}>close</text>
</box>
</box>
</Show>
}
>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["ignore", "update"] as const}>
{(action) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (action === "ignore") return dialog.clear()
beginInstall()
}}
>
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
{action === "update" ? "Update" : "Ignore"}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}

View file

@ -13897,16 +13897,9 @@
"default_agent": {
"type": "string"
},
"autoupdate": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"enum": ["notify"]
}
]
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",

View file

@ -13897,16 +13897,9 @@
"default_agent": {
"type": "string"
},
"autoupdate": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"enum": ["notify"]
}
]
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",

View file

@ -127,16 +127,17 @@ Choose the primary agent used when a session does not select one explicitly.
See the [agents guide](/agents) for built-in and custom
agents.
### Autoupdate
### Updates
Control automatic updates from the global config. Set this to `false` to
disable updates, or `"notify"` to report available updates without installing
them. Set this to `true` to automatically install compatible non-major updates.
Control updates from the global config. Set `update` to `"disable"` to skip
updates, `"notify"` to report available updates without installing them, or
`"auto"` to automatically install compatible non-major updates.
Major updates are reported but never installed automatically.
Project-level values are ignored.
```jsonc
{
"autoupdate": false,
"update": "auto",
}
```

View file

@ -399,13 +399,14 @@ See [Models](/models) for the complete native model shape.
### Supported fields without direct native equivalents
Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `autoupdate`, `watcher`, `formatter`,
Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `watcher`, `formatter`,
`lsp`, `instructions`, `enterprise`, and `tool_output`, require no migration.
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported:
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
- `disabled_providers` becomes internal deny policies for the listed providers.
- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` remains `"notify"`, and `true` maps to `"auto"`.
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
`agents.title.model` instead.