diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 1a5d2b39b030..de45766f2ad5 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -47,7 +47,6 @@ export default Runtime.handler(Commands, (input) => ), ) const updater = yield* Updater.Service - if (!server.service) yield* updater.check().pipe(Effect.forkScoped) preflight.loading() const config = yield* Config.Service const npm = yield* Npm.Service @@ -83,11 +82,14 @@ 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, + updater: { + monitor: (notify, signal) => + runPromise( + updater.monitor((version) => Effect.sync(() => notify(version))), + { signal }, + ), + apply: (version) => runPromise(updater.apply(version)), + }, packages: { prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)), }, diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index c165258dc4cc..3a043c40a079 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -7,14 +7,12 @@ import { Global } from "@opencode-ai/util/global" import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version" import { AppProcess } from "@opencode-ai/util/process" import { randomBytes, randomUUID } from "node:crypto" -import { spawn } from "node:child_process" -import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect" +import { 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" import { ServiceConfig } from "./services/service-config" import { ServiceRegistration } from "./services/service-registration" -import { Updater } from "./services/updater" import { WebUi } from "./services/web-ui" export type Mode = "default" | "service" | "stdio" @@ -29,7 +27,6 @@ export type Options = { // The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace. export const run = Effect.fnUntraced(function* (options: Options) { return yield* processEffect(options).pipe( - Effect.provide(Updater.layer), Effect.provide( LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), { replacements: [ @@ -54,8 +51,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ) const global = yield* Global.Service if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home)) - const replacement = yield* Deferred.make() - const next = yield* Effect.scoped( + return yield* Effect.scoped( Effect.gen(function* () { const foreground = options.mode === "default" const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined @@ -66,7 +62,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 Option.none() + if (incumbent !== undefined) return 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. @@ -163,62 +159,17 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { ) }), ) - if (server === undefined) return Option.none() + if (server === undefined) return 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 - .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" - ? Effect.raceFirst( - server.shutdown.pipe(Effect.as(Option.none())), - Deferred.await(replacement).pipe(Effect.map(Option.some)), - ) + ? server.shutdown : options.mode === "stdio" - ? waitForStdinClose().pipe(Effect.as(Option.none())) + ? waitForStdinClose() : 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((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) { diff --git a/packages/cli/src/services/updater-action.ts b/packages/cli/src/services/updater-action.ts index 9b76507111f3..d447b716692f 100644 --- a/packages/cli/src/services/updater-action.ts +++ b/packages/cli/src/services/updater-action.ts @@ -1,5 +1,5 @@ -export type Policy = "disable" | "notify" | "auto" -export type Action = "none" | "notify" | "upgrade" +export type Policy = "disable" | "notify" +export type Action = "none" | "notify" const maximumComponent = "9007199254740991" const versionPattern = @@ -10,10 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action 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 "notify" - return "upgrade" + return "notify" } export function parseReleaseVersion(input: string) { diff --git a/packages/cli/src/services/updater.test.ts b/packages/cli/src/services/updater.test.ts index e477675c2596..3d9bbeb5458a 100644 --- a/packages/cli/src/services/updater.test.ts +++ b/packages/cli/src/services/updater.test.ts @@ -6,22 +6,17 @@ describe("updater", () => { 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": "auto" }')).toBe("notify") 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") + expect(decodePolicy('{ "autoupdate": true }')).toBe("notify") }) - test("automatically updates patches and minors", () => { - 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", () => { + test("reports every available release", () => { 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") @@ -32,25 +27,21 @@ describe("updater", () => { expect(action("1.2.3", "1.2.4", "disable")).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", "auto")).toBe("none") + expect(action("1.2.3", "1.2.3", "notify")).toBe("none") }) - test("upgrades when latest is lower (rollback)", () => { - expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade") + test("reports when latest is lower (rollback)", () => { + expect(action("1.2.4", "1.2.3", "notify")).toBe("notify") }) test("accepts strict release version variants", () => { - 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") + expect(action("v1.2.3", " 1.2.4\n", "notify")).toBe("notify") + expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "notify")).toBe("notify") + expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "notify")).toBe("notify") + expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "notify")).toBe("notify") + expect(action("1.2.3+old", "1.2.3+new", "notify")).toBe("none") + expect(action("v1.2.3+old", "1.2.3", "notify")).toBe("none") }) test("preserves strict validity", () => { @@ -71,21 +62,21 @@ describe("updater", () => { "0.9007199254740992.0", "0.0.9007199254740992", ] - invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none")) + invalid.forEach((version) => expect(action("1.2.3", version, "notify"), version).toBe("none")) }) test("handles numeric limits without losing precision", () => { - expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade") - expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify") + expect(action("9007199254740991.0.0", "9007199254740991.0.1", "notify")).toBe("notify") + expect(action("9007199254740990.0.0", "9007199254740991.0.0", "notify")).toBe("notify") }) test("preserves equality for oversized numeric prerelease identifiers", () => { - 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") + expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "notify")).toBe("none") + expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "notify")).toBe("notify") }) test("rejects versions longer than semver's limit before trimming", () => { - 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") + expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "notify")).toBe("none") + expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "notify")).toBe("notify") }) }) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 2fb99f308c82..b769cb0ca6d3 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -1,154 +1,36 @@ 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_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version" -import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect" +import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect" import { ChildProcess } from "effect/unstable/process" import { parse, type ParseError } from "jsonc-parser" import path from "node:path" -import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action" +import { action, parseReleaseVersion, type Policy } from "./updater-action" export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const export type Method = (typeof methods)[number] export interface Interface { - readonly check: () => Effect.Effect - readonly monitor: (input: { - readonly url: string - readonly password: string - readonly managed: boolean - readonly notify: (version: string) => Effect.Effect - readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect - }) => Effect.Effect + readonly monitor: (notify: (version: string) => Effect.Effect) => Effect.Effect readonly apply: (version: string) => Effect.Effect readonly method: () => Effect.Effect readonly latest: () => Effect.Effect readonly upgrade: (method: Method, version: string) => Effect.Effect } -export type Inspection = - | { readonly action: "none" } - | { readonly action: Exclude; 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 - readonly install: (version: string) => Effect.Effect - readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect - readonly interval?: Duration.Input - readonly notificationThreshold?: Duration.Input +export const monitorUpdates = Effect.fnUntraced(function* (input: { + readonly inspect: () => Effect.Effect readonly notify: (version: string) => Effect.Effect -} - -export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) { - const state = yield* Ref.make({ 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, - }, - ) + readonly initialDelay?: Duration.Input + readonly interval?: Duration.Input +}) { + const interval = input.interval ?? "10 minutes" + const initialDelay = input.initialDelay ?? "90 seconds" + const check = Effect.gen(function* () { + const version = yield* input.inspect() + if (version !== undefined) yield* input.notify(version) + }).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error }))) + return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay)) }) export class Service extends Context.Service()("@opencode/cli/Updater") {} @@ -161,13 +43,14 @@ export function decodePolicy(text: string): Policy | undefined { 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 + if (value === "disable" || value === "notify") return value + if (value === "auto") return "notify" return } if (!("autoupdate" in input)) return if (input.autoupdate === false) return "disable" if (input.autoupdate === "notify") return "notify" - if (input.autoupdate === true) return "auto" + if (input.autoupdate === true) return "notify" } const make = Effect.gen(function* () { @@ -192,7 +75,7 @@ const make = Effect.gen(function* () { Effect.orElseSucceed(() => undefined), ), ) - return values.findLast((value) => value !== undefined) ?? "auto" + return values.findLast((value) => value !== undefined) ?? "notify" }) const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") { @@ -302,19 +185,19 @@ const make = Effect.gen(function* () { return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`)) }) - const inspect = Effect.fnUntraced(function* (): Effect.fn.Return { + const inspect = Effect.fnUntraced(function* () { 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" } + return undefined } const policy = yield* readPolicy() if (policy === "disable") { yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" }) - return { action: "none" } + return undefined } const version = yield* latest() @@ -325,19 +208,16 @@ const make = Effect.gen(function* () { const next = action(OPENCODE_VERSION, version, policy) if (next === "none") { yield* Effect.logInfo("update check done", { action: "up-to-date" }) - return { action: "none" } + return undefined } - if (next === "notify") { - yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version }) - return { action: next, version } - } - return { action: next, version } + yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version }) + return version }) const install = Effect.fnUntraced(function* (version: string) { const detected = yield* method() if (!detected) { - yield* Effect.logWarning("automatic update skipped: installation method not found") + yield* Effect.logWarning("update skipped: installation method not found") return false } yield* upgrade(detected, version) @@ -349,26 +229,9 @@ const make = Effect.gen(function* () { 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 - readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect - }) { - return yield* monitorServer({ ...input, inspect, install }) - }) + const monitor = (notify: (version: string) => Effect.Effect) => monitorUpdates({ inspect, notify }) - return Service.of({ check, monitor, apply, method, latest, upgrade }) + return Service.of({ monitor, apply, method, latest, upgrade }) }) export const layer = Layer.effect(Service, make) diff --git a/packages/cli/test/fixture/upgrade.ts b/packages/cli/test/fixture/upgrade.ts index 70263f2d15e6..5ed8b4bc479f 100644 --- a/packages/cli/test/fixture/upgrade.ts +++ b/packages/cli/test/fixture/upgrade.ts @@ -12,9 +12,8 @@ await Effect.runPromise( process.argv.slice(2), ).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"), + apply: () => Effect.die("Manual upgrades must not apply TUI updates"), method: () => Effect.sync(() => { record("method") diff --git a/packages/cli/test/updater-monitor.test.ts b/packages/cli/test/updater-monitor.test.ts index 866d59dceee8..92ec7372aa07 100644 --- a/packages/cli/test/updater-monitor.test.ts +++ b/packages/cli/test/updater-monitor.test.ts @@ -1,107 +1,40 @@ import { expect } from "bun:test" -import { Deferred, Effect, Layer, Option } from "effect" +import { Effect, Layer, Queue } from "effect" +import { TestClock } from "effect/testing" 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", () => +it.effect("checks after 90 seconds and every 10 minutes after that", () => Effect.gen(function* () { - const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop())) - const installed = yield* Deferred.make() - const restarted = yield* Deferred.make() - 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, + const updates = yield* Queue.unbounded() + yield* Updater.monitorUpdates({ + inspect: () => Effect.succeed("2.0.0"), + notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid), }).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")))), - ) + yield* Effect.yieldNow + expect(yield* Queue.size(updates)).toBe(0) + yield* TestClock.adjust("89 seconds") + expect(yield* Queue.size(updates)).toBe(0) + yield* TestClock.adjust("1 second") + expect(yield* Queue.take(updates)).toBe("2.0.0") + yield* Effect.yieldNow + yield* TestClock.adjust("10 minutes") + expect(yield* Queue.take(updates)).toBe("2.0.0") }), ) -const wait = (promise: Promise, 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() - const eventOpened = Promise.withResolvers() - const waited = Promise.withResolvers() - let active = true - let events: ReadableStreamDefaultController | 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({ - start(controller) { - events = controller - eventOpened.resolve() - }, - }), - { headers: { "content-type": "text/event-stream" } }, - ) - } - return new Response("Not found", { status: 404 }) - }, - }) +it.effect("does not notify when no update is available", () => + Effect.gen(function* () { + const updates = yield* Queue.unbounded() + yield* Updater.monitorUpdates({ + inspect: () => Effect.succeed(undefined), + notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid), + }).pipe(Effect.forkScoped) - 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) - }, - } -} + yield* Effect.yieldNow + expect(yield* Queue.size(updates)).toBe(0) + }), +) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1717899493b0..3638435548af 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1892,7 +1892,7 @@ export type ConfigEntry = shell?: string model?: string | { providerID: string; model: string; variant?: string } default_agent?: string - update?: "disable" | "notify" | "auto" + update?: "disable" | "notify" share?: "manual" | "auto" | "disabled" enterprise?: { url?: string } username?: string diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts index 2dd14cf64cce..7777eaa8b51c 100644 --- a/packages/core/src/config/normalize.ts +++ b/packages/core/src/config/normalize.ts @@ -73,6 +73,11 @@ export function normalize(input: unknown): Result { const legacyUpdate = own(input, "autoupdate") ? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics) : undefined + const nativeUpdate = own(input, "update") + ? input.update === "auto" + ? "notify" + : decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics) + : undefined const legacyShare = own(input, "autoshare") ? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true ? "auto" @@ -86,7 +91,10 @@ 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 + const migratedUpdate = + legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update + const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics) + if (update !== undefined) encoded.update = update if (legacyShare !== undefined) encoded.share = legacyShare const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded) @@ -196,7 +204,6 @@ export function normalize(input: unknown): Result { shell: Info.fields.shell, model: Info.fields.model, default_agent: Info.fields.default_agent, - update: Info.fields.update, share: Info.fields.share, enterprise: Info.fields.enterprise, username: Info.fields.username, diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 271689ce4129..4ddfbe10c03e 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -29,11 +29,9 @@ export function migrate(info: typeof ConfigV1.Info.Type) { update: info.autoupdate === false ? "disable" - : info.autoupdate === "notify" + : info.autoupdate === "notify" || info.autoupdate === true ? "notify" - : info.autoupdate === true - ? "auto" - : undefined, + : undefined, share: info.share ?? (info.autoshare ? "auto" : undefined), enterprise: info.enterprise, username: info.username, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 2533ba603bc2..d7f5270b70cf 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -13,6 +13,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Credential } from "@opencode-ai/core/credential" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { ConfigNormalize } from "@opencode-ai/core/config/normalize" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Bus } from "@opencode-ai/core/bus" import { Global } from "@opencode-ai/util/global" @@ -665,10 +666,18 @@ 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({ autoupdate: true }).update).toBe("notify") expect(ConfigMigrateV1.migrate({}).update).toBeUndefined() }) + test("normalizes the previous native auto update policy", () => { + expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({ + type: "normalized", + encoded: { update: "notify" }, + diagnostics: [], + }) + }) + test("migrates v1 provider lists to policies", () => { expect( ConfigMigrateV1.migrate({ diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 345ee740ca96..54fa594848c9 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -13904,7 +13904,7 @@ }, "update": { "type": "string", - "enum": ["disable", "notify", "auto"] + "enum": ["disable", "notify"] }, "share": { "type": "string", diff --git a/packages/schema/src/config.ts b/packages/schema/src/config.ts index 653819d6b511..33c0a4c6a81e 100644 --- a/packages/schema/src/config.ts +++ b/packages/schema/src/config.ts @@ -34,8 +34,8 @@ export class Info extends Schema.Class("Config.Info")({ default_agent: Schema.String.pipe(optional).annotate({ description: "Default primary agent to use when no session agent is selected", }), - update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({ - description: "Disable updates, notify when one is available, or install automatically", + update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({ + description: "Disable updates or notify when one is available", }), share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({ description: "Control whether sessions may be shared manually, automatically, or not at all", diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index f515b1a20c2f..99f3b37f1b48 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,11 +1,9 @@ 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, @@ -116,12 +114,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( ) yield* Ref.set(application, Option.some(transform ? transform(app) : app)) yield* status.ready - return { - address: bound.http.address, - shutdown: shutdown.await, - updateAvailable: (version: string) => - Context.get(context, Bus.Service).publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid), - } + return { address: bound.http.address, shutdown: shutdown.await } }).pipe( Effect.catchCause((cause) => { if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause) diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index 228f4c52bdc9..234bd255db8a 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -1,5 +1,4 @@ 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" @@ -100,12 +99,9 @@ it.live("allows browser preflight requests without credentials", () => ) expect(event.status).toBe(200) expect(event.headers.get("content-encoding")).toBeNull() - 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 body = event.body + if (!body) return yield* Effect.die(new Error("Event response has no body")) + yield* Effect.promise(() => body.cancel()) const missing = yield* Effect.promise(() => fetch(new URL("/missing", HttpServer.formatAddress(server.address)), { @@ -130,11 +126,3 @@ it.live("allows browser preflight requests without credentials", () => ) }), ) - -async function readUntil(reader: ReadableStreamDefaultReader, 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 - } -} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index a6501f751680..8895847cb6b1 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -186,6 +186,7 @@ export type TuiInput = { args: Args config: Config.Interface updater?: { + monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise apply: (version: string) => Promise } packages: PackageSource @@ -220,9 +221,6 @@ 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 } @@ -507,6 +505,36 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater" "update-notifications", { initial: { versions: [] } }, ) + const showUpdate = (version: string) => { + const updater = props.updater + if (!updater || 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 })) + const key = `update:${version}` + dialog.replace( + () => ( + updater.apply(version)} + restart={client.restart} + /> + ), + undefined, + { key }, + ) + dialog.setCentered(true) + } + onMount(() => { + const updater = props.updater + if (!updater) return + const controller = new AbortController() + onCleanup(() => controller.abort()) + void updater.monitor(showUpdate, controller.signal).catch((error) => { + if (!controller.signal.aborted) log.error("update monitor failed", { error }) + }) + }) const tabsResize = createPaneResize({ value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH, defaultValue: () => SESSION_SIDEBAR_WIDTH, @@ -1215,19 +1243,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater" }) }) - 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(() => 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({ diff --git a/packages/tui/src/component/dialog-update.tsx b/packages/tui/src/component/dialog-update.tsx index c07d53f7eb95..96f534250b8c 100644 --- a/packages/tui/src/component/dialog-update.tsx +++ b/packages/tui/src/component/dialog-update.tsx @@ -8,22 +8,32 @@ import { useDialog } from "../ui/dialog" import { Spinner } from "./spinner" type State = - | { type: "ready"; active: "update" | "ignore" } + | { type: "ready"; active: "update" | "skip" } | { type: "installing" } | { type: "restarting" } | { type: "failed"; message: string } -export function DialogUpdate(props: { version: string; install: () => Promise; restart: () => Promise }) { +export function DialogUpdate(props: { + dialogKey: string + version: string + install: () => Promise + restart?: () => Promise +}) { const dialog = useDialog() const theme = useTheme("elevated") const [state, setState] = createSignal({ type: "ready", active: "update" }) + const close = () => { + if (dialog.key === props.dialogKey) dialog.clear() + } const install = async () => { setState({ type: "installing" }) await props.install() - setState({ type: "restarting" }) - await props.restart() - dialog.clear() + if (props.restart) { + setState({ type: "restarting" }) + await props.restart() + } + close() } const beginInstall = () => { @@ -34,16 +44,16 @@ export function DialogUpdate(props: { version: string; install: () => Promise { const current = state() if (current.type !== "ready") return - if (current.active === "ignore") return dialog.clear() + if (current.active === "skip") return close() beginInstall() } const toggle = () => setState((current) => - current.type === "ready" ? { ...current, active: current.active === "update" ? "ignore" : "update" } : current, + current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current, ) - const selected = (action: "update" | "ignore") => { + const selected = (action: "update" | "skip") => { const current = state() return current.type === "ready" && current.active === action } @@ -60,7 +70,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise (state().type === "failed" ? dialog.clear() : run()), + run: () => (state().type === "failed" ? close() : run()), }, { bind: "left", @@ -81,9 +91,9 @@ export function DialogUpdate(props: { version: string; install: () => Promise - Update + Update available - dialog.clear()}> + esc @@ -91,14 +101,17 @@ export function DialogUpdate(props: { version: string; install: () => Promise - Update to v{props.version}? It will be applied in the background and active sessions will be restarted. + An update is available. Applying will + {props.restart + ? " restart the server and active sessions will be resumed." + : " install the update but you will need to manually restart."} - Installing OpenCode {props.version}… + Installing OpenCode {props.version}… - Restarting the background service… + Restarting the background service… {failure()} @@ -114,7 +127,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise dialog.clear()} + onMouseUp={close} > close @@ -123,19 +136,19 @@ export function DialogUpdate(props: { version: string; install: () => Promise - + {(action) => ( { - if (action === "ignore") return dialog.clear() + if (action === "skip") return close() beginInstall() }} > - {action === "update" ? "Update" : "Ignore"} + {action === "update" ? "Update" : "Skip"} )} diff --git a/packages/tui/src/component/shimmer-text.tsx b/packages/tui/src/component/shimmer-text.tsx new file mode 100644 index 000000000000..a1e06eade505 --- /dev/null +++ b/packages/tui/src/component/shimmer-text.tsx @@ -0,0 +1,104 @@ +import { + OptimizedBuffer, + RGBA, + TargetChannel, + TextRenderable, + type RenderContext, + type TextOptions, +} from "@opentui/core" +import { extend, type JSX } from "@opentui/solid" +import { splitProps } from "solid-js" +import { coast, intensityAt } from "./tab-pulse" + +type ShimmerTextOptions = TextOptions & { + shimmer: RGBA +} + +const DURATION = 1200 +const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0) +const CONTINUATION = 0xc0000000 | 0 + +class ShimmerTextRenderable extends TextRenderable { + private _shimmer = RGBA.defaultForeground() + private elapsed = 0 + private scratch: OptimizedBuffer | undefined + private mask = new Float32Array(0) + private matrix = new Float32Array(16) + + constructor(ctx: RenderContext, options: ShimmerTextOptions) { + super(ctx, options) + this.matrix[3] = this._shimmer.r + this.matrix[7] = this._shimmer.g + this.matrix[11] = this._shimmer.b + this.matrix[15] = 1 + if (options.shimmer) this.shimmer = options.shimmer + this.live = true + } + + set shimmer(value: RGBA) { + if (value.equals(this._shimmer)) return + this._shimmer = value + this.matrix[3] = value.r + this.matrix[7] = value.g + this.matrix[11] = value.b + this.requestRender() + } + + override render(buffer: OptimizedBuffer, deltaTime: number) { + if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return + this.elapsed = (this.elapsed + deltaTime) % DURATION + if (!this.scratch) + this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true }) + if (this.scratch.width !== this.width || this.scratch.height !== this.height) + this.scratch.resize(this.width, this.height) + + this.scratch.clear(TRANSPARENT) + this.scratch.drawTextBuffer(this.textBufferView, 0, 0) + const characters = this.scratch.buffers.char + let end = 0 + for (let row = 0; row < this.height; row++) { + let column = this.width + while ( + column > 0 && + (characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0) + ) + column-- + end = Math.max(end, column) + } + const front = -4 + coast(this.elapsed / DURATION) * (end + 22) + if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3) + let strength = 0 + for (let cell = 0; cell < characters.length; cell++) { + const column = cell % this.width + if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensityAt(column, front, 4, 18) + this.mask[cell * 3] = column + this.mask[cell * 3 + 1] = Math.floor(cell / this.width) + this.mask[cell * 3 + 2] = strength + } + this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG) + buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch) + this.markClean() + this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num) + } + + override destroy() { + this.scratch?.destroy() + this.scratch = undefined + super.destroy() + } +} + +extend({ shimmer_text: ShimmerTextRenderable }) + +declare module "@opentui/solid" { + interface OpenTUIComponents { + shimmer_text: typeof ShimmerTextRenderable + } +} + +type Props = Omit & { shimmer: RGBA } + +export function ShimmerText(props: Props) { + const [local, text] = splitProps(props, ["shimmer"]) + return +} diff --git a/packages/tui/src/component/spinner.tsx b/packages/tui/src/component/spinner.tsx index 1cca2f4bcffd..0bed53f45ac7 100644 --- a/packages/tui/src/component/spinner.tsx +++ b/packages/tui/src/component/spinner.tsx @@ -1,30 +1,48 @@ -import { Show } from "solid-js" +import { createEffect, createSignal, onCleanup, Show } from "solid-js" import { useTheme } from "../context/theme" import { useConfig } from "../config" import type { JSX } from "@opentui/solid" import type { RGBA } from "@opentui/core" import { registerOpencodeSpinner } from "./register-spinner" import { SPINNER_FRAMES } from "./spinner-frames" +import { ShimmerText } from "./shimmer-text" export { SPINNER_FRAMES } from "./spinner-frames" registerOpencodeSpinner() -export function Spinner(props: { children?: JSX.Element; color?: RGBA }) { +export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?: RGBA }) { const theme = useTheme() const config = useConfig().data const color = () => props.color ?? theme.text.subdued + const [frame, setFrame] = createSignal(0) + createEffect(() => { + if (!(config.animations ?? true) || !props.shimmer) return + const timer = setInterval(() => setFrame((value) => (value + 1) % SPINNER_FRAMES.length), 80) + onCleanup(() => clearInterval(timer)) + }) return ( {props.children ? <>⋯ {props.children} : "⋯"}} > - - - - {props.children} - - + + + + {props.children} + + + } + > + {(shimmer) => ( + + {SPINNER_FRAMES[frame()]} {props.children} + + )} + ) } diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 345ee740ca96..54fa594848c9 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -13904,7 +13904,7 @@ }, "update": { "type": "string", - "enum": ["disable", "notify", "auto"] + "enum": ["disable", "notify"] }, "share": { "type": "string", diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 345ee740ca96..54fa594848c9 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -13904,7 +13904,7 @@ }, "update": { "type": "string", - "enum": ["disable", "notify", "auto"] + "enum": ["disable", "notify"] }, "share": { "type": "string", diff --git a/packages/www/src/docs/content/config.mdx b/packages/www/src/docs/content/config.mdx index c9e10a28be87..f0d93fd4ced0 100644 --- a/packages/www/src/docs/content/config.mdx +++ b/packages/www/src/docs/content/config.mdx @@ -129,15 +129,13 @@ agents. ### 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. +Control update checks from the global config. Set `update` to `"disable"` to +skip them or `"notify"` to show available updates before installing them. Project-level values are ignored. ```jsonc { - "update": "auto", + "update": "notify", } ``` diff --git a/packages/www/src/docs/content/migrate-v1.mdx b/packages/www/src/docs/content/migrate-v1.mdx index 838ea805cc1e..ac939218ea98 100644 --- a/packages/www/src/docs/content/migrate-v1.mdx +++ b/packages/www/src/docs/content/migrate-v1.mdx @@ -409,7 +409,8 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei - `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"`. +- `autoupdate` becomes `update`: `false` maps to `"disable"`, while `"notify"` and `true` map to `"notify"`. +- The previous V2 value `update: "auto"` is treated as `update: "notify"`. - `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use `agents.title.model` instead.