feat(cli): restore automatic update policy (#47161)

This commit is contained in:
James Long 2026-09-04 12:58:30 -04:00 committed by GitHub
parent bff58fc387
commit 0e143437c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 651 additions and 438 deletions

View file

@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option, Queue } from "effect"
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
const update = yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@ -83,9 +84,12 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
updater: {
monitor: (notify, signal) =>
remote: requestedServer !== undefined,
subscribe: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
Fiber.join(update).pipe(
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
),
{ signal },
),
apply: (version) => runPromise(updater.apply(version)),

View file

@ -13,6 +13,7 @@ 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"
@ -163,6 +164,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
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}`)
yield* Updater.Service.pipe(
Effect.flatMap((updater) =>
Updater.pollUpdates({
check: updater.check().pipe(
Effect.flatMap((result) => {
if (!result) return Effect.void
if (result.type === "available") return server.updateAvailable(result.version)
return server.updated(result.version)
}),
),
}),
),
Effect.provide(Updater.layer),
Effect.forkScoped,
)
return yield* options.mode === "service"
? server.shutdown
: options.mode === "stdio"

View file

@ -1,5 +1,5 @@
export type Policy = "disable" | "notify"
export type Action = "none" | "notify"
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "auto"
const maximumComponent = "9007199254740991"
const versionPattern =
@ -10,7 +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"
return "notify"
return policy
}
export function parseReleaseVersion(input: string) {

View file

@ -6,14 +6,14 @@ 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("notify")
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("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
})
test("reports every available release", () => {
@ -23,6 +23,11 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("automatically installs every available release when enabled", () => {
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
})
test("skips when updates are disabled", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})

View file

@ -1,7 +1,7 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@ -9,28 +9,26 @@ 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 type CheckResult = { readonly type: "available" | "installed"; readonly version: string }
export interface Interface {
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
readonly check: () => Effect.Effect<CheckResult | undefined>
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 const monitorUpdates = Effect.fnUntraced(function* (input: {
readonly inspect: () => Effect.Effect<string | undefined, Error>
readonly notify: (version: string) => Effect.Effect<void>
export const pollUpdates = Effect.fnUntraced(function* (input: {
readonly check: Effect.Effect<unknown>
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))
return yield* input.check.pipe(
Effect.repeat(Schedule.spaced(interval)),
Effect.delay(input.initialDelay ?? "1 minute"),
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@ -43,20 +41,20 @@ 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") return value
if (value === "auto") return "notify"
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 "notify"
if (input.autoupdate === true) return "auto"
}
const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
@ -75,7 +73,7 @@ const make = Effect.gen(function* () {
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? "notify"
return values.findLast((value) => value !== undefined) ?? "auto"
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
@ -200,18 +198,19 @@ const make = Effect.gen(function* () {
return undefined
}
const current = yield* Ref.get(installedVersion)
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
current,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
const next = action(current, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return undefined
}
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return version
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
return { policy, version }
})
const install = Effect.fnUntraced(function* (version: string) {
@ -220,8 +219,10 @@ const make = Effect.gen(function* () {
yield* Effect.logWarning("update skipped: installation method not found")
return false
}
const current = yield* Ref.get(installedVersion)
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
yield* Ref.set(installedVersion, version)
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
return true
})
@ -229,9 +230,18 @@ const make = Effect.gen(function* () {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
const check = Effect.fn("cli.updater.check")(
function* () {
const result = yield* inspect()
if (!result) return undefined
if (result.policy === "notify") return { type: "available" as const, version: result.version }
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
return { type: "installed" as const, version: result.version }
},
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
)
return Service.of({ monitor, apply, method, latest, upgrade })
return Service.of({ check, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)

View file

@ -12,7 +12,7 @@ await Effect.runPromise(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
check: () => Effect.die("Manual upgrades must not check for automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
method: () =>
Effect.sync(() => {

View file

@ -1,40 +0,0 @@
import { expect } from "bun:test"
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.effect("checks after 90 seconds and every 10 minutes after that", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed("2.0.0"),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
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")
}),
)
it.effect("does not notify when no update is available", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed(undefined),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)

View file

@ -0,0 +1,24 @@
import { expect } from "bun:test"
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.effect("polls after 1 minute and every 10 minutes after that", () =>
Effect.gen(function* () {
const checks = yield* Queue.unbounded<void>()
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("59 seconds")
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("1 second")
yield* Queue.take(checks)
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
yield* Queue.take(checks)
}),
)

View file

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

View file

@ -74,9 +74,7 @@ export function normalize(input: unknown): Result {
? 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)
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true

View file

@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
update:
info.autoupdate === false
? "disable"
: info.autoupdate === "notify" || info.autoupdate === true
: info.autoupdate === "notify"
? "notify"
: undefined,
: info.autoupdate === true
? "auto"
: undefined,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,

View file

@ -666,14 +666,14 @@ 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("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
})
test("normalizes the previous native auto update policy", () => {
test("normalizes the native auto update policy", () => {
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
type: "normalized",
encoded: { update: "notify" },
encoded: { update: "auto" },
diagnostics: [],
})
})

View file

@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
@ -18221,6 +18221,12 @@
"type": "string",
"enum": ["auto", "manual"]
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
"type": "string"
},
@ -18448,6 +18454,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {

View file

@ -34,8 +34,8 @@ 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",
}),
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
description: "Disable updates or notify when one is available",
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
description: "Disable updates, notify when one is available, or install updates 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,7 +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 { InstallationEvent } from "@opencode-ai/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
@ -114,7 +116,14 @@ 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 }
const bus = Context.get(context, Bus.Service)
return {
address: bound.http.address,
shutdown: shutdown.await,
updateAvailable: (version: string) =>
bus.publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
updated: (version: string) => bus.publish(InstallationEvent.Updated, { version }).pipe(Effect.asVoid),
}
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)

View file

@ -101,7 +101,13 @@ it.live("allows browser preflight requests without credentials", () =>
expect(event.headers.get("content-encoding")).toBeNull()
const body = event.body
if (!body) return yield* Effect.die(new Error("Event response has no body"))
yield* Effect.promise(() => body.cancel())
const reader = body.getReader()
yield* Effect.promise(() => readUntil(reader, "server.connected"))
yield* server.updateAvailable("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
yield* server.updated("2.0.0")
yield* Effect.promise(() => readUntil(reader, "installation.updated"))
yield* Effect.promise(() => reader.cancel())
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
@ -126,3 +132,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,7 +64,6 @@ 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"
@ -88,6 +87,7 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { UpdateNotificationProvider, type UpdateSource } from "./context/update-notification"
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
import { localPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
@ -185,10 +185,7 @@ export type TuiInput = {
}
args: Args
config: Config.Interface
updater?: {
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
apply: (version: string) => Promise<void>
}
updater?: UpdateSource
packages: PackageSource
environment?: Readonly<Record<string, string>>
terminalHandoff?: () => Promise<
@ -397,22 +394,25 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PromptRefProvider>
<EditorContextProvider>
<AttentionProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
<UpdateNotificationProvider
updater={input.updater}
>
<App
updater={input.updater}
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</UpdateNotificationProvider>
</AttentionProvider>
</EditorContextProvider>
</PromptRefProvider>
@ -462,7 +462,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) {
function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@ -501,40 +501,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
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 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(
() => (
<DialogUpdate
dialogKey={key}
version={version}
install={() => 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,

View file

@ -1,160 +0,0 @@
/** @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" | "skip" }
| { type: "installing" }
| { type: "restarting" }
| { type: "failed"; message: string }
export function DialogUpdate(props: {
dialogKey: string
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 close = () => {
if (dialog.key === props.dialogKey) dialog.clear()
}
const install = async () => {
setState({ type: "installing" })
await props.install()
if (props.restart) {
setState({ type: "restarting" })
await props.restart()
}
close()
}
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 === "skip") return close()
beginInstall()
}
const toggle = () =>
setState((current) =>
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
)
const selected = (action: "update" | "skip") => {
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" ? close() : 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 available
</text>
<text fg={theme.text.subdued} onMouseUp={close}>
esc
</text>
</box>
<box paddingBottom={1}>
<Switch>
<Match when={state().type === "ready"}>
<text fg={theme.text.subdued}>
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."}
</text>
</Match>
<Match when={state().type === "installing"}>
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}</Spinner>
</Match>
<Match when={state().type === "restarting"}>
<Spinner shimmer={theme.text.default}>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={close}
>
<text fg={theme.text.action.primary.focused}>close</text>
</box>
</box>
</Show>
}
>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["skip", "update"] as const}>
{(action) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (action === "skip") return close()
beginInstall()
}}
>
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
{action === "update" ? "Update" : "Skip"}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}

View file

@ -0,0 +1,151 @@
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 { useConfig } from "../config"
import { coast, smootherstep } from "./tab-pulse"
type FadeInTextOptions = TextOptions & {
backdrop?: RGBA
enabled?: boolean
sweepOffset?: number
sweepWidth?: number
}
const DURATION = 200
const FEATHER = 8
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
const CONTINUATION = 0xc0000000 | 0
const clamp = (value: number) => Math.max(0, Math.min(1, value))
class FadeInTextRenderable extends TextRenderable {
private _backdrop = RGBA.defaultBackground()
private _enabled = true
private _sweepOffset = 0
private _sweepWidth: number | undefined
private elapsed = 0
private scratch: OptimizedBuffer | undefined
private mask = new Float32Array(0)
private matrix = new Float32Array(16)
constructor(ctx: RenderContext, options: FadeInTextOptions) {
super(ctx, options)
this.matrix[15] = 1
this.updateBackdrop()
if (options.backdrop) this.backdrop = options.backdrop
if (options.enabled === false) this.enabled = false
this.live = this._enabled
}
set backdrop(value: RGBA) {
if (value.equals(this._backdrop)) return
this._backdrop = value
this.updateBackdrop()
this.requestRender()
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
this.live = value && this.elapsed < DURATION
this.requestRender()
}
set sweepOffset(value: number | undefined) {
this._sweepOffset = value ?? 0
this.requestRender()
}
set sweepWidth(value: number | undefined) {
this._sweepWidth = value
this.requestRender()
}
private updateBackdrop() {
this.matrix[3] = this._backdrop.r
this.matrix[7] = this._backdrop.g
this.matrix[11] = this._backdrop.b
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
if (!this._enabled || this.elapsed >= DURATION) return super.render(buffer, deltaTime)
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
this.elapsed = Math.min(DURATION, this.elapsed + deltaTime)
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 progress = this.elapsed / DURATION
const front = -FEATHER + coast(progress) * ((this._sweepWidth ?? end) + FEATHER * 2)
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
let strength = 1
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
if ((characters[cell] & CONTINUATION) !== CONTINUATION)
strength = 1 - smootherstep(clamp((front - (this._sweepOffset + column)) / FEATHER))
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)
if (this.elapsed >= DURATION) this.live = false
}
override destroy() {
this.scratch?.destroy()
this.scratch = undefined
super.destroy()
}
}
extend({ fade_in_text: FadeInTextRenderable })
declare module "@opentui/solid" {
interface OpenTUIComponents {
fade_in_text: typeof FadeInTextRenderable
}
}
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & {
animate?: boolean
backdrop?: RGBA
sweepOffset?: number
sweepWidth?: number
}
export function FadeInText(props: Props) {
const config = useConfig().data
const [local, text] = splitProps(props, ["animate", "backdrop", "sweepOffset", "sweepWidth"])
return (
<fade_in_text
{...text}
backdrop={local.backdrop}
enabled={(local.animate ?? true) && (config.animations ?? true)}
sweepOffset={local.sweepOffset}
sweepWidth={local.sweepWidth}
/>
)
}

View file

@ -1,104 +0,0 @@
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<JSX.IntrinsicElements["text"], "ref"> & { shimmer: RGBA }
export function ShimmerText(props: Props) {
const [local, text] = splitProps(props, ["shimmer"])
return <shimmer_text {...text} shimmer={local.shimmer} />
}

View file

@ -1,48 +1,30 @@
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
import { 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; shimmer?: RGBA }) {
export function Spinner(props: { children?: JSX.Element; color?: 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 (
<Show
when={config.animations ?? true}
fallback={<text fg={color()}>{props.children ? <> {props.children}</> : "⋯"}</text>}
>
<Show
when={props.shimmer}
fallback={
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
</box>
}
>
{(shimmer) => (
<ShimmerText fg={color()} shimmer={shimmer()}>
{SPINNER_FRAMES[frame()]} {props.children}
</ShimmerText>
)}
</Show>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
</box>
</Show>
)
}

View file

@ -0,0 +1,127 @@
import { createSignal, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
import { useStorage } from "./storage"
import { useEvent } from "./event"
import { errorMessage } from "../util/error"
import { useExit } from "./exit"
type ClientNotice = { readonly type: "available" | "installed"; readonly version: string }
type Notice = ClientNotice & ({ readonly source: "client" } | { readonly source: "server"; readonly remote: boolean })
export type UpdateNotificationState =
| Notice
| { readonly source: "client"; readonly type: "installing"; readonly version: string }
| { readonly source: "client"; readonly type: "install-success"; readonly version: string }
| { readonly source: "client"; readonly type: "failed"; readonly version: string; readonly message: string }
export type UpdateSource = {
readonly remote: boolean
readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise<void>
readonly apply: (version: string) => Promise<void>
}
export const { use: useUpdateNotification, provider: UpdateNotificationProvider } = createSimpleContext({
name: "UpdateNotification",
init: (props: { updater?: UpdateSource }) => {
const event = useEvent()
const exit = useExit()
const log = useLog({ component: "update-notification" })
const [state, setState] = createSignal<UpdateNotificationState>()
const [notifications, markNotification] = useStorage().store<{ versions: string[] }>("update-notifications", {
initial: { versions: [] },
})
const notify = (notice: Notice) => {
if (
!props.updater ||
notifications.versions.includes(`${notice.source}:${notice.version}`) ||
(notice.source === "client" && notifications.versions.includes(notice.version))
)
return
setState((current) => {
if (notice.source === "server" && current?.source === "client") return current
return notice
})
}
const seen = (source: Notice["source"], version: string) =>
markNotification((draft) => {
draft.versions = [...draft.versions, `${source}:${version}`].slice(-100)
}).catch((error) => log.error("failed to persist update notification", { error }))
const skip = () => {
const current = state()
if (!current || current.type !== "available" || (current.source === "server" && current.remote)) return
setState(undefined)
void seen(current.source, current.version)
}
const close = () => {
const current = state()
if (!current || current.source !== "server") return
setState(undefined)
void seen(current.source, current.version)
}
const install = async () => {
const updater = props.updater
const current = state()
if (!updater || !current || current.type !== "available" || (current.source === "server" && current.remote))
return
setState({ source: "client", type: "installing", version: current.version })
void seen(current.source, current.version)
await updater.apply(current.version).then(
() => setState({ source: "client", type: "install-success", version: current.version }),
(error) =>
setState({ source: "client", type: "failed", version: current.version, message: errorMessage(error) }),
)
}
const restart = () => {
const current = state()
if (!current || (current.type !== "installed" && current.type !== "install-success")) return
exit()
}
const later = () => {
const current = state()
if (!current || (current.type !== "installed" && current.type !== "install-success")) return
setState(undefined)
}
onMount(() => {
const updater = props.updater
if (!updater) return
const controller = new AbortController()
onCleanup(() => controller.abort())
void updater
.subscribe((notice) => notify({ ...notice, source: "client" }), controller.signal)
.catch((error) => {
if (!controller.signal.aborted) log.error("update check failed", { error })
})
})
onCleanup(
event.on("installation.update-available", (event) =>
notify({
source: "server",
remote: props.updater?.remote ?? false,
type: "available",
version: event.data.version,
}),
),
)
onCleanup(
event.on("installation.updated", (event) =>
notify({
source: "server",
remote: props.updater?.remote ?? false,
type: "installed",
version: event.data.version,
}),
),
)
return { state, skip, close, install, restart, later }
},
})

View file

@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "../component/prompt"
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
import { createEffect, createMemo, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
import { Logo } from "../component/logo"
import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
@ -11,6 +11,12 @@ import { useLocation } from "../context/location"
import { FormPrompt } from "./session/form"
import { Slot } from "../plugin/render"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes, type RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useUpdateNotification } from "../context/update-notification"
import { Spinner } from "../component/spinner"
import { FadeInText } from "../component/fade-in-text"
import { stringWidth } from "../util/string-width"
let once = false
const placeholder = {
@ -81,13 +87,16 @@ export function Home() {
paddingRight={dimensions().width < 44 ? 1 : 2}
>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box height={3} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<Logo />
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0} position="relative">
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
<box position="absolute" top="100%" left={0} right={0} alignItems="center">
<UpdateNotification />
</box>
</box>
<box flexGrow={1} minHeight={0} />
</box>
@ -109,3 +118,174 @@ export function Home() {
</>
)
}
function UpdateNotification() {
const update = useUpdateNotification()
const theme = useTheme()
const action = theme.text.action.primary.selected
const [hovered, setHovered] = createSignal<"primary" | "skip" | "later" | "close">()
createEffect(() => {
update.state()
setHovered(undefined)
})
return (
<Show when={update.state()} keyed>
{(state) => (
<box flexShrink={0} marginTop={4} alignItems="center">
<Switch>
<Match when={state.type === "available" && (state.source === "client" || !state.remote)}>
<box alignItems="center">
<box
alignItems="center"
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() === "primary" ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered("primary")}
onMouseOut={() => setHovered(undefined)}
onMouseUp={() => void update.install()}
>
<UpdateMessage
title="Update available"
description={`Version ${state.version} is available. Click to install`}
backdrop={
hovered() === "primary" ? theme.background.action.primary.hovered : theme.background.default
}
/>
</box>
<box width="100%" alignItems="center" marginTop={1}>
<FadeInText
fg={theme.text.subdued}
backdrop={hovered() === "skip" ? theme.background.action.primary.hovered : theme.background.default}
sweepWidth={stringWidth(`Version ${state.version} is available. Click to install`)}
sweepOffset={Math.floor(
(stringWidth(`Version ${state.version} is available. Click to install`) -
stringWidth("Skip this version")) /
2,
)}
paddingLeft={1}
paddingRight={1}
bg={hovered() === "skip" ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered("skip")}
onMouseOut={() => setHovered(undefined)}
onMouseUp={update.skip}
>
Skip this version
</FadeInText>
</box>
</box>
</Match>
<Match when={state.type === "available" && state.source === "server" && state.remote}>
<box alignItems="center">
<UpdateMessage
title="Server update available"
description="A remote server cannot be updated from here. Updating it is recommended."
backdrop={theme.background.default}
/>
<FadeInText
fg={theme.text.subdued}
backdrop={hovered() === "close" ? theme.background.action.primary.hovered : theme.background.default}
sweepWidth={stringWidth("A remote server cannot be updated from here. Updating it is recommended.")}
sweepOffset={Math.floor(
(stringWidth("A remote server cannot be updated from here. Updating it is recommended.") -
stringWidth("Close")) /
2,
)}
marginTop={1}
paddingLeft={1}
paddingRight={1}
bg={hovered() === "close" ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered("close")}
onMouseOut={() => setHovered(undefined)}
onMouseUp={update.close}
>
Close
</FadeInText>
</box>
</Match>
<Match when={state.type === "installing"}>
<Spinner color={theme.text.subdued}>Installing update</Spinner>
</Match>
<Match when={state.type === "install-success" || state.type === "installed"}>
<box alignItems="center">
<box
alignItems="center"
paddingLeft={1}
paddingRight={1}
backgroundColor={hovered() === "primary" ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered("primary")}
onMouseOut={() => setHovered(undefined)}
onMouseUp={update.restart}
>
<UpdateMessage
title="Update installed"
description={
state.type === "install-success"
? "Click to restart. Active sessions will\nautomatically resume after restart"
: `Version ${state.version} has been installed. Click to restart`
}
animate={state.type === "installed"}
backdrop={
hovered() === "primary" ? theme.background.action.primary.hovered : theme.background.default
}
/>
</box>
<box width="100%" alignItems="center" marginTop={1}>
<FadeInText
fg={theme.text.subdued}
backdrop={
hovered() === "later" ? theme.background.action.primary.hovered : theme.background.default
}
animate={state.type === "installed"}
sweepWidth={stringWidth(`Version ${state.version} has been installed. Click to restart`)}
sweepOffset={Math.floor(
(stringWidth(`Version ${state.version} has been installed. Click to restart`) -
stringWidth("Restart later")) /
2,
)}
paddingLeft={1}
paddingRight={1}
bg={hovered() === "later" ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered("later")}
onMouseOut={() => setHovered(undefined)}
onMouseUp={update.later}
>
Restart later
</FadeInText>
</box>
</box>
</Match>
<Match when={state.type === "failed"}>
<text fg={theme.text.feedback.error.default}>{state.type === "failed" ? state.message : ""}</text>
</Match>
</Switch>
</box>
)}
</Show>
)
}
function UpdateMessage(props: { title: string; description: string; backdrop: RGBA; animate?: boolean }) {
const theme = useTheme()
const lines = props.description.split("\n")
const width = Math.max(stringWidth(props.title), ...lines.map((line) => stringWidth(line)))
const padding = " ".repeat(Math.floor((width - stringWidth(props.title)) / 2))
const description = lines.map((line) => " ".repeat(Math.floor((width - stringWidth(line)) / 2)) + line).join("\n")
return (
<FadeInText
width={width}
height={lines.length + 1}
wrapMode="none"
fg={theme.text.default}
backdrop={props.backdrop}
animate={props.animate}
>
<span style={{ fg: theme.text.action.primary.selected, attributes: TextAttributes.BOLD }}>
{padding}
{props.title}
</span>
{"\n"}
<span style={{ fg: theme.text.subdued }}>{description}</span>
</FadeInText>
)
}

View file

@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
@ -18221,6 +18221,12 @@
"type": "string",
"enum": ["auto", "manual"]
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
"type": "string"
},
@ -18448,6 +18454,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {

View file

@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify"]
"enum": ["disable", "notify", "auto"]
},
"share": {
"type": "string",
@ -18221,6 +18221,12 @@
"type": "string",
"enum": ["auto", "manual"]
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"providerState": {
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
},
"summary": {
"type": "string"
},
@ -18448,6 +18454,9 @@
"Session.Message.ProviderState_4": {
"type": "object"
},
"Session.Message.ProviderState_5": {
"type": "object"
},
"Session.Message.Shell": {
"type": "object",
"properties": {

View file

@ -130,7 +130,10 @@ agents.
### Updates
Control update checks from the global config. Set `update` to `"disable"` to
skip them or `"notify"` to show available updates before installing them.
skip them, `"notify"` to show available updates before installing them, or
`"auto"` to install updates automatically. When omitted, `update` defaults to `"auto"`.
Automatic installation does not restart a running server. Restart it manually to activate the installed update.
Project-level values are ignored.
```jsonc

View file

@ -409,8 +409,7 @@ 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"`, while `"notify"` and `true` map to `"notify"`.
- The previous V2 value `update: "auto"` is treated as `update: "notify"`.
- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` maps to `"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.