Compare commits

...
49 changed files with 1038 additions and 527 deletions
@@ -92,10 +92,16 @@ test("opens and searches project files inline", async ({ page }) => {
const contextButton = page.getByRole("button", { name: "View context usage" })
await contextButton.click()
await expect(panel.getByRole("tab", { name: "Context", selected: true })).toBeVisible()
await expect(panel.getByRole("button", { name: "Open file" }).locator("use")).toHaveAttribute(
"href",
"#opencode-v2-icon-plus",
)
await panel.getByRole("button", { name: "Open file" }).click()
const openFileTab = panel.getByRole("tab", { name: "Open file" })
const openFileTabClose = openFileTab.locator("..").getByRole("button", { name: "Close tab" })
await expect(openFileTab).toHaveAttribute("data-selected", "")
await expect(openFileTab.locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(openFileTab.locator("..")).toHaveCSS("gap", "8px")
await expect(openFileTab.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-file-tree")
await expect(openFileTab.getByText("Open file", { exact: true }).locator("..")).not.toHaveClass(/italic/)
await expect(openFileTabClose).toHaveAttribute("data-variant", "ghost-muted")
@@ -114,6 +120,8 @@ test("opens and searches project files inline", async ({ page }) => {
await panel.getByRole("button", { name: "README.md" }).click()
await expect(panel.getByRole("tab", { name: "README.md", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -129,6 +137,8 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
await filter.press("Enter")
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
+1 -1
View File
@@ -575,7 +575,7 @@ export function ComposerEditorAddMenu(props: {
/>
<Menu.Portal>
<Menu.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-8 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<Menu.Item onSelect={props.onAttach} shortcut={props.attachShortcut}>
@@ -170,10 +170,10 @@ export function SessionFileBrowserTab(props: {
when={!props.placeholder}
fallback={
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
<Icon name="file-tree" size="large" />
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
<Icon name="file-tree" size="large" class="mb-2" />
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
}
@@ -417,7 +417,7 @@ export function SessionSidePanel(props: {
class="flex items-center"
>
<IconButton
icon={<Icon name="plus-small" />}
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={() => openFileBrowser()}
+1
View File
@@ -59,6 +59,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
commands: [
Spec.make("upgrade", {
description: "Upgrade OpenCode to the latest or a specific version",
aliases: ["update"],
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
@@ -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)),
},
+10 -2
View File
@@ -67,9 +67,13 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
function add(node: Spec.Any, value: RuntimeHandlers) {
if (typeof value === "function") {
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
return
}
if (value.$) result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
if (value.$) {
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
}
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
}
@@ -99,8 +103,12 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
)
: node.spec
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
const children = Object.values(node.commands)
return spec.pipe(
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
Command.withSubcommands([
...children.map((child) => provide(child, handlers)),
...children.flatMap((child) => child.aliases.map((alias) => provide(alias, handlers))),
]),
) as ProvidedCommand
}
+21 -5
View File
@@ -2,6 +2,7 @@ import { Command } from "effect/unstable/cli"
type Options<Config extends Command.Command.Config, Commands extends ReadonlyArray<Any>> = {
readonly description?: string
readonly aliases?: ReadonlyArray<string>
readonly params?: Config
readonly commands?: Commands
}
@@ -14,6 +15,7 @@ export interface Node<
readonly name: Name
readonly spec: Spec
readonly commands: Commands
readonly aliases: ReadonlyArray<Any>
}
export type Any = Node<string, Command.Command<any, any, any, any, any>, Children>
@@ -24,14 +26,28 @@ export function make<
const Config extends Command.Command.Config = {},
const Commands extends ReadonlyArray<Any> = [],
>(name: Name, options: Options<Config, Commands> = {}) {
const command = Command.make(name, options.params ?? ({} as Config))
const spec = options.description ? command.pipe(Command.withDescription(options.description)) : command
const aliases = options.aliases ?? []
const params = options.params ?? ({} as Config)
const command = Command.make(name, params)
const described = options.description ? command.pipe(Command.withDescription(options.description)) : command
// Effect supports a single native alias, shown inline as `name, alias` in help.
// Extra aliases become sibling commands sharing params and subcommands.
const spec = aliases.length > 0 ? described.pipe(Command.withAlias(aliases[0])) : described
const commands = Object.fromEntries(
(options.commands ?? []).map((command) => [command.name, command]),
) as ChildrenOf<Commands>
const extra = aliases.slice(1).map((alias) => {
const aliasCommand = Command.make(alias, params)
const aliasSpec = options.description
? aliasCommand.pipe(Command.withDescription(options.description))
: aliasCommand
return { name: alias, spec: aliasSpec, commands, aliases: [] }
})
return {
name,
spec,
commands: Object.fromEntries(
(options.commands ?? []).map((command) => [command.name, command]),
) as ChildrenOf<Commands>,
commands,
aliases: extra,
}
}
+6 -55
View File
@@ -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<PersistentPty.Handoff | null>()
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<PersistentPty.Handoff | null>()
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<PersistentPty.Handoff | null>()
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<PersistentPty.Handoff | null>())),
Deferred.await(replacement).pipe(Effect.map(Option.some)),
)
? server.shutdown
: options.mode === "stdio"
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
? 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<void>((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
...options.env,
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
},
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => new Error("Failed to start replacement server", { cause }),
})
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
+3 -6
View File
@@ -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) {
+19 -28
View File
@@ -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")
})
})
+28 -165
View File
@@ -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<void>
readonly monitor: (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) => Effect.Effect<void>
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export type Inspection =
| { readonly action: "none" }
| { readonly action: Exclude<Action, "none">; readonly version: string }
type State =
| { readonly type: "current" }
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
| { readonly type: "ready-to-restart"; readonly version: string }
export interface MonitorInput {
readonly url: string
readonly password: string
readonly managed: boolean
readonly inspect: () => Effect.Effect<Inspection, Error>
readonly install: (version: string) => Effect.Effect<boolean, Error>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
readonly interval?: Duration.Input
readonly notificationThreshold?: Duration.Input
export const monitorUpdates = Effect.fnUntraced(function* (input: {
readonly inspect: () => Effect.Effect<string | undefined, Error>
readonly notify: (version: string) => Effect.Effect<void>
}
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
const state = yield* Ref.make<State>({ type: "current" })
const applyLock = yield* Semaphore.make(1)
const client = OpenCode.make({
baseUrl: input.url,
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
})
const applyIfIdle = () =>
applyLock.withPermit(
Effect.gen(function* () {
const pending = yield* Ref.get(state)
if (pending.type !== "available") return
const active = yield* Effect.tryPromise({
try: () => client.session.active(),
catch: (cause) => new Error("Failed to read active sessions", { cause }),
})
if (Object.keys(active).length > 0) return
const latest = yield* input.inspect()
if (latest.action !== "upgrade") {
yield* Ref.set(state, { type: "current" })
return
}
const installed = yield* input
.install(latest.version)
.pipe(
Effect.catch((error) =>
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
),
)
if (!installed) return
const handoff = input.managed
? yield* Effect.tryPromise({
try: () => client.experimental.persistentPty.handoff(),
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
})
: undefined
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
if (handoff) yield* input.restart(handoff.handoff)
}),
)
const checkServer = Effect.gen(function* () {
const result = yield* input.inspect()
if (result.action === "notify") {
yield* input.notify(result.version)
return
}
if (result.action !== "upgrade") {
yield* Ref.update(
state,
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
)
return
}
yield* Ref.update(state, (current): State => {
if (current.type === "ready-to-restart" && current.version === result.version) return current
return {
type: "available",
version: result.version,
availableSince: current.type === "available" ? current.availableSince : Date.now(),
}
})
yield* applyIfIdle()
const pending = yield* Ref.get(state)
if (
pending.type === "available" &&
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
)
yield* input.notify(pending.version)
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
const subscribe = Effect.suspend(() =>
Stream.fromAsyncIterable(
client.event.subscribe(),
(cause) => new Error("Update event stream failed", { cause }),
).pipe(
Stream.runForEach((event) => {
if (event.type === "server.connected") return applyIfIdle()
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.failed" &&
event.type !== "session.execution.interrupted"
)
return Effect.void
return Effect.tryPromise({
try: () => client.session.wait({ sessionID: event.data.sessionID }),
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
}).pipe(Effect.andThen(applyIfIdle()))
}),
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
),
).pipe(Effect.repeat(Schedule.spaced("1 second")))
return yield* Effect.all(
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
{
concurrency: "unbounded",
discard: true,
},
)
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<Service, Interface>()("@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<Inspection, Error> {
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 = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) {
return yield* monitorServer({ ...input, inspect, install })
})
return Service.of({ check, monitor, apply, method, latest, upgrade })
return Service.of({ monitor, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
+1 -2
View File
@@ -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")
+27 -94
View File
@@ -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<string>()
const restarted = yield* Deferred.make<void>()
yield* Updater.monitorServer({
url: fixture.url,
password: "test",
managed: true,
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
notify: () => Effect.void,
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* 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<unknown>, message: () => string) =>
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
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)
function makeServer() {
const encoder = new TextEncoder()
const activeRead = Promise.withResolvers<void>()
const eventOpened = Promise.withResolvers<void>()
const waited = Promise.withResolvers<void>()
let active = true
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/session/active") {
activeRead.resolve()
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
}
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
waited.resolve()
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
return Response.json({ handoff: null })
}
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
eventOpened.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return new Response("Not found", { status: 404 })
},
})
return {
url: server.url.origin,
activeRead: activeRead.promise,
eventOpened: eventOpened.promise,
waited: waited.promise,
settle() {
active = false
events?.enqueue(
encoder.encode(
`data: ${JSON.stringify({
id: "evt_settled",
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
data: { sessionID: "ses_test" },
})}\n\n`,
),
)
events?.close()
events = undefined
},
stop() {
server.stop(true)
},
}
}
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@ import { CodeModeCatalog } from "./catalog.js"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools callable inside \`execute\`. It does not affect tools exposed directly outside Code Mode.${hasMoreTools ? `
## Search
+9 -2
View File
@@ -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,
+6 -2
View File
@@ -18,7 +18,9 @@ const RemoteModel = Schema.Struct({
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
// API version 2026-08-01 renamed cache_price to cache_read_price.
cache_price: Schema.optional(Schema.Number),
cache_read_price: Schema.optional(Schema.Number),
input_price: Schema.Number,
output_price: Schema.Number,
}),
@@ -166,7 +168,9 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
cache: {
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
read: Money.USDPerMillionTokens.make(
(prices?.default.cache_read_price ?? prices?.default.cache_price ?? 0) * usdPerMillion,
),
write: Money.USDPerMillionTokens.zero,
},
},
+5 -8
View File
@@ -55,7 +55,6 @@ type Active = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
scope: Scope.Closeable
token: object
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
@@ -77,7 +76,7 @@ type BackgroundResult = {
backgrounded?: Deferred.Deferred<Info>
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
type BlockWait = {
done: Deferred.Deferred<Info>
@@ -184,14 +183,14 @@ export const make = Effect.gen(function* () {
})
})
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.scope !== scope) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
@@ -241,7 +240,6 @@ export const make = Effect.gen(function* () {
return [{ info: snapshot(existing) }, jobs]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
const job = {
info: {
id,
@@ -255,18 +253,17 @@ export const make = Effect.gen(function* () {
done,
backgrounded,
scope,
token,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
}),
)
if ("scope" in result)
yield* restore(input.run).pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, result.token, exit)),
Effect.flatMap((exit) => settle(id, result.scope, exit)),
Effect.asVoid,
Effect.forkIn(result.scope, { startImmediately: true }),
)
+141
View File
@@ -0,0 +1,141 @@
export * as ModalModels from "./models.js"
import { Money } from "@opencode-ai/schema/money"
import { Option, Schema } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
const providerID = Provider.ID.make("modal")
const ReasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const RemoteModel = Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
})
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResponse = Schema.decodeUnknownSync(Response)
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
type RemoteModel = typeof RemoteModel.Type
export async function get(baseURL: string, apiKey: string, existing: readonly Model.Info[]) {
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
})
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
// Decode each item tolerantly so one malformed entry cannot discard the
// whole inventory. A malformed envelope still fails the fetch.
const remote = decodeResponse(await response.json()).data.flatMap((raw) => {
const model = Option.getOrUndefined(decodeModel(raw))
return model ? [model] : []
})
const templates = new Map(existing.map((model) => [model.id, model]))
const result = new Map<Model.ID, Model.Info>()
for (const item of remote) {
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
const id = Model.ID.make(item.id)
result.set(id, build(id, item, baseURL, template))
}
return result
}
function price(value: string | number | undefined, fallback: Money.USDPerMillionTokens) {
if (value === undefined) return fallback
const parsed = Number(value) * 1_000_000
return Number.isFinite(parsed) ? Money.USDPerMillionTokens.make(parsed) : fallback
}
function limit(value: number | undefined, fallback: number) {
const parsed = value === undefined ? fallback : Math.trunc(value)
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function build(id: Model.ID, remote: RemoteModel, baseURL: string, previous?: Model.Info) {
const cost = previous?.cost[0]
const input = previous?.limit.input
return Model.Info.make({
...Model.Info.default(providerID, id),
id,
modelID: Model.ID.make(remote.id),
providerID,
name: remote.name ?? previous?.name ?? remote.id,
family: previous?.family,
compatibility:
remote.interleaved === undefined
? previous?.compatibility
: (Model.compatibility(remote.interleaved) ?? previous?.compatibility),
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: Provider.mergeOverlay(previous?.settings, { baseURL }),
headers: previous?.headers,
body: previous?.body,
capabilities: {
tools: remote.supported_features?.includes("tools") ?? previous?.capabilities.tools ?? true,
input: remote.input_modalities ?? previous?.capabilities.input ?? ["text"],
output: remote.output_modalities ?? previous?.capabilities.output ?? ["text"],
},
variants: remote.reasoning_options === undefined ? (previous?.variants ?? []) : variants(remote),
time: previous?.time ?? { released: 0 },
cost: [
{
input: price(remote.pricing?.prompt, cost?.input ?? Money.USDPerMillionTokens.zero),
output: price(remote.pricing?.completion, cost?.output ?? Money.USDPerMillionTokens.zero),
cache: {
read: price(remote.pricing?.input_cache_read, cost?.cache.read ?? Money.USDPerMillionTokens.zero),
write: cost?.cache.write ?? Money.USDPerMillionTokens.zero,
},
},
],
status: previous?.status ?? "active",
enabled: previous?.enabled ?? true,
limit: {
context: limit(remote.context_length, previous?.limit.context ?? 0),
...(input === undefined ? {} : { input }),
output: limit(remote.max_output_length, previous?.limit.output ?? 0),
},
})
}
function variants(remote: RemoteModel): Model.Info["variants"] {
const seen = new Map<string, Model.Info["variants"][number]>()
for (const option of remote.reasoning_options ?? []) {
for (const value of option.values) {
const effort = value ?? "none"
if (!seen.has(effort))
seen.set(effort, { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } })
}
}
return [...seen.values()]
}
+2
View File
@@ -15,6 +15,7 @@ import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { MistralPlugin } from "./provider/mistral.js"
import { ModalPlugin } from "./provider/modal.js"
import { NvidiaPlugin } from "./provider/nvidia.js"
import { OllamaPlugin } from "./provider/ollama.js"
import { OpenAIPlugin } from "./provider/openai.js"
@@ -48,6 +49,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
LLMGatewayPlugin,
LMStudioPlugin,
MistralPlugin,
ModalPlugin,
NvidiaPlugin,
OllamaPlugin,
OpencodePlugin,
@@ -13,7 +13,7 @@ import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
const apiVersion = "2026-06-01"
const apiVersion = "2026-08-01"
const userApiVersion = "2025-04-01"
const pollingSafetyMargin = 3000
const methodID = Integration.MethodID.make("device")
@@ -250,15 +250,26 @@ export const GithubCopilotPlugin = define({
evt.sdk = mod.createOpenaiCompatible(evt.options)
}),
)
// Runs for every route, unlike http.request, which the AI SDK route bypasses.
yield* ctx.session.hook(
"model.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
const session = yield* ctx.session
.get({ sessionID: evt.sessionID })
.pipe(Effect.orElseSucceed(() => undefined))
const interaction = interactionType(evt.agent, session?.parentID !== undefined)
evt.headers["X-Interaction-Type"] = interaction
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
}),
{ providerID: Provider.ID.githubCopilot },
)
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (evt.agent === Agent.ID.make("title"))
evt.request.headers.set("X-Interaction-Type", "conversation-background")
if (evt.agent === Agent.ID.make("compaction"))
evt.request.headers.set("X-Interaction-Type", "conversation-compaction")
const token = evt.request.headers.get("x-api-key")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
@@ -370,11 +381,23 @@ function applyHeaders(
headers.set("User-Agent", App.useragent(app))
headers.set("Openai-Intent", "conversation-edits")
headers.set("X-GitHub-Api-Version", apiVersion)
headers.set("x-initiator", metadata.agent ? "agent" : "user")
// The step may already have declared itself agent-initiated (subagent, title, compaction);
// the body can only ever escalate to "agent", never back to "user".
if (metadata.agent) headers.set("x-initiator", "agent")
else if (!headers.has("x-initiator")) headers.set("x-initiator", "user")
if (metadata.vision) headers.set("Copilot-Vision-Request", "true")
if (anthropic) headers.set("anthropic-beta", "interleaved-thinking-2025-05-14")
}
// Mirrors the Copilot client's X-Interaction-Type vocabulary: the agent loop is the default,
// nested sessions are subagents, and title/compaction are the two utility overrides.
export function interactionType(agent: Agent.ID, child: boolean) {
if (agent === Agent.ID.make("title")) return "conversation-background"
if (agent === Agent.ID.make("compaction")) return "conversation-compaction"
if (child) return "conversation-subagent"
return "conversation-agent"
}
type RequestMetadata = ReturnType<typeof requestMetadata>
function requestMetadata(url: string, body: unknown) {
@@ -0,0 +1,67 @@
import { Effect, Semaphore, Stream } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Bus } from "../../bus.js"
import { Catalog } from "../../catalog.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { ModalModels } from "../../modal/models.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
const providerID = Provider.ID.make("modal")
export const ModalPlugin = define({
id: "opencode.provider.modal",
effect: Effect.fn(function* (ctx) {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
models?: Map<Model.ID, Model.Info>
} = {}
const load = Effect.fn("ModalPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("modal")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const apiKey = credential?.type === "key" ? credential.key : process.env.MODAL_PROXY_TOKEN
const provider = yield* catalog.provider.get(providerID)
const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
if (!apiKey || !baseURL) {
loaded.baseURL = undefined
loaded.models = undefined
return
}
loaded.baseURL = baseURL
const existing = (yield* catalog.model.all()).filter((model) => model.providerID === providerID)
loaded.models = yield* Effect.tryPromise({
try: () => ModalModels.get(baseURL, apiKey, existing),
catch: (cause) => cause,
}).pipe(
Effect.catch((cause) => Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(undefined))),
)
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
if (!loaded.models) return
for (const id of item.models.keys()) {
if (!loaded.models.has(Model.ID.make(id))) evt.model.remove(item.provider.id, id)
}
for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
}),
} satisfies PluginInternal.InternalPlugin)
+15 -24
View File
@@ -28,11 +28,9 @@ const events = Metric.counter("opencode_session_websocket_events_total", {
const metric = (event: string, attributes: Record<string, string> = {}) =>
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
interface Active {
readonly queue: Queue.Queue<string, AIError>
readonly lifecycle: { delivery: Delivery }
delivery: "send-attempted" | "provider-observed" | "terminal"
}
interface Channel {
@@ -130,14 +128,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "close",
phase: "close",
delivery:
channel.active.lifecycle.delivery === "queued" ||
channel.active.lifecycle.delivery === "connecting" ||
channel.active.lifecycle.delivery === "ready"
? "not-sent"
: channel.active.lifecycle.delivery === "provider-observed" ||
channel.active.lifecycle.delivery === "terminal"
? "accepted"
: "ambiguous",
channel.active.delivery === "provider-observed" || channel.active.delivery === "terminal"
? "accepted"
: "ambiguous",
}),
),
)
@@ -198,7 +191,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
code: "idle-data",
phase: "receive",
})
active.lifecycle.delivery = "provider-observed"
active.delivery = "provider-observed"
if (typeof message !== "string")
return yield* transportError("Unsupported binary WebSocket frame", {
url: exchange.connect.url,
@@ -226,8 +219,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
phase:
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery:
channel.active?.lifecycle.delivery === "provider-observed" ||
channel.active?.lifecycle.delivery === "terminal" ||
channel.active?.delivery === "provider-observed" ||
channel.active?.delivery === "terminal" ||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
? "accepted"
: error.reason._tag === "Transport" && error.reason.code === "1009"
@@ -256,7 +249,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
exchange: WebSocketChannelExchange,
lifecycle: { delivery: Delivery },
) {
if (owner.closed)
return yield* transportError("Session WebSocket owner is closed", {
@@ -288,7 +280,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
yield* closeChannel(owner, current)
}
lifecycle.delivery = owner.channel ? "ready" : "connecting"
if (owner.channel)
yield* Effect.logDebug("session websocket reused", {
sessionTransport: "websocket",
@@ -314,7 +305,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
),
)
if (!channel) return fallback(exchange)
lifecycle.delivery = "ready"
if (channel.pending) {
channel.pending = undefined
@@ -326,9 +316,11 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
const active: Active = {
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
delivery: "send-attempted",
}
channel.active = active
lifecycle.delivery = "send-attempted"
const sent = yield* channel.connection.sendText(create.message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
@@ -366,7 +358,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
operation: "read",
code: "idle-timeout",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
}),
),
}),
@@ -375,7 +367,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.sync(() => {
if (!observationTerminal(observation)) return
terminal = observation
lifecycle.delivery = "terminal"
active.delivery = "terminal"
const staged = observation.type === "completed" ? observation.checkpoint : undefined
if (staged) channel.pending = { token, checkpoint: staged }
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
@@ -411,7 +403,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
operation: "read",
code: "incomplete",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
delivery: active.delivery === "provider-observed" ? "accepted" : "ambiguous",
})
yield* poison(owner, channel, error)
}),
@@ -448,7 +440,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
const lifecycle = { delivery: "queued" as Delivery }
let execution: WebSocketChannelExecution | undefined
return Effect.succeed({
get http() {
@@ -456,7 +447,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
},
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange, lifecycle)),
Effect.andThen(start(owner, exchange)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
+2 -4
View File
@@ -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,
@@ -47,7 +47,7 @@ describe("CodeModeInstructions", () => {
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make({ tools: [echo] }))
expect(initialized.text).toContain(
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
"This catalog is the complete set of tools callable inside `execute`. It does not affect tools exposed directly outside Code Mode.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
+10 -1
View File
@@ -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({
@@ -118,3 +118,40 @@ test("defensively syncs advertised Copilot models", async () => {
await server.stop(true)
}
})
test("prices cache reads from either token price spelling", async () => {
// API version 2026-08-01 renamed cache_price to cache_read_price; older payloads still use cache_price.
const item = (id: string, prices: Record<string, number>) => ({
model_picker_enabled: true,
id,
name: id,
version: `${id}-2026-08-01`,
supported_endpoints: ["/chat/completions"],
billing: { token_prices: { batch_size: 1_000_000, default: { input_price: 250, output_price: 1500, ...prices } } },
capabilities: {
family: "gpt",
limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 },
supports: { tool_calls: true },
},
})
const server = Bun.serve({
port: 0,
fetch: () =>
Response.json({
data: [
item("renamed", { cache_read_price: 25, cache_write_price: 0 }),
item("legacy", { cache_price: 25 }),
item("unpriced", {}),
],
}),
})
try {
const models = await CopilotModels.get(server.url.origin, {}, [])
expect(models.get(Model.ID.make("renamed"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
expect(models.get(Model.ID.make("legacy"))?.cost[0]).toMatchObject({ input: 2.5, output: 15, cache: { read: 0.25 } })
expect(models.get(Model.ID.make("unpriced"))?.cost[0]).toMatchObject({ cache: { read: 0 } })
} finally {
await server.stop(true)
}
})
+65
View File
@@ -64,6 +64,71 @@ describe("Job", () => {
}),
)
it.live("reuses running work when started again with the same ID", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const output = yield* Deferred.make<string>()
const job = yield* jobs.start({ id: "job_reused", type: "test", run: Deferred.await(output) })
expect(
yield* jobs.start({ id: job.id, type: "duplicate", run: Effect.die("Duplicate work must not run") }),
).toEqual(job)
yield* Deferred.succeed(output, "original output")
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
type: "test",
status: "completed",
output: "original output",
})
}),
)
it.live("ignores an obsolete callback after a cancellation waiter starts a same-ID replacement", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const callback = yield* Deferred.make<() => void>()
const output = yield* Deferred.make<string>()
const finalized = yield* Deferred.make<void>()
const job = yield* jobs.start({
id: "job_replaced",
type: "test",
run: Effect.callback<string>((resume) => {
Deferred.doneUnsafe(
callback,
Effect.succeed(() => resume(Effect.succeed("obsolete output"))),
)
}),
})
const complete = yield* Deferred.await(callback)
// Cancellation wakes waiters before closing the old scope, allowing the old callback to race replacement.
const replacement = yield* jobs.wait({ id: job.id }).pipe(
Effect.tap((result) => Effect.sync(() => expect(result.info?.status).toBe("cancelled"))),
Effect.andThen(
jobs.start({
id: job.id,
type: "replacement",
run: Deferred.await(output).pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
}),
),
Effect.andThen(Effect.sync(complete)),
Effect.forkChild({ startImmediately: true }),
)
yield* jobs.cancel(job.id)
yield* Fiber.join(replacement)
expect(yield* jobs.get(job.id)).toMatchObject({ type: "replacement", status: "running" })
expect(yield* Deferred.isDone(finalized)).toBe(false)
yield* Deferred.succeed(output, "replacement output")
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
type: "replacement",
status: "completed",
output: "replacement output",
})
expect(yield* Deferred.isDone(finalized)).toBe(true)
}),
)
it.live("returns finished from a blocking wait when completion wins", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
+137
View File
@@ -0,0 +1,137 @@
import { expect, test } from "bun:test"
import { ModalModels } from "@opencode-ai/core/modal/models"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { Money } from "@opencode-ai/schema/money"
const providerID = Provider.ID.make("modal")
test("modal plugin is registered", () => {
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.modal")
})
function template(id: string, overrides: Partial<Model.Info> = {}) {
return Model.Info.make({
...Model.Info.default(providerID, Model.ID.make(id)),
name: `${id} catalog`,
family: Model.Family.make("catalog-family"),
...overrides,
})
}
test("maps live Modal models onto catalog templates", async () => {
const server = Bun.serve({
port: 0,
fetch: (request) => {
expect(request.headers.get("Authorization")).toBe("Bearer test-key")
expect(new URL(request.url).pathname).toBe("/v1/models")
return Response.json({
data: [
{
id: "live-model",
base_model_id: "base-model",
name: "Live Model",
input_modalities: ["text", "image"],
output_modalities: ["text"],
context_length: 128000,
max_output_length: 8192,
pricing: { prompt: "0.000001", completion: 0.000002, input_cache_read: "0.0000002" },
supported_sampling_parameters: ["temperature"],
supported_features: ["tools", "reasoning"],
reasoning_options: [{ type: "effort", values: ["low", "high", null] }],
interleaved: { field: "reasoning_content" },
},
{
id: "standalone",
context_length: 64000,
},
{ id: "malformed", context_length: "huge" },
],
})
},
})
try {
const base = template("base-model")
const stale = template("stale")
const models = await ModalModels.get(`${server.url.origin}/v1`, "test-key", [base, stale])
expect(models.has(Model.ID.make("stale"))).toBe(false)
expect(models.has(Model.ID.make("malformed"))).toBe(false)
const model = models.get(Model.ID.make("live-model"))
expect(model?.name).toBe("Live Model")
expect(model?.family).toBe(Model.Family.make("catalog-family"))
expect(model?.providerID).toBe(providerID)
expect(model?.modelID).toBe(Model.ID.make("live-model"))
expect(model?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
expect(model?.settings).toMatchObject({ baseURL: `${server.url.origin}/v1` })
expect(model?.compatibility).toMatchObject({ reasoningField: "reasoning_content" })
expect(model?.capabilities).toMatchObject({ tools: true, input: ["text", "image"], output: ["text"] })
expect(model?.cost[0]?.input).toBe(Money.USDPerMillionTokens.make(1))
expect(model?.cost[0]?.output).toBe(Money.USDPerMillionTokens.make(2))
expect(Number(model?.cost[0]?.cache.read)).toBeCloseTo(0.2, 10)
expect(model?.cost[0]?.cache.write).toBe(Money.USDPerMillionTokens.zero)
expect(model?.limit).toMatchObject({ context: 128000, output: 8192 })
expect(model?.variants.map((variant) => variant.id)).toEqual([
Model.VariantID.make("low"),
Model.VariantID.make("high"),
Model.VariantID.make("none"),
])
expect(model?.variants[0]?.settings).toMatchObject({ reasoningEffort: "low" })
expect(model?.status).toBe("active")
const fresh = models.get(Model.ID.make("standalone"))
expect(fresh?.name).toBe("standalone")
expect(fresh?.family).toBeUndefined()
expect(fresh?.capabilities).toMatchObject({ tools: true, input: ["text"], output: ["text"] })
expect(fresh?.variants).toEqual([])
expect(fresh?.limit).toMatchObject({ context: 64000, output: 0 })
} finally {
await server.stop(true)
}
})
test("keeps template cost and limits when the proxy omits them", async () => {
const server = Bun.serve({
port: 0,
fetch: () =>
Response.json({
data: [{ id: "sparse", hugging_face_id: "hf-base" }],
}),
})
try {
const base = template("hf-base", {
cost: [
{
input: Money.USDPerMillionTokens.make(5),
output: Money.USDPerMillionTokens.make(10),
cache: { read: Money.USDPerMillionTokens.make(1), write: Money.USDPerMillionTokens.make(2) },
},
],
limit: { context: 1000, input: 500, output: 250 },
})
const models = await ModalModels.get(server.url.origin, "test-key", [base])
const model = models.get(Model.ID.make("sparse"))
expect(model?.name).toBe("hf-base catalog")
expect(model?.cost[0]).toMatchObject({ input: 5, output: 10, cache: { read: 1, write: 2 } })
expect(model?.limit).toMatchObject({ context: 1000, input: 500, output: 250 })
} finally {
await server.stop(true)
}
})
test("throws on proxy failure so the plugin can fail soft", async () => {
const server = Bun.serve({
port: 0,
fetch: () => new Response("nope", { status: 500 }),
})
try {
await expect(ModalModels.get(server.url.origin, "test-key", [])).rejects.toThrow()
} finally {
await server.stop(true)
}
})
@@ -1,7 +1,8 @@
import { AISDK } from "@opencode-ai/core/aisdk"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { Session } from "@opencode-ai/core/session"
import { Location } from "@opencode-ai/core/location"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -35,6 +36,24 @@ function required<T>(value: T | undefined): T {
return value
}
const sessions = Effect.fn(function* () {
const service = yield* Session.Service
const location = yield* Location.Service
const parent = yield* service.create({ location: { directory: location.directory } })
const child = yield* service.create({ parentID: parent.id })
return { parent: parent.id, child: child.id }
})
const modelRequest = Effect.fn(function* (sessionID: Session.ID, agent: string) {
const hooks = yield* PluginHooks.Service
return yield* hooks.trigger("session", "model.request", {
sessionID,
agent: Agent.ID.make(agent),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
headers: {},
})
})
describe("GithubCopilotPlugin", () => {
test("prefers the account-specific Copilot API endpoint", () => {
expect(
@@ -122,7 +141,7 @@ describe("GithubCopilotPlugin", () => {
expect(requests[0]?.has("x-api-key")).toBe(false)
expect(requests[0]?.get("x-initiator")).toBe("user")
expect(requests[0]?.get("copilot-vision-request")).toBe("true")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
expect(requests[0]?.get("x-github-api-version")).toBe("2026-08-01")
expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
}),
)
@@ -145,35 +164,75 @@ describe("GithubCopilotPlugin", () => {
expect(event.request.headers.has("x-api-key")).toBe(false)
expect(event.request.headers.get("x-initiator")).toBe("user")
expect(event.request.headers.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-06-01")
expect(event.request.headers.get("x-github-api-version")).toBe("2026-08-01")
}),
)
it.effect("classifies main-loop steps as agent interactions", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).parent, "build")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" })
}),
)
it.effect("classifies child-session steps as subagent interactions", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).child, "build")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" })
}),
)
it.effect("classifies title generation as a background interaction", () =>
Effect.gen(function* () {
yield* addPlugin()
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_title"),
agent: Agent.ID.make("title"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
request: new Request("https://api.githubcopilot.com/chat/completions"),
})
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background")
const event = yield* modelRequest((yield* sessions()).parent, "title")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-background", "x-initiator": "agent" })
}),
)
it.effect("classifies compaction requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const event = yield* modelRequest((yield* sessions()).child, "compaction")
expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" })
}),
)
it.effect("ignores other providers' model requests", () =>
Effect.gen(function* () {
yield* addPlugin()
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "http.request", {
sessionID: Session.ID.make("ses_compaction"),
agent: Agent.ID.make("compaction"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
request: new Request("https://api.githubcopilot.com/responses"),
const event = yield* hooks.trigger("session", "model.request", {
sessionID: (yield* sessions()).parent,
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5.4") }),
headers: {},
})
expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction")
expect(event.headers).toEqual({})
}),
)
it.live("keeps a declared agent initiator when the body looks user-initiated", () =>
Effect.gen(function* () {
const requests: Headers[] = []
const send = copilotFetch(
"token",
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
requests.push(new Headers(init?.headers))
return Response.json({ ok: true })
},
App.make({ name: "test", version: "1.2.3", channel: "beta" }),
)
yield* Effect.promise(() =>
send("https://api.githubcopilot.com/chat/completions", {
method: "POST",
headers: { "x-initiator": "agent" },
body: JSON.stringify({ messages: [{ role: "user", content: "summarize" }] }),
}),
)
expect(requests[0]?.get("x-initiator")).toBe("agent")
}),
)
@@ -445,14 +445,17 @@ describe("SessionModelTransport", () => {
)
})
test("closes an active exchange without waiting for its Session permit", async () => {
test.each([false, true])("classifies active and queued close (observed: %s)", async (observed) => {
const started = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () => Deferred.succeed(started, undefined),
sendText: () =>
observed
? Queue.offer(messages, "frame").pipe(Effect.asVoid)
: Deferred.succeed(started, undefined).pipe(Effect.asVoid),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
@@ -462,17 +465,30 @@ describe("SessionModelTransport", () => {
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const running = yield* collect(transport.bind(session), exchange("active")).pipe(
const executor = transport.bind(session)
const item = exchange("active")
const running = yield* collect(executor, {
...item,
driver: {
...item.driver,
observe: (_create, frame) => Deferred.succeed(started, undefined).pipe(Effect.as({ type: "frame", frame })),
},
}).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const queued = yield* collect(executor, exchange("queued")).pipe(
Effect.result,
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* transport.close(session)
const result = yield* Effect.result(Fiber.join(running))
expect(result).toMatchObject({
expect(yield* Fiber.join(running)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "close", delivery: "ambiguous" } },
failure: { reason: { _tag: "Transport", code: "close", delivery: observed ? "accepted" : "ambiguous" } },
})
expect(yield* Fiber.join(queued)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "queue", delivery: "not-sent" } },
})
expect(closed).toBe(1)
}),
@@ -545,6 +561,43 @@ describe("SessionModelTransport", () => {
)
})
test("closes a connection returned after its owner closes during setup", async () => {
const connecting = Deferred.makeUnsafe<void>()
const release = Deferred.makeUnsafe<void>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Deferred.succeed(connecting, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({
sendText: () => Effect.die("Unexpected send after owner close"),
messages: Stream.never,
close: Effect.sync(() => closed++).pipe(Effect.asVoid),
}),
),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const running = yield* collect(
transport.bind(session),
exchange("first", { fallback: () => Stream.die("Unexpected fallback after owner close") }),
).pipe(Effect.result, Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(connecting)
yield* transport.close(session)
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(running)).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", code: "owner-closed", phase: "connect", delivery: "not-sent" } },
})
expect(closed).toBe(1)
}),
)
})
test("falls back once when connection setup fails before send", async () => {
let fallbacks = 0
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
@@ -60,3 +60,10 @@ describe("electron vite publicDir", () => {
expect(existsSync(join(resolved, "oc-theme-preload.js"))).toBe(true)
})
})
test("renders before loading optional telemetry", async () => {
const source = await Bun.file(join(dir, "index.tsx")).text()
expect(source.indexOf("render(() =>")).toBeGreaterThan(-1)
expect(source.indexOf("render(() =>")).toBeLessThan(source.indexOf("initializeSentry(version)"))
expect(source).not.toContain("await initializeSentry")
})
+1 -1
View File
@@ -13,10 +13,10 @@ import { desktopVersion, initializeSentry } from "./startup/sentry"
const root = requireRendererRoot()
const version = desktopVersion()
await initializeSentry(version)
const updater = startDesktopUpdater(api)
startDesktopMenu(api)
startDeepLinks(api)
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
void initializeSentry(version)
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
"enum": ["disable", "notify"]
},
"share": {
"type": "string",
+2 -2
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", "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",
+1 -8
View File
@@ -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* <E, R>(
)
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)
+3 -15
View File
@@ -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<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
}
}
@@ -494,7 +494,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
gap: 8px;
padding: 24px;
padding-bottom: 160px;
text-align: center;
@@ -502,25 +502,26 @@
[data-slot="session-review-v2-empty-changes"] [data-slot="icon-svg"] {
flex: none;
margin-bottom: 8px;
color: var(--v2-icon-icon-muted);
}
[data-slot="session-review-v2-empty-changes-title"] {
flex: none;
margin-top: 4px;
font-size: 13px;
font-weight: 530;
line-height: var(--line-height-compact);
line-height: 13px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
}
[data-slot="session-review-v2-empty-changes-description"] {
flex: none;
height: 20px;
max-width: 282px;
font-size: 13px;
font-weight: 440;
line-height: 20px;
line-height: var(--line-height-base);
text-align: center;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
+31 -16
View File
@@ -186,6 +186,7 @@ export type TuiInput = {
args: Args
config: Config.Interface
updater?: {
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
apply: (version: string) => Promise<void>
}
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(
() => (
<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,
@@ -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(() => <DialogUpdate version={version} install={() => updater.apply(version)} restart={restart} />)
dialog.setCentered(true)
})
event.on("tui.session.select", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
route.navigate({
+31 -18
View File
@@ -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<void>; restart: () => Promise<void> }) {
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()
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<vo
const run = () => {
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<vo
bind: "return",
title: "Confirm update action",
group: "Dialog",
run: () => (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<vo
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Update
Update available
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
<text fg={theme.text.subdued} onMouseUp={close}>
esc
</text>
</box>
@@ -91,14 +101,17 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
<Switch>
<Match when={state().type === "ready"}>
<text fg={theme.text.subdued}>
Update to v{props.version}? It will be applied in the background and active sessions will be restarted.
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>Installing OpenCode {props.version}</Spinner>
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}</Spinner>
</Match>
<Match when={state().type === "restarting"}>
<Spinner>Restarting the background service</Spinner>
<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>
@@ -114,7 +127,7 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
paddingLeft={3}
paddingRight={3}
backgroundColor={theme.background.action.primary.focused}
onMouseUp={() => dialog.clear()}
onMouseUp={close}
>
<text fg={theme.text.action.primary.focused}>close</text>
</box>
@@ -123,19 +136,19 @@ export function DialogUpdate(props: { version: string; install: () => Promise<vo
}
>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["ignore", "update"] as const}>
<For each={["skip", "update"] as const}>
{(action) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
onMouseUp={() => {
if (action === "ignore") return dialog.clear()
if (action === "skip") return close()
beginInstall()
}}
>
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
{action === "update" ? "Update" : "Ignore"}
{action === "update" ? "Update" : "Skip"}
</text>
</box>
)}
+104
View File
@@ -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<JSX.IntrinsicElements["text"], "ref"> & { shimmer: RGBA }
export function ShimmerText(props: Props) {
const [local, text] = splitProps(props, ["shimmer"])
return <shimmer_text {...text} shimmer={local.shimmer} />
}
+26 -8
View File
@@ -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 (
<Show
when={config.animations ?? true}
fallback={<text fg={color()}>{props.children ? <> {props.children}</> : "⋯"}</text>}
>
<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
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>
</Show>
)
}
+1
View File
@@ -57,6 +57,7 @@
gap: 8px;
height: 28px;
padding: 0 12px;
padding-inline-end: 6px;
background: transparent;
border-radius: 4px;
outline: none;
+14 -3
View File
@@ -552,6 +552,17 @@
}
}
&[data-value="open-file"],
&[data-value^="file://"]
{
gap: 8px;
padding-inline-end: 4px;
[data-slot="tabs-trigger-close-button"] {
margin-inline-start: 0;
}
}
&:has([data-selected]) {
background-color: var(--v2-background-bg-layer-02);
box-shadow: none;
@@ -569,19 +580,19 @@
font-variation-settings: "slnt" 0;
font-variant-numeric: tabular-nums;
&[data-selected] {
&[data-selected]:not([data-value="open-file"]) {
color: var(--v2-text-text-base);
}
.tab-fileicon-color,
.tab-fileicon-mono,
[data-slot="icon-svg"] {
.tab-fileicon-mono {
margin-inline-start: -2px;
}
.italic {
font-style: italic !important;
font-variation-settings: "slnt" -10 !important;
padding-inline-end: 2px;
}
}
}
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
"enum": ["disable", "notify"]
},
"share": {
"type": "string",
+1 -1
View File
@@ -13904,7 +13904,7 @@
},
"update": {
"type": "string",
"enum": ["disable", "notify", "auto"]
"enum": ["disable", "notify"]
},
"share": {
"type": "string",
+3 -5
View File
@@ -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",
}
```
+2 -1
View File
@@ -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.