Compare commits

...
10 Commits
Author SHA1 Message Date
Aiden Cline 4a27842fe6 fix(ai): classify gateway account limits as quota and keep 4xx non-retryable (#49195) 2026-09-16 16:14:09 -05:00
Aiden Cline d797722187 feat(codemode): carry cause and own data across the error boundary (#49390) 2026-09-16 15:58:05 -05:00
Dax 7390832f13 feat(tui): reload all locations
Add a global location reload endpoint, shared shutdown lifecycle, client resynchronization, and the TUI /reload command.\n\nfrom OpenCode
2026-09-16 16:47:44 -04:00
Kit Langton 7689c3654e fix(tui): hide error hint when MCP Enter starts sign-in (#49403) 2026-09-16 19:33:08 +00:00
Dax 7df0935ada fix(tui): apply model selection on blank submit (#49374)
Apply the selected model on blank Enter and simplify shared prompt submission setup and recovery.\n\nfrom OpenCode
2026-09-16 19:32:45 +00:00
Dax bcd43760df feat(cli): support inline config content (#49399) 2026-09-16 19:18:55 +00:00
Aiden Cline 9073c522ef feat(plugin): add experimental WebSocket send and receive hooks (#49136) 2026-09-16 14:13:33 -05:00
opencode-agent[bot]andrekram1-node 04c296310e fix(core): skip session warming for subagents (#49387)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-16 13:58:40 -05:00
Kit Langton 79d657b8fe fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:43 -04:00
Kit Langton 606ec4fa38 fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:00 -04:00
52 changed files with 913 additions and 219 deletions
+21 -6
View File
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
"insufficient_quota",
"usage_not_included",
"billing_error",
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
// as a `[code]` label at the start of the rewritten message.
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
((input.status === undefined || input.status < 400) &&
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
)
return new ProviderInternalError({
...details,
+3 -3
View File
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
it.effect("does not let server codes override a 4xx rejection", () =>
Effect.gen(function* () {
const classify = (body: string) =>
Effect.gen(function* () {
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
}),
)
+47 -3
View File
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(Array(5).fill("InvalidRequest"))
})
test("classifies 402 as exhausted quota", () => {
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
})
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
const substituted = (message: string) => ({
error: { type: "server_error", message: `Upstream request failed: ${message}` },
})
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
[402, substituted("Insufficient account funds")],
[402, substituted("Account invoice is overdue")],
[429, substituted("Account budget exceeded")],
]
expect(
cases.map(
([status, body]) =>
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(Array(6).fill("InvalidRequest"))
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
type: "error",
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
}
expect(
[openai, anthropic].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["InvalidRequest", "InvalidRequest"])
// Without a contradicting status the same codes still mark provider trouble.
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
"ProviderInternal",
)
expect(
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
).toBe("ProviderInternal")
})
test("classifies nested provider codes when a top-level code is also present", () => {
+44 -9
View File
@@ -20,7 +20,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
const decode = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
const empty: Info = {}
export const layer = Layer.effect(
@@ -29,14 +29,14 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const content = process.env.OPENCODE_CLI_CONFIG_CONTENT
? Option.getOrUndefined(decode(parseRecord(process.env.OPENCODE_CLI_CONFIG_CONTENT)))
: undefined
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
return parseRecord(text)
})
const write = Effect.fnUntraced(function* (text: string) {
@@ -61,6 +61,9 @@ export const layer = Layer.effect(
}),
),
)
const load = Effect.fnUntraced(function* (migration?: Info) {
return merge(migration ?? Option.getOrUndefined(decode(yield* readJson())), content)
})
const get = Effect.fn("cli.config.get")(() =>
withLock(
@@ -72,8 +75,7 @@ export const layer = Layer.effect(
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
return yield* load(migration?.info)
}),
),
)
@@ -83,7 +85,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const current = yield* load(migration?.info)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
@@ -102,7 +104,7 @@ export const layer = Layer.effect(
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
return merge(config, content)
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
@@ -113,6 +115,39 @@ export const layer = Layer.effect(
type Edit = { readonly path: (string | number)[]; readonly value: any }
function merge(...values: readonly (Info | undefined)[]) {
return Option.getOrElse(
decode(
values.reduce<Record<string, unknown>>(
(result, value) => mergeRecords(result, value ?? {}),
{},
),
),
() => empty,
)
}
function mergeRecords(base: object, overlay: object) {
return Object.entries(overlay).reduce<Record<string, unknown>>(
(result, [key, value]) => {
result[key] = isRecord(result[key]) && isRecord(value) ? mergeRecords(result[key], value) : value
return result
},
{ ...base },
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function parseRecord(text: string) {
const errors: ParseError[] = []
const value: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
}
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
+45
View File
@@ -71,6 +71,51 @@ test("preserves the schema in an existing cli.json", async () => {
expect(await Bun.file(file).json()).toEqual(config)
})
test("merges inline CLI config content over the global config", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "cli.json")
const previous = process.env.OPENCODE_CLI_CONFIG_CONTENT
await Bun.write(
file,
JSON.stringify({
tabs: { enabled: true, scope: "global" },
keybinds: { "app.exit": "ctrl+q" },
plugins: ["global"],
animations: true,
}),
)
process.env.OPENCODE_CLI_CONFIG_CONTENT = JSON.stringify({
tabs: { enabled: false },
keybinds: { "help.show": false },
plugins: ["inline"],
animations: false,
})
try {
const result = await run(
directory.path,
Effect.gen(function* () {
const service = yield* Config.Service
const loaded = yield* service.get()
const updated = yield* service.update((draft) => {
draft.animations = true
draft.mouse = false
})
return { loaded, updated }
}),
)
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
expect(result.loaded.plugins).toEqual(["inline"])
expect(result.updated).toMatchObject({ animations: false, mouse: false })
expect(await Bun.file(file).json()).toMatchObject({ animations: true, mouse: false })
} finally {
if (previous === undefined) delete process.env.OPENCODE_CLI_CONFIG_CONTENT
else process.env.OPENCODE_CLI_CONFIG_CONTENT = previous
}
})
test("migrates tui and kv config into cli.json", async () => {
await using directory = await tmpdir()
await Bun.write(
+1
View File
@@ -4,6 +4,7 @@ export function isolatedEnv(root: string, overrides: Record<string, string | und
return {
...process.env,
HOME: root,
OPENCODE_CLI_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_CONTENT: "{}",
OPENCODE_CONFIG_DIR: path.join(root, "config"),
OPENCODE_DB: path.join(root, "opencode.db"),
+4
View File
@@ -54,8 +54,12 @@ export type LocationGetInput = { readonly location?: { readonly directory?: stri
export type LocationGetOutput = Location.PublicInfo
export type LocationGetOperation<E = never> = (input?: LocationGetInput) => Effect.Effect<LocationGetOutput, E>
export type LocationReloadOutput = void
export type LocationReloadOperation<E = never> = () => Effect.Effect<LocationReloadOutput, E>
export interface LocationApi<E = never> {
readonly get: LocationGetOperation<E>
readonly reload: LocationReloadOperation<E>
}
export type AgentListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
@@ -8,6 +8,7 @@ import type {
ServerStatusOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
@@ -287,7 +288,13 @@ const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: Loca
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({ get: EndpointLocationGet(raw) })
const EndpointLocationReload = (raw: RawClient["server.location"]) => () =>
preserveEffect<LocationReloadOutput>()(raw["location.reload"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({
get: EndpointLocationGet(raw),
reload: EndpointLocationReload(raw),
})
const EndpointAgentList = (raw: RawClient["server.agent"]) => (input?: AgentListInput) =>
preserveEffect<AgentListOutput>()(
@@ -2,6 +2,7 @@ import type {
ServerStatusOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
@@ -416,6 +417,17 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
reload: (requestOptions?: RequestOptions) =>
request<LocationReloadOutput>(
{
method: "POST",
path: `/api/location/reload`,
successStatus: 204,
declaredStatuses: [400, 401, 503],
empty: true,
},
requestOptions,
),
},
agent: {
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
+20 -8
View File
@@ -820,6 +820,15 @@ export type SessionUsageRecorded = {
data: { sessionID: string; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo }
}
export type LocationShutdown = {
id: string
created: number
metadata?: { [x: string]: any }
type: "location.shutdown"
location?: LocationRef
data: {}
}
export type ModelsDevRefreshed = {
id: string
created: number
@@ -2321,6 +2330,7 @@ export type IntegrationInfo = {
}
export type V2Event =
| LocationShutdown
| ModelsDevRefreshed
| CredentialUpdated
| CredentialSwitched
@@ -2429,14 +2439,6 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
readonly message: string
}
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
@@ -2445,6 +2447,14 @@ export type ServiceUnavailableError = {
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
readonly message: string
}
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2653,6 +2663,8 @@ export type LocationGetInput = {
export type LocationGetOutput = LocationPublicInfo
export type LocationReloadOutput = void
export type AgentListInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+6
View File
@@ -592,6 +592,12 @@ export function createData(config: CreateDataInput) {
function handleEvent(event: OpenCodeEvent) {
switch (event.type) {
case "location.shutdown": {
if (!event.location) return
result.location.invalidate(event.location)
refresh(() => result.location.sync(event.location))
return
}
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
activeUpdates = updates
+6 -1
View File
@@ -182,7 +182,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors.
non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -472,6 +473,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
- [x] An Error crosses, in either direction, as its name, message, `cause`, and own enumerable data, so Node's
`code`, `errno`, `syscall`, and `path` reach the program and `err.code === "ENOENT"` works. `stack` stays on its
own side, no field may shadow an Error method, and a field that cannot cross (a class instance, a function) is
left behind rather than replacing the error.
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
step if `new X()` in a program is ever needed.
+12 -4
View File
@@ -6,7 +6,7 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { formatValue } from "../stdlib/console.js"
import { coerceToString } from "../stdlib/value.js"
@@ -146,9 +146,17 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
const prototype = builtins[type]
const construct = (args: Array<unknown>, newTarget: Callable) => {
const proto = prototypeFrom(newTarget, prototype)
return type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
const created =
type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
const options = args[type === "AggregateError" ? 2 : 1]
if (!(options instanceof Obj) || !has(options, "cause")) return created
return Effect.map(created, (value) => {
define(value, "cause", get(options, "cause"), hidden)
return value
})
}
const ctor: Native<R> = constructor<R>(builtins, prototype, {
name: type,
@@ -5,13 +5,16 @@ import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
import { Throw, typeError } from "./model.js"
import { PendingThrow, Throw, typeError } from "./model.js"
import { fn } from "./native.js"
import {
Callable,
define,
entries,
get,
has,
hidden,
keys,
Arr,
Bytes,
DateObj,
@@ -60,14 +63,28 @@ export const extensionGlobals = <R>(
) {
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
if (value instanceof ErrorObj) {
const name = coerceToString(get(value, "name"))
const message = get(value, "message")
const text = message === undefined ? "" : coerceToString(message)
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
const copied =
name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
for (const key of new Set(["cause", ...keys(value)])) {
if (uncrossed.has(key) || !has(value, key)) continue
const item = crossing(() => next(get(value, key)))
if (item === left) continue
Object.defineProperty(copied, key, {
value: item,
writable: true,
configurable: true,
enumerable: key !== "cause",
})
}
seen.delete(value)
return copied
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
const copied =
value instanceof Arr
? value.items.map(next)
@@ -85,18 +102,29 @@ export const extensionGlobals = <R>(
if (isPrimitive(value)) return value
if (typeof value === "function") return wrap(value, label)
if (value !== null && typeof value === "object") {
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
if (value instanceof ArrayBuffer) return new Bytes(builtins.Uint8Array, new Uint8Array(value.slice(0)))
if (value instanceof Error) {
return createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
seen.add(value)
const copied = createErrorValue(builtins[isErrorType(value.name) ? value.name : "Error"], value.message)
const fields = value as unknown as Record<string, unknown>
for (const key of new Set(["cause", ...Object.keys(value)])) {
if (uncrossed.has(key) || !(key in value) || typeof fields[key] === "function") continue
const item = crossing(() => next(fields[key], `${label}.${key}`))
if (item === left) continue
define(copied, key, item, key === "cause" ? hidden : undefined)
}
seen.delete(value)
return copied
}
if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href))
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
@@ -160,6 +188,22 @@ export const extensionGlobals = <R>(
)
}
/**
* An error crosses as its name, message, `cause`, and own enumerable fields, such as Node's `code`, `errno`,
* `syscall`, and `path`. `stack` stays on its own side, and no field may shadow an Error method. A field that cannot
* cross (a socket, a handle, a function) is left behind so the error itself always arrives.
*/
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
const left = Symbol("left behind")
const crossing = (convert: () => unknown): unknown => {
try {
return convert()
} catch (reason) {
if (reason instanceof PendingThrow) return left
throw reason
}
}
const hostErrors = new Map<string, ErrorConstructor>([
["TypeError", TypeError],
["RangeError", RangeError],
+58 -1
View File
@@ -163,12 +163,26 @@ describe("values are converted at the boundary, never shared", () => {
expect(held[0]).toEqual({ a: 1 })
})
test("a program Error crosses as a host Error with its name and message", async () => {
test("a program Error crosses as a host Error with its name, message, cause, and own data", async () => {
held.length = 0
await value(`keep(new TypeError("bad"))`)
expect(held[0]).toBeInstanceOf(TypeError)
expect((held[0] as Error).message).toBe("bad")
expect(Object.keys(held[0] as object)).toEqual([])
held.length = 0
await value(`
const e = new Error("m", { cause: new RangeError("root") })
e.code = "ENOENT"; e.detail = { path: "x" }
e.stack = "chosen"; e.toString = 1; e.constructor = 2; e.fn = () => 1
keep(e)`)
const crossed = held[0] as Error & Record<string, unknown>
expect(crossed.cause).toBeInstanceOf(RangeError)
expect((crossed.cause as Error).message).toBe("root")
expect(Object.keys(crossed)).toEqual(["code", "detail"])
expect(crossed.detail).toEqual({ path: "x" })
expect(crossed.stack).not.toBe("chosen")
expect(String(crossed)).toBe("Error: m")
expect(crossed.constructor).toBe(Error)
})
test("an Error with an unknown name crosses as a plain Error", async () => {
@@ -231,6 +245,49 @@ describe("host errors", () => {
])
})
test("a host Error arrives with its cause and own data; what cannot cross is left behind", async () => {
class Handle {}
const target = CodeMode.make({
extensions: [
Extension.make({
name: "fs",
globals: {
open: () => {
const error = Object.assign(new Error("ENOENT: no such file or directory, open 'x'"), {
code: "ENOENT",
errno: -2,
path: "x",
detail: { retried: true },
handle: new Handle(),
retry: () => 1,
})
throw new Error("open failed", { cause: error })
},
},
}),
],
})
expect(
await value(
`try { open() } catch (e) {
const c = e.cause
return [e.message, Object.keys(e), c instanceof Error, c.code, c.errno, c.path, c.detail, Object.keys(c), "stack" in c]
}`,
target,
),
).toEqual([
"open failed",
[],
true,
"ENOENT",
-2,
"x",
{ retried: true },
["code", "errno", "path", "detail"],
false,
])
})
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
const reason = { status: 404, nested: { a: 1 } }
const target = CodeMode.make({
+10
View File
@@ -350,6 +350,16 @@ describe("Error values and instanceof", () => {
expect(await value(`return new Error("e") instanceof TypeError`)).toBe(false)
})
test("new Error(message, { cause }) installs a non-enumerable cause only when the option is present", async () => {
expect(
await value(`
const inner = new Error("root")
const e = new TypeError("m", { cause: inner })
const agg = new AggregateError([], "a", { cause: 3 })
return [e.cause === inner, Object.keys(e), "cause" in new Error("m"), "cause" in new Error("m", { cause: undefined }), agg.cause]`),
).toEqual([true, [], false, true, 3])
})
test("thrown errors keep instanceof through try/catch", async () => {
expect(await value(`try { throw new Error("x") } catch (e) { return [e instanceof Error, e.message] }`)).toEqual([
true,
+14 -9
View File
@@ -79,6 +79,7 @@ export interface ListInput {
}
export interface Interface {
readonly close: Effect.Effect<void>
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError | InvalidFormError>
readonly ask: (input: CreateInput) => Effect.Effect<TerminalState, AlreadyExistsError | InvalidFormError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
@@ -100,6 +101,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
let closed = false
const forms = yield* Cache.makeWith<ID, Entry>(
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
{
@@ -137,6 +139,7 @@ export const layer = Layer.effect(
}
yield* Cache.set(forms, id, entry)
yield* bus.publish(Form.Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
if (closed) yield* cancel(id).pipe(Effect.orDie)
return form
}),
),
@@ -202,19 +205,21 @@ export const layer = Layer.effect(
),
)
yield* Effect.addFinalizer(() =>
Cache.values(forms).pipe(
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
const close = Effect.sync(() => {
closed = true
}).pipe(
Effect.andThen(Cache.values(forms)),
Effect.flatMap((entries) =>
Effect.forEach(
Array.from(entries).filter((entry) => entry.state.status === "pending"),
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
{ discard: true },
),
),
)
yield* Effect.addFinalizer(() => close)
return Service.of({ create, ask, get, list, state, reply, cancel })
return Service.of({ create, ask, get, list, state, reply, cancel, close })
}),
)
+4 -1
View File
@@ -1,4 +1,4 @@
import { Effect, Layer } from "effect"
import { Context, Effect, Layer } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Model } from "./model.js"
@@ -18,6 +18,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
import { FileAccess } from "./file-access.js"
import { ModelResolver } from "./model-resolver.js"
import { Mcp } from "./mcp/index.js"
@@ -57,6 +58,7 @@ export { Service, node, type Interface } from "./instance/service.js"
const nodes = [
Location.node,
LocationLifecycle.node,
Environment.node,
Config.node,
Agent.node,
@@ -157,6 +159,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
// Instance boot failures are defects; provided operations retain their typed errors.
Layer.orDie,
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
+52
View File
@@ -0,0 +1,52 @@
export * as LocationLifecycle from "./location-lifecycle.js"
import { Context, Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LocationEvent } from "@opencode/schema/location-event"
import { Bus } from "./bus.js"
import { Form } from "./form.js"
import { Location } from "./location.js"
import { Permission } from "./permission.js"
import { Rpc } from "./rpc.js"
export class Service extends Context.Service<
Service,
{ readonly isClosed: () => boolean; readonly shutdown: Effect.Effect<void> }
>()("@opencode/LocationLifecycle") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
const forms = yield* Form.Service
const rpc = yield* Rpc.Service
let closed = false
const shutdown = yield* Effect.cached(
Effect.gen(function* () {
closed = true
yield* permission.close
yield* forms.close
yield* rpc.close
yield* bus.publish(
LocationEvent.Shutdown,
{},
{
location: Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
},
)
}).pipe(Effect.uninterruptible),
)
return Service.of({
isClosed: () => closed,
shutdown,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
})
+19 -1
View File
@@ -1,4 +1,4 @@
import { Context, Effect, Layer, LayerMap } from "effect"
import { Context, Effect, Exit, Layer, LayerMap, RcMap } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Node } from "@opencode/util/effect/app-node"
import { AbsolutePath } from "@opencode/schema/schema"
@@ -17,6 +17,24 @@ export class Service extends Context.Service<
export const node = LayerNode.unbound(Service, Node.tags.values.global)
export const reload = Effect.fn("LocationServiceMap.reload")(function* () {
const locations = yield* Service
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
yield* Effect.forEach(refs, (ref) => locations.invalidate(ref), {
discard: true,
concurrency: "unbounded",
})
// Boot every replacement now and let all builds settle even if one fails.
const results = yield* Effect.forEach(
refs,
(ref) => Effect.scoped(locations.contextEffect(ref)).pipe(Effect.asVoid, Effect.exit),
{ concurrency: "unbounded" },
)
const failure = results.find(Exit.isFailure)
if (failure) return yield* Effect.failCause(failure.cause)
yield* Effect.logInfo("location services reloaded", { count: refs.length })
})
/** Normalize equivalent placements before they become resource-cache keys. */
export function canonical(ref: Location.Ref) {
return Location.Ref.make({
+13 -7
View File
@@ -2,8 +2,8 @@ import { Context, Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Optio
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Instance } from "./instance.js"
import { Location } from "./location.js"
import { LocationLifecycle } from "./location-lifecycle.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Rpc } from "./rpc.js"
export { LocationServiceMap } from "./location-service-map.js"
@@ -28,12 +28,17 @@ export function buildLocationServiceMap(
).pipe(
Effect.onExit((exit) => {
const finish = Effect.suspend(() => {
if (Exit.isSuccess(exit)) {
return Effect.gen(function* () {
const lifecycle = Context.get(exit.value, LocationLifecycle.Service)
// A boot detached while in flight still needs shutdown and cancellation.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build)
return yield* lifecycle.shutdown
build.close = lifecycle.shutdown
})
}
// An explicitly invalidated build must not evict its replacement.
if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build) return Effect.void
if (Exit.isSuccess(exit)) {
build.close = Context.get(exit.value, Rpc.Service).close
return Effect.void
}
MutableHashMap.remove(builds, ref)
// Evict once per failed build, before its result reaches borrowers.
return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void
@@ -61,10 +66,11 @@ export function buildLocationServiceMap(
const key = LocationServiceMap.canonical(ref)
const build = Option.getOrUndefined(MutableHashMap.get(builds, key))
MutableHashMap.remove(builds, key)
// Detach routing first, then end pending RPCs that still borrow the old graph.
// Detach routing first, then cancel interactions and notify clients. Running
// steps retain their borrowed graph until they can hand off at a boundary.
// Do not await a boot here: failed/in-flight builds have their own cleanup path.
return inner.invalidate(key).pipe(Effect.andThen(build?.close ?? Effect.void))
}),
}).pipe(Effect.uninterruptible),
}
// Cached instances borrow their owner instead of retaining its Layer scope.
const bindings: LayerNode.Replacements = [
+23 -12
View File
@@ -101,6 +101,7 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly close: Effect.Effect<void>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -127,18 +128,22 @@ const layer = Layer.effect(
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
discard: true,
}).pipe(
Effect.ensuring(
Effect.sync(() => {
pending.clear()
}),
),
),
)
const close = Effect.gen(function* () {
closed = true
yield* Effect.forEach(Array.from(pending.values()), (item) =>
bus
.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
.pipe(Effect.ensuring(Deferred.fail(item.deferred, new DeclinedError()))),
)
pending.clear()
}).pipe(Effect.uninterruptible)
yield* Effect.addFinalizer(() => close)
const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
@@ -201,6 +206,10 @@ const layer = Layer.effect(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
if (closed) {
yield* Deferred.fail(deferred, new DeclinedError())
return item
}
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
pending.set(request.id, item)
@@ -212,6 +221,7 @@ const layer = Layer.effect(
)
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
if (closed) return { id: input.id ?? ID.create(), effect: "deny" as const }
const result = yield* evaluateInput(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
@@ -220,6 +230,7 @@ const layer = Layer.effect(
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.gen(function* () {
if (closed) return yield* Effect.die(new DeclinedError())
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
@@ -321,7 +332,7 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list })
return Service.of({ ask, assert, reply, get, forSession, list, close })
}),
)
+3 -2
View File
@@ -42,8 +42,9 @@ that section.
CLI and TUI preferences are separate from OpenCode's server and project
configuration. They live in the global `~/.config/opencode/cli.json`, or
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
project-local CLI configuration. Most preferences can also be changed from the
TUI by pressing `Ctrl+P` and selecting **Open settings**.
project-local CLI configuration. Set `OPENCODE_CLI_CONFIG_CONTENT` to merge
inline JSON over the global settings. Most preferences can also be changed from
the TUI by pressing `Ctrl+P` and selecting **Open settings**.
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It covers terminal-only settings such as themes,
+3
View File
@@ -57,6 +57,9 @@ export const Plugin = define({
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const session = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(Effect.orDie)
if (session.parentID) return
const active = sessions.get(event.sessionID)
const settings = yield* loadSettings()
if (!settings) {
+1
View File
@@ -108,6 +108,7 @@ export const layer = Layer.effect(
return yield* SessionRunner.DrainResult.$match(result, {
Complete: () => Effect.void,
Moved: (result) => drain(sessionID, false, result.continuation, promotable),
Reloaded: (result) => drain(sessionID, result.force, result.continuation, promotable),
})
})
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -100,7 +100,6 @@ export const layer = (options?: Options) =>
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
suspended: ReadonlySet<SessionSchema.ID>,
) {
const state = background.status === "running" ? "cancelled" : background.status
const text =
@@ -124,7 +123,9 @@ export const layer = (options?: Options) =>
state,
text,
}),
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
// Restart notices must not revive idle owners of long-lived shells.
// Interrupted executions resume separately after their notices are admitted.
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
@@ -208,7 +209,7 @@ export const layer = (options?: Options) =>
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
yield* recovery.kind === "shell"
? recoverShell(background, recovery, suspended)
? recoverShell(background, recovery)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
+18 -9
View File
@@ -320,15 +320,24 @@ export const layer = Layer.effect(
// which transport actually carries the request, so both hook families are always offered.
const webSocket =
input.webSocket === "session" && model.transport === "websocket"
? transport.bind(session.id, (connect) =>
hooks
.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
})
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
)
? transport.bind(session.id, {
handshake: (connect) =>
hooks
.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
})
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
send: (frame) =>
hooks
.trigger("session", "experimental.ws.send", { ...scope, frame })
.pipe(Effect.map((event) => event.frame)),
receive: (frame) =>
hooks
.trigger("session", "experimental.ws.receive", { ...scope, frame })
.pipe(Effect.map((event) => event.frame)),
})
: undefined
return {
+21 -13
View File
@@ -59,11 +59,18 @@ export interface Handshake {
readonly headers: Record<string, string>
}
/**
* Per-exchange taps. `handshake` runs before the connection is selected; `send` sees each outbound
* frame after the driver builds it; `receive` sees each inbound frame before the driver observes it.
*/
export interface Interceptor {
readonly handshake?: (connect: Handshake) => Effect.Effect<Handshake>
readonly send?: (frame: string) => Effect.Effect<string>
readonly receive?: (frame: string) => Effect.Effect<string>
}
export interface Interface {
readonly bind: (
sessionID: SessionSchema.ID,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
) => WebSocketChannelExecutor
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
@@ -278,7 +285,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
input: WebSocketChannelExchange,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
interceptor?: Interceptor,
) {
if (owner.closed)
return yield* transportError("Session WebSocket owner is closed", {
@@ -288,8 +295,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "not-sent",
})
if (owner.httpFallback) return fallback(input)
const selected = handshake
? yield* handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
const selected = interceptor?.handshake
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
: undefined
const exchange: WebSocketChannelExchange = selected
? { ...input, connect: { ...input.connect, url: selected.url, headers: Headers.fromInput(selected.headers) } }
@@ -354,6 +361,9 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const message = interceptor?.send
? yield* interceptor.send(create.message).pipe(Effect.onInterrupt(() => closeChannel(owner, channel)))
: create.message
yield* Effect.logDebug("session websocket sending", {
sessionTransport: "websocket",
phase: "send",
@@ -364,7 +374,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "send-attempted",
}
channel.active = active
const sent = yield* channel.connection.sendText(create.message).pipe(
const sent = yield* channel.connection.sendText(message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
Effect.result,
@@ -405,6 +415,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
}),
),
}),
Stream.mapEffect((frame) => (interceptor?.receive ? interceptor.receive(frame) : Effect.succeed(frame))),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
@@ -482,10 +493,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
return { frames, complete, http: channel.connection.http }
})
const bind = (
sessionID: SessionSchema.ID,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
): WebSocketChannelExecutor => ({
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
let execution: WebSocketChannelExecution | undefined
@@ -495,7 +503,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, handshake)),
Effect.andThen(start(owner, exchange, interceptor)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
@@ -22,6 +22,7 @@ export type Continuation = { readonly step: number }
export type DrainResult = Data.TaggedEnum<{
Complete: {}
Moved: { readonly continuation?: Continuation }
Reloaded: { readonly force: boolean; readonly continuation?: Continuation }
}>
export const DrainResult = Data.taggedEnum<DrainResult>()
+8
View File
@@ -5,6 +5,7 @@ import { and, desc, eq, sql } from "drizzle-orm"
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { LocationLifecycle } from "../../location-lifecycle.js"
import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
@@ -37,6 +38,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const lifecycle = yield* LocationLifecycle.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelTransport = yield* SessionModelTransport.Service
@@ -69,6 +71,10 @@ const layer = Layer.effect(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
while (true) {
if (lifecycle.isClosed()) {
yield* restore(modelTransport.close(sessionID))
return DrainResult.Reloaded({ force, continuation: continuing ? { step } : undefined })
}
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
const pending = yield* SessionInbox.serialized(
sessionID,
@@ -240,6 +246,7 @@ const layer = Layer.effect(
webSocket: "session",
})
const outcome = yield* steps.attempt({
isLocationClosed: lifecycle.isClosed,
sessionID,
assistantMessageID,
agent: loaded.agent.id,
@@ -356,6 +363,7 @@ export const node = makeLocationNode({
layer,
deps: [
Bus.node,
LocationLifecycle.node,
llmClient,
SessionContext.node,
SessionModelTransport.node,
+8 -3
View File
@@ -42,6 +42,7 @@ export type Outcome = Data.TaggedEnum<{
export const Outcome = Data.taggedEnum<Outcome>()
interface Input {
readonly isLocationClosed: () => boolean
readonly sessionID: SessionSchema.ID
readonly assistantMessageID: SessionMessage.ID
readonly agent: Agent.ID
@@ -190,8 +191,9 @@ export const make = Effect.gen(function* () {
for (const decline of tools.declines)
yield* publisher.failTool(decline.call.id, {
type: "aborted",
message:
decline.reason._tag === "QuestionTool.CancelledError"
message: input.isLocationClosed()
? "Interaction cancelled because the location shut down"
: decline.reason._tag === "QuestionTool.CancelledError"
? decline.reason.message
: "The user declined this tool call",
})
@@ -251,7 +253,10 @@ export const make = Effect.gen(function* () {
return Outcome.Continue({ error: llmError, decision: retry })
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
if (tools.declines.length > 0) return yield* Effect.interrupt
if (tools.declines.length > 0) {
if (input.isLocationClosed()) return Outcome.Completed({ needsContinuation: true })
return yield* Effect.interrupt
}
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
if (record.failure) return yield* new StepFailedError({ error: record.failure })
+2 -2
View File
@@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () =>
it.effect("classifies data-only AI SDK provider codes", () =>
Effect.gen(function* () {
const data = {
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } },
trace: { region: "test-region" },
}
const cause = apiCallError({ statusCode: 400, data })
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason.http?.status).toBe(400)
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect(error.reason.body).toBe(JSON.stringify(data))
+2 -2
View File
@@ -1,5 +1,5 @@
import { Permission } from "@opencode/core/permission"
import { Layer } from "effect"
import { Effect, Layer } from "effect"
export const permissionLayer = (overrides: Partial<Permission.Interface> = {}) =>
Layer.mock(Permission.Service, overrides)
Layer.mock(Permission.Service, { close: Effect.void, ...overrides })
+1
View File
@@ -55,6 +55,7 @@ const generateLayer = Layer.succeed(Generate.Service, Generate.Service.of({ text
const permissionLayer = Layer.succeed(
Permission.Service,
Permission.Service.of({
close: Effect.void,
ask: (input) => Effect.succeed({ id: input.id ?? Permission.ID.create(), effect: "ask" }),
assert: () => Effect.void,
reply: () => Effect.void,
+16 -3
View File
@@ -379,7 +379,7 @@ describe("SessionExecution lifecycle", () => {
})
describe("SessionRestart background recovery", () => {
it.effect("wakes idle shell owners and delivers recovered notices exactly once", () =>
it.effect("keeps shell owners idle until a user prompt delivers recovered notices exactly once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
@@ -416,6 +416,20 @@ describe("SessionRestart background recovery", () => {
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
expect(drained).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(drained).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
yield* seedInbox(database, parent, ["steer"])
yield* seedInbox(database, child, ["steer"])
yield* execution.wake(parent)
yield* execution.wake(child)
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([parent, child].toSorted())
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toMatchObject([
{
@@ -509,7 +523,7 @@ describe("SessionRestart background recovery", () => {
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
expect(drained).toEqual([sessionID])
expect(drained).toEqual([])
const inbox = yield* SessionInbox.list(database.db, sessionID)
expect(inbox).toMatchObject([
{
@@ -561,7 +575,6 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
@@ -80,7 +80,7 @@ describe("SessionModelRequest HTTP hooks", () => {
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
)
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the handshake hook", () =>
it.effect("offers the WebSocket executor alongside HTTP hooks and routes the WebSocket hooks", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const seen: string[] = []
@@ -92,13 +92,31 @@ describe("SessionModelRequest HTTP hooks", () => {
delete event.headers["api-key"]
}),
)
yield* hooks.register("session", "experimental.ws.send", (event) =>
Effect.sync(() => {
seen.push(`send:${event.kind}:${event.frame}`)
event.frame = `${event.frame}+plugin`
}),
)
yield* hooks.register("session", "experimental.ws.receive", (event) =>
Effect.sync(() => {
seen.push(`receive:${event.kind}:${event.frame}`)
event.frame = event.frame.toUpperCase()
}),
)
const bound: Array<{ url: string; headers: Record<string, string> }> = []
const frames: string[] = []
const websocketTransport = SessionModelTransport.Service.of({
bind: (_sessionID, handshake) => ({
bind: (_sessionID, interceptor) => ({
execute: () =>
Effect.gen(function* () {
if (!handshake) throw new Error("Expected a handshake interceptor")
bound.push(yield* handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }))
if (!interceptor?.handshake || !interceptor.send || !interceptor.receive)
throw new Error("Expected a full WebSocket interceptor")
bound.push(
yield* interceptor.handshake({ url: "wss://example.test/v1/responses", headers: { "api-key": "k" } }),
)
frames.push(yield* interceptor.send("create"))
frames.push(yield* interceptor.receive("created"))
return { frames: Stream.empty, complete: Effect.void }
}),
}),
@@ -127,7 +145,12 @@ describe("SessionModelRequest HTTP hooks", () => {
expect(prepared.options.webSocket).toBeDefined()
yield* prepared.options.webSocket!.execute({} as never)
expect(bound).toEqual([{ url: "wss://example.test/v1/responses", headers: { authorization: "Bearer minted" } }])
expect(seen).toEqual(["handshake:primary:wss://example.test/v1/responses"])
expect(frames).toEqual(["create+plugin", "CREATED"])
expect(seen).toEqual([
"handshake:primary:wss://example.test/v1/responses",
"send:primary:create",
"receive:primary:created",
])
}),
)
})
@@ -178,12 +178,13 @@ describe("SessionModelTransport", () => {
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session, (connect) =>
Effect.succeed({
url: connect.url,
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
}),
)
const executor = transport.bind(session, {
handshake: (connect) =>
Effect.succeed({
url: connect.url,
headers: { ...connect.headers, authorization: `Bearer ${tokens.shift()}` },
}),
})
yield* collect(executor, exchange("first", { headers: { "api-key": "k" } }))
yield* collect(executor, exchange("second", { headers: { "api-key": "k" } }))
yield* collect(executor, exchange("third", { headers: { "api-key": "k" } }))
@@ -196,6 +197,36 @@ describe("SessionModelTransport", () => {
)
})
test("sends the frame the send tap returns and observes the frame the receive tap returns", async () => {
const fixture = automatic()
const seen: Array<{ tap: "send" | "receive"; frame: string }> = []
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session, {
send: (frame) => {
seen.push({ tap: "send", frame })
return Effect.succeed(`${frame}:rewritten`)
},
receive: (frame) => {
seen.push({ tap: "receive", frame })
return Effect.succeed(`${frame}:observed`)
},
})
const frames = yield* collect(executor, exchange("first"))
// The wire carries the rewritten outbound frame; the driver sees the rewritten inbound frame.
expect(fixture.connections.map((item) => item.sent)).toEqual([["first:rewritten"]])
expect(frames).toEqual(["completed:first:rewritten:observed"])
expect(seen).toEqual([
{ tap: "send", frame: "first" },
{ tap: "receive", frame: "completed:first:rewritten" },
])
}),
)
})
test("does not carry a checkpoint across physical connection rotation", async () => {
const fixture = automatic()
const checkpoints: Array<unknown> = []
+1
View File
@@ -100,6 +100,7 @@ for (const fixture of [
)
const result = yield* steps
.attempt({
isLocationClosed: () => false,
sessionID,
assistantMessageID,
agent: Agent.defaultID,
+27
View File
@@ -99,6 +99,31 @@ export interface SessionWebSocketHandshake {
headers: Record<string, string>
}
/**
* Outbound frame about to be written to the Session's socket, after the provider driver has built
* it. Replacing `frame` sends the replacement verbatim; the driver still tracks state from the
* provider's replies, so a rewrite that changes protocol meaning is on the plugin. Experimental.
*/
export interface SessionWebSocketSend {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
/**
* Inbound frame read from the Session's socket, before the provider driver observes it. Replacing
* `frame` hands the replacement to the driver verbatim. Experimental.
*/
export interface SessionWebSocketReceive {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
export interface SessionRetry {
@@ -120,6 +145,8 @@ export interface SessionHooks {
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
+27
View File
@@ -99,6 +99,31 @@ export interface SessionWebSocketHandshake {
headers: Record<string, string>
}
/**
* Outbound frame about to be written to the Session's socket, after the provider driver has built
* it. Replacing `frame` sends the replacement verbatim; the driver still tracks state from the
* provider's replies, so a rewrite that changes protocol meaning is on the plugin. Experimental.
*/
export interface SessionWebSocketSend {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
/**
* Inbound frame read from the Session's socket, before the provider driver observes it. Replacing
* `frame` hands the replacement to the driver verbatim. Experimental.
*/
export interface SessionWebSocketReceive {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
readonly kind: SessionRequestKind
frame: string
}
export type SessionRetryDecision = { retry: false } | { retry: true; delay: number }
export interface SessionRetry {
@@ -120,6 +145,8 @@ export interface SessionHooks {
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
+8 -6
View File
@@ -23,7 +23,7 @@ import { PersistentPtyGroup } from "./groups/persistent-pty.js"
import { ShellGroup } from "./groups/shell.js"
import { ReferenceGroup } from "./groups/reference.js"
import { Authorization } from "./middleware/authorization.js"
import { LocationGroup } from "./groups/location.js"
import { makeLocationGroup } from "./groups/location.js"
import { IntegrationGroup } from "./groups/integration.js"
import { WebSearchGroup } from "./groups/websearch.js"
import { McpGroup } from "./groups/mcp.js"
@@ -35,7 +35,6 @@ import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof AgentGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof PluginGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ModelGroup, LocationId>
@@ -60,15 +59,17 @@ type SessionGroups<
FormLocationId extends HttpApiMiddleware.AnyId,
FormLocationService,
> =
| ReturnType<
typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
>
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>>
| typeof MessageGroup
type FormGroups<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeFormGroup<LocationId, LocationService>
>
type LocationGroup<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeLocationGroup<LocationId, LocationService>
>
type MixedMiddlewareGroups<
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
@@ -93,6 +94,7 @@ type ApiGroups<
| typeof PersistentPtyGroup
| typeof CredentialGroup
| LocationGroups<LocationId>
| LocationGroup<LocationId, LocationService>
| FormGroups<LocationId, LocationService>
| SessionGroups<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
| MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
@@ -152,7 +154,7 @@ const makeApiFromGroup = <
> =>
HttpApi.make("server")
.add(ServerGroup)
.add(LocationGroup.middleware(locationMiddleware))
.add(makeLocationGroup(locationMiddleware))
.add(AgentGroup.middleware(locationMiddleware))
.add(PluginGroup.middleware(locationMiddleware))
.add(makeSessionGroup(sessionLocationMiddleware, formLocationMiddleware))
+35 -15
View File
@@ -1,6 +1,7 @@
import { Location } from "@opencode/schema/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../errors.js"
export const LocationQuery = Schema.Struct({
location: Schema.optional(
@@ -25,19 +26,38 @@ export const locationQueryOpenApi = OpenApi.annotations({
},
})
export const LocationGroup = HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.PublicInfo,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
// Middleware is applied per endpoint: reload acts on every loaded location and
// must not boot the caller's location first.
export const makeLocationGroup = <LocationId extends HttpApiMiddleware.AnyId, LocationService>(
locationMiddleware: Context.Key<LocationId, LocationService>,
) =>
HttpApiGroup.make("server.location")
.add(
HttpApiEndpoint.get("location.get", "/api/location", {
query: LocationQuery,
success: Location.PublicInfo,
})
.middleware(locationMiddleware)
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
),
)
.add(
HttpApiEndpoint.post("location.reload", "/api/location/reload", {
success: HttpApiSchema.NoContent,
error: ServiceUnavailableError,
}).annotateMerge(
OpenApi.annotations({
identifier: "location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
identifier: "location.reload",
summary: "Reload locations",
description:
"Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "location" }))
)
.annotateMerge(OpenApi.annotations({ title: "location" }))
+2
View File
@@ -14,6 +14,7 @@ import { InstallationEvent } from "./installation-event.js"
import { Integration } from "./integration.js"
import { LegacyEventV1 } from "./legacy-event.js"
import { LspEvent } from "./lsp-event.js"
import { LocationEvent } from "./location-event.js"
import { McpEvent } from "./mcp-event.js"
import { Model } from "./model.js"
import { ModelsDev } from "./models-dev.js"
@@ -40,6 +41,7 @@ import { WebSearch } from "./websearch.js"
const coreDefinitions = Event.inventory(...SessionEvent.Definitions)
const foundationDefinitions = Event.inventory(
...LocationEvent.Definitions,
...ModelsDev.Event.Definitions,
...Credential.Event.Definitions,
...Integration.Event.Definitions,
+8
View File
@@ -0,0 +1,8 @@
export * as LocationEvent from "./location-event.js"
import { ephemeral, inventory } from "./event.js"
/** The location's cached services were shut down; clients must revalidate its reads. */
export const Shutdown = ephemeral({ type: "location.shutdown", schema: {} })
export const Definitions = inventory(Shutdown)
+27 -11
View File
@@ -1,17 +1,33 @@
import { Location } from "@opencode/core/location"
import { Effect } from "effect"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { ServiceUnavailableError } from "@opencode/protocol/errors"
import { Cause, Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
handlers.handle(
"location.get",
Effect.fn(function* () {
const location = yield* Location.Service
return new Location.Info({
directory: location.directory,
project: location.project,
})
}),
),
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
return handlers
.handle(
"location.get",
Effect.fn(function* () {
const location = yield* Location.Service
return new Location.Info({
directory: location.directory,
project: location.project,
})
}),
)
.handle("location.reload", () =>
LocationServiceMap.reload().pipe(
Effect.provideService(LocationServiceMap.Service, locations),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.fail(new ServiceUnavailableError({ message: Cause.pretty(cause), service: "location" })),
),
),
)
}),
)
+16
View File
@@ -1003,6 +1003,22 @@ function App(props: { pair?: DialogPairCredentials }) {
},
]
: []),
{
name: "location.reload",
title: "Reload locations",
slash: { name: "reload" },
run: async () => {
dialog.clear()
toast.show({ variant: "info", message: "Reloading all locations…", duration: 30000 })
await client.api.location
.reload()
.then(() => {
toast.show({ variant: "success", message: "Locations reloaded" })
})
.catch(toast.error)
},
category: "System",
},
{
name: "opencode.debug",
title: "View debug info",
@@ -93,6 +93,8 @@ export function DialogMcp(props: { initialServer?: string; details?: boolean } =
const focusedError = createMemo(() => {
const server = focusedServer()
// Enter starts sign-in for auth-gated integrations instead of showing the auth reason
if (server?.status.status === "needs_auth" && server.integrationID) return undefined
return server ? statusError(server.status) : undefined
})
+56 -65
View File
@@ -1111,24 +1111,19 @@ export function Prompt(props: PromptProps) {
if (move.creating()) return false
if (auto()?.visible) return false
const trimmed = store.prompt.text.trim()
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
if (
delivery === "queue" &&
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
) {
if (!trimmed && (!props.sessionID || store.mode === "shell" || delivery === "queue"))
return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
const exitWord = trimmed === "exit" || trimmed === "quit" || trimmed === ":q"
const slash = argumentSlash(store.prompt.text, keymapCommands())
if (delivery === "queue" && (store.mode === "shell" || exitWord || slash)) {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
if (exitWord) {
void exit()
return true
}
const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) {
if (delivery === "queue") {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
clearPrompt()
await slash.command.run(slash.input)
return true
@@ -1178,8 +1173,10 @@ export function Prompt(props: PromptProps) {
// snapshot unless the user has started typing something new.
const currentMode = store.mode
const entry = { ...store.prompt, mode: currentMode }
resetComposer()
props.onSubmit?.()
if (trimmed) {
resetComposer()
props.onSubmit?.()
}
const restoreEntry = () => {
if (disposed || input.isDestroyed || input.plainText !== "") return
input.setText(entry.text)
@@ -1188,6 +1185,19 @@ export function Prompt(props: PromptProps) {
restoreExtmarksFromPrompt(entry)
input.cursorOffset = entry.text.length
}
const fail = (title: string, error: unknown) => {
toast.show({ title, message: errorMessage(error), variant: "error" })
restoreEntry()
}
const attempt = async (title: string, run: () => Promise<unknown>) => {
return run().then(
() => true,
(error: unknown) => {
fail(title, error)
return false
},
)
}
const variant = selection.variant
let sessionID = props.sessionID
@@ -1269,22 +1279,30 @@ export function Prompt(props: PromptProps) {
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
})
}
history.append(entry)
const dispatch = (send: () => Promise<unknown>) => {
const setup = newSession
if (setup) void setup.gate.then(send).catch(setup.recover)
else void send()
const commitSelection = async () => {
await prepareAgent()
await commitModel()
}
if (!trimmed) {
// Blank Enter in an existing session commits the composer's agent and
// model selection, then hands off to the route (queued prompt promotion).
await attempt("Failed to prepare session", async () => {
await commitSelection()
await props.onEmptySubmit?.()
})
return true
}
history.append(entry)
if (currentMode === "shell") {
move.startSubmit()
dispatch(() => client.api.session.shell({ sessionID: target, command: inputText }))
const send = () => client.api.session.shell({ sessionID: target, command: inputText })
void (newSession ? newSession.gate.then(send).catch(newSession.recover) : send())
setStore("mode", "normal")
} else if (slashHead && isCommand) {
const send = async () => {
await prepareAgent()
// Commands inherit the composer selection; command-specific overrides
// remain server-owned and run after this preparation.
await commitModel()
await commitSelection()
return client.api.session.command({
sessionID: target,
name: slashHead.name,
@@ -1295,32 +1313,20 @@ export function Prompt(props: PromptProps) {
delivery,
})
}
const setup = newSession
void (setup ? setup.gate.then(send) : send()).catch((error) => {
if (setup) return setup.recover(error)
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
restoreEntry()
})
void (newSession ? newSession.gate.then(send) : send()).catch((error) =>
newSession ? newSession.recover(error) : fail("Failed to run command", error),
)
} else {
move.startSubmit()
try {
await prepareAgent()
} catch (error) {
toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" })
restoreEntry()
return true
}
if (session?.revert) {
const error = await client.api.session.revert.commit({ sessionID: target }).then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
restoreEntry()
return false
}
}
if (!(await attempt("Failed to prepare session", prepareAgent))) return true
// Revert must settle before optimistic admission: its committed echo
// splices every local row at or after the boundary, which would include
// a freshly admitted prompt.
if (
session?.revert &&
!(await attempt("Failed to commit revert", () => client.api.session.revert.commit({ sessionID: target })))
)
return false
if (pendingEditorSelection) {
// Keep editor context hidden while admitting it before the corresponding user prompt.
const send = () =>
@@ -1329,21 +1335,10 @@ export function Prompt(props: PromptProps) {
text: formatEditorContext(pendingEditorSelection),
resume: false,
})
if (newSession) {
// Fold into the setup gate so the context still admits before the
// user prompt once the session exists.
newSession.gate = newSession.gate.then(send)
} else {
const error = await send().then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
restoreEntry()
return false
}
}
// Fold into the setup gate so the context still admits before the
// user prompt once the session exists.
if (newSession) newSession.gate = newSession.gate.then(send)
else if (!(await attempt("Failed to send editor context", send))) return false
}
// The data layer admits optimistically: the prompt renders immediately
// and rolls back if the server rejects it, so submission does not wait
@@ -1363,11 +1358,7 @@ export function Prompt(props: PromptProps) {
// the server makes an unchanged selection a no-op.
prepare: commitModel,
})
.catch((error) => {
if (newSession) return newSession.recover(error)
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
restoreEntry()
})
.catch((error) => (newSession ? newSession.recover(error) : fail("Failed to send prompt", error)))
if (pendingEditorSelection) editor.markSelectionSent()
}
@@ -22,6 +22,7 @@ test.each(["enter", "space"])("starts OAuth with %s for an MCP server requiring
try {
await fixture.app.waitForFrame((frame) => frame.includes("Sign in required"))
expect(fixture.app.captureCharFrame()).not.toContain("enter to view error")
if (key === "enter") fixture.app.mockInput.pressEnter()
else fixture.app.mockInput.pressKey(" ")
await fixture.app.waitForFrame((frame) => frame.includes("Waiting for authorization"))
@@ -79,7 +80,7 @@ async function renderMcp(options?: { failed?: boolean; location?: { directory: s
name: "linear",
status: options?.failed
? { status: "failed", error: "MCP error -32000: Connection closed" }
: { status: "needs_auth" },
: { status: "needs_auth", error: "Authentication required" },
integrationID: "mcp_linear",
},
],
@@ -1277,6 +1277,26 @@ effect: (ctx) =>
}),
```
`experimental.ws.send` and `experimental.ws.receive` expose the frames themselves: `send` runs after the provider
driver builds an outbound frame, `receive` runs on each inbound frame before the driver observes it. Whatever `frame` holds when the hook returns is what crosses the wire or reaches the driver;
OpenCode does not validate it.
```ts
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.session.hook(
"experimental.ws.send",
(event) =>
Effect.sync(() => {
const body = JSON.parse(event.frame)
if (body.type === "response.create") body.metadata = { ...body.metadata, session: event.sessionID }
event.frame = JSON.stringify(body)
}),
{ providerID: "openai" },
)
}),
```
Override the retry decision for a provider failure or replace its delay in milliseconds. The hook runs after OpenCode
classifies the failure and proposes its policy, but before any retry is scheduled. It does not expose how OpenCode
internally performs the next attempt.
@@ -1317,6 +1337,9 @@ interface SessionHooks {
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
readonly "experimental.ws.handshake": SessionWebSocketHandshake
readonly "experimental.ws.send": SessionWebSocketSend
readonly "experimental.ws.receive": SessionWebSocketReceive
readonly retry: SessionRetry
}
@@ -1410,7 +1410,31 @@ await ctx.session.hook(
)
```
This hook is experimental and its name or shape may change.
`experimental.ws.send` and `experimental.ws.receive` expose the frames themselves, the WebSocket counterpart of
editing an HTTP request or response body. `send` runs after the provider driver builds an outbound frame and before it
is written; `receive` runs on each inbound frame before the driver observes it. Both carry the frame as a string and
send whatever `frame` holds when the hook returns.
OpenCode does not validate rewritten frames. The driver tracks state from the provider's replies, so a rewrite that
changes protocol meaning is the plugin's responsibility, just as a rewritten HTTP body is.
```ts
await ctx.session.hook(
"experimental.ws.send",
(event) => {
const body = JSON.parse(event.frame)
if (body.type === "response.create") body.metadata = { ...body.metadata, session: event.sessionID }
event.frame = JSON.stringify(body)
},
{ providerID: "openai" },
)
await ctx.session.hook("experimental.ws.receive", (event) => {
if (event.frame.includes('"type":"error"')) console.error(event.frame)
})
```
These hooks are experimental and their names or shapes may change.
#### Retry policy
@@ -1458,6 +1482,8 @@ interface SessionHooks {
"http.request": SessionHttpRequestHook
"http.response": SessionHttpResponseHook
"experimental.ws.handshake": SessionWebSocketHandshakeHook
"experimental.ws.send": SessionWebSocketSendHook
"experimental.ws.receive": SessionWebSocketReceiveHook
retry: SessionRetryHook
}
@@ -1470,6 +1496,22 @@ interface SessionWebSocketHandshakeHook {
headers: Record<string, string>
}
interface SessionWebSocketSendHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly kind: "primary" | "compaction" | "title" | "generate"
frame: string
}
interface SessionWebSocketReceiveHook {
readonly sessionID: string
readonly agent: string
readonly model: { providerID: string; id: string; variant?: string }
readonly kind: "primary" | "compaction" | "title" | "generate"
frame: string
}
type RetryDecision = { retry: false } | { retry: true; delay: number }
interface SessionRetryHook {
@@ -28,6 +28,17 @@ Add the published JSON Schema for editor validation and autocomplete:
OpenCode includes this automatically when it creates or migrates your CLI config.
## Inline config
Set `OPENCODE_CLI_CONFIG_CONTENT` to apply CLI settings from inline JSON:
```sh
OPENCODE_CLI_CONFIG_CONTENT='{"tabs":{"enabled":false}}' opencode
```
OpenCode merges the inline settings over the global `cli.json`. Nested objects are merged, while arrays and scalar
values from the environment replace global values. Inline settings remain authoritative while the variable is set.
## Theme
Set the theme and color mode: