Compare commits

...
Author SHA1 Message Date
Aiden Cline 4c52e9cf6c refactor(core): decode legacy AI SDK settings once and trim comments
Type the AI SDK settings whose spelling differs from the native package
with a lenient schema decoded at the top of AISDKNative.map, replacing
the per-field isRecord and typeof checks. nativeSettings takes
Provider.Settings and its comment states the reason it exists.
2026-09-11 00:08:54 -05:00
Aiden Cline b844971cf4 fix(core): drop legacy key list from AI SDK mapping
Only OpenRouter forwards unknown request options to the wire, so the
constructor-only exclusion belongs with the other OpenRouter keys.
2026-09-10 23:08:36 -05:00
Aiden Cline 79041059c2 fix(core): pass flat settings through to native provider packages
Drop the connection-key list: core knows nothing about a package's shape.
The flat bag is offered to the package as-is and again as providerOptions,
excluding only the credentials core injects, the ProviderPackage.Settings
base keys, and opencode transport keys. Each side reads the names it
declares, so new package settings work without core changes. A legacy
nested providerOptions is flattened with flat keys taking precedence.

AISDKNative.map now emits flat settings and keeps only spelling
translations between AI SDK and native packages.
2026-09-10 23:01:09 -05:00
Aiden Cline eb82c26b75 fix(core): shape flat catalog settings for native provider packages
Catalog settings are flat, but native @opencode/ai packages read request
options from a nested providerOptions object. Only the AI SDK mapping path
lifted flat keys, so a model on a native package silently dropped
reasoningEffort and every other request setting, including the variants
models.dev generates, while a config that already wrote providerOptions
was nested a second time on the aisdk: path.

Provider.nativeSettings now does the lift once for every native package:
connection keys stay on top, everything else moves into providerOptions,
and a nested providerOptions merges instead of wrapping. AISDKNative.map
uses it in place of its per-package whitelists and keeps only the real
vocabulary translations for Bedrock, OpenRouter, Azure, and Mantle.
2026-09-10 22:28:21 -05:00
James Long 1c723c56fa feat(tui): add recursive grouping tree (#48394) 2026-09-10 21:40:23 -04:00
Aiden Cline 181428a2f3 fix(codemode): use Bun's wording for the missing atob/btoa argument (#48381) 2026-09-10 20:08:36 -05:00
Dax Raad 9b1891fb7e docs(cli): expand provider connections 2026-09-10 20:43:17 -04:00
Dax Raad d4bf78b348 docs: add websearch guides 2026-09-10 20:30:58 -04:00
Dax Raad d4ceffe787 docs: refresh v2 guides 2026-09-10 20:12:08 -04:00
Aiden Cline 872e38055e fix(ai): normalize flat Responses stream errors (#48376) 2026-09-10 18:23:32 -05:00
James Long 0c1dfa9186 refactor(tui): extract shared session rendering primitives (#48393) 2026-09-10 18:07:14 -04:00
Dax Raad 8f4d706647 feat(cli): add command docs and simplify session list 2026-09-10 16:27:12 -04:00
Aiden Cline 929374cdfd feat(core): parse JSON text results from MCP tools without an output schema (#48357) 2026-09-10 15:20:13 -05:00
opencode-agent[bot] cfa5ba700e fix(stats): canonicalize DeepSeek Flash usage (#48373) 2026-09-10 13:58:51 -05:00
Shoubhit Dash 45a2ed9a97 feat(core): per-session permission rules (#48351) 2026-09-10 22:30:53 +05:30
opencode-agent[bot]andnexxeln f6333546f8 fix(tui): disambiguate plugin actions (#48354)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-10 22:10:01 +05:30
opencode-agent[bot]andnexxeln 9e153ce7b3 fix(core): raise instruction entry limit (#48350)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-09-10 20:59:38 +05:30
Simon Klee eb357f17cf fix(tui): strip NUL characters before clipboard writes (#48337) 2026-09-10 15:47:03 +02:00
Shoubhit Dash 573d76933f fix(ai): make xAI Responses websockets work and enable them (#48318) 2026-09-10 17:27:43 +05:30
usrnk1andBrendonovich bb8194395a feat(desktop): respect follow-up behavior for slash commands (#48169)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-10 19:24:23 +08:00
99 changed files with 5228 additions and 2443 deletions
@@ -18,7 +18,6 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
@@ -27,6 +26,7 @@ export interface Options {
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly continuation?: OpenResponsesContinuation.Shape
}
export interface Prepared {
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,6 +163,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -6,7 +6,6 @@ import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "open-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
@@ -15,12 +14,19 @@ interface CheckpointValue {
readonly output: ReadonlyArray<unknown>
}
/**
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
*/
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
readonly continuation?: Shape
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
@@ -127,22 +133,26 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
const fields = previous ? shape(request) : undefined
const delta = previous && fields ? incremental(request, previous) : undefined
if (!previous || !fields || !delta)
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -195,4 +205,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export const OpenResponsesContinuation = { driver } as const
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
+32 -4
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -325,9 +325,8 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
// event-level `error` envelope, so accept all three shapes here.
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -401,10 +400,39 @@ export const Event = Schema.StructWithRest(
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
),
)
export interface ProviderAdapter {
readonly id: string
readonly name: string
+4
View File
@@ -41,6 +41,10 @@ const responsesRoute = Route.make({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
}),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
@@ -0,0 +1,78 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
"type": "error",
"sequence_number": 4,
"code": "server_shutting_down",
"message": "Server is shutting down. Please retry your request.",
"param": null,
"diagnostic": "retain-original-frame"
}`
for (const model of [
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
),
]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
expect(error.reason.body).toBe(raw)
expect(error.reason.http?.status).toBe(200)
}
}),
)
@@ -90,7 +90,11 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -98,6 +102,7 @@ const continuationDriver = (request: Readonly<Record<string, unknown>>, base = b
request,
message,
base: base(message),
continuation,
})
}
@@ -921,6 +926,58 @@ describe("OpenAI Responses route", () => {
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
const internal = ProviderShared.encodeJson({
type: "error",
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
})
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "ProviderInternal" } },
})
}),
)
it.effect("shapes the incremental send with the route continuation", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "grok-4.6",
store: true,
instructions: "You are terse.",
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const secondRequest = {
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
yield* continuationDriver(firstRequest).create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const trimmed = yield* continuationDriver(
secondRequest,
baseChannelDriver,
({ instructions: _, ...rest }) => rest,
).create(saved)
expect(trimmed.mode).toBe("incremental")
expect(JSON.parse(trimmed.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// Declining the continuation sends the step in full and never sends a previous_response_id.
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
expect(declined.mode).toBe("full")
expect(JSON.parse(declined.message)).toEqual(secondRequest)
}),
)
+110 -2
View File
@@ -1,11 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer, Stream } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import { LLMClient } from "../../src/route.js"
import {
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -13,6 +20,35 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
Effect.gen(function* () {
let driver: WebSocketChannelDriver | undefined
yield* LLMClient.generate(request, {
webSocket: {
execute: (exchange) =>
Effect.sync(() => {
driver = exchange.driver
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
if (!driver) throw new Error("Expected a WebSocket channel driver")
return driver
})
const completed = (driver: WebSocketChannelDriver, id: string) =>
Effect.gen(function* () {
const create = yield* driver.create(undefined)
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
const observation = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
)
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
return observation.checkpoint
})
describe("xAI Responses route", () => {
it.effect("composes the Open Responses baseline with xAI extensions", () =>
Effect.gen(function* () {
@@ -162,6 +198,78 @@ describe("xAI Responses route", () => {
}),
)
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
Effect.gen(function* () {
// xAI answers a rejected response.create with an error envelope that carries no event type.
const envelope = ProviderShared.encodeJson({
error: {
message:
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
type: "api_error",
},
})
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
})
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
expect(error.reason.body).toBe(envelope)
}),
)
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
Effect.gen(function* () {
const step = (store: boolean, ...prompts: string[]) =>
LLM.request({
model,
system: "You are terse.",
messages: prompts.map((prompt) => Message.user(prompt)),
providerOptions: { store },
})
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
expect(stored.mode).toBe("incremental")
expect(JSON.parse(stored.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
include: ["reasoning.encrypted_content"],
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// The connection cache only serves stored responses, so the default store: false never chains.
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
expect(unstored.mode).toBe("full")
expect(JSON.parse(unstored.message)).toMatchObject({
instructions: "You are terse.",
store: false,
input: [
{ role: "user", content: [{ type: "input_text", text: "First" }] },
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
],
})
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
+5 -10
View File
@@ -126,15 +126,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -326,7 +320,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
null,
2,
)
: formatTable(page.data)) + EOL
: formatList(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
@@ -96,18 +96,14 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
),
)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
function formatList(sessions: ReadonlyArray<SessionInfo>) {
return sessions
.map((session) =>
[
session.id,
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
new Date(session.time.updated).toLocaleString(),
].join("\t"),
)
.join(EOL)
}
+19 -1
View File
@@ -11,6 +11,7 @@ import type { RelativePath } from "@opencode/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode/schema/model"
import type { DateTime } from "effect"
import type { Permission } from "@opencode/schema/permission"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptInput } from "@opencode/schema/prompt-input"
@@ -26,7 +27,6 @@ import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { Permission } from "@opencode/schema/permission"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
import type { Command } from "@opencode/schema/command"
@@ -209,6 +209,7 @@ export type SessionCreateInput = {
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionCreateOutput = Session.Info
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@@ -437,6 +438,7 @@ export type SessionLogOutput =
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
readonly version: string
}
}
@@ -489,6 +491,15 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
}
| {
readonly id: Event.ID
readonly created: number
@@ -1585,6 +1596,12 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1592,6 +1609,7 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
@@ -181,6 +181,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -395,6 +397,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -1145,6 +1148,14 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
request: { list: EndpointPermissionRequestList(raw) },
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
@@ -1152,6 +1163,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
@@ -175,6 +175,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -565,6 +567,7 @@ export function make(options: ClientOptions) {
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -1566,6 +1569,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
file: {
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
+127 -42
View File
@@ -551,28 +551,6 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
version: string
}
}
export type SessionAgentSelected = {
id: string
created: number
@@ -1651,24 +1629,6 @@ export type SessionInboxMove = {
delivery: SessionInboxDelivery
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
revert?: SessionRevert
}
export type SessionRevertStaged = {
id: string
created: number
@@ -1912,6 +1872,58 @@ export type AgentInfo = {
permissions: PermissionRuleset
}
export type SessionPermissionsUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.permissions.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; permissions: PermissionRuleset }
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
permissions?: PermissionRuleset
revert?: SessionRevert
}
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
permissions?: PermissionRuleset
version: string
}
}
export type ConfigEntry =
| {
type: "document"
@@ -2084,8 +2096,6 @@ export type ConfigEntry =
| { type: "agents"; path: string }
| { type: "claude"; path: string }
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = {
id: string
sessionID: string
@@ -2140,6 +2150,8 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields2 = [FormField1, ...Array<FormField1>]
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionInboxEnqueued = {
@@ -2233,6 +2245,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionDeleted
| SessionForked
@@ -2292,6 +2305,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
@@ -2804,6 +2818,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["id"]
readonly title?: {
readonly id?: string | null
@@ -2812,6 +2831,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["title"]
readonly agent?: {
readonly id?: string | null
@@ -2820,6 +2844,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["agent"]
readonly model?: {
readonly id?: string | null
@@ -2828,6 +2857,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["model"]
readonly location?: {
readonly id?: string | null
@@ -2836,6 +2870,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["location"]
readonly metadata?: {
readonly id?: string | null
@@ -2844,7 +2883,25 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["metadata"]
readonly permissions?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["permissions"]
}
export type SessionCreateOutput = { data: SessionInfo }["data"]
@@ -2882,6 +2939,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3187,6 +3249,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3492,6 +3559,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -5753,6 +5825,19 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void
export type PermissionRulesInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly permissions: {
readonly permissions: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}["permissions"]
}
export type PermissionRulesOutput = void
export type FileReadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+4
View File
@@ -695,6 +695,10 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
+2 -3
View File
@@ -5,9 +5,8 @@ import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
const base64 = (name: "atob" | "btoa") =>
sync(name, (args, node) => {
if (args.length === 0) {
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
}
if (args.length === 0)
throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node).as("TypeError")
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
+155 -341
View File
@@ -1,248 +1,187 @@
export * as AISDKNative from "./aisdk-native.js"
import { isRecord } from "@opencode/ai/utils/record"
import { Effect, Option, Schema, Struct } from "effect"
import { Provider } from "./provider.js"
export interface Mapping {
readonly package: string
readonly settings: Readonly<Record<string, unknown>>
readonly settings: Provider.Settings
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
}
export interface MapInput {
readonly packageName: string | undefined
readonly settings: Readonly<Record<string, unknown>>
readonly settings: Provider.Settings
readonly modelID: string
readonly providerID: string
}
// A wrongly typed legacy value is dropped rather than failing the whole decode.
const lenient = <S extends Schema.Top>(schema: S) =>
Schema.optional(Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))))
const Credentials = Schema.Struct({
accessKeyId: Schema.String,
secretAccessKey: Schema.String,
sessionToken: lenient(Schema.String),
region: lenient(Schema.String),
})
/** AI SDK settings whose spelling differs from the native package. Everything else passes through. */
const Legacy = Schema.StructWithRest(
Schema.Struct({
apiKey: lenient(Schema.String),
baseURL: lenient(Schema.String),
headers: lenient(Schema.Record(Schema.String, Schema.String)),
extraBody: lenient(Schema.Record(Schema.String, Schema.Unknown)),
useCompletionUrls: lenient(Schema.Boolean),
// Bedrock
auth: lenient(Schema.Literals(["bearer", "sigv4"])),
bearerToken: lenient(Schema.String),
endpoint: lenient(Schema.String),
region: lenient(Schema.String),
credentials: lenient(Credentials),
accessKeyId: lenient(Schema.String),
secretAccessKey: lenient(Schema.String),
sessionToken: lenient(Schema.String),
anthropicBeta: lenient(Schema.Array(Schema.String)),
serviceTier: lenient(Schema.String),
reasoningConfig: lenient(
Schema.Struct({
type: lenient(Schema.String),
display: lenient(Schema.String),
maxReasoningEffort: lenient(Schema.String),
budgetTokens: lenient(Schema.Number),
}),
),
additionalModelRequestFields: lenient(
Schema.StructWithRest(
Schema.Struct({
anthropic_beta: lenient(Schema.Array(Schema.String)),
output_config: lenient(Schema.Record(Schema.String, Schema.Unknown)),
reasoning: lenient(Schema.Record(Schema.String, Schema.Unknown)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
// OpenRouter
appName: lenient(Schema.String),
appUrl: lenient(Schema.String),
api_keys: lenient(Schema.Record(Schema.String, Schema.String)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type Legacy = typeof Legacy.Type
const decode = Schema.decodeUnknownSync(Legacy)
/** Maps a legacy AI SDK package onto the native package that replaces it. */
export function map(input: MapInput): Mapping | undefined {
const baseSettings = mapBaseSettings(input.settings)
switch (input.packageName) {
const settings = decode(input.settings)
const native = mapPackage(input.packageName, input.modelID, settings)
if (!native) return
const converse = native === "@opencode/ai/providers/amazon-bedrock"
const mapped = {
...Struct.omit(settings, ["headers", "extraBody", ...OPENROUTER_KEYS]),
...(native === "@opencode/ai/providers/openai-compatible" ? { provider: input.providerID } : {}),
}
return {
package: native,
settings: native.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(mapped, converse) : mapped,
...(settings.headers === undefined ? {} : { headers: settings.headers }),
...(settings.extraBody === undefined ? {} : { body: settings.extraBody }),
...(converse ? bedrockRequest(input.modelID, settings) : {}),
...(native === "@opencode/ai/providers/openrouter" ? openRouterRequest(settings) : {}),
}
}
function mapPackage(packageName: string | undefined, modelID: string, settings: Legacy) {
switch (packageName) {
case "@ai-sdk/anthropic":
return {
package: "@opencode/ai/providers/anthropic",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
...mapProviderOptions(input.settings, ["apiKey", "authToken", "baseURL"]),
},
}
case "@ai-sdk/amazon-bedrock":
return {
package: "@opencode/ai/providers/amazon-bedrock",
settings: mapBedrockSettings(input.settings, baseSettings),
...mapBedrockRequest(input),
}
case "@ai-sdk/amazon-bedrock/mantle":
return mapBedrockMantle(input, baseSettings)
case "@ai-sdk/azure":
return {
package: `@opencode/ai/providers/azure/${input.settings.useCompletionUrls === true ? "chat" : "responses"}`,
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.resourceName === "string" ? { resourceName: input.settings.resourceName } : {}),
...(typeof input.settings.apiVersion === "string" ? { apiVersion: input.settings.apiVersion } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...(typeof input.settings.useDeploymentBasedUrls === "boolean"
? { useDeploymentBasedUrls: input.settings.useDeploymentBasedUrls }
: {}),
...mapOpenAIOptions(input.settings),
},
}
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/groq":
case "@ai-sdk/togetherai":
return {
package: `@opencode/ai/providers/${input.packageName.slice("@ai-sdk/".length)}`,
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "fetch", "headers", "name"]),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/google":
return {
package: "@opencode/ai/providers/google",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/google-vertex":
return {
package: "@opencode/ai/providers/google-vertex",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...mapAPIKey(input.settings),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...mapGoogleOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/google-vertex/anthropic":
return {
package: "@opencode/ai/providers/google-vertex/messages",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isRecord(input.settings.thinking) || typeof input.settings.effort === "string"
? {
providerOptions: {
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
},
}
: {}),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/groq":
case "@ai-sdk/mistral":
return {
package: "@opencode/ai/providers/mistral",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...mapMistralOptions(input.settings),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
...(isRecord(input.settings.extraBody) ? { body: input.settings.extraBody } : {}),
}
case "@ai-sdk/openai":
return {
package: "@opencode/ai/providers/openai",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "organization", "project", "queryParams"]),
},
}
case "@ai-sdk/openai-compatible":
if (typeof input.settings.baseURL !== "string") return
return {
package: "@opencode/ai/providers/openai-compatible",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
provider: input.providerID,
...mapProviderOptions(input.settings, ["apiKey", "baseURL"]),
},
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/togetherai":
case "@ai-sdk/xai":
return {
package: "@opencode/ai/providers/xai",
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...mapXAIOptions(input.settings),
},
}
case "@ai-sdk/amazon-bedrock":
return `@opencode/ai/providers/${packageName.slice("@ai-sdk/".length)}`
case "@ai-sdk/amazon-bedrock/mantle":
return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}`
case "@ai-sdk/azure":
return `@opencode/ai/providers/azure/${settings.useCompletionUrls === true ? "chat" : "responses"}`
case "@ai-sdk/google-vertex/anthropic":
return "@opencode/ai/providers/google-vertex/messages"
case "@ai-sdk/openai-compatible":
return settings.baseURL === undefined ? undefined : "@opencode/ai/providers/openai-compatible"
case "@openrouter/ai-sdk-provider":
return "@opencode/ai/providers/openrouter"
}
}
function mapProviderOptions(settings: Readonly<Record<string, unknown>>, excluded: ReadonlyArray<string>) {
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
// AI SDK spellings the native Bedrock packages do not read.
const BEDROCK_KEYS = [
"bearerToken",
"endpoint",
"credentials",
"credentialProvider",
"accessKeyId",
"secretAccessKey",
"sessionToken",
]
// Request settings Converse takes in the body; translated by `bedrockRequest`.
const CONVERSE_KEYS = ["additionalModelRequestFields", "reasoningConfig", "anthropicBeta", "serviceTier"]
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
const settings = input.settings
const chat = input.modelID.includes("gpt-oss")
function bedrockSettings(settings: Legacy, converse: boolean) {
const region = settings.region ?? settings.credentials?.region
const credentials = settings.credentials ?? settings
const baseURL = settings.baseURL ?? settings.endpoint
return {
package: `@opencode/ai/providers/amazon-bedrock/mantle/${chat ? "chat" : "responses"}`,
settings: {
...mapBedrockSettings(settings, baseSettings),
...mapOpenAIOptions(settings),
},
...(isStringRecord(settings.headers) ? { headers: settings.headers } : {}),
...Struct.omit(settings, converse ? [...BEDROCK_KEYS, ...CONVERSE_KEYS] : BEDROCK_KEYS),
...(baseURL === undefined
? {}
: { baseURL: region === undefined ? baseURL : baseURL.replaceAll("${AWS_REGION}", region) }),
...(settings.apiKey === undefined && settings.bearerToken !== undefined ? { apiKey: settings.bearerToken } : {}),
...(region === undefined || credentials.accessKeyId === undefined || credentials.secretAccessKey === undefined
? {}
: {
credentials: {
region,
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
...(credentials.sessionToken === undefined ? {} : { sessionToken: credentials.sessionToken }),
},
}),
}
}
function mapBedrockSettings(
settings: Readonly<Record<string, unknown>>,
baseSettings: Readonly<Record<string, unknown>>,
) {
const apiKey =
typeof settings.apiKey === "string"
? settings.apiKey
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const region = bedrockRegion(settings)
const credentials = mapBedrockCredentials(settings, region)
return {
...baseSettings,
...(typeof baseSettings.baseURL === "string" && region !== undefined
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
: {}),
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
? { baseURL: settings.endpoint }
: {}),
...(apiKey === undefined ? {} : { apiKey }),
...(settings.auth === "bearer" || settings.auth === "sigv4" ? { auth: settings.auth } : {}),
...(credentials === undefined ? {} : { credentials }),
...(typeof settings.profile === "string" ? { profile: settings.profile } : {}),
...(typeof settings.region === "string" ? { region: settings.region } : {}),
...(typeof settings.topP === "number" ? { topP: settings.topP } : {}),
}
}
function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
const settings = input.settings
const headers = isStringRecord(settings.headers) ? settings.headers : undefined
const additional = isRecord(settings.additionalModelRequestFields) ? settings.additionalModelRequestFields : {}
const reasoning = isRecord(settings.reasoningConfig) ? settings.reasoningConfig : undefined
const anthropic = input.modelID.includes("anthropic")
const openai = input.modelID.includes("openai.")
// Converse passes OpenAI fields through verbatim. gpt-oss (Harmony) takes the
// flat chat-completions `reasoning_effort`; GPT-5.6+ reject it and take the
// Responses-style `reasoning.effort` instead.
const harmony = input.modelID.includes("openai.gpt-oss")
const effort = typeof reasoning?.maxReasoningEffort === "string" ? reasoning.maxReasoningEffort : undefined
const type = typeof reasoning?.type === "string" ? reasoning.type : undefined
const budget = typeof reasoning?.budgetTokens === "number" ? reasoning.budgetTokens : undefined
const display = typeof reasoning?.display === "string" ? reasoning.display : undefined
const betas = Array.isArray(settings.anthropicBeta)
? settings.anthropicBeta.filter((item): item is string => typeof item === "string")
: []
const existingBetas = Array.isArray(additional.anthropic_beta)
? additional.anthropic_beta.filter((item): item is string => typeof item === "string")
: []
function bedrockRequest(modelID: string, settings: Legacy): Pick<Mapping, "body"> {
const additional = settings.additionalModelRequestFields ?? {}
const reasoning = settings.reasoningConfig
const anthropic = modelID.includes("anthropic")
const openai = modelID.includes("openai.")
// gpt-oss (Harmony) takes the flat chat-completions `reasoning_effort`; GPT-5.6+ take Responses-style `reasoning.effort`.
const harmony = modelID.includes("openai.gpt-oss")
const effort = reasoning?.maxReasoningEffort
const type = reasoning?.type
const budget = reasoning?.budgetTokens
const display = reasoning?.display
const betas = settings.anthropicBeta ?? []
const fields = Provider.mergeOverlay(additional, {
...(betas.length > 0 ? { anthropic_beta: [...existingBetas, ...betas] } : {}),
...(betas.length > 0 ? { anthropic_beta: [...(additional.anthropic_beta ?? []), ...betas] } : {}),
...(anthropic && type === "enabled" && budget !== undefined
? { thinking: { type: "enabled", budget_tokens: budget } }
: {}),
...(anthropic && type === "adaptive"
? { thinking: { type: "adaptive", ...(display === undefined ? {} : { display }) } }
: {}),
...(anthropic && effort !== undefined
? {
output_config: {
...(isRecord(additional.output_config) ? additional.output_config : {}),
effort,
},
}
: {}),
...(anthropic && effort !== undefined ? { output_config: { ...additional.output_config, effort } } : {}),
...(!anthropic && openai && harmony && effort !== undefined ? { reasoning_effort: effort } : {}),
...(!anthropic && openai && !harmony && effort !== undefined
? { reasoning: { ...(isRecord(additional.reasoning) ? additional.reasoning : {}), effort } }
? { reasoning: { ...additional.reasoning, effort } }
: {}),
...(!anthropic && !openai && effort !== undefined
? {
@@ -256,151 +195,26 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
})
const body = {
...(fields && Object.keys(fields).length > 0 ? { additionalModelRequestFields: fields } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: { type: settings.serviceTier } } : {}),
}
return {
...(headers === undefined ? {} : { headers }),
...(Object.keys(body).length === 0 ? {} : { body }),
...(settings.serviceTier === undefined ? {} : { serviceTier: { type: settings.serviceTier } }),
}
return Object.keys(body).length === 0 ? {} : { body }
}
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
if (
region === undefined ||
typeof credentials.accessKeyId !== "string" ||
typeof credentials.secretAccessKey !== "string"
)
return undefined
return {
region,
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
...(typeof credentials.sessionToken === "string" ? { sessionToken: credentials.sessionToken } : {}),
}
}
// Constructor options the native OpenRouter package takes as headers, plus `compatibility`, which the
// native package would otherwise forward to the request body.
const OPENROUTER_KEYS = ["appName", "appUrl", "api_keys", "compatibility"] as const
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
return typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function mapMistralOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.safePrompt === "boolean" ? { safePrompt: settings.safePrompt } : {}),
...(typeof settings.documentImageLimit === "number" ? { documentImageLimit: settings.documentImageLimit } : {}),
...(typeof settings.documentPageLimit === "number" ? { documentPageLimit: settings.documentPageLimit } : {}),
...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}),
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(settings.promptMode === "reasoning" ? { promptMode: settings.promptMode } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
return {
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
}
}
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
}
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
const input = settings.thinkingConfig
const thinkingConfig = {
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
...(isRecord(input) && typeof input.includeThoughts === "boolean"
? { includeThoughts: input.includeThoughts }
: {}),
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
}
const options = {
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
...(isStringRecord(settings.labels) ? { labels: settings.labels } : {}),
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
}
function mapOpenRouter(
settings: Readonly<Record<string, unknown>>,
baseSettings: Readonly<Record<string, unknown>>,
): Mapping {
function openRouterRequest(settings: Legacy): Pick<Mapping, "headers"> {
const headers =
Provider.mergeHeaders(
{
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
: {}),
...(settings.appName === undefined ? {} : { "X-OpenRouter-Title": settings.appName }),
...(settings.appUrl === undefined ? {} : { "HTTP-Referer": settings.appUrl }),
...(settings.api_keys === undefined || Object.keys(settings.api_keys).length === 0
? {}
: { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }),
},
isStringRecord(settings.headers) ? settings.headers : undefined,
settings.headers,
) ?? {}
return {
package: "@opencode/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapOpenRouterOptions(settings),
},
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
}
}
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
return mapProviderOptions(settings, [
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"promptCacheKey",
"timeout",
])
}
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
}
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
}
if (Object.keys(options).length === 0) return {}
return { providerOptions: options }
return Object.keys(headers).length === 0 ? {} : { headers }
}
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260910120000_clear_v1_session_permission",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`UPDATE \`session_v2\` SET \`permission\` = NULL;`)
})
},
}
export default migration
@@ -600,7 +600,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
+1 -1
View File
@@ -183,7 +183,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
if (!native) return yield* unsupported(resolved)
const specifier = native
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
const mapped = yield* prepareProviderSettings(resolved, Provider.nativeSettings(mapping?.settings ?? configured))
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
+1 -1
View File
@@ -154,7 +154,7 @@ const layer = Layer.effect(
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
+3
View File
@@ -404,6 +404,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
rules: sessions.setPermissions,
},
plugin: {
list: () => response(plugin.list()),
@@ -509,6 +510,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
title: input?.title,
agent: input?.agent,
model: input?.model,
metadata: input?.metadata,
permissions: input?.permissions,
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
+1
View File
@@ -101,6 +101,7 @@ export const XAIPlugin = define({
for (const model of provider.models.values()) {
catalog.model.update(providerID, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
draft.websocket = true
})
}
})
+17 -1
View File
@@ -1,6 +1,6 @@
export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Effect, Schema, Struct } from "effect"
import { Provider } from "@opencode/schema/provider"
import type { ProviderPackageDefinition } from "@opencode/ai"
import { isRecord } from "@opencode/ai/utils/record"
@@ -103,6 +103,22 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
return yield* importPackage(specifier, entrypoint)
})
// opencode-only; handled in aisdk.ts.
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
// Credentials and request overlays that must not be duplicated into providerOptions.
const PACKAGE_KEYS = ["accessToken", "apiKey", "authToken", "baseURL", "body", "headers"] as const
/**
* opencode settings are flat, but `@opencode/ai` packages still read request options from a nested
* `providerOptions`. Until that is flattened, hand the same settings to both places and let each side
* pick the keys it knows.
*/
export function nativeSettings(settings: Settings): Settings {
const flat = Struct.omit({ ...settings.providerOptions, ...settings }, ["providerOptions", ...TRANSPORT_KEYS])
const providerOptions = Struct.omit(flat, PACKAGE_KEYS)
return { ...flat, ...(Object.keys(providerOptions).length === 0 ? {} : { providerOptions }) }
}
export function mergeOverlay(
base: Readonly<Record<string, unknown>> | undefined,
overlay: Readonly<Record<string, unknown>> | undefined,
+10 -2
View File
@@ -18,6 +18,7 @@ import { SessionMessageTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { RelativePath } from "./schema.js"
import { Agent } from "@opencode/schema/agent"
import type { Permission } from "@opencode/schema/permission"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import path from "path"
@@ -81,6 +82,7 @@ type CreateBaseInput = {
agent?: Agent.ID
model?: Model.Ref
metadata?: SessionSchema.Metadata
permissions?: Permission.Ruleset
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
@@ -157,6 +159,10 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly setPermissions: (input: {
sessionID: SessionSchema.ID
permissions: Permission.Ruleset
}) => Effect.Effect<void, NotFoundError>
readonly move: SessionMove.Interface["move"]
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
@@ -248,9 +254,10 @@ const layer = Layer.effect(
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
// Children inherit metadata the way they inherit location, so
// host policies that read it treat the family uniformly.
// Children inherit metadata and permissions the way they inherit
// location, so host policies that read them treat the family uniformly.
metadata: input.metadata ?? parent?.metadata,
permissions: input.permissions ?? parent?.permissions,
model: input.model
? {
id: Model.ID.make(input.model.id),
@@ -387,6 +394,7 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
+2 -1
View File
@@ -1,6 +1,7 @@
export * as SessionContext from "./context.js"
import { Model } from "@opencode/schema/model"
import { Permission } from "../permission.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
@@ -129,7 +130,7 @@ const layer = Layer.effect(
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(agent.info.permissions),
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
+1
View File
@@ -50,6 +50,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
metadata: row.metadata ?? undefined,
permissions: row.permission ?? undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
@@ -116,6 +116,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.permissions.updated": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
+10
View File
@@ -160,6 +160,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
permission: parent.permission,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -450,6 +451,7 @@ const layer = Layer.effectDiscard(
agent: event.data.agent,
model: event.data.model,
metadata: event.data.metadata,
permission: event.data.permissions,
version: event.data.version,
time_created: event.created,
time_updated: event.created,
@@ -571,6 +573,14 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
db
.update(SessionTable)
.set({ permission: event.data.permissions, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) => {
const idle = event.data.idle
return db
+11
View File
@@ -3,6 +3,7 @@ export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Scope } from "effect"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Permission } from "@opencode/schema/permission"
import { Event } from "@opencode/schema/event"
import { FSUtil } from "@opencode/util/fs-util"
import { Bus } from "../bus.js"
@@ -72,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const setPermissions = Effect.fn("Session.setPermissions")(function* (
sessionID: SessionSchema.ID,
input: { permissions: Permission.Ruleset },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
input: { agent: Agent.ID },
@@ -334,6 +342,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
@@ -356,6 +365,7 @@ export const make = Effect.fn("Session.make")(function* () {
const message = operations.message.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
const inbox = operations.inbox.bind(undefined, sessionID)
@@ -381,6 +391,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
+2 -2
View File
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { PermissionV1 } from "@opencode/schema/permission-v1"
import type { Permission } from "@opencode/schema/permission"
import type { Project } from "@opencode/schema/project"
import type { SessionSchema } from "./schema.js"
import type { Workspace } from "@opencode/schema/workspace"
@@ -49,7 +49,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
+1
View File
@@ -103,6 +103,7 @@ const layer = Layer.effect(
agent: input.data.info.agent,
model: input.data.info.model,
metadata: input.data.info.metadata,
permissions: input.data.info.permissions,
},
{
location: input.location,
+12 -1
View File
@@ -99,8 +99,19 @@ export const layer = Layer.effect(
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const output = () => {
if (result.structured !== undefined) return result.structured
if (text === "") return null
// Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.
if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) {
try {
return JSON.parse(text)
} catch {}
}
return text
}
return {
output: result.structured ?? (text === "" ? null : text),
output: output(),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
+47 -113
View File
@@ -21,12 +21,10 @@ describe("AISDKNative", () => {
settings: {
apiKey: "secret",
baseURL: "https://api.meta.ai/v1",
providerOptions: {
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
truncation: "auto",
},
reasoningEffort: "xhigh",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
truncation: "auto",
organization: "org",
},
})
@@ -35,7 +33,7 @@ describe("AISDKNative", () => {
settings: {
baseURL: "https://example.com/v1",
provider: "test-provider",
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
},
})
})
@@ -53,10 +51,8 @@ describe("AISDKNative", () => {
settings: {
authToken: "token",
baseURL: "https://anthropic.example/v1",
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
})
})
@@ -68,7 +64,6 @@ describe("AISDKNative", () => {
apiKey: "secret",
baseURL: `https://${name}.example/v1`,
headers: { "x-provider": name },
name: "custom-provider",
reasoningEffort: "high",
customOption: { enabled: true },
}),
@@ -77,7 +72,8 @@ describe("AISDKNative", () => {
settings: {
apiKey: "secret",
baseURL: `https://${name}.example/v1`,
providerOptions: { reasoningEffort: "high", customOption: { enabled: true } },
reasoningEffort: "high",
customOption: { enabled: true },
},
headers: { "x-provider": name },
})
@@ -101,10 +97,8 @@ describe("AISDKNative", () => {
settings: {
project: "project",
location: "us-central1",
providerOptions: {
labels: { environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
},
labels: { environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
},
})
})
@@ -123,52 +117,25 @@ describe("AISDKNative", () => {
promptCacheKey: "session-123",
reasoningEffort: "high",
promptMode: "reasoning",
fetch: "ignored",
generateId: "ignored",
structuredOutputs: true,
unsupported: true,
}),
).toEqual({
package: "@opencode/ai/providers/mistral",
settings: {
apiKey: "secret",
baseURL: "https://mistral.example/v1",
providerOptions: {
safePrompt: false,
documentImageLimit: 4,
documentPageLimit: 12,
parallelToolCalls: false,
promptCacheKey: "session-123",
reasoningEffort: "high",
promptMode: "reasoning",
},
safePrompt: false,
documentImageLimit: 4,
documentPageLimit: 12,
parallelToolCalls: false,
promptCacheKey: "session-123",
reasoningEffort: "high",
promptMode: "reasoning",
},
headers: { "x-provider": "mistral" },
body: { custom: { enabled: true } },
})
})
test("omits invalid and runtime-only Mistral settings", () => {
expect(
map("@ai-sdk/mistral", {
headers: { valid: "header", invalid: 1 },
extraBody: "invalid",
safePrompt: "false",
documentImageLimit: "4",
documentPageLimit: null,
parallelToolCalls: 0,
promptCacheKey: false,
reasoningEffort: false,
promptMode: "unsupported",
fetch: "ignored",
generateId: "ignored",
}),
).toEqual({
package: "@opencode/ai/providers/mistral",
settings: {},
})
})
test("maps both models.dev Bedrock packages to native providers", () => {
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
package: "@opencode/ai/providers/amazon-bedrock",
@@ -197,7 +164,7 @@ describe("AISDKNative", () => {
apiVersion: "2025-01-01-preview",
queryParams: { feature: "enabled" },
useDeploymentBasedUrls: true,
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
},
})
expect(map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")?.package).toBe(
@@ -259,9 +226,9 @@ describe("AISDKNative", () => {
// GPT-5.6+ reject `reasoning_effort` and take the Responses-style nested field.
for (const modelID of ["global.openai.gpt-5.6-sol", "us.openai.gpt-5.6-sol", "us.openai.gpt-6-astra"]) {
expect(
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "none" } }, modelID)?.body,
).toEqual({ additionalModelRequestFields: { reasoning: { effort: "none" } } })
expect(map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "none" } }, modelID)?.body).toEqual(
{ additionalModelRequestFields: { reasoning: { effort: "none" } } },
)
}
expect(
map(
@@ -292,11 +259,9 @@ describe("AISDKNative", () => {
apiKey: "token",
baseURL: "https://mantle.test/v1",
region: "us-west-2",
providerOptions: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
headers: { "x-test": "value" },
})
@@ -330,7 +295,6 @@ describe("AISDKNative", () => {
},
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
credentialProvider: "ignored",
fetch: "ignored",
store: false,
},
"openai.gpt-oss-120b",
@@ -345,7 +309,7 @@ describe("AISDKNative", () => {
region: "eu-west-1",
},
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
providerOptions: { store: false },
store: false,
},
})
})
@@ -389,12 +353,10 @@ describe("AISDKNative", () => {
).toEqual({
package: "@opencode/ai/providers/openrouter",
settings: {
providerOptions: {
models: ["anthropic/claude-sonnet-4.6"],
provider: { only: ["anthropic"], require_parameters: true },
reasoning: { effort: "high" },
future_option: { enabled: true },
},
models: ["anthropic/claude-sonnet-4.6"],
provider: { only: ["anthropic"], require_parameters: true },
reasoning: { effort: "high" },
future_option: { enabled: true },
},
headers: {
"x-openrouter-title": "Configured",
@@ -415,21 +377,18 @@ describe("AISDKNative", () => {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
unknown: true,
},
}),
).toEqual({
package: "@opencode/ai/providers/google",
settings: {
providerOptions: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "flex",
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
},
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "flex",
thinkingConfig: {
thinkingBudget: 0,
includeThoughts: false,
thinkingLevel: "high",
},
},
})
@@ -438,7 +397,7 @@ describe("AISDKNative", () => {
test("maps Google thinking settings independently", () => {
for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) {
expect(map("@ai-sdk/google", { thinkingConfig })).toMatchObject({
settings: { providerOptions: { thinkingConfig } },
settings: { thinkingConfig },
})
}
})
@@ -452,11 +411,9 @@ describe("AISDKNative", () => {
}),
).toMatchObject({
settings: {
providerOptions: {
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "future-tier",
},
cachedContent: "cachedContents/example",
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
serviceTier: "future-tier",
},
})
})
@@ -479,10 +436,8 @@ describe("AISDKNative", () => {
baseURL: "https://vertex.example/v1",
location: "eu",
project: "vertex-project",
providerOptions: {
labels: { component: "opencode", environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
},
labels: { component: "opencode", environment: "test" },
thinkingConfig: { thinkingLevel: "high" },
},
headers: { "x-test": "value" },
})
@@ -506,10 +461,8 @@ describe("AISDKNative", () => {
baseURL: "https://vertex.example/v1",
location: "eu",
project: "vertex-project",
providerOptions: {
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
thinking: { type: "adaptive", display: "summarized" },
effort: "high",
},
headers: { "x-test": "value" },
})
@@ -528,28 +481,9 @@ describe("AISDKNative", () => {
settings: {
apiKey: "secret",
baseURL: "https://xai.example/v1",
providerOptions: {
reasoningEffort: "custom",
store: true,
},
reasoningEffort: "custom",
store: true,
},
})
})
test("omits invalid and unsupported xAI settings", () => {
expect(
map("@ai-sdk/xai", {
reasoningEffort: 10,
store: "yes",
include: ["unknown"],
logprobs: true,
topLogprobs: 8,
previousResponseId: "response-id",
searchParameters: { mode: "auto" },
}),
).toEqual({
package: "@opencode/ai/providers/xai",
settings: {},
})
})
})
+74
View File
@@ -324,6 +324,32 @@ const mcp = Layer.mock(Mcp.Service, {
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "issues",
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "count",
description: "Returns a number as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "typed",
description: "Declares a string output and returns JSON as text",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "string" },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "issues",
codemode: false,
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
@@ -374,6 +400,20 @@ const mcp = Layer.mock(Mcp.Service, {
isError: false,
content: [{ type: "text", text: "hello" }],
})
if (input.name === "issues" || input.name === "typed")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
})
if (input.name === "count")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "42" }],
})
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
@@ -1943,6 +1983,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_issues",
"direct_lookup",
"direct_media",
"execute",
@@ -2033,6 +2074,39 @@ it.effect("returns content-only MCP results through Code Mode", () =>
}),
)
it.effect("parses JSON text results from MCP tools without an output schema", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
const run = (code: string) =>
toolSet
.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
})
.pipe(Effect.map((execution) => execution.output.output))
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
// Outside Code Mode the content the model reads is the original text.
expect(
yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
}),
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+2 -5
View File
@@ -855,6 +855,7 @@ describe("ModelResolver", () => {
expect(modelID).toBe("api-test-model")
expect(settings).toEqual({
region: "test",
providerOptions: { region: "test" },
headers: { "x-package": "header" },
body: { custom: true },
})
@@ -1039,11 +1040,7 @@ describe("ModelResolver", () => {
const packages = [
["@ai-sdk/anthropic", "@opencode/ai/providers/anthropic", "api-model"],
["@ai-sdk/amazon-bedrock", "@opencode/ai/providers/amazon-bedrock", "api-model"],
[
"@ai-sdk/amazon-bedrock/mantle",
"@opencode/ai/providers/amazon-bedrock/mantle/chat",
"openai.gpt-oss-120b",
],
["@ai-sdk/amazon-bedrock/mantle", "@opencode/ai/providers/amazon-bedrock/mantle/chat", "openai.gpt-oss-120b"],
["@ai-sdk/azure", "@opencode/ai/providers/azure/responses", "api-model"],
["@ai-sdk/cerebras", "@opencode/ai/providers/cerebras", "api-model"],
["@ai-sdk/deepinfra", "@opencode/ai/providers/deepinfra", "api-model"],
+28
View File
@@ -224,6 +224,34 @@ describe("Permission", () => {
}),
)
it.effect("merges session rules after agent rules and before saved approvals", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const { db } = yield* Database.Service
const service = yield* Permission.Service
const setSession = (permission: Permission.Ruleset) =>
db
.update(SessionTable)
.set({ permission })
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
yield* setSession([{ action: "edit", resource: "/original/**", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "edit", resources: ["/original/src/index.ts"] }))).toMatchObject({
effect: "deny",
})
yield* setRules([])
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
yield* setSession([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "deny" })
yield* setSession([{ action: "bash", resource: "*", effect: "ask" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "allow" })
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
+1
View File
@@ -108,6 +108,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
reply: () => Effect.die("unused permission.reply"),
rules: () => Effect.die("unused permission.rules"),
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
@@ -69,7 +69,7 @@ describe("XAIPlugin", () => {
}),
)
it.effect("keeps xAI Responses WebSockets opt-in", () =>
it.effect("enables xAI Responses WebSockets", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("xai")
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
const model = yield* catalog.model.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.capabilities.responsesWebsockets).toBe(true)
expect(model?.websocket).toBeUndefined()
expect(model?.websocket).toBe(true)
}),
)
})
+20
View File
@@ -27,4 +27,24 @@ describe("Provider", () => {
expect(loaded.model).toBeFunction()
}
})
test("offers flat settings to native packages as both connection settings and request options", () => {
expect(
Provider.nativeSettings({
apiKey: "secret",
baseURL: "https://example.com/v1",
region: "us-east-1",
reasoningEffort: "high",
chunkTimeout: 1000,
providerOptions: { textVerbosity: "low" },
}),
).toEqual({
apiKey: "secret",
baseURL: "https://example.com/v1",
region: "us-east-1",
reasoningEffort: "high",
textVerbosity: "low",
providerOptions: { region: "us-east-1", reasoningEffort: "high", textVerbosity: "low" },
})
})
})
+39 -2
View File
@@ -388,6 +388,32 @@ describe("Session.create", () => {
}),
)
it.effect("stores permission rules, inherits them through children and forks, and replaces them", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const permissions = [{ action: "edit", resource: "/original/**", effect: "deny" as const }]
const created = yield* session.create({ location, permissions })
expect(created.permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id })).permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id, permissions: [] })).permissions).toEqual([])
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
expect(forked.permissions).toEqual(permissions)
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
yield* session.setPermissions({ sessionID: created.id, permissions: replaced })
expect((yield* session.get(created.id)).permissions).toEqual(replaced)
expect(
yield* session.setPermissions({ sessionID: Session.ID.create(), permissions: replaced }).pipe(Effect.flip),
).toBeInstanceOf(Session.NotFoundError)
}),
)
it.effect("inherits location from an existing parent when omitted", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1330,7 +1356,12 @@ describe("SessionTransfer", () => {
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } })
const template = yield* session.create({
location,
title: "Exported",
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
@@ -1376,7 +1407,13 @@ describe("SessionTransfer", () => {
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } })
expect(imported).toMatchObject({
id: sessionID,
title: "Exported",
location,
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(imported.time).toMatchObject({
updated: DateTime.makeUnsafe(1_000),
idle: DateTime.makeUnsafe(200),
+10 -1
View File
@@ -1944,7 +1944,16 @@ describe("SessionRunnerLLM", () => {
expect(yield* entries.list(sessionID)).toEqual([{ key: "nullable", value: null }])
})
scenario("rejects API instruction entries larger than 8KB", function* () {
scenario("accepts API instruction entries up to 256 KiB", function* () {
const entries = yield* InstructionEntry.Service
const value = "x".repeat(InstructionEntry.MaxValueBytes - 2)
yield* entries.put({ sessionID, key: "large", value })
expect(yield* entries.list(sessionID)).toEqual([{ key: "large", value }])
})
scenario("rejects API instruction entries larger than 256 KiB", function* () {
const entries = yield* InstructionEntry.Service
const exit = yield* entries
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
readonly hook: Hooks<PermissionHooks>
}
+1
View File
@@ -438,6 +438,7 @@ export function fromPromise(plugin: Plugin) {
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
readonly hook: Hooks<PermissionHooks>
}
@@ -132,4 +132,21 @@ export const makePermissionGroup = <
}),
),
)
.add(
HttpApiEndpoint.put("session.permission.rules", "/api/session/:sessionID/permission/rules", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ permissions: Permission.Ruleset }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.rules",
summary: "Replace session permission rules",
description:
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
+2
View File
@@ -27,6 +27,7 @@ import {
import { Agent } from "@opencode/schema/agent"
import { Skill } from "@opencode/schema/skill"
import { Model } from "@opencode/schema/model"
import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
@@ -175,6 +176,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
metadata: Session.Metadata.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
}).annotateMerge(
+1 -1
View File
@@ -27,7 +27,7 @@ export const Snapshot = Schema.Array(
).annotate({ identifier: "InstructionEntry.Snapshot" })
export type Snapshot = typeof Snapshot.Type
export const MaxValueBytes = 8 * 1024
export const MaxValueBytes = 256 * 1024
export class ValueTooLargeError extends Schema.TaggedError<ValueTooLargeError>()(
"InstructionEntryValueTooLargeError",
+13
View File
@@ -25,6 +25,7 @@ import { TokenUsage } from "./token-usage.js"
import { SessionInbox } from "./session-inbox.js"
import { Project } from "./project.js"
import { SessionFork } from "./session-fork.js"
import { Permission } from "./permission.js"
export { FileAttachment }
@@ -62,6 +63,7 @@ export const Created = Event.durable({
model: Model.Ref.pipe(optional),
/** Host-supplied annotations resolved at creation, including any inherited from a parent. */
metadata: SessionMetadata.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
version: Schema.String,
},
})
@@ -109,6 +111,16 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const PermissionsUpdated = Event.durable({
type: "session.permissions.updated",
...options,
schema: {
...Base,
permissions: Permission.Ruleset,
},
})
export type PermissionsUpdated = typeof PermissionsUpdated.Type
export const Viewed = Event.durable({
type: "session.viewed",
...options,
@@ -634,6 +646,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
PermissionsUpdated,
Viewed,
UsageUpdated,
Deleted,
+3
View File
@@ -10,6 +10,7 @@ import { SessionEvent } from "./session-event.js"
import { SessionID } from "./session-id.js"
import { SessionMetadata } from "./session-metadata.js"
import { Money } from "./money.js"
import { Permission } from "./permission.js"
import { TokenUsage } from "./token-usage.js"
import { Revert } from "./session-revert.js"
import { SessionFork } from "./session-fork.js"
@@ -54,6 +55,8 @@ export const Info = Schema.Struct({
location: Location.Ref,
subpath: RelativePath.pipe(optional),
metadata: Metadata.pipe(optional),
/** Evaluated after the agent's rules; the last matching rule wins. */
permissions: Permission.Ruleset.pipe(optional),
revert: Revert.pipe(optional),
}).annotate({ identifier: "Session.Info" })
@@ -115,6 +115,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.permissions.updated.1",
"session.viewed.1",
"session.message.content.updated.1",
"session.usage.recorded.1",
@@ -83,6 +83,15 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.permission.rules",
Effect.fn(function* (ctx) {
yield* sessions
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"permission.saved.list",
Effect.fn(function* (ctx) {
+1
View File
@@ -120,6 +120,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
agent: ctx.payload.agent,
model: ctx.payload.model,
metadata: ctx.payload.metadata,
permissions: ctx.payload.permissions,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(Effect.orDie),
@@ -39,6 +39,7 @@ describe("inference stat normalization", () => {
})
test("merges renamed models under their current name", () => {
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5")
expect(toModelAggregate(aggregate("x-preview-f", "openai"))).toMatchObject([
@@ -14,6 +14,7 @@ export const MODEL_AUTHOR_RULES = [
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export const MODEL_NAME_ALIASES: Record<string, string> = {
"deepseek-flash": "deepseek-v4.1-flash",
"x-preview-f": "ox-alpha",
"xiaomi/mimo-v2.5": "mimo-v2.5",
}
+2 -1
View File
@@ -51,7 +51,8 @@ export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedCl
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
const result = await clipboard.writeText(text, {
// OpenTUI rejects NUL before any destination; host clipboard text cannot contain it.
const result = await clipboard.writeText(text.replaceAll("\0", ""), {
destination: "all-available",
selection: "clipboard",
})
+1
View File
@@ -274,6 +274,7 @@ export const Definitions = {
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("return", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.error": keybind("space", "View plugin error"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"),
"dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"),
@@ -223,6 +223,15 @@ export function PluginsDialog(props: {
disabled: checking(),
onTrigger: check,
},
{
title: "view error",
command: "dialog.plugins.error",
hidden: !pluginError(focusedTui()),
onTrigger: (option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
},
},
{
title: toggleTitle(),
command: "plugins.toggle",
@@ -239,7 +248,7 @@ export function PluginsDialog(props: {
},
]}
footer={
<Show when={pluginError(focusedEntry())}>
<Show when={pluginError(focusedEntry()) && !focusedTui()}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
@@ -0,0 +1,111 @@
export type GroupNode<Entry, Kind extends string> =
| { readonly type: "entry"; readonly entry: Entry; readonly size: 1 }
| {
readonly type: "group"
readonly kind: Kind
readonly children: readonly GroupNode<Entry, Kind>[]
/** Number of descendant leaves, independent of disclosure state. */
readonly size: number
}
/**
* Group adjacent entries by their configured nesting paths. For example, a read
* can use ["exploration"] today or ["activity", "exploration"] in Low.
* Entries are opaque: message/part identity, visibility and live state remain
* owned by the session projection. A path of [] creates a standalone leaf.
*/
export function groupEntries<Entry, Kind extends string>(
entries: readonly Entry[],
path: (entry: Entry) => readonly Kind[],
): readonly GroupNode<Entry, Kind>[] {
const result: BuildingNode<Entry, Kind>[] = []
entries.forEach((entry) => {
appendEntry(result, entry, path(entry))
})
return result
}
// Only freshly constructed nodes are writable; the published tree is readonly.
type BuildingNode<Entry, Kind extends string> =
| { type: "entry"; entry: Entry; size: 1 }
| { type: "group"; kind: Kind; children: BuildingNode<Entry, Kind>[]; size: number }
function appendEntry<Entry, Kind extends string>(
nodes: BuildingNode<Entry, Kind>[],
entry: Entry,
path: readonly Kind[],
depth = 0,
) {
const kind = path[depth]
if (kind === undefined) {
nodes.push({ type: "entry", entry, size: 1 })
return
}
const previous = nodes.at(-1)
if (previous?.type === "group" && previous.kind === kind) {
previous.size++
appendEntry(previous.children, entry, path, depth + 1)
return
}
const children: BuildingNode<Entry, Kind>[] = []
appendEntry(children, entry, path, depth + 1)
nodes.push({ type: "group", kind, children, size: 1 })
}
/**
* Concatenate ordered, disjoint chunks, recursively merging compatible groups
* at their seam. Untouched subtrees retain their object identity.
*
* This is concatenation, not ingestion: callers must reconcile overlapping
* pages/replayed message IDs before merging. Equal payloads may be distinct
* entries and must not be silently deduplicated here.
*/
export function mergeGroups<Entry, Kind extends string>(
left: readonly GroupNode<Entry, Kind>[],
right: readonly GroupNode<Entry, Kind>[],
): readonly GroupNode<Entry, Kind>[] {
if (!left.length) return right
if (!right.length) return left
const a = left[left.length - 1]
const b = right[0]
if (a.type !== "group" || b.type !== "group" || a.kind !== b.kind) return [...left, ...right]
return [
...left.slice(0, -1),
{
type: "group",
kind: a.kind,
size: a.size + b.size,
children: mergeGroups(a.children, b.children),
},
...right.slice(1),
]
}
/**
* Split at a depth-first leaf offset. Group headers count as zero. Cached sizes
* skip whole subtrees; only the ancestors crossing the cut are reconstructed.
* The returned halves can be seam-merged again without changing their meaning.
*/
export function splitGroups<Entry, Kind extends string>(
nodes: readonly GroupNode<Entry, Kind>[],
count: number,
): readonly [readonly GroupNode<Entry, Kind>[], readonly GroupNode<Entry, Kind>[]] {
if (!Number.isInteger(count) || count < 0) throw new RangeError("Group split requires a non-negative integer")
if (count === 0) return [[], nodes]
let offset = 0
for (const [index, node] of nodes.entries()) {
const end = offset + node.size
if (count === end) return [nodes.slice(0, index + 1), nodes.slice(index + 1)]
if (count < end) {
if (node.type !== "group") throw new RangeError("Cannot split inside an entry")
const size = count - offset
const [left, right] = splitGroups(node.children, size)
return [
[...nodes.slice(0, index), { ...node, children: left, size }],
[{ ...node, children: right, size: node.size - size }, ...nodes.slice(index + 1)],
]
}
offset = end
}
throw new RangeError("Group split exceeds entry count")
}
+3 -280
View File
@@ -1,6 +1,5 @@
import {
batch,
createContext,
createEffect,
createMemo,
createSignal,
@@ -12,7 +11,6 @@ import {
onMount,
Show,
Switch,
useContext,
type Accessor,
} from "solid-js"
import path from "node:path"
@@ -29,7 +27,6 @@ import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
SessionMessageInfo,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
@@ -111,6 +108,9 @@ import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
export { InlineToolRow } from "./message-parts"
addDefaultParsers(parsers.parsers)
@@ -121,34 +121,6 @@ const BACKGROUND_TOOL_HINT_DELAY = 3_000
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
width: number
/**
* Shared reactive terminal size. Transcript-row components must read this
* instead of calling useTerminalDimensions(), which registers one renderer
* resize listener per mounted component and grows with transcript length.
*/
terminal: { width: number; height: number }
sessionID: string
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
messageIndex: (messageID: string) => number | undefined
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
}>()
function use() {
const ctx = useContext(context)
if (!ctx) throw new Error("useContext must be used within a Session component")
return ctx
}
export function Session(props: {
scrollRef?: (scroll: ScrollBoxRenderable | undefined) => void
@@ -2462,160 +2434,6 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
)
}
const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: {
last: boolean
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => reasoningContent(props.part))
const isDone = createMemo(
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time?.completed ?? props.message.time.completed
const start = props.part.time?.created ?? props.message.time.created
return end === undefined ? 0 : Math.max(0, end - start)
})
const summary = createMemo(() => reasoningSummary(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content()}>
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={inMinimal() && !expanded() ? summary().title : null}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
</box>
<Show when={!inMinimal() || expanded()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={inMinimal() ? 3 : 1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</box>
</Show>
</box>
</Show>
)
}
function reasoningContent(part: SessionMessageAssistantReasoning) {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return part.text.replace("[REDACTED]", "").trim()
}
function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
const theme = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
: theme.text.feedback.warning.default
return (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
)
}
function TextPart(props: { last: boolean; part: SessionMessageAssistantText; message: SessionMessageAssistant }) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
/>
</box>
</Show>
)
}
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
@@ -2918,101 +2736,6 @@ function InlineTool(props: {
)
}
export function InlineToolRow(props: {
icon: string
iconColor?: RGBA
color?: RGBA
errorColor?: RGBA
failed?: boolean
denied?: boolean
error?: string
errorExpanded?: boolean
complete: unknown
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
onMouseUp?: () => void
}) {
return (
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
<Switch>
<Match when={props.spinner}>
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
{(status) => (
<box flexDirection="row" gap={1}>
<Spinner color={props.color} />
<InlineToolLabel color={props.color} status={status()}>
{props.children}
</InlineToolLabel>
</box>
)}
</Show>
</Match>
<Match when={true}>
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
<box flexDirection="row">
<text
width={INLINE_TOOL_ICON_WIDTH}
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.icon}
</text>
<Show
when={props.status}
fallback={
<text
flexGrow={1}
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
}
>
{(status) => (
<InlineToolLabel
color={props.failed ? props.errorColor : props.color}
denied={props.denied}
status={status()}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</InlineToolLabel>
)}
</Show>
</box>
</Show>
</Match>
</Switch>
<Show when={props.failed && props.errorExpanded}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={props.errorColor}>{props.error}</text>
</box>
</Show>
</box>
)
}
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
return (
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
<text
maxWidth="100%"
flexShrink={0}
fg={props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
</text>
{props.status}
</box>
)
}
function StatusBadge(props: { children: string }) {
const theme = useTheme()
return (
@@ -0,0 +1,269 @@
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { RGBA, TextAttributes } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type {
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
} from "@opencode/client"
import { Spinner } from "../../component/spinner"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
import { reasoningSummary } from "../../context/thinking"
import { usePlugin } from "../../plugin/context"
import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { use } from "./render-context"
import { generateThinkingSyntax } from "./thinking-syntax"
export const INLINE_TOOL_ICON_WIDTH = 2
export function ReasoningPart(props: {
last: boolean
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => reasoningContent(props.part))
const isDone = createMemo(
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time?.completed ?? props.message.time.completed
const start = props.part.time?.created ?? props.message.time.created
return end === undefined ? 0 : Math.max(0, end - start)
})
const summary = createMemo(() => reasoningSummary(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content()}>
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={inMinimal() && !expanded() ? summary().title : null}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
</box>
<Show when={!inMinimal() || expanded()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={inMinimal() ? 3 : 1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</box>
</Show>
</box>
</Show>
)
}
export function reasoningContent(part: SessionMessageAssistantReasoning) {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return part.text.replace("[REDACTED]", "").trim()
}
function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
const theme = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
: theme.text.feedback.warning.default
return (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
)
}
export function TextPart(props: {
last: boolean
part: SessionMessageAssistantText
message: SessionMessageAssistant
}) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
/>
</box>
</Show>
)
}
export function InlineToolRow(props: {
icon: string
iconColor?: RGBA
color?: RGBA
errorColor?: RGBA
failed?: boolean
denied?: boolean
error?: string
errorExpanded?: boolean
complete: unknown
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
onMouseUp?: () => void
}) {
return (
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
<Switch>
<Match when={props.spinner}>
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
{(status) => (
<box flexDirection="row" gap={1}>
<Spinner color={props.color} />
<InlineToolLabel color={props.color} status={status()}>
{props.children}
</InlineToolLabel>
</box>
)}
</Show>
</Match>
<Match when={true}>
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
<box flexDirection="row">
<text
width={INLINE_TOOL_ICON_WIDTH}
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.icon}
</text>
<Show
when={props.status}
fallback={
<text
flexGrow={1}
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
}
>
{(status) => (
<InlineToolLabel
color={props.failed ? props.errorColor : props.color}
denied={props.denied}
status={status()}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</InlineToolLabel>
)}
</Show>
</box>
</Show>
</Match>
</Switch>
<Show when={props.failed && props.errorExpanded}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={props.errorColor}>{props.error}</text>
</box>
</Show>
</box>
)
}
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
return (
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
<text
maxWidth="100%"
flexShrink={0}
fg={props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
</text>
{props.status}
</box>
)
}
@@ -0,0 +1,34 @@
import { createContext, useContext } from "solid-js"
import type { ModelInfo } from "@opencode/client"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { useConfig } from "../../config"
import type { ThinkingMode } from "../../context/thinking"
export type PendingAction = "steer" | "queue" | "cancel"
export const context = createContext<{
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
width: number
/**
* Shared reactive terminal size. Transcript-row components must read this
* instead of calling useTerminalDimensions(), which registers one renderer
* resize listener per mounted component and grows with transcript length.
*/
terminal: { width: number; height: number }
sessionID: string
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
messageIndex: (messageID: string) => number | undefined
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
}>()
export function use() {
const ctx = useContext(context)
if (!ctx) throw new Error("useContext must be used within a Session component")
return ctx
}
@@ -0,0 +1,82 @@
import { expect, test } from "bun:test"
import { groupEntries, mergeGroups, splitGroups } from "../../../src/routes/session/grouping/tree"
const path = (entry: { path: string[] }) => entry.path
const read = { id: "read", path: ["activity", "exploration"] }
const search = { id: "search", path: ["activity", "exploration"] }
const thought = { id: "thought", path: ["activity", "reasoning"] }
const text = { id: "text", path: [] }
const leaf = (entry: typeof read) => ({ type: "entry" as const, entry, size: 1 as const })
test("groups adjacent entries by path and counts leaves, not wrappers", () => {
expect(groupEntries([read, search, thought, text, read], path)).toEqual([
{
type: "group",
kind: "activity",
size: 3,
children: [
{ type: "group", kind: "exploration", size: 2, children: [leaf(read), leaf(search)] },
{ type: "group", kind: "reasoning", size: 1, children: [leaf(thought)] },
],
},
leaf(text),
{
type: "group",
kind: "activity",
size: 1,
children: [{ type: "group", kind: "exploration", size: 1, children: [leaf(read)] }],
},
])
})
test("a direct child breaks a subgroup without ending the outer group", () => {
const shell = { id: "shell", path: ["activity"] }
expect(groupEntries([read, shell, search], path)).toEqual([
{
type: "group",
kind: "activity",
size: 3,
children: [
{ type: "group", kind: "exploration", size: 1, children: [leaf(read)] },
leaf(shell),
{ type: "group", kind: "exploration", size: 1, children: [leaf(search)] },
],
},
])
})
test("merges both grouping levels at a page seam without changing the inputs", () => {
const left = groupEntries([text, read], path)
const right = groupEntries([search, thought], path)
const saved = structuredClone([left, right])
const merged = mergeGroups(left, right)
expect(merged).toEqual(groupEntries([text, read, search, thought], path))
expect([left, right]).toEqual(saved)
expect(merged[0]).toBe(left[0])
})
test("splits at each leaf boundary and merges back to the original tree", () => {
const entries = [read, search, thought, text]
const tree = groupEntries(entries, path)
for (let count = 0; count <= entries.length; count++) {
const [left, right] = splitGroups(tree, count)
expect(left).toEqual(groupEntries(entries.slice(0, count), path))
expect(right).toEqual(groupEntries(entries.slice(count), path))
expect(mergeGroups(left, right)).toEqual(tree)
}
})
test("handles empty chunks", () => {
const tree = groupEntries([read], path)
expect(groupEntries([], path)).toEqual([])
expect(mergeGroups([], tree)).toBe(tree)
expect(mergeGroups(tree, [])).toBe(tree)
expect(splitGroups([], 0)).toEqual([[], []])
})
test("rejects invalid split offsets", () => {
const tree = groupEntries([read], path)
for (const count of [-1, 0.5, 2, NaN]) {
expect(() => splitGroups(tree, count)).toThrow(RangeError)
}
})
@@ -13,6 +13,10 @@ import { ThemeProvider, useThemes } from "../../../src/context/theme"
// the context back, so the context must load first exactly as it does in the app.
import type { usePlugin } from "../../../src/plugin/context"
import "../../../src/plugin/context"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider } from "../../../src/context/data"
import { LocationProvider } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { PluginsDialog } from "../../../src/feature-plugins/system/plugins"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
@@ -32,11 +36,19 @@ function packagePlugin(outdated: boolean): PluginInfo {
}
}
async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) {
async function renderPlugins(
root: string,
inventory: { list: PluginInfo[]; check: PluginInfo[] },
tui?: {
registered: { id: string; source: "builtin" | "external"; active: boolean }[]
list: { target: string; id?: string; status: "active" | "inactive" | "failed"; error?: string }[]
},
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const requests: { path: string; body: unknown }[] = []
const toasts: ToastOptions[] = []
const activations: string[] = []
const location = { directory: root, project: { id: "proj_test", directory: root, canonical: root } }
const transport = createFetch(async (url, request) => {
if (url.pathname === "/api/plugin") return json({ location, data: inventory.list })
@@ -49,13 +61,14 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
return new Response(null, { status: 204 })
}
})
const api = createApi(transport.fetch)
function Harness() {
function Content() {
onCleanup(Keymap.use().mode.push("modal"))
const theme = useThemes().currentTokens()
const context = {
client: createApi(transport.fetch),
client: api,
data: { location: { default: () => ({ directory: root }) }, on: () => () => {} },
get theme() {
return theme
@@ -66,9 +79,12 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
},
} as unknown as Context
const plugins = {
registered: () => [],
list: () => [],
activate: async () => true,
registered: () => tui?.registered ?? [],
list: () => tui?.list ?? [],
activate: async (id: string) => {
activations.push(id)
return true
},
deactivate: async () => true,
} as unknown as ReturnType<typeof usePlugin>
return <PluginsDialog context={context} plugins={plugins} />
@@ -77,15 +93,23 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={api}>
<DataProvider directory={root}>
<LocationProvider>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
)
@@ -93,10 +117,46 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("team.plugins") || frame.includes("local.plugin"))
return { app, requests, toasts }
const expected = tui?.list[0]?.id ?? inventory.list[0]?.id ?? "local.plugin"
await app.waitForFrame((frame) => frame.includes(expected))
return { app, requests, toasts, activations }
}
test("failed TUI plugins keep enter to enable and use space to show the error", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(
tmp.path,
{ list: [], check: [] },
{
registered: [{ id: "broken.plugin", source: "external", active: false }],
list: [
{
target: "./broken.ts",
id: "broken.plugin",
status: "failed",
error: "Plugin setup failed",
},
],
},
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("broken.plugin") && frame.includes("view error"))
expect(fixture.app.captureCharFrame()).toContain("enable")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.activations.length === 1)
expect(fixture.activations).toEqual(["broken.plugin"])
fixture.app.mockInput.pressKey(" ")
await fixture.app.waitForFrame(
(frame) => frame.includes("TUI plugin error") && frame.includes("Plugin setup failed"),
)
} finally {
fixture.app.renderer.destroy()
}
})
test("checking for updates refreshes the inventory and reveals the update action", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
+13
View File
@@ -102,6 +102,19 @@ test("uses all available routes but skips the process host remotely", async () =
expect(writes).toEqual({ host: 0, terminal: 1 })
})
test("removes NUL characters before writing", async () => {
const writes: string[] = []
const clipboard = createClipboardAdapter(
coreClipboard({
onWrite: (text) => writes.push(text),
}),
)
expect(await clipboard.write("before\0after")).toBeUndefined()
expect(await clipboard.write("clean")).toBeUndefined()
expect(writes).toEqual(["beforeafter", "clean"])
})
test("rejects only when no clipboard route accepted the write", async () => {
const writes: [string, ClipboardWriteOptions][] = []
const failure = new Error("native clipboard failed")
+235 -162
View File
@@ -2,85 +2,13 @@
title: "Agents"
---
Agents combine a system prompt, model preference, tool permissions, and display
metadata into a reusable assistant profile. OpenCode includes agents for common
workflows, and you can override them or add your own in configuration or
Markdown files.
## Built-in agents
| Agent | Mode | Purpose |
| ----------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Build** (`build`) | `primary` | Default coding agent. Tools are allowed by default, sensitive environment-file reads ask for approval, and access outside the workspace asks for approval. |
| **Plan** (`plan`) | `primary` | Planning agent. File edits are denied except for OpenCode plan files. Shell commands are not generally denied. |
| **General** (`general`) | `subagent` | General-purpose research and multi-step work. It has broad tool access but cannot launch more subagents. |
| **Explore** (`explore`) | `subagent` | Read-only code and web exploration using `read`, `glob`, `grep`, `webfetch`, and `websearch`. |
OpenCode also has hidden `compaction`, `title`, and `summary` system agents.
They run internal maintenance tasks and are not available for direct use. There is no built-in
`scout` agent in V2.
You can override a built-in agent with an entry of the same ID. Set
`disabled: true` to remove one.
## Default agent
Set the primary agent used when a session has not selected one:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "reviewer",
}
```
The configured agent must exist, must not have `mode: "subagent"`, and must not
be hidden. If it is unavailable, OpenCode falls back to `build`, then to the
first visible agent that can run as a primary agent. This selection does not
rewrite the agent already stored on an existing session.
## Modes
An agent's `mode` controls where it can run:
| Mode | Behavior |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primary` | Can be selected as the main agent for a session. It cannot be launched as a subagent. This is the default for a custom agent when `mode` is omitted. |
| `subagent` | Can run in a child session through the `subagent` tool, but cannot be selected as the main agent. |
| `all` | Can be used either way. |
Subagents run in child sessions with fresh context. A primary agent can invoke
one with the `subagent` tool, either in the foreground or in the background.
The parent agent's `subagent` permission controls which agents it may launch.
The child currently uses its own configured permissions, not a restricted copy
of the parent's permissions.
## Configure agents
### Markdown files
The recommended file locations are:
```text
~/.config/opencode/agents/<name>.md
.opencode/agents/<name>.md
```
OpenCode discovers project `.opencode` directories from the current directory
up to the project root. The path below `agents/` becomes the agent ID, so
`.opencode/agents/team/reviewer.md` defines `team/reviewer`.
Frontmatter uses the same fields as an entry under `agents`. The Markdown body
becomes `system`:
Create a Markdown file to add a reusable agent. This example adds a read-only reviewer that the main agent can launch for code reviews:
```md title=".opencode/agents/reviewer.md"
---
description: Reviews changes without modifying files
description: Reviews changes for correctness and regressions
mode: subagent
model: anthropic/claude-sonnet-4-5#high
color: "#ff6b6b"
steps: 8
permissions:
- action: edit
resource: "*"
@@ -90,70 +18,191 @@ permissions:
effect: deny
---
Review for correctness, security, regressions, and missing tests.
List findings in severity order with file and line references.
Review the current changes. List findings in severity order with file and line references.
```
### JSON or JSONC
Ask your primary agent to use it:
Use the `agents` field in any [OpenCode configuration file](/config):
```text
Use the reviewer subagent to review my current changes.
```
An agent combines a system prompt, model preference, permissions, and display details into a named assistant profile.
## Locations
Save Markdown agents globally for all projects or inside a project:
```text
~/.config/opencode/agents/<name>.md
.opencode/agents/<name>.md
```
OpenCode discovers project `.opencode` directories from the current directory up to the project root. A nested path becomes part of the agent ID:
```text
.opencode/agents/team/reviewer.md → team/reviewer
```
## Formats
### Markdown
Frontmatter accepts the same fields as an `agents` configuration entry. The Markdown body becomes the agent's `system` prompt:
```md title=".opencode/agents/explainer.md"
---
description: Explains code without changing it
mode: subagent
---
Explain the relevant code with short examples. Do not edit files.
```
### JSONC
Define agents under `agents` in any [OpenCode configuration file](/config):
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "reviewer",
"agents": {
"reviewer": {
"description": "Reviews changes for correctness, security, and missing tests",
"mode": "all",
"model": "anthropic/claude-sonnet-4-5#high",
"system": "Review the current changes. Report findings before any summary.",
"color": "#ff6b6b",
"steps": 8,
"description": "Reviews current changes",
"mode": "subagent",
"system": "Report findings in severity order.",
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
],
},
"build": {
"permissions": [{ "action": "shell", "resource": "git push *", "effect": "ask" }],
},
},
}
```
Agent definitions merge in configuration order. Later scalar fields replace
earlier values, request maps merge by key, and permission rules are appended.
Global `permissions` are applied to every agent before its agent-specific rules,
so a later agent rule can refine a global rule.
## Selection
## Options
Set the primary agent used when a session has not selected one:
### `description`
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "writer",
"agents": {
"writer": { "mode": "primary" },
},
}
```
Explains the agent's purpose. It is optional, but strongly recommended for
subagents because OpenCode includes it in the subagent catalog shown to the
model.
The selected default must exist, be visible, and support primary use. Otherwise OpenCode uses `build`, then the first visible primary-capable agent. Changing this setting does not replace the agent stored on an existing session.
### `mode`
## Modes
Accepts `primary`, `subagent`, or `all`. The default is `all`.
### `model`
Selects a model using `provider/model` with an optional `#variant`:
Set `mode` according to where the agent should run:
```jsonc
{
"agents": {
"reviewer": {
"model": "anthropic/claude-sonnet-4-5#high",
"reviewer": { "mode": "subagent" },
},
}
```
| Mode | Behavior |
| --- | --- |
| `primary` | Runs as the main agent for a session. This is the default for a new custom agent. |
| `subagent` | Runs only in a child session through the `subagent` tool. |
| `all` | Runs either as a primary agent or a subagent. |
Subagents run with fresh context in foreground or background child sessions. The parent agent's `subagent` permissions control which agents it may launch; the child uses its own configured permissions.
```jsonc
{
"agents": {
"orchestrator": {
"permissions": [
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "reviewer", "effect": "allow" },
],
},
},
}
```
The equivalent expanded form is:
## Builtins
OpenCode includes these visible agents:
| Agent | Mode | Purpose |
| --- | --- | --- |
| **Build** (`build`) | `primary` | Default coding agent. Tools are allowed by default; sensitive environment-file reads and access outside the workspace ask for approval. |
| **Plan** (`plan`) | `primary` | Explores and plans without editing normal project files. It may write OpenCode plan files when asked, and shell commands remain permission-controlled. |
| **General** (`general`) | `subagent` | Handles research and multi-step work with broad tool access, but cannot launch more subagents. |
| **Explore** (`explore`) | `subagent` | Searches and reads code or web sources without editing files. |
Override a built-in by using the same ID:
```jsonc
{
"agents": {
"build": {
"permissions": [
{ "action": "shell", "resource": "git push *", "effect": "ask" },
],
},
},
}
```
Hidden `compaction`, `title`, and `summary` agents perform maintenance and cannot be selected directly. V2 has no built-in `scout` agent.
## Merging
Agent definitions merge in configuration order. Later scalar values replace earlier values, request maps merge by key, and permission rules append:
```jsonc
{
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" },
],
"agents": {
"build": {
"permissions": [
{ "action": "shell", "resource": "git status", "effect": "allow" },
],
},
},
}
```
Global `permissions` apply before agent-specific rules, so later agent rules can refine them.
## Options
### Description
`description` explains the agent's purpose. Add it to subagents because OpenCode shows it to the model choosing which agent to launch:
```yaml
description: Reviews database migrations for safety
```
### Mode
`mode` accepts `primary`, `subagent`, or `all`. When omitted on a new custom agent, it defaults to `primary`:
```yaml
mode: all
```
### Model
`model` uses `provider/model` with an optional `#variant`:
```yaml
model: anthropic/claude-sonnet-4-5#high
```
JSON configuration also accepts the expanded form:
```jsonc
{
@@ -169,84 +218,108 @@ The equivalent expanded form is:
}
```
This is the preferred model when the agent is activated. A child session uses
its subagent's configured model, or inherits the parent session's model when
none is configured. The session's selected model is stored separately;
creating or switching a primary session with only an agent ID does not itself
change that session model.
- A subagent uses its configured model, or inherits the parent session's model when none is configured.
- A session stores its selected model separately. Selecting a primary agent by ID does not change that model.
### `system`
### System
Sets the agent's system prompt. A non-empty value replaces OpenCode's
provider-specific base prompt for that agent. Project instructions, skills,
references, and other instruction sources are still added separately.
For a Markdown agent, use the document body instead of a `system` frontmatter
field.
### `permissions`
Permissions are an ordered array of rules:
`system` sets the agent's system prompt. A non-empty value replaces the provider's base prompt for that agent:
```jsonc
{
"agents": {
"orchestrator": {
"reviewer": { "system": "Review only. Do not modify files." },
},
}
```
Project instructions, skills, references, and other instruction sources are still added. In a Markdown agent, put this text in the document body instead of a `system` frontmatter field.
### Permissions
`permissions` is an ordered list of matching rules:
```jsonc
{
"agents": {
"reviewer": {
"permissions": [
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "explore", "effect": "allow" },
{ "action": "shell", "resource": "git *", "effect": "ask" },
{ "action": "*", "resource": "*", "effect": "deny" },
{ "action": "read", "resource": "src/**", "effect": "allow" },
],
},
},
}
```
Each rule has:
| Field | Meaning |
| --- | --- |
| `action` | Tool or permission action. Wildcards are supported. |
| `resource` | Path, command, agent ID, or other value matched by the action. Wildcards are supported. |
| `effect` | `allow`, `ask`, or `deny`. |
| Field | Meaning |
| ---------- | ---------------------------------------------------------------------------------------------- |
| `action` | Tool or permission action, with `*` wildcards supported. |
| `resource` | The path, command, agent ID, or other resource matched by the action. Wildcards are supported. |
| `effect` | `allow`, `ask`, or `deny`. |
The last matching rule wins, so put broad rules before exceptions. Common actions include:
The last matching rule wins. Important V2 action names include `shell` for
shell commands, `edit` for all edit/write/patch tools, and `subagent` for child
agents. Other tools generally use their tool name, such as `read`, `glob`,
`grep`, `webfetch`, `websearch`, and `skill`.
| Action | Covers |
| --- | --- |
| `shell` | Shell commands |
| `edit` | Edit, write, and patch tools |
| `subagent` | Child agents |
| `read`, `glob`, `grep` | Local discovery tools |
| `webfetch`, `websearch` | Web tools |
| `skill` | Skill loading |
<Callout type="tip">
Put broad wildcard rules first and exceptions afterward. For example, deny all subagents first, then allow `explore`.
</Callout>
For `read`, `edit`, and `external_directory` resources, OpenCode expands `~` and `$HOME`:
`~` and `$HOME` are expanded in filesystem resources for `read`, `edit`, and
`external_directory`. Shell resources are raw command text and are not
expanded.
```jsonc
{ "action": "read", "resource": "~/notes/**", "effect": "allow" }
```
### `steps`
Shell resources remain raw command text and do not expand those values.
Sets a positive maximum number of model steps. On the final allowed step,
OpenCode removes tools and asks the model to summarize its work in text. New
user input resets the allowance.
### Steps
### `hidden`
`steps` sets a positive maximum number of model steps:
When `true`, removes the agent from normal agent listings, interactive
discovery, and the subagent catalog advertised to models. It is a visibility
setting, not a security boundary.
```yaml
steps: 8
```
### `color`
On the final step, OpenCode removes tools and asks the model to summarize in text. New user input resets the allowance.
Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`.
### Hidden
### `disabled`
`hidden` removes an agent from normal listings, interactive discovery, and the subagent catalog:
When `true`, removes the agent definition at that point in configuration
loading. This works for built-in and custom agents.
```yaml
hidden: true
```
### `request`
This controls visibility, not security. Use permissions to restrict behavior.
The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
### Color
`color` sets the agent's UI color using a six-digit hex value:
```yaml
color: "#ff6b6b"
```
### Disabled
`disabled` removes a built-in or custom agent at that point in configuration loading:
```jsonc
{
"agents": {
"plan": { "disabled": true },
},
}
```
### Request
`request` accepts per-agent header and JSON body overlays:
```jsonc
{
@@ -262,8 +335,8 @@ The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
```
<Callout type="warning">
The current V2 session runner preserves these overlays on the agent definition but does not yet apply them to model
requests. Configure effective request settings on the provider, model, or model variant instead. Do not use legacy
top-level agent fields such as `temperature`, `top_p`, `prompt`, `permission`, `tools`, `disable`, or `maxSteps` in
new V2 configuration.
The V2 session runner preserves these values but does not yet send them with model requests. Configure active request
settings on the provider, model, or model variant instead.
</Callout>
Do not use legacy top-level fields such as `temperature`, `top_p`, `prompt`, `permission`, `tools`, `disable`, or `maxSteps` in new V2 agent configuration.
+118 -63
View File
@@ -2,52 +2,78 @@
title: "Attachments"
---
Attachments add local context to a prompt as text or image media. Regardless
of how a prompt is submitted, current V2 sessions make these attachment types
visible to the model:
## Attach
| Input | Model receives |
| ----------------------- | -------------------------------------------------------------- |
| UTF-8 text file | The filename and decoded text |
| Directory | A non-recursive listing of its immediate files and directories |
| PNG, JPEG, GIF, or WebP | Image media |
Attach a local file, then ask OpenCode to use it in your prompt. In the desktop
or web client, choose **Attach file**, paste a file, or drag it into the prompt.
SVG files are treated as text, not image media. PDF, AVIF, BMP, audio, video,
and other binary prompt attachments are not currently included in the model
request. A client accepting a file does not mean its contents are visible to
the model.
```text
Summarize the attached README.md and list the required setup steps.
```
Desktop file-picker selections can total up to 20 MiB. Other interfaces may
apply lower client-side limits.
<Callout type="warning">
Use a model that supports image input before attaching an image. OpenCode passes supported image media to the selected
provider, but the provider and model still enforce their own formats, dimensions, file counts, and size limits. A
text-only model may reject the request.
Choose a model with image input before attaching an image. OpenCode can pass supported images to the provider, but the
provider and model still enforce their own formats, dimensions, file counts, and sizes. A text-only model may reject the
request.
</Callout>
## Add attachments
## Syntax
Desktop and web clients provide **Attach file**, paste, and drag-and-drop controls for supported text and image files. The
desktop file picker limits one selection to 20 MiB in total; the server also applies the per-attachment limit below.
V2 prompt and command inputs describe an attachment with a `uri` and optional
`name` and `description`:
Attachment controls and client-side limits depend on the interface. For programmatic submission, see the generated
[API reference](/api).
```json
{
"uri": "file:///home/me/project/src/server.ts",
"name": "server.ts",
"description": "HTTP server entrypoint"
}
```
V2 prompt and command inputs represent each attachment with a `uri` and
optional `name` and `description`. Use an absolute `file:` URL for a file or
directory available to the server, or an inline `data:` URL. For a text `file:`
URL, optional positive `start` and `end` query parameters select one-based
lines:
Use an absolute `file:` URL for a file or directory that is available to the
server. For text files, positive `start` and `end` parameters select one-based
lines.
```text
file:///home/me/project/src/server.ts?start=20&end=60
```
HTTP and HTTPS attachment URLs are not supported. OpenCode materializes each
attachment before admitting the prompt and rejects invalid URLs, unreadable
paths, non-files other than directories, and attachments over 20 MiB decoded.
The server infers the media type from the bytes. A supplied filename or data
URL media type does not make an unsupported binary format model-visible.
Use a `data:` URL to send content inline:
## Configure image processing
```json
{
"uri": "data:text/plain;base64,SGVsbG8sIE9wZW5Db2RlIQ==",
"name": "greeting.txt"
}
```
HTTP and HTTPS attachment URLs are not supported. See the generated
[API reference](/api) for programmatic prompt submission.
## Formats
Current V2 sessions make these attachment types visible to the model:
| Input | Model receives | Example |
| ----------------------- | -------------------------------------------------------------- | ------------------ |
| UTF-8 text file | Filename and decoded text | `README.md` |
| Directory | Non-recursive listing of immediate files and directories | `file:///home/me/` |
| PNG, JPEG, GIF, or WebP | Image media | `diagram.png` |
SVG is treated as text. PDF, AVIF, BMP, audio, video, and other binary prompt
attachments are not included in the model request. Convert an unsupported
binary to text or a supported image first; for example, export a PDF page as
`page-1.png` before attaching it.
OpenCode reads each attachment before admitting the prompt. It rejects invalid
URLs, unreadable paths, paths other than files or directories, and decoded
attachments over 20 MiB. Media type is detected from the bytes, so changing a
filename or `data:` URL media type does not make an unsupported binary visible.
## Images
Configure image normalization in `opencode.json` or `opencode.jsonc`:
@@ -67,41 +93,70 @@ Configure image normalization in `opencode.json` or `opencode.jsonc`:
All fields are optional:
| Field | Default | Behavior |
| ------------------ | --------: | ----------------------------------------------------------------------------------- |
| `auto_resize` | `true` | Resize an image that exceeds any configured limit. If `false`, reject it. |
| `max_width` | `2000` | Maximum width in pixels. Must be a positive integer. |
| `max_height` | `2000` | Maximum height in pixels. Must be a positive integer. |
| `max_base64_bytes` | `5242880` | Maximum byte length of the Base64-encoded image string. Must be a positive integer. |
| Field | Default | Behavior |
| ------------------ | --------- | ------------------------------------------------------------------------- |
| `auto_resize` | `true` | Resize an image over a configured limit; when `false`, reject the image. |
| `max_width` | `2000` | Maximum width in pixels; must be a positive integer. |
| `max_height` | `2000` | Maximum height in pixels; must be a positive integer. |
| `max_base64_bytes` | `5242880` | Maximum bytes in the Base64-encoded image; must be a positive integer. |
<Callout type="note">
These settings apply to supported image media attached directly to prompts and images produced by the built-in `read`
tool. If the image resizer is unavailable, OpenCode passes the original image through unchanged.
</Callout>
For example, this rejects rather than resizes an image wider than 1200 pixels:
The `read` tool recognizes PNG, JPEG, GIF, and WebP by their contents and will
ingest at most 20 MiB of source image bytes. It decodes the image and compares
its width, height, and encoded Base64 length with all three configured limits.
```jsonc title="opencode.jsonc"
{
"media": {
"image": {
"auto_resize": false,
"max_width": 1200,
},
},
}
```
When `auto_resize` is `true`, OpenCode preserves the aspect ratio, scales the
image down to the dimension limits, and tries progressively smaller PNG and
JPEG encodings until the Base64 limit is met. The resulting media type can
therefore change to PNG or JPEG. If no encoding fits, the tool call fails.
These settings apply both to supported images attached to prompts and to images
returned by the built-in `read` tool.
When `auto_resize` is `false`, exceeding any limit fails the tool call without
modifying the image. An image that cannot be decoded also fails. If the image resizer cannot be loaded, OpenCode uses the
original image instead, so these settings are processing limits rather than an upload or security boundary.
## Processing
## Limits and provider behavior
The `read` tool recognizes PNG, JPEG, GIF, and WebP by their contents and reads
up to 20 MiB of source image data. It checks width, height, and Base64 length
against the configured image limits.
- Direct prompt attachments are limited to 20 MiB decoded per item by the V2
server. Client-specific limits can be lower.
- `max_base64_bytes` counts the encoded Base64 characters in bytes, not the
decoded file size and not the complete `data:` URL.
- Text attachments are inserted into the prompt as text and do not require a
multimodal model. Large text read through the `read` tool has separate
paging and truncation limits.
- Image attachments use provider-native image input. Provider errors can still
occur when OpenCode's limits pass but the selected model's limits do not.
- PDFs and other unsupported binary prompt attachments should be converted to
text or supported images before attaching them.
With `auto_resize: true`, OpenCode preserves the aspect ratio and scales down
to the dimension limits. It then tries progressively smaller PNG and JPEG
encodings until the Base64 limit is met, so the output media type can change.
```text
Input: 4000 × 2000 WebP
Limits: 2000 × 2000
Output: 2000 × 1000 PNG or JPEG
```
If no encoding fits, processing fails. With `auto_resize: false`, an image that
exceeds any limit fails without modification; an image that cannot be decoded
also fails.
```text
Input: 2400 × 1600 JPEG
Limit: max_width = 2000, auto_resize = false
Result: Image processing fails
```
If the image resizer is unavailable, OpenCode passes the original image through
unchanged. Image settings are therefore processing limits, not an upload or
security boundary.
## Limits
| Limit | Value or behavior | Example |
| ----------------------------- | ------------------------------------------------------------- | -------------------------------------------- |
| Direct attachment | 20 MiB decoded per item; clients may impose lower limits | Two 12 MiB files pass the per-item limit |
| Desktop picker selection | 20 MiB total | Two 12 MiB files exceed the selection limit |
| `max_base64_bytes` | Encoded Base64 only, excluding the complete `data:` URL | `SGVsbG8=` counts as 8 bytes |
| Provider image limits | Apply after OpenCode processing | A provider may reject an accepted image |
| Text attachment model support | Does not require a multimodal model | `notes.txt` is inserted as prompt text |
| `read` text limits | Uses separate paging and truncation limits | Read a large log in pages |
A client accepting a file does not guarantee that its contents reach the
model. The attachment must use a model-visible format and satisfy both OpenCode
and provider limits.
@@ -7,10 +7,6 @@ API. Use it when your application connects to an OpenCode server over the
network. Its native types and methods are generated from the same contract as the
[API reference](/api). Plugin RPC types come from imported RPC definitions.
<Callout type="warning">
The V2 API and client are beta. Method names, inputs, and outputs may change before the stable release.
</Callout>
## Install
```sh
@@ -510,7 +510,7 @@ Expose the CLI plugin through `./tui`; add OpenTUI peers when the plugin renders
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode/plugin": "beta"
"@opencode/plugin": "latest"
},
"peerDependencies": {
"@opentui/core": ">=0.5.8",
@@ -1326,7 +1326,7 @@ entrypoint and declare both runtime dependencies.
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "beta",
"@opencode/plugin": "latest",
"effect": "4.0.0-rc.111"
}
}
@@ -656,6 +656,16 @@ const request = await ctx.permission.get({ sessionID, requestID })
await ctx.permission.reply({ sessionID, requestID, reply: "once" })
```
Replace the session-scoped permission rules. They are evaluated after the agent's rules, and the
last matching rule wins. Child sessions inherit the rules in effect when they are created.
```ts
await ctx.permission.rules({
sessionID,
permissions: [{ action: "edit", resource: "/path/to/original/checkout/**", effect: "deny" }],
})
```
### Sessions
Create or read a session.
@@ -1511,7 +1521,7 @@ manifest is:
"./rpc": "./src/rpc.ts"
},
"dependencies": {
"@opencode/plugin": "beta"
"@opencode/plugin": "latest"
}
}
```
@@ -1521,9 +1531,8 @@ The `./rpc` export is optional; include it when publishing a shared
without loading your implementation.
Use versions compatible with the OpenCode release you target and test the
installed package, not only a workspace-linked copy. Because the plugin API is
beta, publish compatible plugin updates when V2 entrypoints or contracts
change.
installed package, not only a workspace-linked copy. Publish a compatible
plugin update when you adopt a newer API contract.
## Support V1
@@ -6,7 +6,7 @@ Use `@opencode/sdk/workerd` inside a Cloudflare Durable Object. This profile use
persists durable events for eviction recovery, and replaces unavailable local filesystem and process services.
```sh
bun add @opencode/sdk@dev
bun add @opencode/sdk@beta
```
Hold one host for the lifetime of the Durable Object instance instead of creating one for every request.
@@ -6,7 +6,7 @@ title: "Effect"
the owning Scope releases the router, Location services, fibers, and plugin registrations.
```sh
bun add @opencode/sdk@dev effect
bun add @opencode/sdk@beta effect
```
## Create a host
@@ -9,10 +9,11 @@ network hop between the client and server.
For Cloudflare Durable Objects, see the [Cloudflare guide](/build/sdk/cloudflare).
<Callout type="warning">
The V2 SDK is beta. Install the current preview with `bun add @opencode/sdk@dev`; its API may change before a
stable release.
</Callout>
Install the SDK:
```sh
bun add @opencode/sdk@beta
```
## Create a host
@@ -0,0 +1,548 @@
---
title: "Commands"
description: "Reference for the opencode2 command line."
---
Every command accepts `--help` for its full flag list, for example `opencode2 run --help`. Commands that talk to a server also accept `--standalone` to run a private server and `--server <url>` to target a specific one.
## run
`opencode2 run` sends a message and prints the reply without opening the interactive interface.
```bash
$ opencode2 run "Explain this repository"
```
Choose a model.
```bash
$ opencode2 run --model anthropic/claude-sonnet-4-5 "Refactor parseToken"
```
Continue the last session.
```bash
$ opencode2 run --continue "Now handle the expired case"
```
Emit newline-delimited JSON for scripts.
```bash
$ opencode2 run --format json "List the TODO comments"
```
Attach files to the message.
```bash
$ opencode2 run --file src/server.ts --file src/client.ts "Review these for bugs"
```
Run with a specific agent.
```bash
$ opencode2 run --agent build "Fix the failing test"
```
View all subcommands and flags.
```bash
$ opencode2 run --help
```
## mini
`opencode2 mini` starts the minimal interactive interface instead of the full-screen TUI.
```bash
$ opencode2 mini
```
Continue the last session.
```bash
$ opencode2 mini --continue
```
Start with a model and an initial prompt.
```bash
$ opencode2 mini --model anthropic/claude-sonnet-4-5 --prompt "Summarize this repository"
```
View all subcommands and flags.
```bash
$ opencode2 mini --help
```
## session
`opencode2 session` manages sessions.
```bash
$ opencode2 session list
```
Limit the list and print JSON.
```bash
$ opencode2 session list --max-count 20 --format json
```
Delete a session and its child sessions.
```bash
$ opencode2 session delete ses_9c1b08
```
Export session data as JSON.
```bash
$ opencode2 session export ses_4f2a1c
```
Redact sensitive transcript and file data when exporting.
```bash
$ opencode2 session export ses_4f2a1c --sanitize
```
Import session data from a JSON file or URL.
```bash
$ opencode2 session import session.json
```
Import into a specific directory.
```bash
$ opencode2 session import session.json --directory ~/code/project
```
View all subcommands and flags.
```bash
$ opencode2 session --help
```
## auth
`opencode2 auth` manages AI providers and credentials.
```bash
$ opencode2 auth list
```
List them as JSON.
```bash
$ opencode2 auth list --format json
```
Log in to a provider.
```bash
$ opencode2 auth login anthropic
```
Log in with a specific authentication method.
```bash
$ opencode2 auth login anthropic --method api-key
```
Log out of a saved account.
```bash
$ opencode2 auth logout anthropic work
```
Switch the active account for an integration.
```bash
$ opencode2 auth switch anthropic work
```
View all subcommands and flags.
```bash
$ opencode2 auth --help
```
## models
`opencode2 models` lists every available model.
```bash
$ opencode2 models
```
View all subcommands and flags.
```bash
$ opencode2 models --help
```
## mcp
`opencode2 mcp` manages MCP (Model Context Protocol) servers.
```bash
$ opencode2 mcp list
```
Add a remote server.
```bash
$ opencode2 mcp add context7 --url https://mcp.context7.com/mcp
```
Add a local server to the global config.
```bash
$ opencode2 mcp add everything --global -- npx -y @modelcontextprotocol/server-everything
```
Add a local server with an environment variable.
```bash
$ opencode2 mcp add everything --env LOG_LEVEL=debug -- npx -y @modelcontextprotocol/server-everything
```
Add a remote server with a header.
```bash
$ opencode2 mcp add context7 --url https://mcp.context7.com/mcp --header CONTEXT7_API_KEY=secret
```
Authenticate with an OAuth-capable remote server.
```bash
$ opencode2 mcp auth sentry
```
Remove stored OAuth credentials for a server.
```bash
$ opencode2 mcp logout sentry
```
View all subcommands and flags.
```bash
$ opencode2 mcp --help
```
## plugin
`opencode2 plugin` manages plugins.
```bash
$ opencode2 plugin list
```
Include built-in server plugins.
```bash
$ opencode2 plugin list --builtin
```
Install a plugin and add it to the global configuration.
```bash
$ opencode2 plugin add @example/opencode-tui
```
Check package plugins for updates.
```bash
$ opencode2 plugin check
```
Update package plugins.
```bash
$ opencode2 plugin update
```
Remove a plugin from global configuration.
```bash
$ opencode2 plugin remove @example/opencode-tui
```
View all subcommands and flags.
```bash
$ opencode2 plugin --help
```
## stats
`opencode2 stats` shows shareable usage statistics.
```bash
$ opencode2 stats
```
Show the last 7 days.
```bash
$ opencode2 stats --days 7
```
Show model usage.
```bash
$ opencode2 stats --models
```
Show cost and token details.
```bash
$ opencode2 stats --cost
```
Print JSON instead of a report.
```bash
$ opencode2 stats --json
```
View all subcommands and flags.
```bash
$ opencode2 stats --help
```
## serve
`opencode2 serve` starts the API and web server. See [Web](/cli/web).
```bash
$ opencode2 serve
```
Bind to all interfaces on a fixed port.
```bash
$ opencode2 serve --hostname 0.0.0.0 --port 4096
```
Allow a browser client from another origin.
```bash
$ opencode2 serve --cors https://app.example.com
```
View all subcommands and flags.
```bash
$ opencode2 serve --help
```
## pair
`opencode2 pair` shows server pairing information, including URLs, credentials, and a QR code.
```bash
$ opencode2 pair
```
Advertise an external URL in the QR code.
```bash
$ opencode2 pair --url https://dev.example.com
```
View all subcommands and flags.
```bash
$ opencode2 pair --help
```
## service
`opencode2 service` manages the background server. See [Web](/cli/web).
```bash
$ opencode2 service start
```
Restart it.
```bash
$ opencode2 service restart
```
Show its status.
```bash
$ opencode2 service status
```
Stop it.
```bash
$ opencode2 service stop
```
Read a setting.
```bash
$ opencode2 service get hostname
```
Set a setting.
```bash
$ opencode2 service set hostname 0.0.0.0
```
Allow an extra CORS origin.
```bash
$ opencode2 service set cors https://app.example.com
```
Pass an environment variable to the server process.
```bash
$ opencode2 service set env OPENCODE_LOG_LEVEL DEBUG
```
Reset a setting to its default.
```bash
$ opencode2 service unset hostname
```
View all subcommands and flags.
```bash
$ opencode2 service --help
```
## api
`opencode2 api` makes a request to the running server.
```bash
$ opencode2 api GET /api/session
```
Call an operation ID with a query parameter.
```bash
$ opencode2 api v2.session.list --param limit=10
```
Send a JSON body.
```bash
$ opencode2 api v2.session.create --data '{"title": "New session"}'
```
Add a request header.
```bash
$ opencode2 api GET /api/session -H "accept: application/json"
```
View all subcommands and flags.
```bash
$ opencode2 api --help
```
## acp
`opencode2 acp` starts an Agent Client Protocol server over stdin and stdout for editor integrations. It runs until the client closes the connection.
```bash
$ opencode2 acp
```
View all subcommands and flags.
```bash
$ opencode2 acp --help
```
## debug
`opencode2 debug` provides debugging and troubleshooting tools.
```bash
$ opencode2 debug agents
```
List configuration sources.
```bash
$ opencode2 debug config
```
Show global paths.
```bash
$ opencode2 debug paths
```
Print a single path.
```bash
$ opencode2 debug paths db
```
View all subcommands and flags.
```bash
$ opencode2 debug --help
```
## upgrade
`opencode2 upgrade` upgrades OpenCode to the latest or a specific version. Alias: `update`.
```bash
$ opencode2 upgrade
```
Upgrade to a specific version with a specific package manager.
```bash
$ opencode2 upgrade 1.18.15 --method bun
```
View all subcommands and flags.
```bash
$ opencode2 upgrade --help
```
## uninstall
`opencode2 uninstall` removes OpenCode and all related files.
```bash
$ opencode2 uninstall
```
Preview what would be removed.
```bash
$ opencode2 uninstall --dry-run
```
Keep configuration and session data.
```bash
$ opencode2 uninstall --keep-config --keep-data
```
View all subcommands and flags.
```bash
$ opencode2 uninstall --help
```
@@ -1,3 +1,140 @@
---
title: "Providers"
description: "Connect provider accounts and manage credentials from the CLI and TUI."
---
Connect a provider in the TUI, then choose one of its models:
```text
/connect
/models
```
`/connect` lists the integrations available from the current server and project. Select a provider, choose an
authentication method when it offers more than one, and follow the prompts.
## TUI
The TUI supports API keys, OAuth, and provider authentication commands. OAuth opens an authorization URL or shows a
code to enter; press `o` to open the URL and `c` to copy the authorization details.
```text
/connect
# Select OpenAI, then ChatGPT Pro/Plus (headless).
```
Run `/connect` again to add another account. Selecting an already connected provider opens its account list, where you
can activate, rename, or delete a saved account.
## CLI
Use `auth login` for the same provider methods without opening the TUI. With no provider argument, the command opens an
interactive provider picker.
```bash
opencode2 auth login
```
Pass an integration ID or name to skip the first picker. Use `--method key` to select API-key entry explicitly.
```bash
opencode2 auth login anthropic --method key
```
Method IDs are provider-specific. Run the command without `--method` to see the available methods when a provider has
more than one.
```bash
opencode2 auth login openai
```
API-key entry and provider forms require an interactive terminal. OAuth methods that ask you to paste an authorization
code also require one.
## Methods
OpenCode receives provider credentials through four integration methods:
| Method | Behavior |
| --- | --- |
| API key | Prompts for a secret and saves it as a provider account. |
| OAuth | Provides a browser URL, device code, or authorization-code prompt, then saves tokens and refreshes them when supported. |
| Command | Runs a provider-supplied authentication command and saves its standard output as a key. |
| Environment | Reads a supported variable from the server process without saving it. |
Providers can add forms to API-key and OAuth methods for required details such as an Azure resource name or a GitHub
Enterprise domain. The CLI and TUI render those forms before starting authentication.
## Environment
Set a provider's supported environment variable on the server process that runs model requests. For a one-off private
server, pass it when starting standalone mode.
```bash
ANTHROPIC_API_KEY=sk-ant-... opencode2 --standalone
```
For the shared background server, add the variable to its managed environment. This stops a running service; the next
OpenCode command starts it with the new value.
```bash
opencode2 service set env ANTHROPIC_API_KEY sk-ant-...
opencode2 auth list
```
Environment connections appear in `auth list` with type `environment`. They are not accounts: `auth logout` cannot
remove them, so unset the variable to disconnect. A saved account takes precedence over an environment connection for
the same integration.
```bash
opencode2 auth list
opencode2 service unset env ANTHROPIC_API_KEY
```
Some cloud providers also use their native ambient credential chain instead of an API-key variable:
- Amazon Bedrock supports the AWS default credential chain, including profiles, access-key environments, web identity,
and container credentials. It also supports `AWS_BEARER_TOKEN_BEDROCK`.
- Google Vertex uses Application Default Credentials and a resolvable project. For example, authenticate with
`gcloud auth application-default login` and set `GOOGLE_CLOUD_PROJECT`.
- Azure exposes **Microsoft Entra ID (Azure CLI)** as a connect method when `az` is installed. Run `az login` first, then
select that method in `/connect` or `auth login azure`.
```bash
gcloud auth application-default login
GOOGLE_CLOUD_PROJECT=my-project opencode2
```
See [Providers](/providers) for provider-specific and server-side setup.
## Accounts
Each successful API-key, OAuth, or command login creates a saved account. The newest account becomes active; switch the
active account by its label or credential ID.
```bash
opencode2 auth list
opencode2 auth switch anthropic work
```
Remove a saved account with `auth logout`. Both commands open pickers when their arguments are omitted.
```bash
opencode2 auth logout anthropic work
```
In the TUI, `/connect` provides the same add, activate, rename, and delete operations for saved accounts.
## Storage
Saved API keys and OAuth tokens live in the server's SQLite database. For the local server, print its database path with:
```bash
opencode2 debug paths db
```
The usual release path is `~/.local/share/opencode/opencode.db`; `XDG_DATA_HOME`, the release channel, and `OPENCODE_DB`
can change it. Do not edit the database to manage credentials; use `/connect` or the `auth` commands.
V2 imports supported credentials from the legacy `auth.json` in the OpenCode data directory during its database
migration. New and updated credentials are stored in SQLite rather than written back to that file.
+74
View File
@@ -0,0 +1,74 @@
---
title: "Web"
description: "Run OpenCode in the browser."
---
OpenCode ships with a web ui that is served from the same server that powers the
TUI. It's available by default and password protected.
## Access
```bash
$ opencode2 pair
URLs http://127.0.0.1:49374
Username opencode
Password ********
```
By default the server runs on port 49374 and listens only on localhost. You can
change this config with the `opencode2 service` command.
## Configure
Set any option with `opencode2 service set`:
```bash
# Listen on every network interface
$ opencode2 service set hostname 0.0.0.0
# Use a fixed port instead of the channel default
$ opencode2 service set port 49374
# Replace the generated password
$ opencode2 service set password "a-long-secret"
# Allow a web client served from another origin
$ opencode2 service set cors https://app.example.com,https://other.example.com
# Pass an environment variable to the server process
$ opencode2 service set env OPENCODE_LOG_LEVEL DEBUG
```
Changing a setting stops the background server. To apply the new config
```bash
$ opencode2 service start
```
## Standalone
`opencode2 serve` runs the same server in the foreground instead of through the
shared background service.
```bash
$ opencode2 serve --hostname 0.0.0.0 --port 4096
server listening on http://0.0.0.0:4096
server password <password>
```
Use it when you want to:
- Run OpenCode on a shared, always-on, or remote host, then connect clients with
`opencode2 --server <url>`.
- Control the hostname, port, and CORS origins for a single process.
- Run under a supervisor like systemd, Docker, or another environment that expects
a foreground process.
- Keep a dedicated server instead of the shared background service.
Connect a client to it with `--server`:
```bash
$ opencode2 --server http://127.0.0.1:4096
```
+189 -90
View File
@@ -2,52 +2,62 @@
title: "Commands"
---
Custom commands turn a named prompt template into a reusable command.
Create `.opencode/commands/review.md` to turn a prompt into `/review`:
## Configure with Markdown
OpenCode discovers `.md` command files in `commands/` directories:
```text
~/.config/opencode/commands/ # Global
.opencode/commands/ # Project
```md title=".opencode/commands/review.md"
Review $ARGUMENTS for bugs and missing tests.
```
Files may be nested; for example, `.opencode/commands/team/review.md` defines
the command `team/review`. Files with other extensions, including `.mdx`, are not
discovered.
Run it from the TUI with a target:
```text
/review src/auth.ts
```
OpenCode submits `Review src/auth.ts for bugs and missing tests.` as a user prompt.
## Markdown
Put global commands in `~/.config/opencode/commands/` and project commands in `.opencode/commands/`.
```text
~/.config/opencode/commands/review.md
.opencode/commands/review.md
```
Only `.md` files are discovered. Nested paths become command names with `/` separators:
```md title=".opencode/commands/team/review.md"
Review $ARGUMENTS using the team's checklist.
```
Run the nested command as `/team/review src/auth.ts`. The legacy singular directories `command/` are also discovered,
but use `commands/` for new files.
Add YAML frontmatter when the command needs metadata. The trimmed Markdown body is always the prompt template.
```md title=".opencode/commands/review.md"
---
description: Review code for correctness and missing tests
description: Review code for correctness
agent: plan
model: anthropic/claude-sonnet-4-5#high
---
Review $ARGUMENTS. Report bugs first, then missing tests.
Review $ARGUMENTS. Report bugs first.
```
The file body, with surrounding whitespace removed, is the command template.
JSON and Markdown commands share one registry. Project definitions take
precedence over global definitions, and a later definition can override a
built-in or earlier command with the same name. Changes are reloaded
automatically.
## JSON
## Configure with JSON
Add commands under the `commands` key in any OpenCode JSON or JSONC
[configuration file](/config). Each entry's key is the command name and
`template` is required.
Define commands under `commands` in any OpenCode JSON or JSONC [configuration file](/config). Each command requires a
`template`.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"commands": {
"review": {
"description": "Review code for correctness and missing tests",
"template": "Review $ARGUMENTS. Report bugs first, then missing tests.",
"agent": "plan",
"model": "anthropic/claude-sonnet-4-5#high",
"description": "Review code for correctness",
"template": "Review $ARGUMENTS. Report bugs first.",
},
},
}
@@ -55,107 +65,196 @@ Add commands under the `commands` key in any OpenCode JSON or JSONC
## Fields
| Field | Required | Behavior |
| ------------- | --------- | --------------------------------------------------------------------------------- |
| `template` | JSON only | Prompt template. In a Markdown command, the file body supplies it. |
| `description` | No | Text shown with the command in command listings and discovery. |
| `agent` | No | Agent that runs the command. |
| `model` | No | Model override in `provider/model` or `provider/model#variant` format. |
| `subagent` | No | Run in a background child session, or use `false` to stay in the current session. |
| `subtask` | No | Deprecated alias for `subagent`. |
Markdown frontmatter and JSON entries accept the same fields, except that Markdown gets `template` from the file body.
The optional fields can be used in JSON or YAML frontmatter. Do not put
`template` in frontmatter because the Markdown body always supplies it.
| Field | Required | Behavior |
| --- | --- | --- |
| `template` | JSON only | Prompt template. |
| `description` | No | Text shown in command lists and discovery. |
| `agent` | No | Agent selected when the command runs. |
| `model` | No | Model override in `provider/model` or `provider/model#variant` format. |
| `subagent` | No | `true` runs in a background child session; `false` forces the current session. |
| `subtask` | No | Deprecated alias for `subagent`. |
For example, this JSON command sets every current optional field:
```jsonc title="opencode.jsonc"
{
"commands": {
"audit": {
"template": "Audit $ARGUMENTS.",
"description": "Audit a package",
"agent": "general",
"model": "anthropic/claude-sonnet-4-5#high",
"subagent": true,
},
},
}
```
Do not put `template` in Markdown frontmatter; the body supplies it.
## Arguments
Use `$ARGUMENTS` for the complete argument string:
Use `$ARGUMENTS` for the complete argument string exactly as entered.
```md title=".opencode/commands/component.md"
---
description: Create a component
---
Create a typed React component named $ARGUMENTS.
```
Use `$1`, `$2`, and higher numbers for parsed positional arguments. Single and
double quotes group text containing spaces and are removed during parsing.
For `/component Account Settings`, the placeholder becomes `Account Settings`.
## Positions
Use `$1`, `$2`, and higher numbers for parsed positional arguments. Single or double quotes group words and are removed.
```md title=".opencode/commands/check.md"
---
description: Check one area with a specific focus
---
Check $1. Focus on $2.
```
The highest-numbered positional placeholder present in the template consumes
that argument and all remaining arguments. For example, if a template contains
only `$1`, then `$1` receives the full parsed argument list. Missing positions
become empty strings.
For `/check src/auth.ts "error handling"`, `$1` becomes `src/auth.ts` and `$2` becomes `error handling`.
If a template contains neither positional placeholders nor `$ARGUMENTS`,
OpenCode appends non-empty arguments to the template after a blank line.
The highest-numbered placeholder consumes its argument and everything after it. Missing positions become empty strings.
## Shell interpolation
```md title=".opencode/commands/compare.md"
Compare $1 with $2.
```
Wrap a shell command in `!` followed by backticks to insert its output before
the prompt is submitted:
For `/compare api stable branch`, OpenCode submits `Compare api with stable branch.`
## Fallback
When a template has no `$ARGUMENTS` or positional placeholder, OpenCode appends non-empty arguments after a blank line.
```md title=".opencode/commands/explain.md"
Explain this code clearly.
```
For `/explain src/cache.ts`, the prompt becomes:
```text
Explain this code clearly.
src/cache.ts
```
## Shell
Wrap a shell command in `!` followed by backticks to insert its output before the prompt is submitted.
```md title=".opencode/commands/review-diff.md"
---
description: Review the current diff
---
Review this diff:
!`git diff --stat && git diff`
```
OpenCode runs each interpolation with the configured shell in the active
project location and inserts its combined output into the template. Argument
interpolation happens first, so avoid placing untrusted arguments inside shell
interpolations.
OpenCode runs each block with the configured shell in the active project location and inserts its combined output.
Argument placeholders are expanded first:
```md title=".opencode/commands/history.md"
Summarize these commits:
!`git log --oneline -$1`
```
For `/history 5`, the shell receives `git log --oneline -5`. Do not place untrusted arguments inside shell blocks.
<Callout type="warning">
Shell interpolations run when the command is evaluated, outside the agent's tool permission flow. Only use commands
from sources you trust.
Shell blocks run when OpenCode evaluates the command, outside the agent's tool permission flow. Only use commands from
sources you trust.
</Callout>
No other template interpolation is performed. In particular, an `@path`
written into a stored template remains ordinary prompt text; V2 does not
automatically attach that file.
## Attachments
## Agent, model, and execution
Stored templates do not expand `@path`; it remains ordinary prompt text.
Commands evaluate their arguments and shell blocks before submitting a durable
user prompt. Commands run in the current session unless background delegation
is enabled as described below.
```md title=".opencode/commands/readme.md"
Review @README.md.
```
For current-session commands, `agent` overrides the active agent when the command is invoked
and becomes the session's active agent. If `model` is set, it overrides the
model. Otherwise, a model configured on the command's agent takes precedence
over the model active at invocation.
To attach a file, add it through the composer when invoking the command. OpenCode preserves those composer attachments
when it submits the expanded prompt.
### Background subagents
## Execution
Set `subagent: true` to run a command in a background child session. The parent
keeps its agent and model, stays available for other work, and receives the
child's result or failure when it finishes.
Commands expand arguments and shell blocks before submitting a durable user prompt. They run in the current session by
default.
```md title=".opencode/commands/review.md"
```md title=".opencode/commands/plan.md"
---
description: Review changes in the background
agent: plan
---
Plan $ARGUMENTS.
```
Running `/plan migration` switches the current session to `plan` before submitting the prompt.
Model selection follows these rules:
1. A command `model` overrides every other model.
2. Otherwise, the selected command agent's configured model overrides the model active at invocation.
3. Otherwise, the current session model remains active.
For example, this command selects both its agent and an explicit model:
```md title=".opencode/commands/design.md"
---
agent: plan
model: openai/gpt-5#high
---
Design $ARGUMENTS.
```
## Background
Set `subagent: true` to run a command in a background child session.
```md title=".opencode/commands/audit.md"
---
description: Audit changes
agent: general
subagent: true
---
Review $ARGUMENTS for bugs and missing tests.
Audit $ARGUMENTS for bugs and missing tests.
```
- `true` forces child execution, including for an agent with `mode: primary`.
- `false` forces execution in the current session.
- When omitted, a command targeting an agent with `mode: subagent` runs in the background.
- The child uses the command's model override, then the selected agent's model, then the parent's model.
- Legacy `subtask` is still accepted in JSON and Markdown. If both fields are present, `subagent` takes precedence.
The parent stays available and keeps its agent and model. OpenCode sends the child's result or failure back to the parent
when the child finishes.
| Value | Behavior |
| --- | --- |
| `true` | Always use a child, even when the selected agent has `mode: primary`. |
| `false` | Always use the current session, even when the selected agent has `mode: subagent`. |
| Omitted | Use a child only when the selected agent has `mode: subagent`. |
The child uses the command model, then the selected agent's model, then the parent's model. Legacy `subtask` remains
accepted; if both fields are present, `subagent` wins.
```yaml
subagent: false
subtask: true
```
This example runs in the current session because `subagent` takes precedence.
## Loading
Markdown and JSON commands share one registry. Later sources replace earlier commands with the same name.
```text
~/.config/opencode/commands/review.md # Lower priority
.opencode/commands/review.md # Replaces the global command
```
Project sources take precedence over global sources, and nearer project sources take precedence over ancestor sources.
A custom definition can also replace an earlier built-in command. OpenCode reloads command files and configuration changes
automatically.
```md title=".opencode/commands/review.md"
Review only the staged changes: !`git diff --cached`
```
Saving this file updates `/review` without restarting OpenCode.
+237 -130
View File
@@ -2,54 +2,118 @@
title: "Compaction"
---
Compaction replaces the active model context from an older part of a session
with a generated checkpoint. The checkpoint contains a structured summary and
a serialized tail of recent context, so the agent can continue with more room
in the model's context window.
Compaction gives a long session more context space by replacing its older active
context with a generated checkpoint. Earlier session messages remain stored,
but future model requests start from the latest completed checkpoint.
Compaction is lossy, but it does not delete the earlier durable session
messages. After a successful compaction, V2 builds model requests from the
latest completed checkpoint and the messages that follow it.
## Start
## Automatic compaction
Automatic compaction is enabled by default. This minimal configuration keeps
about 15,000 tokens of recent conversation beside the generated summary:
Automatic compaction is enabled by default. Before a model call, V2 estimates
the size of the final system prompt, messages, and advertised tools. It starts
compaction when:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"compaction": {
"auto": true,
"keep": { "tokens": 15000 },
"buffer": 20000,
},
}
```
Continue using the session normally. When its active context approaches the
model limit, OpenCode creates a checkpoint and retries the pending work with
more room. Compaction is lossy, so increase `keep.tokens` when exact recent
details matter.
## Manual
Request compaction when you want a checkpoint before the automatic threshold.
The server endpoint works even for a short history:
```sh
curl -X POST http://localhost:4096/api/session/ses_example/compact \
-H 'content-type: application/json' \
-d '{}'
```
OpenCode stores the request before waking the session. The response confirms
admission; it does not wait for the summary. Wait for the session or follow
`session.compaction.*` events to learn when it finishes. See the generated
[API reference](/api) for the full operation.
```json
{
"data": {
"id": "msg_example",
"type": "compaction",
"sessionID": "ses_example",
"timeCreated": 1789056000000,
"payload": {},
"delivery": "steer"
}
}
```
Manual requests follow these rules:
| Rule | Behavior |
| --- | --- |
| Timing | Runs at the next safe model-step boundary. By default it runs before pending steered or queued prompts, even when a prompt was submitted first. |
| Repeats | Requests made while one is pending merge into the pending request. |
| Failure | Success or failure clears the wait point so pending prompts can continue. |
| Automation | Works when `compaction.auto` is `false`. |
Supply an optional message `id` to make an exact retry idempotent:
```sh
curl -X POST http://localhost:4096/api/session/ses_example/compact \
-H 'content-type: application/json' \
-d '{"id":"msg_compact_once"}'
```
Reusing that ID for another record returns a conflict.
## Automatic
Before each model call, OpenCode estimates the final size of the system prompt,
messages, and advertised tools. It starts compaction at this ceiling:
```text
estimated tokens >= min(input limit - buffer, context limit - max(output reserve, buffer))
```
The estimate uses the latest model response's input usage plus output and newer
content. Without usage, it estimates text, media, instructions, and tools locally.
The output reserve is capped at 32,000 tokens; an absent input limit does not
constrain the ceiling. Successful compaction rebuilds the request without promoting
input again or spending another agent step.
For example, with a 128,000-token input limit and the default 20,000-token
buffer, the input-limit side of the ceiling is 108,000 tokens.
V2 also recognizes provider errors classified as context overflow. If an
overflow occurs before the provider produces assistant output or other retry
evidence, V2 can compact and retry that step once. This recovery is attempted
only when `auto` is enabled. A second overflow after recovery is returned as an error.
```text
128,000 - 20,000 = 108,000
```
## Manual compaction
The estimate follows these rules:
Manual compaction is available through session interfaces. See the generated [API reference](/api) for the server
operation.
- The latest model response's input usage is the starting point; output and
newer content are then added.
- Without provider usage, OpenCode estimates text, media, instructions, and
tools locally.
- The reserved model output is capped at 32,000 tokens.
- A model without an input limit is constrained by its context limit instead.
- A successful checkpoint rebuilds the same pending model step. It does not
promote the input again or spend another agent step.
A manual request is durably admitted and wakes the session runner. It can
compact short histories that would not trigger automatic compaction. By default,
compaction runs at the next safe step boundary before pending steered or queued
prompts, even if they were submitted first. Repeated requests while one is pending
coalesce into that pending request. Whether compaction completes or fails, the
barrier is then settled so pending prompts can proceed.
OpenCode also recognizes provider errors classified as context overflow. If no
assistant output or other retry evidence was produced, it can compact and retry
that model step once:
The server operation returns the admitted compaction input; it does not wait
for summary generation. Clients can then wait for the session or follow the
`session.compaction.*` events. Supplying an optional message `id` makes an exact
retry idempotent, but reusing an ID owned by another record returns a conflict.
```text
model call → context overflow → compact → retry same step once
```
## Configuration
This recovery requires `compaction.auto: true`. A second overflow is returned
as an error.
## Settings
Add `compaction` to any [OpenCode configuration file](/config):
@@ -57,30 +121,27 @@ Add `compaction` to any [OpenCode configuration file](/config):
{
"$schema": "https://opencode.ai/config.json",
"compaction": {
"auto": true,
"keep": {
"tokens": 15000,
},
"buffer": 20000,
"auto": false,
"keep": { "tokens": 24000 },
"buffer": 16000,
},
}
```
| Field | Default | V2 behavior |
| ------------- | ------: | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto` | `true` | Enables preflight context-size checks and one-shot provider-overflow recovery. Disabling it does not affect manual compaction. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
| Field | Default | Behavior |
| --- | ---: | --- |
| `auto` | `true` | Enables preflight checks and one provider-overflow recovery attempt. It does not control manual compaction. |
| `keep.tokens` | `15000` | Approximate recent serialized context retained beside a local summary, or real user input retained for a provider checkpoint. |
| `buffer` | `20000` | Safety margin below an explicit input limit. Without one, it is the minimum context reserve; a larger model output allowance wins. |
`keep.tokens` and `buffer` accept non-negative integers. Larger `keep.tokens`
preserves more recent detail but leaves less room for future work. Larger
`buffer` triggers preflight compaction earlier.
`keep.tokens` and `buffer` accept non-negative integers. Larger
`keep.tokens` preserves more recent detail but leaves less room for new work;
larger `buffer` starts automatic compaction earlier.
## Provider compaction
## Providers
By default, compaction generates a local text summary. To use the selected
provider's native compaction operation for automatic and manual requests, set a
provider policy. An individual model's policy replaces the entire provider policy:
Local text summaries are the default. Use a provider policy to request the
selected provider's native compaction for both automatic and manual requests:
```jsonc title="opencode.jsonc"
{
@@ -97,94 +158,140 @@ provider policy. An individual model's policy replaces the entire provider polic
}
```
- `threshold` is an optional positive integer in provider mode. Omit it to use the
selected model's usable input ceiling above. A configured threshold is clamped
to that ceiling. In this example, `gpt-5.4-mini` uses its own ceiling, not 120,000.
- Scheduling uses the normal safe session step boundaries, not in-band provider
context management. `compaction.auto: false` disables all new automatic work.
- After installing a native checkpoint, automatic checks wait for a fresh model
usage anchor. Encrypted checkpoint bytes are not a meaningful token count.
- OpenAI Responses uses a streamed compaction trigger when the route supports it.
Endpoint-only routes use their standalone compaction endpoint. Deployment/model
support can vary. Transient provider failures retry under the same session retry
policy and plugin hook as other requests; nothing is installed until a checkpoint
is returned.
- A known automatic context overflow uses local recovery over the durable original
history, re-expanding native checkpoints. This applies both to ordinary model
calls and native compaction rejection. If local recovery fails, the prior
checkpoint remains intact and the error surfaces. Authentication, rate limits,
cancellation, and other failures do not trigger local fallback. Manual native
compaction also surfaces errors without fallback.
- Unsupported routes are rejected during model resolution. Configure custom
endpoints through provider/model `settings.baseURL`, not a `model.request` hook;
native compaction rejects endpoint rewrites by that hook.
- Trigger checkpoints retain whole, real user messages and attachments up to the
`compaction.tokens` budget, including users retained across earlier native
compactions. Synthetic guidance is not retained as user input. Endpoint results
are stored as the provider returned them. Neither path fabricates a text summary.
Keep `threshold` comfortably above `tokens` plus the system prompt and tools, or
every step will compact again as soon as the next response reports usage.
- Successful native checkpoints advance the instruction epoch and are replayed
only with a matching provider, model, protocol, and endpoint. Switching to an
incompatible route reuses an earlier compatible checkpoint or retained transcript.
Disabling automatic compaction does not remove an installed checkpoint.
A model policy replaces the entire provider policy. In this example,
`gpt-5.4-mini` therefore uses its own usable input ceiling rather than inheriting
the provider's `120000` threshold.
## Local checkpoint contents
| Topic | Provider behavior |
| --- | --- |
| Threshold | `threshold` is an optional positive integer in provider mode. When omitted, OpenCode uses the model's usable input ceiling; when larger than that ceiling, it is clamped. |
| Scheduling | Checkpoints run at normal safe step boundaries. `compaction.auto: false` disables all new automatic work. |
| Usage | After a native checkpoint is installed, automatic checks wait for fresh model usage because encrypted checkpoint bytes cannot provide a meaningful token count. |
| OpenAI | Responses routes use a streamed compaction trigger when supported; endpoint-only routes use the standalone compaction endpoint. Deployment and model support vary. |
| Retries | Transient provider failures use the normal session retry policy and plugin hook. Nothing is installed until the provider returns a checkpoint. |
| Routes | Unsupported routes fail during model resolution. Configure custom endpoints with provider or model `settings.baseURL`. Native compaction rejects endpoint rewrites made by a `model.request` hook. |
| Replay | Native checkpoints are reused only with the same provider, model, protocol, and endpoint. An incompatible route falls back to an earlier compatible checkpoint or retained transcript. |
V2 uses the session's selected agent, model, and variant to generate the summary.
The request reuses the normal instructions, tool definitions, and structured
history prefix, then appends a user message requesting a checkpoint. Context
hooks run as they do for normal session requests.
Choose a threshold comfortably above `keep.tokens` plus the system prompt and
tools. For example, avoid a 20,000-token threshold when the retained input alone
is 15,000 tokens and the prompt and tools require another 8,000:
Compaction does not dispatch local tool calls or override tool choice. The
summary must contain at least one heading from the requested template, such as
`## Objective`. If it does not, V2 makes one additional request asking the model
to fill in the template correctly. A second invalid response fails compaction.
Provider-hosted tools remain subject to the selected provider's behavior.
```text
15,000 retained + 8,000 prompt and tools > 20,000 threshold
```
The summary records the objective, requirements, decisions, completed and active
work, blockers, next moves, relevant files, and additional context.
Otherwise each fresh usage report can immediately trigger another checkpoint.
Disabling automatic compaction does not remove a checkpoint already installed.
The newest serialized context up to `keep.tokens` is retained separately. This
is not a byte-for-byte transcript: tool output is limited to 2000 characters,
and file or media attachments become textual descriptors rather than embedded
data. On later compactions, V2 updates the previous summary and carries forward
its retained recent context before selecting a new tail.
Streamed trigger checkpoints preserve whole, real user messages and attachments
up to the `keep.tokens` budget, including user messages retained across earlier
native checkpoints. Synthetic guidance is excluded. Standalone endpoint results
are stored as returned; OpenCode does not fabricate a text summary for either
native route.
The completed compaction is presented to the model as historical conversation
context, explicitly not as new instructions. Running and failed compactions are
not included in model context.
On a recognized automatic context overflow, OpenCode attempts local recovery
from the original stored history, expanding native checkpoints first:
## Compaction advances the instruction epoch
```text
native request rejected for overflow → rebuild stored history → local checkpoint
```
Conversation compaction and instruction synchronization are separate. Before
each physical model attempt, V2 compares live instruction sources with the
latest admitted values, before delivering pending input for that attempt.
Ordinary changes become durable value deltas. Later changes freeze their
model-facing text when admitted and project it as chronological System messages;
request assembly renders only the epoch baseline from stored values.
This applies to ordinary model calls and rejected native compaction. If recovery
fails, the previous checkpoint remains active and the error is returned.
Authentication, rate-limit, cancellation, and other errors do not use local
fallback. Manual native compaction also returns errors without fallback.
Completed compaction advances the instruction epoch at the exact ended-event
sequence and makes the currently admitted values initial. It does not reread
sources or publish an instruction event. Session movement retains instruction
state so destination changes become chronological updates. Committed revert
clears instruction state so the next model attempt requires one complete source
read. See [Instructions](/instructions) for source ordering and update behavior.
## Checkpoints
## Current limitations
For local compaction, OpenCode uses the session's selected agent, model, and
variant. It sends the normal instructions, tools, and older history followed by
a request for a structured checkpoint:
- Compaction requires a resolvable model. Automatic scheduling needs a positive
catalog context limit; manual and overflow recovery do not.
There is no separate compaction-model setting or fallback model.
- Summary generation can fail if the summary prompt itself cannot fit beside
its output allowance, the model returns no summary, or the provider fails.
- Automatic and overflow compaction need older conversation context that can be
replaced. A provider overflow can still surface when there is no compressible
head or fixed instructions and tool schemas dominate the request.
- Overflow recovery retries only once per step. Token estimation is heuristic,
so it cannot prevent every provider-specific overflow.
- Earlier durable messages remain stored even though they are no longer in the
active model context.
```md
## Objective
Finish the authentication migration.
V1 used additional tail-turn and pruning behavior. Those V1 details are only
migration context; the settings and behavior on this page describe V2.
## Next Move
- Update the callback handler.
- Verify the login flow.
```
Context hooks run as they do for a normal session request. Local tool calls are
not dispatched, and compaction does not override tool choice. Provider-hosted
tools remain subject to provider behavior.
The summary must contain at least one requested heading such as `## Objective`.
An invalid response gets one corrective request; a second invalid response fails
compaction. The template covers:
- objective and requirements
- decisions, completed work, and active work
- blockers and next moves
- relevant files and additional context
The newest serialized context up to `keep.tokens` is retained separately from
the summary. For example, a large tool result is represented by a shortened
record rather than copied exactly:
```text
recent user message
recent assistant message
tool output (limited to 2,000 characters)
attachment descriptor (embedded data omitted)
```
On later local compactions, OpenCode updates the previous summary, carries its
retained recent context forward, and then selects a new tail. Completed
checkpoints appear to the model as historical conversation, not new
instructions. Running and failed checkpoints are excluded from model context.
## Instructions
Instruction updates and conversation compaction are tracked separately. Before
each actual provider attempt, OpenCode checks live instruction sources before it
delivers pending input. Later changes appear to the model as chronological
system messages, while request construction renders the stored baseline.
```text
initial instructions → instruction update → completed checkpoint → new baseline
```
When compaction completes, the instruction values already accepted at that exact
point become the new baseline. Compaction does not reread sources or emit a new
instruction update. Moving a session keeps this state, so instructions changed
at the destination appear chronologically. A committed revert clears it, and
the next model attempt performs one complete source read. See
[Instructions](/instructions) for source ordering and update behavior.
## Limits
| Limit | Result |
| --- | --- |
| Model | Compaction requires a resolvable model. There is no separate compaction model or fallback model. |
| Catalog | Automatic scheduling requires a positive catalog context limit; manual compaction and overflow recovery do not. |
| Summary | Generation can fail when its prompt and output allowance do not fit, the model returns no summary, or the provider fails. |
| History | Automatic and overflow compaction require older context that can be replaced. An overflow can remain when there is no compressible history or fixed instructions and tool schemas dominate the request. |
| Recovery | Overflow recovery retries only once per model step. Heuristic token estimates cannot prevent every provider-specific overflow. |
| Storage | Earlier messages remain stored even when excluded from active model context. |
For example, compaction cannot create room when almost the entire request is a
fixed system prompt and tool schemas:
```text
128k context = 120k fixed instructions and tools + 8k conversation
```
## Migration
V1 also used tail-turn and pruning behavior. V2 instead uses checkpoint-based
compaction and `compaction.keep.tokens`:
```jsonc
{
"compaction": {
"keep": { "tokens": 15000 }
}
}
```
The settings and behavior on this page apply to V2.
+45 -78
View File
@@ -2,56 +2,51 @@
title: "Config"
---
You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you.
## Format
OpenCode supports both **JSON** and **JSONC** (JSON with Comments) configuration files.
Create `opencode.jsonc` in your project to configure OpenCode. Add the schema for editor validation, then set only the options you need.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "openai/gpt-5.2-custom",
"providers": {
"openai": {
"models": {
"gpt-5.2-custom": {
"modelID": "gpt-5.2",
"name": "GPT-5.2 Custom",
},
},
},
},
"model": "anthropic/claude-sonnet-4-5",
}
```
You can also ask OpenCode to update this file for you.
## Format
OpenCode supports JSON and JSONC. Use JSONC when you want comments or trailing commas.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
// Use this model by default.
"model": "anthropic/claude-sonnet-4-5",
}
```
## Locations
OpenCode loads global configuration from:
Put settings for every project in the global configuration:
```text
~/.config/opencode/opencode.json(c)
```
Project-specific configuration can use either form:
Put project settings in either of these files:
```text
/home/user/projects/my-app/opencode.json(c)
/home/user/projects/my-app/.opencode/opencode.json(c)
```
During ordinary project discovery, OpenCode searches for configuration files
from the current Location directory through every ancestor to the filesystem
root, including directories above the detected project or repository root. It
merges direct `opencode.json(c)` files from the farthest ancestor toward the
current directory, then does the same for files inside `.opencode` directories.
A discovered `.opencode` config therefore overrides every discovered direct
config, even when the direct config is closer to the current directory. Avoid
mixing the two forms across one directory hierarchy unless this precedence is
intentional.
OpenCode searches from the current directory to the filesystem root. It first
merges direct `opencode.json(c)` files from the farthest directory to the
closest, then merges files inside `.opencode` directories in the same order.
This means every discovered `.opencode` config overrides every direct config.
Use one form throughout a directory tree unless you need that behavior.
For example, consider a monorepo with OpenCode started from
`/home/user/projects/acme/packages/web`:
For example, start OpenCode from `/home/user/projects/acme/packages/web`:
```text
~/.config/opencode/opencode.json
@@ -70,9 +65,8 @@ OpenCode applies these files from lowest to highest precedence:
2. `/home/user/projects/acme/opencode.json`
3. `/home/user/projects/acme/packages/web/opencode.json`
In this direct-config example, the package config overrides matching settings
from the repository config, which overrides matching settings from the global
config. Settings that do not conflict are preserved from every file.
The package config overrides matching settings from the repository config,
which overrides the global config. Settings that do not conflict are preserved.
## Schema
@@ -114,7 +108,7 @@ does not retain a `#variant`; agent and command model references can select one.
See the [models guide](/models) for model selection and local models.
### Default agent
### Agent
Choose the primary agent used when a session does not select one explicitly.
@@ -144,8 +138,8 @@ Project-level values are ignored.
### Sharing
Set the intended session sharing policy. V2 accepts this field, but session
sharing is not implemented yet.
Set the session sharing policy. OpenCode accepts this field, but session sharing
is not supported yet.
```jsonc
{
@@ -157,8 +151,8 @@ See the [sharing guide](/sharing) for more details.
### Username
Set a username for future display behavior. V2 accepts this field but does not
currently display it in conversations.
Set a username. OpenCode accepts this field but does not display it in
conversations.
```jsonc
{
@@ -231,39 +225,16 @@ Ignore files and directories that should not trigger filesystem updates.
### Formatter
Define formatter settings for compatibility and future use. V2 accepts this
field, but it does not run formatters yet.
Format files after the `write`, `edit`, or `patch` tools change them. Set
`formatter` to `true` to enable available built-in formatters.
```jsonc
{
"formatter": {
"prettier": {
"command": ["bunx", "prettier", "--write", "$FILE"],
"extensions": [".js", ".ts", ".tsx"],
},
},
"formatter": true,
}
```
See the [formatters guide](/formatters) for accepted fields and current limitations.
### LSP
Define language server settings for compatibility and future use. V2 accepts
this field, but it does not start language servers yet.
```jsonc
{
"lsp": {
"typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
},
},
}
```
See the [LSP guide](/lsp) for accepted fields and current limitations.
See the [formatters guide](/formatters) for built-ins and custom formatters.
### Media
@@ -285,7 +256,7 @@ before they are sent to a model.
See the [attachments guide](/attachments) for image processing and limits.
### Tool output
### Output
Set the maximum number of lines and bytes retained from a tool result.
@@ -298,10 +269,10 @@ Set the maximum number of lines and bytes retained from a tool result.
}
```
### Web search
### Search
Use `"random"` to randomly choose a search provider for each session and keep using it until it
returns HTTP 429. OpenCode then retries the query with another available provider.
Choose how OpenCode searches the web. Use `"random"` to select an available
provider automatically.
```jsonc
{
@@ -311,12 +282,8 @@ returns HTTP 429. OpenCode then retries the query with another available provide
}
```
- Rate-limited providers cool down for `Retry-After`, or 60 seconds if it is missing or invalid.
- When every provider is cooling down, the search fails without waiting.
- Each session remembers its preferred provider; cooldowns are shared within a Location.
- State is kept in memory. Moving a session or restarting its Location services resets its preference.
- API and plugin queries without session context share a Location-level preference.
- Set `provider` to a provider ID to disable automatic switching, or set `websearch` to `false` to disable search.
See the [websearch guide](/websearch) for providers, credentials, selection,
rate limits, and disabling search.
### MCP
@@ -378,7 +345,7 @@ Top-level `compaction.auto: false` disables new automatic compaction without
discarding installed checkpoints. See the [compaction guide](/compaction) for
budgeting and overflow recovery.
### Session warming
### Warming
Keep recently active model sessions warm with periodic transient requests.
Warming is disabled by default; set it to `true` to use the four-minute idle
@@ -394,7 +361,7 @@ interval and 30-minute active window.
}
```
See the [session warming guide](/warming) for request behavior, customization,
See the [warming guide](/warming) for request behavior, customization,
and cost considerations.
### Skills
@@ -428,8 +395,8 @@ See the [commands guide](/commands) for arguments, models, agents, and file-base
### Instructions
Declare additional instruction files, globs, or URLs. V2 accepts this field,
but does not load these entries yet; use `AGENTS.md` for active instructions.
Declare additional instruction files, globs, or URLs. OpenCode accepts this
field but does not load its entries; use `AGENTS.md` for instructions.
```jsonc
{
@@ -0,0 +1,44 @@
---
title: "Websearch"
description: "Hosted websearch for OpenCode through Console."
---
Console provides hosted Websearch to connected OpenCode v2 users. A workspace owner or admin enables it once for the workspace, without giving members a separate search provider key.
Each successful search costs **$0.01** and counts toward the workspace balance and member spending limits.
## Enable
1. Sign in to the [Console](https://console.opencode.ai) as a workspace owner or admin.
2. Open **Settings** > **General**.
3. Turn on **Hosted web search**.
Each member then connects OpenCode to the workspace with `/connect`.
```text
/connect
```
OpenCode loads **OpenCode Web Search** from the workspace's managed configuration. Members do not need to set `websearch.provider` or add a provider API key.
## Search
Ask OpenCode for current information in a prompt. Searches run from OpenCode rather than from an interactive page in Console.
```text
Find the latest Bun release and summarize the changes with source links.
```
The hosted provider searches the public web and returns up to eight results. OpenCode uses the results to answer the prompt and cite sources.
See the [Websearch guide](/websearch) to configure permissions or disable the tool.
## Billing
Console charges **$0.01** after a search returns a valid result set. Failed and rate-limited searches are not charged.
Websearch appears separately from model usage in **Usage**, **My Activity**, member activity, invoices, and CSV exports. Workspace and member monthly spending limits include Websearch charges.
## Privacy
Hosted Websearch has zero data retention. Console and its hosted search provider process each query and its results without storing their contents.
+161 -52
View File
@@ -2,91 +2,200 @@
title: "Formatters"
---
OpenCode V2 accepts formatter configuration, but it does not yet include a
formatter runtime. File writes and edits are not automatically formatted.
OpenCode can format files after its `write`, `edit`, or `patch` tools change
them. Formatters are disabled by default, so enable them in your configuration:
<Callout type="warning">
V2 currently has no built-in formatters. The built-in formatter list and automatic post-edit formatting documented for
V1 do not apply to V2.
</Callout>
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": true,
}
```
## Configuration
## Enable
The `formatter` field accepts a boolean or an object keyed by formatter name:
Set `formatter` to `true` to enable every built-in formatter. OpenCode runs a
built-in only when its executable and any project-specific requirements are
available.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": true,
}
```
An object also enables the built-ins and lets you override them or add custom
formatters. An empty object is therefore equivalent to `true`.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": {},
}
```
## Builtins
OpenCode includes these formatter definitions. Most require the named command
to be available; definitions with extra detection rules list them below.
| Formatter | Extensions | Requirement |
| --- | --- | --- |
| `gofmt` | `.go` | `gofmt` command |
| `mix` | `.ex`, `.exs`, `.eex`, `.heex`, `.leex`, `.neex`, `.sface` | `mix` command |
| `oxfmt` | `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, `.cts` | `oxfmt` dependency in `package.json` |
| `prettier` | `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, `.cts`, `.html`, `.htm`, `.css`, `.scss`, `.sass`, `.less`, `.vue`, `.svelte`, `.json`, `.jsonc`, `.yaml`, `.yml`, `.toml`, `.xml`, `.md`, `.mdx`, `.graphql`, `.gql` | `prettier` dependency in `package.json` |
| `biome` | Same extensions as `prettier` above | `biome.json` or `biome.jsonc` and an installed `@biomejs/biome` binary |
| `zig` | `.zig`, `.zon` | `zig` command |
| `clang-format` | `.c`, `.cc`, `.cpp`, `.cxx`, `.c++`, `.h`, `.hh`, `.hpp`, `.hxx`, `.h++`, `.ino`, `.C`, `.H` | `clang-format` command and `.clang-format` |
| `ktlint` | `.kt`, `.kts` | `ktlint` command |
| `ruff` | `.py`, `.pyi` | `ruff` command and a Ruff config or dependency declaration |
| `air` | `.R` | `air` command that identifies itself as the R formatter |
| `uv` | `.py`, `.pyi` | `uv` command with `uv format` support |
| `rubocop` | `.rb`, `.rake`, `.gemspec`, `.ru` | `rubocop` command |
| `standardrb` | `.rb`, `.rake`, `.gemspec`, `.ru` | `standardrb` command |
| `htmlbeautifier` | `.erb` | `htmlbeautifier` command |
| `dart` | `.dart` | `dart` command |
| `ocamlformat` | `.ml`, `.mli` | `ocamlformat` command and `.ocamlformat` |
| `terraform` | `.tf`, `.tfvars` | `terraform` command |
| `latexindent` | `.tex` | `latexindent` command |
| `gleam` | `.gleam` | `gleam` command |
| `shfmt` | `.sh`, `.bash` | `shfmt` command |
| `nixfmt` | `.nix` | `nixfmt` command |
| `rustfmt` | `.rs` | `rustfmt` command |
| `pint` | `.php` | `laravel/pint` in `composer.json` |
| `ormolu` | `.hs` | `ormolu` command |
| `cljfmt` | `.clj`, `.cljs`, `.cljc`, `.edn` | `cljfmt` command |
| `dfmt` | `.d` | `dfmt` command |
For example, enabling built-ins lets OpenCode discover and run a project-local
Prettier dependency for matching files:
```jsonc title="opencode.jsonc"
{
"formatter": true,
}
```
## Customize
Add a named entry to change a built-in or define a custom formatter. A custom
formatter needs both `command` and `extensions` to run.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": {
"prettier": {
"disabled": false,
"command": ["prettier", "--write", "$FILE"],
"environment": {
"NODE_ENV": "development",
},
"extensions": [".js", ".jsx", ".ts", ".tsx"],
"extensions": [".js", ".ts"],
},
"deno-markdown": {
"command": ["deno", "fmt", "$FILE"],
"extensions": [".md"],
},
},
}
```
This example is valid V2 configuration, but V2 does not currently execute the
command.
| Field | Type | Behavior |
| --- | --- | --- |
| `disabled` | `boolean` | Removes the named formatter when `true`. |
| `command` | `string[]` | Replaces the built-in command or defines a custom command. |
| `environment` | `Record<string, string>` | Adds environment variables while preserving the parent environment. |
| `extensions` | `string[]` | Replaces the built-in extension list or defines the custom list. Include the leading dot. |
Each named formatter entry supports these optional fields:
All fields are optional. A built-in entry inherits omitted values, while a new
entry without a command or extensions cannot run.
| Field | Type | Current V2 behavior |
| ------------- | ------------------------ | ------------------------------------------------------------------------------------- |
| `disabled` | `boolean` | Accepted, but there is no runtime formatter to enable or disable. |
| `command` | `string[]` | Accepted as an argument array, but not executed. |
| `environment` | `Record<string, string>` | Accepts string environment variable names and values, but they are not applied. |
| `extensions` | `string[]` | Accepted without extension-specific validation, but files are not matched against it. |
All entry fields are optional. The schema therefore also accepts an empty entry
such as `"prettier": {}`.
## Enable and disable
The schema accepts all of the following forms:
```jsonc
// Omit `formatter`, or use false, when formatting is not requested.
```jsonc title="opencode.jsonc"
{
"formatter": false,
"formatter": {
"prettier": {
"extensions": [".md", ".mdx"],
},
},
}
```
```jsonc
// Reserved for enabling all built-ins once a V2 runtime provides them.
{
"formatter": true,
}
```
## Commands
`command` is an argument array, not a shell command string. OpenCode replaces
`$FILE` with the file's absolute path and runs the command from the active
project directory.
```jsonc
// Configure named entries or mark one as disabled.
```jsonc title="opencode.jsonc"
{
"formatter": {
"prettier": { "disabled": true },
"custom": {
"command": ["custom-fmt", "$FILE"],
"command": ["custom-fmt", "--write", "$FILE"],
"extensions": [".custom"],
},
},
}
```
## Matching
OpenCode compares the file's final extension with `extensions`. Matching is
case-sensitive, and compound entries such as `.part.md` do not match
`notes.part.md` because its final extension is `.md`.
```jsonc title="opencode.jsonc"
{
"formatter": {
"markdown": {
"command": ["deno", "fmt", "$FILE"],
"extensions": [".md"],
},
},
}
```
When several formatters match, OpenCode tries them in registered order and
stops after the first successful command. Built-ins retain their built-in
order; custom entries follow in object order. If one exits unsuccessfully,
OpenCode logs the failure and tries the next match.
```jsonc title="opencode.jsonc"
{
"formatter": {
"preferred": {
"command": ["preferred-fmt", "$FILE"],
"extensions": [".foo"],
},
"fallback": {
"command": ["fallback-fmt", "$FILE"],
"extensions": [".foo"],
},
},
}
```
At present, omitted, `false`, `true`, and object forms have the same runtime
result: V2 runs no formatter. `disabled` is retained as configuration data but
does not control an executable formatter.
## Disable
## Commands and placeholders
Omit `formatter` or set it to `false` to disable all formatting. An explicit
`false` can override a lower-priority configuration that enabled formatters.
`command` is an array of strings, not a shell command string. `$FILE` is the V1
file-path placeholder and is often retained in migrated configuration. V2 does
not currently substitute `$FILE` or define another formatter placeholder.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": false,
}
```
Likewise, V2 does not currently use `extensions` to select commands, merge
`environment` into a child process, discover formatter executables or project
configuration, or run multiple matching formatters. These behaviors will only
be available after a V2 formatter runtime is implemented.
To disable one built-in while leaving the others enabled, mark its named entry
as disabled.
```jsonc title="opencode.jsonc"
{
"formatter": {
"prettier": {
"disabled": true,
},
},
}
```
+2 -3
View File
@@ -2,8 +2,7 @@
title: "Intro"
---
These docs are for the beta version of OpenCode, which will become OpenCode 2.0. The beta is still changing: things may
break, and APIs, configuration, and plugin APIs may change.
These docs describe OpenCode 2 and its released APIs, configuration, and plugin system.
OpenCode 2 installs and runs as `opencode2`. It does not replace OpenCode 1's `opencode` binary, so you can keep both
versions installed and run them side by side.
@@ -27,7 +26,7 @@ commands above explicitly allow that script to run.
On Arch Linux, install [`opencode-beta`](https://aur.archlinux.org/packages/opencode-beta) from the AUR with `paru`.
It provides the `opencode2` command; manage updates through your AUR helper.
Homebrew, Windows package managers, Docker, and standalone binaries are not supported during the beta.
Homebrew, Windows package managers, Docker, and standalone binaries are not supported in V2.
---
+88 -103
View File
@@ -2,126 +2,111 @@
title: "Instructions"
---
Instructions are privileged context that guide an agent throughout a session.
V2 combines built-in context, discovered `AGENTS.md` files, and dynamic sources
such as skill, reference, MCP, and session context. It stores source values as
durable deltas, then renders initial instructions and chronological updates when
assembling each model request.
Add an `AGENTS.md` file to give OpenCode persistent project guidance. Use it for build commands, architecture notes, code conventions, and verification requirements.
## AGENTS.md
```md title="AGENTS.md"
# Project instructions
Use `AGENTS.md` for persistent guidance such as build commands, architecture,
code conventions, and verification requirements. Commit project files so the
whole team receives the same instructions.
V2 loads:
1. The global file at `$XDG_CONFIG_HOME/opencode/AGENTS.md`, normally
`~/.config/opencode/AGENTS.md`.
2. Every `AGENTS.md` from the current Location up to and including the home
directory when the Location is inside it. For Locations outside home, the
scan stops at the project root.
For example, when the Location is `packages/web`, OpenCode can load all three
project files below:
```text
my-project/
├── AGENTS.md
└── packages/
├── AGENTS.md
└── web/
└── AGENTS.md
- Run `bun typecheck` after changing TypeScript.
- Keep database queries in `src/database`.
- Do not edit generated files directly.
```
The files are combined rather than selecting a single winner. They are rendered
in this order: global, then files from the Location toward home or the project
root. OpenCode does not resolve conflicts between their contents, so keep broad
guidance global and put scoped guidance in the relevant directory.
Commit project instruction files so everyone working in the repository receives the same guidance.
If the Location is outside the project root, only the global file is loaded.
Setting `OPENCODE_DISABLE_PROJECT_CONFIG=1` also skips project `AGENTS.md`
discovery but does not disable the global file.
## Scope
Place `AGENTS.md` in the directory where its guidance should apply. OpenCode loads the global file followed by every `AGENTS.md` from the current workspace directory toward the home directory. For workspaces outside the home directory, it stops at the project root.
```text
~/.config/opencode/AGENTS.md
~/code/my-project/AGENTS.md
~/code/my-project/packages/AGENTS.md
~/code/my-project/packages/web/AGENTS.md ← current workspace
```
In this example, all four files are loaded. They are combined in this order:
```text
~/.config/opencode/AGENTS.md
packages/web/AGENTS.md
packages/AGENTS.md
AGENTS.md
```
Keep guidance that applies everywhere in the global file. Put repository-wide guidance at the project root and more specific guidance closer to the code it covers. OpenCode combines the files and does not resolve conflicts between them.
If the workspace is outside the project root, only the global file is loaded. Set `OPENCODE_DISABLE_PROJECT_CONFIG=1` to skip project `AGENTS.md` discovery without disabling the global file.
<Callout type="note">
Current V2 discovery only recognizes `AGENTS.md`. The `CLAUDE.md` fallback and related precedence described by older
OpenCode documentation do not apply.
OpenCode V2 recognizes `AGENTS.md` only. It does not use `CLAUDE.md` as a fallback.
</Callout>
### Nested instructions
## Discovery
An `AGENTS.md` below the Location is not part of the initial upward scan. When
the read tool successfully reads a file or lists a directory, OpenCode discovers
`AGENTS.md` files from that target upward to, but not including, the Location.
It adds newly discovered files to the session in nearest-first order.
Instruction files below the workspace are discovered as the agent explores the project. Reading a file or listing a directory loads any `AGENTS.md` files between that target and the workspace.
Each nested file is injected once per session and recorded in durable session
history. Reading the same area again does not inject it again. Consequently,
editing an already injected nested `AGENTS.md` does not replace its earlier
session entry automatically; start a new session if the updated text must apply
immediately.
```text
my-project/ ← current workspace
├── AGENTS.md loaded initially
└── packages/
└── web/
├── AGENTS.md loaded when this area is read
└── src/
└── app.ts read target
```
## Config entries
Nested files are loaded nearest-first and deduplicated while their instruction entry remains in model-visible history. Reading the same area again does not normally inject them again. If compaction or a revert removes that entry, a later read can load the file again.
The V2 config schema accepts an `instructions` array of strings:
Edits to a nested file are not detected automatically after it loads. Start a new session when updated text must apply immediately.
## Ordering
The selected agent or provider system prompt is sent first. OpenCode then assembles initial instructions in this order:
```text
1. Agent or provider system prompt
2. Built-in environment and date context
3. Code Mode tool guidance, when enabled
4. Global and project AGENTS.md files
5. Available skill, reference, and MCP guidance
6. Session-specific instruction entries supplied through the API
```
These sources are combined rather than used as overrides. Nested `AGENTS.md` files discovered later are added to session history in discovery order.
## Updates
Edit a global or upward-discovered `AGENTS.md` while a session is running to update its guidance.
```bash
$ printf '\n- Run the integration suite before committing.\n' >> AGENTS.md
```
Before the next model request, OpenCode detects the change and adds an instruction update before delivering pending input:
```text
AGENTS.md changes
→ instruction update
→ next prompt
```
- Removing every ambient `AGENTS.md` tells the session that the previous ambient instructions no longer apply.
- A temporary read failure preserves the last known instructions instead of treating them as deleted.
- Moving a session retains its instruction state, so guidance at the destination is introduced as an update.
- Committing a session revert clears its instruction state and reloads instructions before the next prompt.
Instruction values remain privileged. Clients can see which sources changed, but not their contents.
## Configuration
The V2 config schema accepts an `instructions` array, but V2 does not currently resolve its files, glob patterns, or URLs. This configuration does not add instructions to the model yet:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md", "https://example.com/shared-instructions.md"],
"instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md", "https://example.com/instructions.md"],
}
```
Configuration is loaded from global through project-local files. If more than
one config defines `instructions`, the highest-precedence, closest config's
entire array is selected; arrays are not merged.
<Callout type="warning">
V2 currently parses and retains this field but does not resolve its entries into instruction sources. Local files,
glob patterns, and HTTP or HTTPS URLs in `instructions` therefore do not reach the model yet. Use `AGENTS.md` for
active V2 instructions. URL fetching and timeout behavior documented for V1 are not supported by the current V2
implementation.
</Callout>
See [Config](/config) for config locations and general precedence.
## Ordering
The selected agent or provider system prompt is sent first. OpenCode then sends
the session's initial instructions, composed in this order:
1. Built-in environment and date context.
2. Ambient `AGENTS.md` discovery.
3. Available skill, reference, and MCP guidance.
4. Session-specific instruction entries supplied through the API.
These sources are combined; ordering is not an override mechanism. Nested
`AGENTS.md` files discovered by reads are chronological session entries rather
than part of the initial instructions.
## Changes
Before each physical model attempt, V2 compares live instruction sources with
the latest admitted source values. This comparison happens before pending input
is delivered for that attempt:
- A new or changed ambient `AGENTS.md` aggregate is announced as a system update
that replaces the previous ambient aggregate.
- Removing all ambient files announces that the previous ambient instructions
no longer apply.
- A temporary read or discovery failure preserves the session's last known
instructions instead of treating them as deleted. If no instruction epoch
exists yet, pending input waits until every source is available.
- Completed conversation compaction advances the instruction epoch, making the
currently admitted values initial without rereading sources or authoring an
instruction event.
- Moving a session retains instruction state, so destination changes become
chronological updates. Committing a revert clears instruction state; the next
model attempt requires one complete source read before delivering input.
The durable event stores changed source keys and value hashes. Initial baseline
events contain no rendered prose. Later changes render once when admitted and
freeze that optional text in the event, which projects it as a chronological
System message. During request assembly, OpenCode renders the epoch's initial
values and reuses projected update messages verbatim. Clients see changed keys
but never the privileged value bodies.
Use `AGENTS.md` for active V2 instructions. See [Config](/config) for configuration locations and precedence.
-103
View File
@@ -1,103 +0,0 @@
---
title: "LSP"
---
Language Server Protocol (LSP) integrations can provide code diagnostics,
symbols, definitions, references, and other language-aware context.
<Callout type="warning">
OpenCode V2 does not yet have an LSP runtime or built-in language servers. The `lsp` configuration is accepted and
preserved, but it does not currently start or download servers, expose an LSP tool, or add diagnostics to file tool
results.
</Callout>
## Built-in servers
There are no built-in LSP servers in the current V2 implementation. Setting
`lsp` to `true` declares that built-ins should be enabled, but has no runtime
effect until V2 provides a server registry and LSP runtime.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": true,
}
```
## Configuration
The `lsp` field accepts a boolean or an object keyed by server name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"custom-typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
"env": {
"TSS_LOG": "-level verbose",
},
"initialization": {
"preferences": {
"importModuleSpecifierPreference": "relative",
},
},
},
},
}
```
Each enabled server entry has this shape:
| Property | Type | Required | Description |
| ---------------- | ------------------------- | -------- | --------------------------------------------------------------------------------------------- |
| `command` | `string[]` | Yes | Executable followed by any arguments. |
| `extensions` | `string[]` | No | File extensions associated with the server, including the leading dot. |
| `disabled` | `boolean` | No | Disables the entry when `true`. |
| `env` | `Record<string, string>` | No | Environment variables for the server process. The property is named `env`, not `environment`. |
| `initialization` | `Record<string, unknown>` | No | Server-specific options for the LSP `initialize` request. |
The only entry that may omit `command` is the disable-only form:
```jsonc
{
"lsp": {
"typescript": {
"disabled": true,
},
},
}
```
Server names are arbitrary. The V2 schema permits `extensions` to be omitted,
including for a custom server, although a future runtime will need a way to
associate that server with files.
## Disable LSP
Omit `lsp` when no configuration is needed. Set it to `false` to explicitly
disable the whole integration, including when a lower-priority configuration
set it to `true` or supplied an object:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": false,
}
```
Use `{ "disabled": true }` under a server name to disable one server while
retaining the object form. `OPENCODE_DISABLE_LSP_DOWNLOAD` is not used by V2;
V2 currently performs no automatic LSP downloads.
## Current usage
V2 loads and validates the configuration shape for compatibility and future
integration. It does not currently use LSP when reading, writing, editing, or
patching files, and those tools do not notify a language server or return LSP
diagnostics.
For reliable feedback today, have the agent run the project's lint, typecheck,
test, or compiler commands. Record those commands in an `AGENTS.md` file or a
skill so the agent knows when and where to run them.
+203 -66
View File
@@ -2,11 +2,32 @@
title: "MCP servers"
---
OpenCode can connect to [Model Context Protocol](https://modelcontextprotocol.io/) servers and make their tools, prompts, and instructions available to agents. MCP tools consume model context, so enable only the servers you need.
OpenCode connects to [Model Context Protocol](https://modelcontextprotocol.io/) servers and exposes capabilities such as tools, prompts, resources, and instructions. MCP tools consume model context, so add only the servers you need.
## Configure servers
## Setup
Define each server by a unique name under `mcp.servers` in your [OpenCode configuration](/config). V2 does not place server names directly under `mcp`.
Add a remote server from the project that should use it, then check its connection:
```sh
opencode2 mcp add context7 --url https://mcp.context7.com/mcp
opencode2 mcp list
```
The command writes the server to the project [configuration](/config). Add `--global` to make it available in every project:
```sh
opencode2 mcp add context7 --global --url https://mcp.context7.com/mcp
```
Remote servers use OAuth by default. If the list shows `needs authentication`, open OpenCode, run `/mcps`, select the server, and sign in. A connected server is ready for an agent to use:
```text
✓ context7 connected
```
## Config
To configure a server by hand, give it a unique name under `mcp.servers`. V2 does not place server names directly under `mcp`.
```jsonc title="opencode.jsonc"
{
@@ -22,7 +43,7 @@ Define each server by a unique name under `mcp.servers` in your [OpenCode config
}
```
Servers connect automatically unless `disabled` is `true`. There is no V2 `enabled` field.
Servers connect automatically. Use `disabled`, not an `enabled` field, to keep one configured without connecting it:
```jsonc
{
@@ -38,11 +59,30 @@ Servers connect automatically unless `disabled` is `true`. There is no V2 `enabl
}
```
As with other configuration, a server in a higher-precedence project config replaces a server with the same name from a lower-precedence config. Use different names when you need separate connections or accounts.
A higher-precedence project config replaces the entire server object with the same name. Use different names for separate connections or accounts; otherwise repeat every required field in the override:
## Local servers
```jsonc title="opencode.jsonc"
{
"mcp": {
"servers": {
"my-server": {
"type": "remote",
"url": "https://mcp.example.com/mcp",
},
},
},
}
```
A local server is a command that OpenCode starts using the MCP stdio transport.
## Local
A local server is a command that OpenCode starts over the MCP stdio transport. Add one with a command after `--`:
```sh
opencode2 mcp add everything -- npx -y @modelcontextprotocol/server-everything
```
Use configuration for process options such as a working directory or environment variables:
```jsonc title="opencode.jsonc"
{
@@ -63,21 +103,35 @@ A local server is a command that OpenCode starts using the MCP stdio transport.
}
```
| Field | Required | Description |
| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `type` | Yes | Must be `"local"`. |
| `command` | Yes | Executable followed by its arguments. |
| `cwd` | No | Process working directory. Relative paths resolve from the workspace directory; the workspace is the default. |
| `environment` | No | String environment variables added to the inherited OpenCode process environment. |
| `disabled` | No | Set to `true` to prevent the server from connecting. Defaults to `false`. |
| `codemode` | No | Set to `false` to expose the server's tools directly to the model instead of through Code Mode. Defaults to `true`. |
| `timeout` | No | Per-server timeout overrides. |
| Field | Required | Description |
| --- | --- | --- |
| `type` | Yes | Must be `"local"`. |
| `command` | Yes | Executable followed by its arguments. |
| `cwd` | No | Process directory. Relative paths resolve from the workspace, which is also the default. |
| `environment` | No | String variables added to OpenCode's inherited process environment. |
| `disabled` | No | Prevents connection when `true`. Defaults to `false`. |
| `codemode` | No | Set to `false` to expose tools directly instead of through Code Mode. Defaults to `true`. |
| `timeout` | No | Per-server timeout overrides. |
Use `{env:NAME}` to substitute an environment variable while loading config. Shell expressions such as `$NAME` are not expanded in JSON strings.
Use `{env:NAME}` for environment substitution. Shell expressions such as `$NAME` are not expanded in JSON strings:
## Remote servers
```jsonc
{
"environment": {
"MCP_API_KEY": "{env:MCP_API_KEY}",
},
}
```
A remote server uses the MCP Streamable HTTP transport. Its `url` must be a valid absolute URL.
## Remote
A remote server uses the MCP Streamable HTTP transport and requires an absolute URL:
```sh
opencode2 mcp add context7 --url https://mcp.context7.com/mcp
```
Use configuration when the server needs headers or other options. Store secrets in environment variables rather than in the file:
```jsonc title="opencode.jsonc"
{
@@ -97,23 +151,32 @@ A remote server uses the MCP Streamable HTTP transport. Its `url` must be a vali
}
```
| Field | Required | Description |
| ---------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `type` | Yes | Must be `"remote"`. |
| `url` | Yes | Streamable HTTP endpoint. |
| `headers` | No | String HTTP headers sent to the MCP endpoint. |
| `oauth` | No | OAuth client settings, or `false` to disable OAuth support. |
| `disabled` | No | Set to `true` to prevent the server from connecting. Defaults to `false`. |
| `codemode` | No | Set to `false` to expose the server's tools directly to the model instead of through Code Mode. Defaults to `true`. |
| `timeout` | No | Per-server timeout overrides. |
| Field | Required | Description |
| --- | --- | --- |
| `type` | Yes | Must be `"remote"`. |
| `url` | Yes | Absolute Streamable HTTP endpoint. |
| `headers` | No | String HTTP headers sent to the endpoint. |
| `oauth` | No | OAuth settings, or `false` to disable OAuth. |
| `disabled` | No | Prevents connection when `true`. Defaults to `false`. |
| `codemode` | No | Set to `false` to expose tools directly instead of through Code Mode. Defaults to `true`. |
| `timeout` | No | Per-server timeout overrides. |
Use `oauth: false` for a server that exclusively uses an API key or another header-based credential.
Use `oauth: false` only when the server exclusively uses an API key or another header credential:
```jsonc
{
"type": "remote",
"url": "https://mcp.example.com/mcp",
"oauth": false,
"headers": { "Authorization": "Bearer {env:MCP_API_KEY}" },
}
```
## OAuth
OAuth support is enabled for remote servers unless `oauth` is `false`. OpenCode discovers the authorization server, uses PKCE, refreshes tokens, and attempts dynamic client registration when the server supports it. OAuth credentials are stored outside project configuration.
OAuth is enabled for remote servers unless `oauth` is `false`. OpenCode discovers the authorization server, uses PKCE, refreshes tokens, and attempts dynamic client registration when supported; credentials stay outside project configuration.
For a server that supports dynamic client registration, only the remote server is required:
For dynamic registration, configure only the server URL:
```jsonc title="opencode.jsonc"
{
@@ -129,10 +192,13 @@ For a server that supports dynamic client registration, only the remote server i
}
```
When a server reports that it needs authentication, start its OAuth flow using
an MCP management interface and complete authorization in the browser.
If the server needs authentication, run `/mcps`, select it, and complete authorization in the browser. The CLI can start the same flow:
If the provider issued client credentials, configure them using V2's snake_case field names:
```sh
opencode2 mcp auth sentry
```
When a provider gives you client credentials, use V2's snake_case OAuth fields:
```jsonc title="opencode.jsonc"
{
@@ -155,17 +221,23 @@ If the provider issued client credentials, configure them using V2's snake_case
}
```
| OAuth field | Description |
| --------------- | ----------------------------------------------------------------------------------------------- |
| `client_id` | Pre-registered OAuth client ID. If omitted, OpenCode attempts dynamic client registration. |
| `client_secret` | Client secret for a pre-registered client. |
| `scope` | Space-delimited scopes to request. |
| `callback_port` | Local callback port, from `1` through `65535`. An available ephemeral port is used by default. |
| `redirect_uri` | Pre-registered loopback redirect URI. Its path and port must reach the local callback listener. |
| Field | Description |
| --- | --- |
| `client_id` | Pre-registered client ID. Omit it to attempt dynamic registration. |
| `client_secret` | Secret for a pre-registered client. |
| `scope` | Space-delimited scopes to request. |
| `callback_port` | Local callback port from `1` through `65535`. An available ephemeral port is the default. |
| `redirect_uri` | Pre-registered loopback URI whose path and port reach the local callback listener. |
Remove stored OAuth credentials when you need to sign in again or switch accounts:
```sh
opencode2 mcp logout sentry
```
## Timeouts
Timeouts are positive integer milliseconds. Configure defaults under `mcp.timeout`; a server's `timeout` fields override matching defaults.
Timeouts are positive integer milliseconds. Set defaults under `mcp.timeout`; a server's `timeout` object overrides matching defaults.
```jsonc title="opencode.jsonc"
{
@@ -189,19 +261,37 @@ Timeouts are positive integer milliseconds. Configure defaults under `mcp.timeou
}
```
| Timeout | Default | Applies to |
| ----------- | ---------- | ---------------------------------------------------------- |
| `startup` | 30 seconds | Establishing the transport and initializing the server. |
| `catalog` | 30 seconds | Listing tools, prompts, resources, and resource templates. |
| `execution` | 12 hours | Calling tools, getting prompts, and reading resources. |
| Timeout | Default | Applies to |
| --- | --- | --- |
| `startup` | 30 seconds | Transport connection and server initialization. |
| `catalog` | 30 seconds | Listing tools, prompts, resources, and resource templates. |
| `execution` | 12 hours | Tool calls, prompt retrieval, and resource reads. |
## Names and permissions
## Names
OpenCode combines the server name and MCP tool name as `<server>_<tool>`. Characters other than letters, numbers, `_`, and `-` are replaced with `_`; for example, server `context 7` and tool `resolve.library/id` become `context_7_resolve_library_id`. MCP prompts become available as commands named `<server>:<prompt>` using the same normalization.
OpenCode names a tool `<server>_<tool>`. It replaces characters other than letters, numbers, `_`, and `-` with `_`:
Choose short server names that remain unique after normalization. Under the default Code Mode, MCP tools are grouped by the normalized server name.
```text
server: context 7
tool: resolve.library/id
name: context_7_resolve_library_id
```
Set `codemode` to `false` on a server when its tools should remain on the provider's native tool list:
MCP prompts become commands named `<server>:<prompt>` with the same normalization. For example:
```text
/context_7:find_docs
```
Choose short server names that remain unique after normalization. Under the default Code Mode, tools are grouped by the normalized server name:
```text
tools.context_7.resolve_library_id(...)
```
## Permissions
Code Mode is the default. Set `codemode` to `false` when a server's tools must stay on the provider's native tool list:
```jsonc
{
@@ -217,7 +307,7 @@ Set `codemode` to `false` on a server when its tools should remain on the provid
}
```
Use permission actions to hide or deny a server's tools without stopping its connection:
Use permission actions to hide or deny tools without disconnecting their server. Match the normalized `<server>_<tool>` name:
```jsonc
{
@@ -231,21 +321,68 @@ Use permission actions to hide or deny a server's tools without stopping its con
}
```
## Session context
## Context
When OpenCode invokes an MCP tool on behalf of a session, it includes the invoking
session's ID in `CallToolRequest.params._meta.sessionID`. This applies to direct tool
calls and Code Mode over both stdio and Streamable HTTP.
For calls made on behalf of a session, OpenCode sends the session ID in `CallToolRequest.params._meta.sessionID`. This applies to direct tools and Code Mode over stdio and Streamable HTTP:
The ID is request metadata, not a tool argument, so it does not appear in the
model-visible tool schema. Treat it as an opaque correlation value: it identifies the
invoking OpenCode session rather than the MCP transport session, can be absent for
calls without session context, and must not be used by itself for authentication or
authorization. Remote MCP servers receive the raw ID and may log or retain it.
```json
{
"method": "tools/call",
"params": {
"name": "lookup",
"arguments": { "query": "example" },
"_meta": { "sessionID": "ses_..." }
}
}
```
## Manage servers
The ID is request metadata, not a tool argument, so it is absent from the model-visible schema. Treat it as an opaque correlation value:
OpenCode interfaces can add servers to project or global configuration, list
configured servers and their connection status, authenticate remote servers,
and remove stored OAuth credentials. Edit configuration directly for OAuth client settings, timeouts, working
directories, or enablement.
| Rule | Behavior |
| --- | --- |
| Identity | It identifies the invoking OpenCode session, not the MCP transport session. |
| Presence | It can be absent for calls without session context. |
| Security | Do not use it by itself for authentication or authorization. |
| Privacy | Remote servers receive the raw ID and may log or retain it. |
## Management
List servers and their current connection state from any project:
```sh
opencode2 mcp list
```
Use `/mcps` in OpenCode to view, connect, disconnect, or authenticate servers. Use the CLI to add servers and manage OAuth credentials:
```sh
opencode2 mcp add sentry --url https://mcp.sentry.dev/mcp
opencode2 mcp auth sentry
opencode2 mcp logout sentry
```
To remove a server, delete its entry from the project or global configuration where it was added:
```jsonc
{
"mcp": {
"servers": {},
},
}
```
Edit configuration directly for OAuth client settings, timeouts, working directories, or persistent enablement:
```jsonc
{
"mcp": {
"servers": {
"sentry": {
"type": "remote",
"url": "https://mcp.sentry.dev/mcp",
"disabled": true,
},
},
},
}
```
+10 -18
View File
@@ -20,20 +20,16 @@ schema never had a V2 equivalent and are intentionally ignored; these are listed
Existing supported server config fields, agent definitions, command definitions, skills, and other files in `.opencode/`
should continue to work without changes. If supported behavior described in this guide stops working in V2, treat it as a
beta compatibility bug rather than an expected migration requirement.
compatibility bug rather than an expected migration requirement.
<Callout type="tip">
If supported V1 functionality does not work in V2, follow the issue-reporting guidance in
[Troubleshooting](/troubleshooting) and file a compatibility issue.
</Callout>
<Callout type="warning">
OpenCode 2.0 is in beta. Features may break unintentionally, and the server and plugin APIs may continue to change.
</Callout>
## Install V2
## Install the beta
The V2 terminal client is published on the `beta` distribution tag. See the [terminal startup guide](/cli).
Install the V2 terminal client with the [terminal startup guide](/cli).
## Configuration
@@ -546,24 +542,20 @@ Moving a file between these directories does not migrate its implementation.
<Callout type="warning">V1 plugins will not work in V2.</Callout>
The config entry can be translated automatically, but plugin implementation code must be ported to the new API. The V2
plugin API is still being finalized during beta, and detailed plugin migration guidance will be published when it is
ready.
Once the V2 plugin API is finalized, OpenCode should be able to migrate the majority of V1 plugins while keeping related
local modules and dependencies together. See the current beta [Plugins guide](/build/plugins).
The config entry can be translated automatically, but plugin implementation code must be ported to the released V2 API.
Use the [Plugins guide](/build/plugins) to replace V1 hooks and entrypoints. Related local modules and dependencies can
remain with the plugin while you port its implementation.
## Server API and clients
OpenCode 2 has a revised, more ergonomic server API and a new set of clients. Integrations that call the V1 server API
must migrate to the V2 API.
Use the `@opencode/client` package to access the new clients. The server API and clients are still being finalized
during beta, so their contracts may continue to change. See the generated [API reference](/api) for the current endpoints,
request types, and responses.
Use the `@opencode/client` package to access the released V2 API. See the generated [API reference](/api) for its
endpoints, request types, and responses.
## Verify your setup
Verify your model, provider credentials, agents, permissions, MCP servers, and plugins in a project before relying on the
beta for regular work. Keep your V1 setup until you have confirmed the V2 behavior you need, and do not point V1 at
Verify your model, provider credentials, agents, permissions, MCP servers, and plugins in a project before relying on V2
for regular work. Keep your V1 setup until you have confirmed the V2 behavior you need, and do not point V1 at
configuration that you have converted to the native V2 shape.
+234 -143
View File
@@ -2,34 +2,44 @@
title: "Models"
---
OpenCode builds its model catalog from [models.dev](https://models.dev), provider integrations, and your configuration.
Only enabled models whose provider is available for the current project are available for selection.
Choose a model for the current session with `/models`:
Configure provider availability in [Providers](/providers).
```text
/models
```
## Choose a model
OpenCode lists models from [models.dev](https://models.dev), provider integrations, and your configuration. The selector
only shows enabled models whose provider is available in the current project. Connect providers in
[Providers](/providers).
Clients can select any model available from providers connected to the current project. Selecting a model updates the
current session without changing configuration. Use an available catalog entry rather than guessing a provider or model
name.
## Select
## Per-run model
Pick an entry from `/models` instead of guessing its provider or model ID. The selection applies to the current session
and does not change your configuration.
Command-line runs can select a model without changing the configured default.
```text
anthropic/claude-sonnet-4-5
```
Agents and commands can also select their own model. See [Agents](/agents) and [Commands](/commands).
Availability is project-specific:
## Variants
- The model must be enabled.
- Its provider must be available in the current project.
- Credentials and configuration from another project do not carry over automatically.
Variants are named request overlays for one model, commonly used for reasoning effort or token budgets. Available names
are model-specific and are derived from current catalog metadata. Do not assume that names such as `low`, `high`, or
`max` exist for every model. Clients should present only the variants available for the selected model.
## Runs
## Configure
Use `--model` to choose a model for one command-line run without changing the configured default:
### Default model
```bash
opencode2 run --model anthropic/claude-sonnet-4-5 "Refactor parseToken"
```
Set `model` in `opencode.json` or `opencode.jsonc`:
Agents and commands can also choose their own model. See [Agents](/agents) and [Commands](/commands).
## Defaults
Set `model` in `opencode.json` or `opencode.jsonc` to choose the default for new work:
```jsonc title="opencode.jsonc"
{
@@ -38,87 +48,28 @@ Set `model` in `opencode.json` or `opencode.jsonc`:
}
```
The configured model becomes the catalog default when its provider is available and the model is enabled. Otherwise,
session execution falls back to the newest available supported model. An explicit model already selected on a session
takes precedence over the default; switching models changes that session and does not rewrite your config.
The configured model becomes the catalog default when it is enabled and its provider is available. Otherwise, OpenCode
falls back to the newest available supported model.
- A model already selected for a session takes precedence over the configured default.
- Switching a session's model does not rewrite the config file.
- The root `model` currently retains only the default provider and model, not a variant.
See [Config](/config) for configuration locations and precedence.
### Model settings
## Variants
Provider and model entries can supply three kinds of request configuration:
Variants are named options for one model, often used for reasoning effort or token budgets. Add `#variant` when selecting
one for a run, session, agent, or command:
- `settings` contains provider-package options such as `baseURL`, `reasoningEffort`, or `thinkingConfig`.
- `headers` adds HTTP request headers.
- `body` adds provider-specific fields to the request body.
These values are provider-specific JSON. OpenCode applies provider values first, then model values, then the selected
variant. Nested `settings` and `body` objects are merged; later array and scalar values replace earlier values. Header
names are matched case-insensitively.
You can also map a friendly catalog ID to a different API model ID with `modelID`:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "openai/coding-default",
"providers": {
"openai": {
"models": {
"coding-default": {
"modelID": "gpt-5.2",
"name": "Coding default",
"capabilities": {
"tools": true,
"input": ["text", "image"],
"output": ["text"],
},
"limit": {
"context": 200000,
"output": 32000,
},
},
},
},
},
}
```bash
opencode2 run --model openai/gpt-5.2#high "Review this migration plan"
```
Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. A model that is
not already in the catalog receives fallback metadata:
Variant names come from the selected model's current catalog metadata. Names such as `low`, `high`, and `max` are not
available for every model, and an unknown variant produces a model-resolution error.
- Tool support, text and image input, and text output.
- A 200,000-token context limit and 32,000-token output limit. The input limit remains unspecified.
These values are assumptions, not model discovery. Configure accurate `capabilities` and `limit` values whenever they are
known; explicit values override the fallbacks. Set `disabled: true` on a model entry to hide it from the available
catalog.
OpenAI-compatible models that stream reasoning through a custom assistant-message field can set
`compatibility.reasoningField`:
```jsonc title="opencode.jsonc"
{
"providers": {
"local": {
"models": {
"reasoner": {
"compatibility": {
"reasoningField": "reasoning_content",
},
},
},
},
},
}
```
OpenCode recognizes `reasoning`, `reasoning_content`, and `reasoning_text`, and accepts any provider-specific string. It
reads streamed reasoning from this field and includes the field when replaying assistant messages to the model.
### Custom variants
Add a variant, or override a catalog variant with the same ID, under the model's `variants` array:
Define new variants, or replace catalog variants with the same ID, in the model's `variants` array:
```jsonc title="opencode.jsonc"
{
@@ -152,15 +103,143 @@ Add a variant, or override a catalog variant with the same ID, under the model's
}
```
Variant entries support `settings`, `headers`, and `body`. Selecting one deeply overlays its values on the effective
provider and model configuration. An unknown variant fails model resolution instead of silently using the base model.
Each variant can contain `settings`, `headers`, and `body`. Its values are applied after provider and model values.
### Local models
## Options
#### Ollama
Provider and model entries can customize requests with `settings`, `headers`, and `body`:
OpenCode automatically discovers language models from an Ollama server listening on its default address,
`http://127.0.0.1:11434`. Discovered models use the `ollama` provider ID and Ollama's model name:
```jsonc title="opencode.jsonc"
{
"providers": {
"openai": {
"settings": {
"baseURL": "https://api.example.com/v1",
},
"headers": {
"X-Team": "platform",
},
"models": {
"gpt-5.2": {
"settings": {
"reasoningEffort": "high",
},
"body": {
"store": false,
},
},
},
},
},
}
```
| Field | Purpose |
| ---------- | ------------------------------------------------------------------ |
| `settings` | Provider-package options such as `baseURL` or `reasoningEffort`. |
| `headers` | Additional HTTP request headers. |
| `body` | Provider-specific request body fields. |
OpenCode applies provider values first, model values second, and the selected variant last.
- Nested `settings` and `body` objects are merged.
- Later arrays and scalar values replace earlier values.
- Header names are matched case-insensitively.
- Options are provider-specific and may be ignored or rejected by another provider package.
## Aliases
Use `modelID` to give an API model a friendlier catalog ID:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "openai/coding-default",
"providers": {
"openai": {
"models": {
"coding-default": {
"modelID": "gpt-5.2",
"name": "Coding default",
"capabilities": {
"tools": true,
"input": ["text", "image"],
"output": ["text"],
},
"limit": {
"context": 200000,
"output": 32000,
},
},
},
},
},
}
```
Here, `openai/coding-default` is the selectable reference and `gpt-5.2` is sent to the provider. For a model that is not
already in the catalog, OpenCode assumes:
- Tool support, text and image input, and text output.
- A 200,000-token context limit and 32,000-token output limit.
- An unspecified input limit.
These are fallback assumptions, not detected capabilities. Set accurate `capabilities` and `limit` values when known;
explicit values replace the fallbacks. Add `disabled: true` to hide a model from the available catalog:
```jsonc title="opencode.jsonc"
{
"providers": {
"openai": {
"models": {
"legacy-model": {
"disabled": true,
},
},
},
},
}
```
## Reasoning
For an OpenAI-compatible model that streams reasoning in a custom assistant-message field, set
`compatibility.reasoningField`:
```jsonc title="opencode.jsonc"
{
"providers": {
"local": {
"models": {
"reasoner": {
"compatibility": {
"reasoningField": "reasoning_content",
},
},
},
},
},
}
```
OpenCode recognizes `reasoning`, `reasoning_content`, and `reasoning_text`, and also accepts another provider-specific
string. It reads streamed reasoning from that field and restores the field when sending prior assistant messages back to
the model.
## Local
OpenCode automatically discovers models from Ollama, LM Studio, and vLLM at their default local addresses. You can also
configure any OpenAI-compatible server manually.
```text
ollama/gemma3:4b
lmstudio/google/gemma-4-26b-a4b
vllm/Qwen/Qwen3-Coder-30B-A3B-Instruct
```
### Ollama
With Ollama listening at `http://127.0.0.1:11434`, select a discovered model with the `ollama` provider ID:
```jsonc title="opencode.jsonc"
{
@@ -170,15 +249,13 @@ OpenCode automatically discovers language models from an Ollama server listening
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from Ollama.
Embedding-only models are excluded because they cannot drive a session. Disable discovery with
`"plugins": ["-opencode.provider.ollama"]`.
Embedding-only models are excluded.
For a different host or port, configure Ollama's OpenAI-compatible base URL. Models are still discovered through the
native Ollama API at the same path prefix:
For another host or port, set Ollama's OpenAI-compatible URL. Discovery still uses the native Ollama API at the same
path prefix:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"ollama": {
"settings": {
@@ -190,12 +267,13 @@ native Ollama API at the same path prefix:
}
```
Omit `apiKey` when the Ollama endpoint does not require bearer authentication.
- Omit `apiKey` when the endpoint does not require bearer authentication.
- Disable discovery with `"plugins": ["-opencode.provider.ollama"]`.
#### LM Studio
### LMStudio
OpenCode automatically discovers language models from an unauthenticated LM Studio server listening on its default
address, `http://127.0.0.1:1234`. Discovered models use the `lmstudio` provider ID and LM Studio's model key:
With an unauthenticated LM Studio server at `http://127.0.0.1:1234`, select a discovered model with the `lmstudio`
provider ID:
```jsonc title="opencode.jsonc"
{
@@ -204,15 +282,13 @@ address, `http://127.0.0.1:1234`. Discovered models use the `lmstudio` provider
}
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM
Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
`"plugins": ["-opencode.provider.lmstudio"]`.
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM Studio.
Embedding models are excluded.
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
For another host or port, set the OpenAI-compatible URL. Models are still discovered automatically:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"lmstudio": {
"settings": {
@@ -224,12 +300,12 @@ For a different host or port, configure the OpenAI-compatible base URL. Models a
}
```
Omit `apiKey` when LM Studio authentication is disabled.
- Omit `apiKey` when LM Studio authentication is disabled.
- Disable discovery with `"plugins": ["-opencode.provider.lmstudio"]`.
#### vLLM
### vLLM
OpenCode automatically discovers models from a vLLM server listening on its default address, `http://127.0.0.1:8000`.
Discovered models use the `vllm` provider ID and the model ID reported by vLLM:
With vLLM listening at `http://127.0.0.1:8000`, select a discovered model with the `vllm` provider ID:
```jsonc title="opencode.jsonc"
{
@@ -238,17 +314,16 @@ Discovered models use the `vllm` provider ID and the model ID reported by vLLM:
}
```
OpenCode checks vLLM's `/health` endpoint and refreshes `/v1/models` in the background. It uses the reported
`max_model_len` as the context limit and only includes model cards owned by `vllm`. Discovered vLLM models advertise
text input and output, but not vision or tools. Tool calling is conservative because vLLM enables it with server-level
flags such as `--enable-auto-tool-choice` and `--tool-call-parser`, which model discovery does not report. Disable
discovery with `"plugins": ["-opencode.provider.vllm"]`.
OpenCode checks `/health`, refreshes `/v1/models` in the background, uses the reported `max_model_len` as the context
limit, and includes only model cards owned by `vllm`.
For a different endpoint or an authenticated server, configure its OpenAI-compatible base URL:
Discovered models advertise text input and output, but not vision or tools. vLLM enables tool calling with server flags
such as `--enable-auto-tool-choice` and `--tool-call-parser`, which discovery does not report.
For another endpoint or an authenticated server, set its OpenAI-compatible URL:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"vllm": {
"settings": {
@@ -260,10 +335,14 @@ For a different endpoint or an authenticated server, configure its OpenAI-compat
}
```
Omit `apiKey` when authentication is disabled. Path-prefixed proxy URLs are supported; for example,
`https://example.com/vllm/v1` checks `/vllm/health` and discovers `/vllm/v1/models`.
- Omit `apiKey` when authentication is disabled.
- Disable discovery with `"plugins": ["-opencode.provider.vllm"]`.
- Path-prefixed proxies are supported. For example, `https://example.com/vllm/v1` checks `/vllm/health` and discovers
`/vllm/v1/models`.
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
### Compatible
For another OpenAI-compatible server, define its provider package, endpoint, and at least one model:
```jsonc title="opencode.jsonc"
{
@@ -295,14 +374,13 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea
}
```
Use the server's real model name, limits, modalities, and tool support. OpenCode applies the custom-model capability
defaults described above but cannot infer the server's actual limits or whether those defaults are accurate. If the
endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as
`"apiKey": "{env:LOCAL_API_KEY}"`; do not commit secrets.
Use the server's real model name, limits, input and output types, and tool support. OpenCode cannot detect whether the
custom-model fallback values are accurate. If the endpoint requires a key, add `apiKey` to `settings` with an environment
substitution such as `"apiKey": "{env:LOCAL_API_KEY}"`; do not commit secrets.
### Model references
## References
Configuration fields and model-selection inputs identify a model as `provider/model`, with an optional `#variant`:
Model selectors use `provider/model` with an optional `#variant`:
```text
openai/gpt-5.2
@@ -310,10 +388,15 @@ openai/gpt-5.2#high
openrouter/anthropic/claude-sonnet-4.5#high
```
OpenCode splits the reference at the first `/`, so model IDs may contain additional slashes. Provider and model IDs are
case-sensitive. Provider IDs cannot contain `/` or `#`, and model IDs cannot contain `#`.
| Part | Rule |
| ---------- | ---------------------------------------------------------- |
| Provider | Ends at the first `/`; cannot contain `/` or `#`. |
| Model | May contain additional `/` characters; cannot contain `#`. |
| Variant | Follows `#` when present. |
| Casing | Provider and model IDs are case-sensitive. |
The expanded config form is equivalent when generated or programmatic configuration is more convenient:
Use catalog IDs rather than provider display names. Root, agent, and command `model` fields accept the string form above
or an expanded object:
```jsonc
{
@@ -324,15 +407,23 @@ The expanded config form is equivalent when generated or programmatic configurat
}
```
Root, agent, and command `model` fields accept both forms. Use IDs from the available catalog, not provider display names.
The expanded form is useful for generated or programmatic configuration.
### Caveats
## Caveats
- The selector object uses `model`, while a provider catalog entry uses `modelID` for the upstream API identifier.
- The root `model` currently sets the default provider and model only. Although its selection shape accepts a variant,
the V2 catalog default does not retain it; select a variant for the session, run, agent, or command instead.
- Model options are provider-specific. A setting accepted by one provider package may be ignored or rejected by another.
- Catalog data, credentials, and config are location-scoped. A model available in one project may be unavailable in
another.
- Configuration files are watched and normally reload automatically, but an in-flight model request keeps the settings
with which it started.
Keep these rules in mind when configuring models:
```jsonc
{
"model": "openai/gpt-5.2"
}
```
- A selector object uses `model`; a provider catalog entry uses `modelID` for the ID sent to the provider.
- Although the root selection shape accepts a variant, the V2 catalog default does not retain it. Select variants for a
session, run, agent, or command instead.
- Model options are provider-specific. Another provider package may ignore or reject them.
- Catalog data, credentials, and configuration are location-scoped. A model available in one project may be unavailable
in another.
- Configuration files normally reload automatically. A model request already in progress keeps the settings it started
with.
+182 -138
View File
@@ -2,135 +2,180 @@
title: "Permissions"
---
Permissions control whether an agent may perform an action on a resource. V2
configuration uses the `permissions` field and an ordered array of rules.
Permissions control whether an agent may perform an action on a resource.
<Callout type="warning">
The V1 object syntax uses different field and action names. Do not use `permission`, `bash`, or `task` in V2
configuration; use `permissions`, `shell`, and `subagent`.
</Callout>
## Configure
## Rule schema
Each rule has three required string fields:
A common setup is to ask before running shell commands, allow routine Git
inspection, and always block pushes. Add these ordered rules to `opencode.jsonc`:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "*", "resource": "*", "effect": "ask" },
{ "action": "shell", "resource": "*", "effect": "ask" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git push *", "effect": "deny" },
],
}
```
The last matching rule wins, so the specific exceptions follow the broad rule.
<Callout type="warning">
V1 uses different field and action names. In V2, use `permissions`, `shell`, and `subagent` instead of `permission`,
`bash`, and `task`.
</Callout>
## Rules
Each rule requires three string fields:
| Field | Meaning |
| ---------- | -------------------------------------------------------------------------- |
| `action` | Tool permission action |
| `resource` | Value being used, such as a path, command, URL, query, skill ID, or agent ID |
| `effect` | `allow`, `deny`, or `ask` |
```jsonc
{
"permissions": [
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "read", "resource": "*.env", "effect": "deny" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
{ "action": "shell", "resource": "git push *", "effect": "deny" },
],
}
```
| Effect | Result |
| ------- | ----------------------------------- |
| `allow` | Continue without prompting |
| `deny` | Block the operation |
| `ask` | Wait for a decision from the client |
If no rule matches, OpenCode uses `ask`.
## Matching
Actions and resources use simple whole-value wildcards:
| Pattern | Match |
| ------- | ------------------------------------------ |
| `*` | Zero or more characters, including `/` |
| `?` | Exactly one character |
| Other | The literal character |
```jsonc
{
"permissions": [
{ "action": "edit", "resource": "packages/docs/*.mdx", "effect": "allow" },
],
}
```
- `action` matches a tool permission action.
- `resource` matches the value the tool is trying to use, such as a path,
command, URL, query, or agent ID.
- `effect` is `"allow"`, `"deny"`, or `"ask"`.
This pattern matches the entire normalized path. Backslashes are normalized to
slashes, and matching is case-insensitive on Windows.
`allow` proceeds without prompting, `deny` blocks the operation, and `ask`
waits for a user decision. If no rule matches, the result is `ask`.
A shell pattern ending in ` *` also matches the command without arguments:
## Matching and order
```jsonc
{ "action": "shell", "resource": "git status *", "effect": "allow" }
```
Both `action` and `resource` support simple wildcards:
This matches both `git status` and `git status --short`.
- `*` matches zero or more characters, including `/`.
- `?` matches exactly one character.
- All other characters are literal.
OpenCode combines rules in order and uses the last match. Lower-priority
configuration is loaded first, global rules are appended next, and agent rules
are appended last.
Matches cover the entire value. Slashes are normalized, and matching is
case-insensitive on Windows. For shell convenience, a pattern ending in
`" *"` also matches the command without arguments: `"git status *"` matches
both `git status` and `git status --short`.
```jsonc
{
"permissions": [
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "read", "resource": "secrets/*", "effect": "deny" },
],
}
```
The **last matching rule wins**. Put broad rules first and exceptions later.
Rules from lower-priority configuration files are loaded first. OpenCode then
appends all global rules before agent-specific rules, so a matching agent rule
overrides a global rule.
Operations may check several resources, such as a patch that touches multiple
files. Any `deny` denies the operation; otherwise any `ask` asks; otherwise the
operation is allowed.
Some operations check several resources at once, such as a patch touching
multiple files. OpenCode denies the operation if any resource resolves to
`deny`; otherwise it asks if any resolves to `ask`; otherwise it allows it.
## Actions
## Actions and resources
V2 action names are strings, so plugins may define more actions. Built-in tools
currently use these actions and resources:
V2 action names are strings, so plugins may introduce additional actions. The
current built-in actions use these resources:
| Action | Resource |
| -------------------- | --------------------------------------------------------------------------- |
| `read` | Location-relative internal path or canonical absolute external path |
| `edit` | Target path for `edit`, `write`, and `patch` |
| `glob` | Requested glob pattern |
| `grep` | Requested regular expression, not the search path |
| `shell` | Scanner-produced command string; compound commands may produce several |
| `subagent` | Target agent ID |
| `skill` | Skill ID |
| `question` | `*` |
| `webfetch` | Requested URL |
| `websearch` | Search query |
| `external_directory` | Canonical external directory boundary, normally ending in `/*` |
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
| `execute` | `*`; controls Code Mode availability, while nested tools enforce their rules |
| Action | Resource matched |
| -------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `read` | Location-relative path for an internal file or directory; canonical absolute path for an external target |
| `edit` | Target path for `edit`, `write`, and `patch`; all three tools share this action |
| `glob` | The requested glob pattern |
| `grep` | The requested regular expression, not the search path |
| `shell` | The complete raw shell command string |
| `subagent` | The target agent ID |
| `skill` | The skill ID |
| `question` | `*` |
| `webfetch` | The requested URL |
| `websearch` | The search query |
| `external_directory` | A canonical external directory boundary, normally ending in `/*` |
| `<server>_<tool>` | `*` for an MCP tool; unsupported characters in both names become `_` |
| `execute` | `*`; controls availability of the Code Mode dispatcher, while each nested tool still enforces its own permission |
For example, allow one skill and deny all other skills:
```jsonc
{
"permissions": [
{ "action": "skill", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "effect", "effect": "allow" },
],
}
```
`doom_loop` and `lsp` are not current V2 Core permission actions.
## External directories
## Directories
A path outside both the active Location and its non-root project worktree
requires a separate `external_directory` decision before the tool's own `read`
or `edit` decision. This applies to external paths used by `read`, `edit`,
`write`, and `patch`, and to an external `shell` working directory.
A path outside both the active Location and its non-root project worktree needs
`external_directory` approval before its `read` or `edit` approval.
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{
"action": "external_directory",
"resource": "~/projects/reference/*",
"effect": "allow",
},
{
"action": "read",
"resource": "~/projects/reference/*",
"effect": "allow",
},
{
"action": "edit",
"resource": "~/projects/reference/*",
"effect": "deny",
},
{ "action": "external_directory", "resource": "~/projects/reference/*", "effect": "allow" },
{ "action": "read", "resource": "~/projects/reference/*", "effect": "allow" },
{ "action": "edit", "resource": "~/projects/reference/*", "effect": "deny" },
],
}
```
For `external_directory`, `read`, and `edit` resources, a leading `~`, `~/`,
`$HOME`, or `$HOME/` is expanded when configuration loads. Shell resources are
raw command text and are **not** home-expanded.
This applies to external paths used by `read`, `edit`, `write`, and `patch`.
Shell checks its external working directory and directories inferred by its
scanner before checking `shell` resources.
For `external_directory`, `read`, and `edit`, a leading `~`, `~/`, `$HOME`, or
`$HOME/` is expanded when configuration loads:
```jsonc
{ "action": "read", "resource": "$HOME/reference/*", "effect": "allow" }
```
Shell resources remain raw command text and are not home-expanded.
<Callout type="warning">
`shell` runs with the host user's filesystem, process, and network authority. Its resource is raw text, not a parsed
command. External command arguments produce only best-effort warnings; `external_directory` is enforced for the
working directory, not every path embedded in a command. Prefer a narrow shell allowlist over patterns intended to
identify every dangerous command.
`shell` runs with the host user's filesystem, process, and network authority. Directory inference from command text is
best effort, so prefer a narrow shell allowlist instead of patterns intended to recognize every dangerous command.
</Callout>
Relative mutation paths may traverse outside the active Location while
remaining inside its project worktree. Paths outside both boundaries require
`external_directory` approval. Explicit external paths are canonicalized before
matching, so authorize only trusted directory boundaries.
Relative mutation paths may leave the active Location while remaining inside
its project worktree. Explicit external paths are canonicalized before matching,
so authorize only trusted directory boundaries.
## Experimental shell scanner
## Scanner
Set `experimental.portable_shell_scanner` to `true` to test the portable shell
permission scanner. The default remains the tree-sitter scanner.
Enable the experimental portable shell scanner with:
```jsonc
{
@@ -141,60 +186,55 @@ permission scanner. The default remains the tree-sitter scanner.
}
```
When enabled, shell commands are analyzed only by the portable scanner.
Tree-sitter is not used as a fallback or a second opinion. If the scanner
cannot analyze a command, the shell tool reports a scanner error rather than
silently retrying with Tree-sitter. These failures are experimental parser
gaps to fix, not permission denials.
The portable scanner replaces the default tree-sitter scanner; tree-sitter is
not a fallback or second opinion. A command the portable scanner cannot analyze
returns a scanner error, not a permission denial.
The flag changes parser selection, not permission policy. Existing rules,
saved approval patterns, and best-effort directory inference continue to apply.
There is no additional approval mode or blanket unknown-directory restriction.
With the flag disabled, the existing Tree-sitter path is unchanged.
The flag changes only parser selection. Existing permission rules, saved
approval patterns, and best-effort directory inference still apply. There is no
extra approval mode or blanket restriction for unknown directories.
## Defaults
Every agent, including custom agents, starts with ordered defaults that allow
tools, ask for external directories, ask for `.env` reads, and allow
`.env.example` reads. Shipped agents then add their own policies:
| Agent | Effective default policy |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `build` | Allows most actions; asks for external directories and `.env` reads; allows questions and entering plan mode; denies exiting plan mode |
| `plan` | Uses the same base, allows questions and exiting plan mode, and denies edits except OpenCode plan files |
| `general` | Uses the base policy but cannot launch another subagent; questions and plan transitions remain denied |
| `explore` | Denies everything except `read`, `glob`, `grep`, `webfetch`, and `websearch`; cannot launch subagents and asks for external directories |
| Hidden maintenance agents | Deny all actions |
The base read rules are ordered as follows:
Every agent, including custom agents, starts with this ordered base policy:
```jsonc
[
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "external_directory", "resource": "*", "effect": "ask" },
{ "action": "read", "resource": "*.env", "effect": "ask" },
{ "action": "read", "resource": "*.env.*", "effect": "ask" },
{ "action": "read", "resource": "*.env.example", "effect": "allow" },
]
```
OpenCode also permits its managed tool-output, shell-output, temporary, and
global configuration directories. These exceptions apply only to the
external-directory boundary for every agent; the underlying action still uses
its own permission rules. The environment instructions identify the temporary
directory available for work outside the workspace. Later global and
agent-specific rules can override these defaults.
Shipped agents append these policies:
## Agent overrides
| Agent | Additional policy |
| ------------ | ------------------------------------------------------------------------------ |
| `build` | Allows questions |
| `plan` | Allows questions; denies edits except files under `~/.opencode/plan` |
| `general` | Denies questions and launching subagents |
| `explore` | Denies everything except reads, globs, grep, web fetches, and web searches; asks for external directories and `.env` reads |
| `title` | Denies all actions |
| `summary` | Denies all actions |
| `compaction` | Keeps the base policy |
Configure shared policy at the top level and append narrower rules to a named
agent under `agents.<id>.permissions`:
OpenCode also allows external-directory access to its managed tool-output,
shell-output, temporary, and global configuration directories. The underlying
`read`, `edit`, or other action still uses its own rules. Later global and agent
rules can override these defaults.
## Agents
Put shared rules at the top level and narrower rules under
`agents.<id>.permissions`:
```jsonc
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
],
"agents": {
@@ -203,33 +243,37 @@ agent under `agents.<id>.permissions`:
"mode": "subagent",
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "git diff *", "effect": "allow" },
{ "action": "shell", "resource": "git status *", "effect": "allow" },
],
},
},
}
```
Agent rules do not replace the global array; they are appended after it. A
custom subagent executes with its own permissions, not a permission subset
derived from the parent agent.
Agent rules are appended after global rules; they do not replace the global
array. A custom subagent uses its own permissions, not a subset of its parent's
permissions.
## Approval choices
## Approvals
When an `ask` rule matches, clients can reply with:
When a rule resolves to `ask`, clients can reply with:
- **Allow once** (`once`): approve only the pending request.
- **Allow always** (`always`): approve this request and save the patterns
proposed by the tool for the current project.
- **Reject** (`reject`): reject the request. Rejecting also rejects other
pending permission requests in the same session; clients may attach feedback.
| Choice | Reply | Result |
| ------------ | ---------- | -------------------------------------------------------------------- |
| Allow once | `once` | Approve only the pending request |
| Allow always | `always` | Approve it and save the tool's proposed patterns for the project |
| Reject | `reject` | Reject it and every other pending permission request in that session |
Saved approvals are durable and project-scoped. They are additional `allow`
rules, but they can never override a configured `deny`. The proposed saved
pattern may be broader than the displayed resource: several tools propose `*`,
shell proposes command prefixes, and skills and subagents propose their
IDs. Review the confirmation carefully and remove saved approvals that are no
longer needed.
For example, choosing **Allow always** for `git status --short` may save a
shell prefix that covers later `git status` commands:
Non-interactive clients must decide how to handle requests that require approval; explicit `deny` rules remain enforced.
```text
shell: git status * → allow
```
Saved approvals are durable, project-scoped `allow` rules. They never override
a configured `deny`. Tools choose the proposed saved pattern: some propose `*`,
shell proposes command prefixes, and skills and subagents propose their IDs.
Review broad approvals and remove those no longer needed.
Clients may attach feedback when rejecting. Non-interactive clients must decide
how to handle approval requests; configured `deny` rules always remain enforced.
+15 -4
View File
@@ -2,8 +2,9 @@
title: "Plugins"
---
Load published packages, versioned packages, scoped packages, local plugin directories, or configured plugins from
`opencode.json(c)`.
Add published packages, versioned packages, scoped packages, or local plugin directories to `opencode.json(c)`.
## Configure
```jsonc title="opencode.jsonc"
{
@@ -36,6 +37,8 @@ from lowest to highest precedence instead of replacing one another.
./.opencode/opencode.jsonc
```
## Discover
OpenCode also loads direct `.ts` and `.js` files and immediate plugin package directories from every discovered
`.opencode/plugins/` directory.
@@ -62,6 +65,8 @@ explicitly or move it under `.opencode/`.
}
```
## Control
Plugin entries are processed in order. Prefix an ID or wildcard with `-` to disable it, use `*` for every plugin, and
use `.*` to match an ID prefix. A later ID re-enables a plugin.
@@ -71,7 +76,9 @@ use `.*` to match an ID prefix. A later ID re-enables a plugin.
}
```
Install, inspect, list, or remove global package plugins with the CLI.
## Manage
Install, list, check, update, or remove global package plugins with the CLI.
```sh
opencode2 plugin add opencode-acme-plugin@1.2.0
@@ -91,7 +98,7 @@ Git repositories can use hosted shortcuts, HTTPS, or SSH, including private repo
Git credentials.
```sh
opencode2 plugin add @acme/opencode-plugin@beta
opencode2 plugin add @acme/opencode-plugin@latest
opencode2 plugin add github:acme/opencode-plugin
opencode2 plugin add git+ssh://git@github.com/acme/opencode-plugin.git#main
opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
@@ -100,6 +107,8 @@ opencode2 plugin add 'github:acme/plugins#main::path:packages/opencode-plugin'
Branches, tags, complete commit hashes, and npm's `::path:` repository-subdirectory selectors are supported. Configure
local paths directly; tarball and npm alias targets are not accepted by `plugin add`.
## Reload
Changes under watched config directories reload automatically. Server startup loads cached package plugins immediately,
installs missing packages in the background, and checks unpinned npm and Git plugins for updates without changing the
installed package. Exact npm versions and full Git commit hashes stay pinned. Changes to unwatched local dependencies may
@@ -110,6 +119,8 @@ touch .opencode/plugins/concise/index.ts
opencode2 service restart
```
## Terminal
CLI-only plugins are configured separately and remain active when connected to a remote server.
```json title="cli.json"
+200 -126
View File
@@ -2,8 +2,35 @@
title: "Providers"
---
OpenCode includes a built-in catalog of providers and models from [models.dev](https://models.dev). You can also add a
custom provider to your configuration.
OpenCode includes a provider and model catalog from [models.dev](https://models.dev). For most providers, connect an
account first, then select a model.
## Setup
Run `/connect`, choose a provider, and enter its credentials. Then run `/models` to select one of its models.
```text
/connect
/models
```
## Go
[OpenCode Go](/console/go) is an optional subscription for coding models tested by the OpenCode team. Subscribe in the
[console](https://console.opencode.ai), copy your API key, then connect it as **OpenCode Go**.
```text
/connect
# Select OpenCode Go, then paste your API key.
/models
```
See the [Go guide](/console/go) for usage limits, endpoints, and privacy details.
## Custom
Add a provider when its API is not already in the catalog. This OpenAI-compatible example defines the credential,
runtime package, endpoint, and first model together.
```jsonc title="opencode.jsonc"
{
@@ -26,120 +53,25 @@ custom provider to your configuration.
}
```
The `providers` object is keyed by provider ID. Each provider accepts these fields:
The `providers` object is keyed by the provider ID used in model references, such as `acme/qwen3-coder`.
| Field | Purpose |
| ---------- | --------------------------------------------------------------- |
| `name` | Display name. |
| `env` | Ordered environment variable names that provide a connection. |
| `package` | Runtime provider package. |
| `settings` | JSON settings passed to the runtime package, such as `baseURL`. |
| `headers` | String-valued HTTP headers added to requests. |
| `body` | JSON fields merged into request bodies. |
| `models` | Models to add or override, keyed by catalog model ID. |
## OpenCode Go
[OpenCode Go](/console/go) is an optional subscription that provides access to coding models tested by the OpenCode team.
Subscribe in the [console](https://console.opencode.ai), copy your API key, then run `/connect` in the TUI and select
**OpenCode Go**:
```text
/connect
```
Paste your API key, then run `/models` to select a Go model. See the [Go guide](/console/go) for setup, usage limits,
endpoints, and privacy details.
## Azure OpenAI and Microsoft Foundry
Azure supports either an API key or your existing Microsoft Entra ID session from the Azure CLI.
1. Install the [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) and sign in:
```bash
az login
```
If the resource belongs to another tenant or subscription, select them first:
```bash
az login --tenant TENANT_ID
az account set --subscription NAME_OR_ID
```
2. Find your Azure resource name in the [Azure portal](https://portal.azure.com/) or
[Microsoft Foundry](https://ai.azure.com/): open the Azure OpenAI or Foundry resource and copy its **Resource name**.
It is also the first part of the endpoint: `my-models` in `https://my-models.openai.azure.com/` or
`https://my-models.services.ai.azure.com/`.
If your identity can list resources, the Azure CLI can display the names and resource groups:
```bash
az cognitiveservices account list \
--query "[].{name:name,resourceGroup:resourceGroup}" \
--output table
```
3. In OpenCode, run `/connect`, select **Azure**, and choose **Microsoft Entra ID (Azure CLI)**. Enter the Azure resource
name when prompted. If `AZURE_RESOURCE_NAME` is already set, OpenCode uses it without prompting.
4. Select a deployed model with `/models`.
OpenCode does not query Azure management APIs or discover deployments. Select a model whose catalog name matches your
deployment, or configure the deployment name explicitly:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"azure": {
"models": {
"gpt-5-mini": {
"modelID": "gpt-production",
},
},
},
},
}
```
Your identity needs the **Cognitive Services OpenAI User** role for Azure OpenAI models or the **Cognitive Services User**
role for other Foundry models. If a request fails because the token belongs to another tenant, sign in again with
`az login --tenant TENANT_ID`.
## WebSocket transport
OpenAI and supported Azure Responses models keep one WebSocket connection open per session and send each step over it
instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit what was
added since the previous response, which cuts upload volume on long sessions. Provider compaction runs over the same
connection.
The connection is transparent. When the provider closes the socket, the next step reconnects; when a connection cannot
be opened at all, the session continues over HTTP. Plugins that register `http.request` or `http.response` hooks for a
provider keep it on HTTP so the hooks observe every request.
WebSockets are opt-in per built-in provider. Set `websocket: true` to enable a supported provider or model, or `false`
to disable a built-in opt-in; a model policy overrides the provider policy:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"websocket": false,
"models": {
"gpt-5.5": { "websocket": true },
},
},
},
}
```
| Field | Purpose |
| --- | --- |
| `name` | Display name. |
| `env` | Ordered environment variable names that can provide the credential. |
| `package` | Runtime provider package. |
| `canonical` | Built-in provider ID whose catalog defaults this provider inherits. |
| `settings` | JSON options passed to the runtime package. |
| `headers` | String-valued HTTP headers added to requests. |
| `body` | JSON fields merged into request bodies. |
| `models` | Models to add or override, keyed by the OpenCode model ID. |
| `websocket` | Provider-level WebSocket policy for supported routes. |
| `compaction` | Local or provider [compaction](/compaction) policy. |
## Endpoint
Override `settings.baseURL` to send an existing provider through a proxy or compatible endpoint. Its existing package,
models, and connection continue to apply:
Override `settings.baseURL` to send a catalog provider through a proxy or compatible endpoint. Its existing package,
models, and connection still apply.
```jsonc title="opencode.jsonc"
{
@@ -156,10 +88,9 @@ models, and connection continue to apply:
`settings` is package-specific. A field only has an effect when the selected package supports it.
## Headers and body
## Requests
Use `headers` to add HTTP headers to provider requests. Use `body` to merge additional JSON fields into each request
body:
Use `headers` for additional HTTP headers and `body` for JSON fields that should be merged into every request body.
```jsonc title="opencode.jsonc"
{
@@ -179,10 +110,33 @@ body:
}
```
## Package
Both fields can also be set on a model or variant when only part of a provider's traffic needs the override.
The `package` field selects the runtime used to communicate with a provider. For an OpenAI-compatible API, use the
built-in compatible package:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"models": {
"gpt-5.2": {
"headers": { "X-Model-Tier": "coding" },
"variants": [
{
"id": "batch",
"body": { "service_tier": "flex" },
},
],
},
},
},
},
}
```
## Packages
The `package` field selects the runtime that communicates with a provider. Use the compatible runtime for APIs that
implement the OpenAI request format.
```jsonc title="opencode.jsonc"
{
@@ -194,16 +148,14 @@ built-in compatible package:
"baseURL": "https://llm.acme.example/v1",
},
"models": {
"qwen3-coder": {
"name": "Qwen 3 Coder",
},
"qwen3-coder": {},
},
},
},
}
```
Native package options:
Native package options include:
- `@opencode/ai/providers/openai`
- `@opencode/ai/providers/openai/chat`
@@ -230,12 +182,22 @@ Native package options:
You can also use an npm package such as `@acme/opencode-provider` or an absolute `file://` URL for a local package.
Use `settings` for options supported by the selected package.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"acme": {
"package": "@acme/opencode-provider",
"models": { "acme-coder": {} },
},
},
}
```
## Models
Add a model under a provider's `models` map. The object key is the model ID used in OpenCode; `modelID` is the ID sent to
the provider:
Add or override models in a provider's `models` map. The map key is the model ID used by OpenCode; `modelID` changes the
ID sent to the provider.
```jsonc title="opencode.jsonc"
{
@@ -254,4 +216,116 @@ the provider:
}
```
See [Models](/models) for model selection, defaults, capabilities, limits, costs, and variants.
| Field | Purpose |
| --- | --- |
| `modelID` | Model or deployment ID sent to the provider. |
| `name` | Display name. |
| `family` | Model family used for grouping related models. |
| `package` | Runtime override for this model. |
| `settings` | Package-specific JSON settings. |
| `headers` | Additional string-valued request headers. |
| `body` | Additional JSON request body fields. |
| `capabilities` | Tool support plus accepted input and output media types. |
| `compatibility` | Request and response compatibility overrides. |
| `variants` | Named variants with their own `settings`, `headers`, and `body`. |
| `cost` | Input, output, and optional cache pricing per million tokens. |
| `limit` | Context, input, and output token limits. |
| `disabled` | Removes the model from selection when `true`. |
| `websocket` | Model-level WebSocket policy; overrides the provider policy. |
| `compaction` | Model-level [compaction](/compaction) policy; overrides the provider policy. |
See [Models](/models) for selection, defaults, capabilities, limits, costs, and variants.
## Azure
Azure supports either an API key or the Microsoft Entra ID session from the Azure CLI. To use Entra ID, install the
[Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) and sign in before connecting.
```bash
az login
```
For a resource in another tenant or subscription, select both explicitly.
```bash
az login --tenant TENANT_ID
az account set --subscription NAME_OR_ID
```
Find the **Resource name** in the [Azure portal](https://portal.azure.com/) or
[Microsoft Foundry](https://ai.azure.com/). It is also the first part of endpoints such as
`https://my-models.openai.azure.com/` and `https://my-models.services.ai.azure.com/`.
```bash
az cognitiveservices account list \
--query "[].{name:name,resourceGroup:resourceGroup}" \
--output table
```
In OpenCode, select **Azure**, then **Microsoft Entra ID (Azure CLI)**. Enter the resource name when prompted;
`AZURE_RESOURCE_NAME` skips that prompt when already set.
```text
/connect
/models
```
OpenCode does not query Azure management APIs or discover deployments. If a deployment does not match its catalog
model name, map an OpenCode model ID to the deployment with `modelID`.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"azure": {
"models": {
"gpt-5-mini": {
"modelID": "gpt-production",
},
},
},
},
}
```
Your identity needs one of these roles:
- **Cognitive Services OpenAI User** for Azure OpenAI models.
- **Cognitive Services User** for other Foundry models.
If a request uses a token from the wrong tenant, sign in again with the required tenant.
```bash
az login --tenant TENANT_ID
```
## WebSockets
OpenAI, xAI, and supported Azure Responses models can keep one WebSocket connection open per session. Consecutive steps
reuse the unchanged request prefix and only send content added since the previous response, reducing uploads in long
sessions.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"openai": {
"websocket": false,
"models": {
"gpt-5.5": { "websocket": true },
},
},
},
}
```
WebSocket behavior follows these rules:
- Built-in providers opt supported models in according to their own policy.
- `websocket: true` enables a supported provider or model; `false` disables it.
- A model value overrides its provider value.
- OpenAI provider compaction uses the same connection.
- xAI continues from stored responses only. With its default `store: false`, each step is sent in full over the reused
connection.
- A closed socket reconnects on the next step. If the connection cannot open, the session continues over HTTP.
- Provider plugins with `http.request` or `http.response` hooks stay on HTTP so each request remains observable.
+167 -58
View File
@@ -3,10 +3,8 @@ title: "References"
---
References give OpenCode named access to directories outside the current
project. Use them for documentation, shared libraries, examples, or source from
another repository.
Configure references by alias in `opencode.json` or `opencode.jsonc`:
project. Add one in `opencode.json` or `opencode.jsonc`, then attach its alias in
your client when you need it.
```jsonc title="opencode.jsonc"
{
@@ -16,16 +14,20 @@ Configure references by alias in `opencode.json` or `opencode.jsonc`:
"path": "../product-docs",
"description": "Use for product behavior and terminology",
},
"effect": {
"repository": "Effect-TS/effect",
"branch": "main",
"description": "Use for Effect implementation details",
},
},
}
```
## Local directories
After attaching `docs`, ask the agent to inspect the relevant path:
```text
Read the attached docs reference and summarize api/authentication.md.
```
Use references for documentation, shared libraries, examples, or source from
another repository.
## Local
Use `path` for a local directory:
@@ -40,11 +42,13 @@ Use `path` for a local directory:
}
```
Relative paths resolve from the directory containing the config file that
defines them. Absolute paths and home-relative paths such as `~/docs` are also
supported.
Paths behave as follows:
The string shorthand is useful when no other fields are needed:
- Relative paths resolve from the directory containing the config file.
- Absolute paths are supported.
- Home-relative paths such as `~/docs` are supported.
Use the string shorthand when no other fields are needed:
```jsonc
{
@@ -56,14 +60,13 @@ The string shorthand is useful when no other fields are needed:
```
<Callout type="note">
A shorthand string is treated as a local path only when it starts with `.`, `/`, or `~`. Use `./docs`, not `docs`; a
bare `docs` value is interpreted as a Git repository.
A shorthand string is a local path only when it starts with `.`, `/`, or `~`. Use `./docs`, not `docs`; a bare `docs`
value is interpreted as a Git repository.
</Callout>
## Git repositories
## Git
Use `repository` for a remote Git repository. GitHub `owner/repo` shorthand,
Git URLs, host/path forms, and SCP-style remotes are supported.
Use `repository` for a remote Git repository:
```jsonc
{
@@ -80,12 +83,16 @@ Git URLs, host/path forms, and SCP-style remotes are supported.
}
```
Without `branch`, OpenCode checks out and refreshes the remote's default
branch. Branch names may contain letters, numbers, `/`, `_`, `.`, and `-`, but
cannot start with `-` or contain `..`. Local `file:` repositories are not
supported.
Supported remote forms include:
Git references also support shorthand:
- GitHub `owner/repo` shorthand
- Git URLs
- Host/path forms
- SCP-style remotes
Local `file:` repositories are not supported.
Use the string shorthand to follow the remote's default branch:
```jsonc
{
@@ -96,47 +103,114 @@ Git references also support shorthand:
}
```
### Cloning and storage
Without `branch`, OpenCode checks out and refreshes the remote's default branch.
Branch names may contain letters, numbers, `/`, `_`, `.`, and `-`, but cannot
start with `-` or contain `..`.
OpenCode normalizes a remote and stores one checkout per remote and branch
under its global data directory. Without an explicit branch, the checkout is
stored at `opencode/repos/<host>/<repository-path>`. On a typical Linux
installation, for example, `Effect-TS/effect` is stored at:
```jsonc
{
"references": {
"sdk": {
"repository": "example/sdk",
"branch": "release/v2.1",
},
},
}
```
## Storage
OpenCode normalizes each remote and stores one checkout per remote and branch
under its global data directory. Without an explicit branch, the path uses this
shape:
```text
opencode/repos/<host>/<repository-path>
```
On a typical Linux installation, `Effect-TS/effect` is stored at:
```text
~/.local/share/opencode/repos/github.com/Effect-TS/effect
```
An explicit branch adds an encoded `@<branch>` suffix to the checkout path.
An explicit branch adds an encoded `@<branch>` suffix:
Missing repositories are cloned. When references load or reload, and after a
new user prompt is admitted in their Location, OpenCode checks them in the
background. An existing checkout is eligible when its last refresh attempt was
at least 24 hours ago, or no attempt has been recorded. A refresh fetches and
resets to the requested branch, or to the remote default branch when `branch`
is omitted.
```text
~/.local/share/opencode/repos/github.com/Effect-TS/effect@main
```
Refresh timestamps persist across service restarts and are shared by Locations
using the same checkout. Failed refresh attempts are logged and remain subject
to the 24-hour limit. There is no periodic polling while a Location is unused.
## Refresh
Prompts do not wait for background refreshes. An attachment can therefore
contain older content even if a later tool read sees the updated checkout.
Initial cloning is also asynchronous, so a new reference can appear before its
checkout is ready. Clone and refresh failures do not stop other references
from loading.
Missing repositories are cloned asynchronously. Existing checkouts are checked
in the background when references load or reload and after a new user prompt is
admitted in their Location.
```text
Prompt admitted → refresh check starts in background → agent continues
```
A checkout is eligible when its last refresh attempt was at least 24 hours ago,
or when no attempt has been recorded. A refresh fetches and resets to the
configured branch or the remote's default branch.
```text
Last attempt: 25 hours ago → eligible
Last attempt: 2 hours ago → skipped
```
Refresh behavior follows these rules:
- Timestamps persist across service restarts.
- Locations using the same checkout share its timestamp.
- Failed attempts are logged and remain subject to the 24-hour limit.
- There is no periodic polling while a Location is unused.
- Clone or refresh failures do not stop other references from loading.
Prompts do not wait for cloning or refreshing. An attachment can contain older
content even if a later tool read sees the updated checkout, and a new reference
can appear before its checkout is ready.
```text
Attachment created → cached content
Background refresh completes → later tool read sees newer content
```
<Callout type="warning">
Cached checkouts are shared and can update while an agent is using them. Avoid editing cached checkouts because a
refresh resets them.
</Callout>
## Description and visibility
## Guidance
`description` tells agents when a reference is relevant. References with a
description are included in agent instructions with their alias and resolved
path. References without one remain available to clients but are not advertised
automatically.
description appear in agent instructions with their alias and resolved path.
```jsonc
{
"references": {
"docs": {
"path": "../docs",
"description": "Use for product behavior and terminology",
},
},
}
```
References without a description remain available to clients but are not
advertised automatically.
```jsonc
{
"references": {
"archive": {
"path": "../archive",
},
},
}
```
## Visibility
Set `hidden` to `true` to remove a reference from interactive client selectors:
@@ -152,20 +226,32 @@ Set `hidden` to `true` to remove a reference from interactive client selectors:
}
```
`hidden` controls only interactive visibility. It does not remove the
reference from the reference API or agent instructions when a description is
present.
`hidden` affects only interactive visibility. The reference remains in the
reference API and, when it has a description, in agent instructions.
## Use references
## Usage
Clients can attach a reference by its root alias. The attachment provides a
non-recursive listing of the root's immediate files and directories. Ask the
agent to inspect a particular path when more detail is needed.
Clients attach a reference by its root alias. The attachment contains a
non-recursive listing of the root's immediate files and directories.
```text
Attached alias: docs
Ask: Inspect docs/guides/deployment.md and explain the deployment steps.
```
Ask the agent to inspect a specific path when you need content below the root.
## Permissions
References do not grant extra tool permissions. Access outside the active
Location remains subject to the agent's normal tool rules and the
`external_directory` permission. Editing a reference additionally requires the
applicable edit permission.
Location still follows normal tool rules and the `external_directory`
permission; editing also requires the applicable edit permission.
```text
Read ../product-docs/api.md
```
The request succeeds only when the active permissions allow that external read.
## Fields
@@ -177,5 +263,28 @@ applicable edit permission.
| `description` | Optional | Optional | Guidance describing when agents should use it |
| `hidden` | Optional | Optional | Hide it from interactive client selectors |
A complete Git entry can use every Git-compatible field:
```jsonc
{
"references": {
"sdk": {
"repository": "example/sdk",
"branch": "main",
"description": "Use for SDK implementation details",
"hidden": false,
},
},
}
```
## Aliases
An alias cannot be empty or contain `/`, `\`, whitespace, a backtick, or a
comma.
```text
Valid: docs
Invalid: product docs
Invalid: docs/api
```
+2 -32
View File
@@ -1,35 +1,5 @@
---
title: "Session sharing"
title: "Sharing"
---
Session sharing is not yet available in OpenCode V2. V2 does not currently
publish sessions, upload conversation history to a sharing service, or create
public links.
There is no functional share or unshare server API endpoint.
## Configuration
The V2 configuration schema accepts a `share` field with three values:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"share": "manual",
}
```
- `"manual"` represents sharing only when explicitly requested.
- `"auto"` represents automatically sharing new sessions.
- `"disabled"` represents preventing session sharing.
These values are parsed but are not acted on by the current V2 runtime. In
particular, setting `"auto"` does not publish sessions. If `share` is omitted,
V2 leaves the sharing policy unspecified.
## Beta limitations
V2 currently provides no public session viewer, share URL, history sync,
retention controls, or unshare/delete operation. Until those surfaces are
implemented in the V2 server and protocol, keep using sessions locally and do
not treat the `share` configuration field as a privacy or publishing control.
OpenCode V2 does not support session sharing yet.
+116 -93
View File
@@ -2,23 +2,7 @@
title: "Skills"
---
Skills are Markdown instructions that OpenCode can advertise to an agent and
load when they are relevant. A skill can include supporting scripts,
references, and other files in the same directory.
## Create a skill
Create one directory per skill with a `SKILL.md` file:
```text
.opencode/skills/
└── git-release/
├── SKILL.md
├── scripts/
│ └── changelog.ts
└── references/
└── release-policy.md
```
Create a skill to give an agent reusable instructions for a specific task. Put a `SKILL.md` file in `.opencode/skills/<skill-id>` and describe when to use it in `description`.
```markdown title=".opencode/skills/git-release/SKILL.md"
---
@@ -34,11 +18,27 @@ description: Prepare release notes, version bumps, and GitHub releases
4. Run `scripts/changelog.ts` only after the user approves the version.
```
Paths in a skill are relative to the directory containing `SKILL.md`.
OpenCode advertises this skill when it is relevant. The agent can then load its instructions with the `skill` tool instead of adding every skill to every prompt.
## Create
Keep related scripts, references, and templates beside `SKILL.md`:
```text
.opencode/skills/
└── git-release/
├── SKILL.md
├── scripts/
│ └── changelog.ts
└── references/
└── release-policy.md
```
Paths written inside the skill are relative to the directory containing `SKILL.md`. The directory form is recommended because it gives supporting files a private base directory.
## Discovery
OpenCode automatically adds the following source directories:
OpenCode automatically searches these locations:
| Scope | Sources |
| --------------------- | -------------------------------------- |
@@ -47,21 +47,23 @@ OpenCode automatically adds the following source directories:
| Project | `.opencode/skills` |
| Project compatibility | `.claude/skills`, `.agents/skills` |
For project sources, OpenCode searches from the current directory upward to
the project root and includes matching directories at every level.
For project sources, OpenCode searches from the current directory up to the project root and includes matching directories at every level.
Within each source directory, OpenCode discovers:
Each source can contain either form:
- Markdown files at the source root, such as `skills/git-release.md`
- `SKILL.md` files at any depth, such as `skills/git-release/SKILL.md`
```text
skills/
├── review.md
└── git-release/
└── SKILL.md
```
The directory form is recommended because it gives the skill a private base
directory for supporting files.
- Markdown files must be at the source root, such as `skills/review.md`.
- Files named exactly `SKILL.md` can be at any depth, such as `skills/git-release/SKILL.md`.
## Configure sources
## Sources
Use the `skills` array in any `opencode.json` or `opencode.jsonc` to add local
directories or HTTP catalogs:
Add more local directories or HTTP catalogs with the `skills` array in any `opencode.json` or `opencode.jsonc`:
```jsonc title="opencode.jsonc"
{
@@ -75,17 +77,18 @@ directories or HTTP catalogs:
}
```
Relative paths are resolved from the active OpenCode working directory, not
from the directory containing the config file. Paths beginning with `~/` use
the current user's home directory. Only `http://` and `https://` values are
treated as URL sources.
| Value | Resolution |
| ----- | ---------- |
| Relative path | From the active OpenCode working directory, not the config file |
| `~/` path | From the current user's home directory |
| Absolute path | Used as written |
| `http://` or `https://` URL | Loaded as an HTTP catalog |
Every discovered config document contributes its `skills` entries; the arrays
are additive rather than replacing one another.
Every discovered config file contributes its entries. `skills` arrays are combined rather than replaced.
### HTTP catalogs
## Catalogs
An HTTP source is a base URL containing an `index.json`:
An HTTP catalog is a base URL with an `index.json` file:
```json title="index.json"
{
@@ -99,41 +102,47 @@ An HTTP source is a base URL containing an `index.json`:
}
```
OpenCode downloads those files from
`<base-url>/git-release/<file>`. File paths must be safe, relative,
same-origin paths. Each entry must include either `SKILL.md` or a Markdown file
named after the index entry, such as `git-release.md`.
For that entry, OpenCode downloads each file from `<base-url>/git-release/<file>`.
Use the named Markdown form for HTTP catalogs. Each downloaded skill directory
is itself a source root, so `git-release.md` produces the ID `git-release`; a
root-level `SKILL.md` produces the literal ID `SKILL` in the current V2
implementation. Increment `version` when files change so OpenCode refreshes
the cached copy.
| Rule | Requirement |
| ---- | ----------- |
| Paths | Must be safe, relative, and same-origin |
| Entry file | Include `SKILL.md` or `<name>.md`, such as `git-release.md` |
| Updates | Increment `version` when files change so OpenCode refreshes its cache |
Prefer the named Markdown form in an HTTP catalog. Each downloaded skill directory becomes a source root, so `git-release.md` has the ID `git-release`. A root-level `SKILL.md` currently has the literal ID `SKILL` in V2.
## Frontmatter
V2 reads these fields:
Use frontmatter to name the skill and control where it appears:
| Field | Purpose |
```markdown title="SKILL.md"
---
name: Git Release
description: Prepare a repository release
slash: true
metadata:
opencode/autoinvoke: false
---
Prepare the changelog, version bump, tag, and release notes.
```
| Field | Behavior |
| ------------------------------ | ------------------------------------------------------------------ |
| `name` | Display name; defaults to the path-derived ID |
| `description` | Summary used for model-facing discovery |
| `description` | Summary used to show the skill to the model |
| `slash` | Set to `false` to hide the skill from interactive command catalogs |
| `metadata.opencode/slash` | Boolean or `"true"`/`"false"`; overrides `slash` |
| `metadata.opencode/autoinvoke` | Set to `false` to omit the skill from model-facing discovery |
| `metadata.opencode/autoinvoke` | Set to `false` to omit the skill from the model's available list |
Frontmatter, `name`, and `description` are optional at runtime. However, a
clear `description` is strongly recommended: skills without one are not
advertised to the model. `license`, `compatibility`, and other metadata may be
included for portability, but V2 does not interpret them.
All frontmatter is optional at runtime. Add a clear `description` when the model should discover the skill; skills without one are not advertised.
`opencode/autoinvoke: false` only removes the skill from the model's available
skills list. The skill remains registered and can still be activated explicitly
by its ID.
`opencode/autoinvoke: false` only hides the skill from the model's available list. The skill remains registered and can still be loaded explicitly by ID. V2 accepts portability fields such as `license` and `compatibility` but does not interpret them.
## IDs and validation
## IDs
The skill ID comes from its path, not its frontmatter:
The file path determines the skill ID. The frontmatter `name` is only a display label.
| File | ID |
| --------------------------------- | ------------- |
@@ -141,54 +150,58 @@ The skill ID comes from its path, not its frontmatter:
| `<source>/git-release/SKILL.md` | `git-release` |
| `<source>/teams/release/SKILL.md` | `release` |
IDs are exact and case-sensitive. V2 currently does not enforce the Agent
Skills name regex, length limits, a match between `name` and the directory, or
a maximum description length. The frontmatter `name` is only a display label.
For portable, predictable skills, use a unique lowercase kebab-case ID of 1-64
characters and keep it aligned with the directory name:
IDs are exact and case-sensitive. For portable skills, use a unique lowercase kebab-case ID of 164 characters and keep it aligned with the directory name:
```text
^[a-z0-9]+(-[a-z0-9]+)*$
```
V2 currently does not enforce this pattern, the 164 character recommendation, a match between `name` and the directory, or a maximum description length.
## Precedence
Skills are keyed by ID. If several sources define the same ID, the later source
wins. Sources are registered in this order, from lower to higher precedence:
Skills are selected by ID. If two sources define `git-release`, the source registered later supplies the skill that OpenCode loads:
```text
~/.config/opencode/skills/git-release/SKILL.md ← lower precedence
.opencode/skills/git-release/SKILL.md ← loaded
```
Sources are registered from lower to higher precedence:
1. Built-in skills
2. `.claude/skills` sources, global first and then from the farthest ancestor toward the current directory
3. `.agents/skills` sources, global first and then from the farthest ancestor toward the current directory
2. `.claude/skills`, global first and then from the farthest ancestor toward the current directory
3. `.agents/skills`, global first and then from the farthest ancestor toward the current directory
4. `~/.config/opencode/skills`
5. Project `.opencode/skills`, from the project root toward the current directory
6. Explicit `skills` config entries, in config priority and array order
Avoid duplicate IDs unless an override is intentional.
Avoid duplicate IDs unless you intend to override an earlier skill.
## Runtime loading
## Loading
At each model step, OpenCode advertises permitted skills that have a
description and do not set `opencode/autoinvoke` to `false`. The advertisement
contains only each skill's ID, name, and description; it does not add every
skill body to the prompt.
At each model step, OpenCode lists permitted skills that have a description and do not set `opencode/autoinvoke` to `false`. The list includes only the ID, name, and description, not the full Markdown body.
When the model calls the `skill` tool with an exact ID, OpenCode:
The model loads a skill by calling the `skill` tool with its exact ID:
1. Resolves the current winning definition for that ID
2. Checks the `skill` permission for the selected agent
3. Adds the Markdown body, without frontmatter, to the conversation
4. Provides the skill's base directory and a sample of up to ten supporting file paths
```json title="skill tool input"
{
"id": "git-release"
}
```
Supporting file contents are not loaded automatically. The agent can read a
referenced file when the skill instructs it to do so. The supporting-file
sample is available for directory-based `SKILL.md` skills; flat Markdown skills
receive no neighboring file list.
OpenCode then:
1. Selects the current definition for that ID.
2. Checks the selected agent's `skill` permission.
3. Adds the Markdown body, without frontmatter, to the conversation.
4. Provides the skill's base directory and a sample of up to ten supporting file paths.
Supporting file contents are not loaded automatically. The agent reads them when the skill directs it to do so. The file sample is available for directory-based `SKILL.md` skills; flat Markdown skills do not receive a neighboring file list.
## Permissions
Permission rules use the `skill` action and the skill ID as the resource. Rules
are evaluated in order, with the last matching rule winning:
Use the `skill` action and the skill ID as the resource. Rules run in order, and the last matching rule wins:
```jsonc title="opencode.jsonc"
{
@@ -200,18 +213,28 @@ are evaluated in order, with the last matching rule winning:
}
```
`deny` removes matching skills from model-facing discovery and rejects skill
tool loading. `ask` advertises the skill but requests approval when the model
loads it. The same rules can be placed under an individual
`agents.<id>.permissions` array.
| Effect | Behavior |
| ------ | -------- |
| `allow` | Advertises and loads matching skills without approval |
| `ask` | Advertises matching skills and asks before loading them |
| `deny` | Hides matching skills from the model and rejects loading |
Place the same rules under `agents.<id>.permissions` to apply them only to one agent.
## Troubleshooting
For example, this skill's ID is `release`, not its frontmatter name `Git Release`:
```text
.opencode/skills/release/SKILL.md
└─ ID: release
```
If a skill is missing or loads the wrong content:
1. Confirm the file is either a root-level `*.md` or a nested file named exactly `SKILL.md`.
2. Check the path-derived ID rather than the frontmatter `name`.
3. Add a `description` if the skill should be advertised to the model.
2. Check the path-derived, case-sensitive ID rather than the frontmatter `name`.
3. Add a `description` if the model should discover the skill.
4. Check `opencode/autoinvoke` and the selected agent's `skill` permissions.
5. Look for a later source defining the same ID.
5. Look for a later source that defines the same ID.
6. For HTTP catalogs, verify `index.json`, same-origin file paths, and a changed `version`.
+109 -45
View File
@@ -2,13 +2,104 @@
title: "Snapshots"
---
OpenCode snapshots let clients roll back conversation history and related file changes. They are a convenience for
revising recent work, not a replacement for Git commits or backups.
Snapshots let you undo recent conversation and file changes together. Use them to revise recent work, but keep Git commits
or backups for anything important.
## Configuration
## Workflow
Snapshots are enabled by default. Set `snapshots` to `false` in your [configuration](/config#snapshots) to stop capturing
filesystem state:
Run `/undo` after a response you want to revise. OpenCode stages the rollback, restores the affected files, and puts the
removed prompt back in the composer so you can edit it.
```text
/undo
# Edit the restored prompt, then submit it again.
Add validation without changing the public API.
```
Submitting the edited prompt commits the staged rollback. Before submitting, run `/redo` to cancel it and restore the
conversation and files you had before `/undo`.
```text
/redo
```
The terminal also provides default shortcuts:
| Action | Command | Shortcut |
| --- | --- | --- |
| Stage the previous message rollback | `/undo` | `<leader>u` |
| Cancel the staged rollback | `/redo` | `<leader>r` |
You can also select an earlier message and choose **Revert** to stage a rollback to that point.
## Capture
For each model step, OpenCode attempts a snapshot immediately before the model call and another when the step reaches a
recorded success or failure. The assistant message records which paths changed between those snapshots.
```text
before model step ── snapshot A
model edits src/api.ts
step finishes ────── snapshot B ── changed: src/api.ts
```
Capture is best effort. If either snapshot is unavailable, the conversation continues, but that step may not have file
changes available to restore.
## Scope
Snapshots cover the session's active directory inside a Git worktree. For example, a session opened in `packages/app`
captures eligible files below `packages/app`, not changes elsewhere in the repository.
| Content | Captured | Example |
| --- | --- | --- |
| Tracked files in the active directory | Yes | `packages/app/src/app.tsx` |
| Non-ignored untracked files up to 2 MiB each | Yes | `packages/app/notes.txt` |
| Git-ignored files | No | `packages/app/dist/app.js` |
| Individual untracked files larger than 2 MiB | No | `packages/app/debug.log` |
| Files outside the active directory | No | `packages/server/src/index.ts` |
| Git metadata and other side effects | No | commits, branches, databases, or processes |
Filesystem snapshots require a Git repository. Outside one, `/undo` can still roll back conversation history, but it
cannot restore files.
## Restore
A rollback restores only paths attributed to assistant steps after the selected conversation boundary. Each path returns
to its state before the first reverted step; a file created by those steps is removed if it did not exist then.
| Timeline | `src/api.ts` | `src/new.ts` |
| --- | --- | --- |
| Selected boundary | version A | missing |
| Later assistant steps | version B | created |
| After rollback | version A | removed |
Paths not attributed to those steps are left alone. Current edits to an affected path can be overwritten, so review the
restored summary and `git diff` before continuing.
```sh
git diff --stat
git diff
```
## Storage
OpenCode stores snapshots locally in a separate internal Git object database under its data directory. Snapshot capture
does not create commits, move branches, or intentionally change your repository's index.
```sh
# Your repository remains an ordinary dirty worktree after capture.
git status --short
```
Snapshot objects can contain the complete contents of tracked and eligible untracked files. Treat the OpenCode data
directory as sensitive local data.
## Settings
Snapshots are enabled by default. Set `snapshots` to `false` in your [configuration](/config#snapshots) to stop future
filesystem capture.
```jsonc title="opencode.jsonc"
{
@@ -17,47 +108,20 @@ filesystem state:
}
```
Filesystem snapshots require a Git repository. With snapshots disabled, unavailable, or missing, a client can still
request a conversation rollback, but there is no captured file state to restore. Disabling snapshots does not delete
snapshots that were already stored.
Disabling snapshots does not delete snapshots already stored. Conversation rollback remains available, but without a
captured filesystem state it cannot restore related file changes.
## What is captured
## Safety
For each model step, OpenCode attempts to capture the worktree immediately before the model call and after a cleanly
completed step. It records the paths changed between those two points on the assistant message.
Snapshots are a convenience, not a transaction or backup:
Snapshots use a separate internal Git object database in the OpenCode data directory. They do not create commits, move
branches, or intentionally modify your repository's Git index. Capture is limited to the session's active directory, which
may be a subdirectory of the repository.
- Shell commands can change services, processes, network resources, databases, Git state, ignored output, or files outside
the active directory. Restoring a snapshot does not reverse those effects.
- Interrupted or failed work can leave changes when capture fails or no usable snapshot pair exists.
- Restoring affected paths overwrites their current contents. Cancelling a staged rollback can also overwrite later edits.
- OpenCode rejects rollback operations while the session is running, but external editors and commands can still change
the worktree between capture and restoration.
- Committing a rollback removes messages from the active conversation view, not from durable session history or existing
snapshot storage. It is not secure erasure.
Within that directory, snapshots include tracked files and untracked files that are not ignored by Git. An individual
untracked file larger than 2 MiB is excluded. Ignored files, files outside the active directory, and changes to Git
metadata are not captured.
## Restoration
A restoration selects a conversation boundary and restores only paths attributed to cleanly completed assistant steps
after it. Each affected path returns to its state before the first restored step, and a file created by those steps is
removed when it did not exist in the earlier snapshot.
Clients may stage a restoration before committing it so the previous conversation and filesystem state can still be
recovered.
## Limitations and safety
- Capture is best effort. A failed capture is logged and the model step continues, so conversation rollback may have no
matching file rollback.
- Interrupted or failed steps do not receive a completed end snapshot. File changes made before the failure may remain.
- Shell commands can change databases, services, processes, network resources, Git state, ignored build output, or files
outside the active directory. Snapshot restoration does not reverse those side effects.
- Restoring a snapshot overwrites the current contents of affected paths. Recovering the pre-restoration state can also
overwrite edits made after the restoration was staged.
- Other processes can edit the worktree between capture and restore. The server rejects revert operations while the
session is actively running, but it cannot protect against external editors or commands.
- Snapshot objects can contain complete contents of tracked and non-ignored untracked files. They are stored locally in
the OpenCode data directory; do not treat snapshots as secret-free metadata.
- Snapshot restoration is not secure erasure. Committing a revert removes messages from the active projection, not from
durable session history or existing snapshot storage.
Review the restored file summary and your Git diff before continuing. Commit or back up important work independently
before restoring snapshots on a dirty worktree.
Commit or back up important work before using `/undo` on a dirty worktree.
+264 -4
View File
@@ -2,8 +2,268 @@
title: "Themes"
---
Themes are currently a terminal-client capability rather than a shared OpenCode configuration feature. The terminal
client includes built-in light and dark themes, supports terminal-aware color modes, and owns theme selection and custom
theme loading.
Choose a theme from the terminal UI by pressing `Ctrl+P`, selecting **Open settings**, and opening **Theme**. You can also
set it directly in your global CLI config:
See [CLI config](/cli/config) for terminal-client theme settings.
```json title="~/.config/opencode/cli.json"
{
"theme": {
"name": "tokyonight",
"mode": "system"
}
}
```
Themes belong to the terminal client. Put theme selection in `cli.json`, not `opencode.json`. See
[CLI config](/cli/config) for the config location and other terminal settings.
## Modes
Use `system` to follow your terminal's appearance, or lock the theme to one mode:
```json title="~/.config/opencode/cli.json"
{
"theme": {
"name": "opencode",
"mode": "dark"
}
}
```
| Value | Behavior |
| -------- | --------------------------------------------- |
| `system` | Follows the light or dark mode of the terminal. |
| `dark` | Always uses the dark theme. |
| `light` | Always uses the light theme. |
If a custom theme provides only one mode, OpenCode uses that mode when the other is requested.
## Builtins
Select a built-in theme by its name:
```json title="~/.config/opencode/cli.json"
{
"theme": {
"name": "catppuccin"
}
}
```
Available themes include:
- `aura`, `ayu`, `carbonfox`, `cobalt2`, `cursor`, `dracula`, `everforest`, `flexoki`
- `catppuccin`, `catppuccin-frappe`, `catppuccin-macchiato`
- `github`, `gruvbox`, `kanagawa`, `material`, `matrix`, `mercury`, `monokai`, `nightowl`, `nord`
- `one-dark`, `opencode`, `orng`, `lucent-orng`, `osaka-jade`, `palenight`, `rosepine`
- `solarized`, `synthwave84`, `tokyonight`, `vercel`, `vesper`, `zenburn`
The `system` theme is available when OpenCode can read your terminal palette.
## Files
To create a global theme named `ocean`, save this file:
```json title="~/.config/opencode/themes/ocean.json"
{
"version": 2,
"dark": {
"hue": {
"accent": "$hue.cyan"
},
"text": {
"default": "#d8f3ff"
}
}
}
```
Then select `ocean` in settings or set `theme.name` to `ocean` in `cli.json`. The filename, without `.json`, is the
theme name.
| Scope | Directory |
| ------- | ------------------------------------------ |
| Global | `~/.config/opencode/themes/` |
| Project | `.opencode/themes/` in the project tree |
OpenCode reads `.json` files only. A theme closer to the current directory replaces a global or parent theme with the
same name.
## Format
Every V2 theme declares `version: 2` and at least one of `light` or `dark`:
```json title="themes/ocean.json"
{
"version": 2,
"light": {
"text": {
"default": "#16324f"
}
},
"dark": {
"text": {
"default": "#d8f3ff"
}
}
}
```
Custom themes inherit unspecified values from the built-in OpenCode theme. To base one mode on the other, add
`mergeMode: true` to the mode containing the overrides:
```json title="themes/ocean.json"
{
"version": 2,
"light": {
"text": {
"default": "#16324f"
}
},
"dark": {
"mergeMode": true,
"text": {
"default": "#d8f3ff"
}
}
}
```
Only one mode can use `mergeMode`, and the other mode must be present.
## Colors
Use a hex color, `transparent`, or a reference to another theme color:
```json title="themes/ocean.json"
{
"version": 2,
"dark": {
"text": {
"default": "#d8f3ff",
"subdued": "$hue.neutral.400"
},
"background": {
"default": "transparent"
}
}
}
```
Hex values may use 3, 4, 6, or 8 digits. References begin with `$`; hue references use the form
`$hue.<name>.<step>`.
## Hues
Override a hue alias to change a family of related colors at once:
```json title="themes/ocean.json"
{
"version": 2,
"dark": {
"hue": {
"accent": "$hue.cyan",
"interactive": "$hue.blue"
},
"categorical": ["accent", "purple", "green"]
}
}
```
| Kind | Values |
| ------- | -------------------------------------------------------------------- |
| Base | `gray`, `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple` |
| Alias | `accent`, `interactive`, `neutral` |
| Step | `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900` |
`categorical` sets the ordered hues used to distinguish agents and other repeated items. It must contain at least one
base hue or alias.
## States
Use state keys to change interactive colors. Unspecified states use the nearest default value:
```json title="themes/ocean.json"
{
"version": 2,
"dark": {
"text": {
"action": {
"primary": {
"default": "$hue.interactive.400",
"$hovered": "$hue.interactive.300",
"$disabled": "$hue.neutral.600"
}
}
}
}
}
```
| Group | Values |
| --------- | --------------------------------------------------------- |
| Actions | `primary`, `secondary`, `destructive` |
| States | `$hovered`, `$focused`, `$pressed`, `$selected`, `$disabled` |
Form fields use the same state keys directly under `text.formfield` or `background.formfield`.
## Tokens
Override only the token groups your theme needs:
```json title="themes/ocean.json"
{
"version": 2,
"dark": {
"border": {
"default": "$hue.neutral.700"
},
"syntax": {
"comment": "$hue.neutral.500",
"keyword": "$hue.accent.400"
},
"diff": {
"text": {
"added": "$hue.green.400",
"removed": "$hue.red.400"
}
}
}
}
```
| Group | Purpose |
| ------------ | ------------------------------------------------------------- |
| `text` | Default, subdued, action, form-field, status, and feedback text |
| `background` | Default, surfaces, actions, form fields, and feedback fills |
| `border` | Borders |
| `scrollbar` | Scrollbars |
| `diff` | Added, removed, context, highlight, and line-number colors |
| `syntax` | Source-code highlighting |
| `markdown` | Markdown elements |
Syntax keys are `comment`, `keyword`, `function`, `variable`, `string`, `number`, `type`, `operator`, and
`punctuation`. Markdown keys are `text`, `heading`, `link`, `linkText`, `code`, `blockQuote`, `emphasis`, `strong`,
`horizontalRule`, `listItem`, `listEnumeration`, `image`, `imageText`, and `codeBlock`.
## Contexts
Use context overrides for elevated panels and overlays while keeping the base theme elsewhere:
```json title="themes/ocean.json"
{
"version": 2,
"dark": {
"background": {
"default": "#071521"
},
"@context:overlay": {
"background": {
"default": "#102a3c"
}
}
}
}
```
The available keys are `@context:elevated` and `@context:overlay`. Each accepts the same token groups as the base mode.
+109 -44
View File
@@ -1,13 +1,12 @@
---
title: "Session warming"
title: "Warming"
---
Session warming sends periodic model requests for recently active sessions.
This can preserve provider-side prompt caches or other short-lived session
state while you pause between prompts.
Session warming sends periodic model requests to preserve provider-side prompt
caches or other short-lived state while you pause between prompts. It is
disabled by default.
Warming is disabled by default. Enable it with the default settings in any
[OpenCode configuration file](/config):
Enable warming in any [OpenCode configuration file](/config):
```jsonc title="opencode.jsonc"
{
@@ -16,60 +15,126 @@ Warming is disabled by default. Enable it with the default settings in any
}
```
With the defaults, OpenCode sends a warming request after a session has made no
model request for four minutes. It repeats this while the session remains idle,
but stops 30 minutes after the last non-warming request. New model activity
starts a new 30-minute window.
With this configuration, OpenCode warms a recently active session after four
minutes without a model request. It continues every four minutes until 30
minutes have passed since the latest non-warming request.
## Configuration
## Options
Use the object form to customize the prompt, idle interval, or active duration:
Use the object form to change the warming prompt, idle interval, or active
window:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"warming": {
"prompt": "Do not perform any work. Reply with exactly: OK",
"interval": "4 minutes",
"duration": "30 minutes",
"interval": "5 minutes",
"duration": "1 hour",
},
}
```
| Field | Default | Description |
| ---------- | ---------------------- | ------------------------------------------------------------------------------------------------------- |
| `prompt` | Keep-alive instruction | Prompt sent in each warming request. The default instructs the model to do no work and reply with `OK`. |
| `interval` | `"4 minutes"` | Idle time between warming requests. |
| `duration` | `"30 minutes"` | Maximum warming window after the latest non-warming model request. |
| Field | Default | Purpose |
| --- | --- | --- |
| `prompt` | Keep-alive instruction | Appended as a transient user message for each warming request. The default asks the model to do no work, use no tools, and reply with `OK`. |
| `interval` | `"4 minutes"` | Idle time before the next warming request. |
| `duration` | `"30 minutes"` | Warming window measured from the latest non-warming model request. |
`interval` and `duration` accept duration strings such as `"30 seconds"`,
`"4 minutes"`, or `"1 hour"`. Both must be finite and greater than zero.
Omitted object fields keep their defaults. For example, this changes only the
interval:
To disable warming explicitly:
```jsonc title="opencode.jsonc"
{
"warming": {
"interval": "2 minutes",
},
}
```
```jsonc
## Durations
`interval` and `duration` accept duration strings. Both must resolve to finite
values greater than zero; otherwise OpenCode skips warming and logs a warning.
```jsonc title="opencode.jsonc"
{
"warming": {
"interval": "30 seconds",
"duration": "1 hour",
},
}
```
## Timing
Warming starts after a non-warming model request. After a warming request
finishes, the next idle interval starts, but the active window is not extended.
```text
00:00 normal model request
00:04 warming request
00:08 warming request
00:30 warming stops
```
Any new non-warming model activity resets both timers. If the interval is as
long as or longer than the duration, the active window expires before a warming
request is sent.
```text
00:00 normal model request
00:20 normal model request; window restarts
00:24 warming request
00:50 warming stops
```
## Requests
A warming request uses the session's current model, agent, instructions, and
conversation context. OpenCode appends the configured prompt transiently,
makes one model call, and discards the response.
```text
[current session context]
[transient user message: keep-alive prompt]
→ one model response, discarded
```
The request does not admit input, add messages to history, or mutate durable
session state. OpenCode does not dispatch local tool calls or continue a tool
loop; the default prompt also tells the model not to use tools.
## Failures
Warming failures are logged without failing or changing the session. OpenCode
waits until the next interval before trying again, while the original active
window continues to count down.
```text
00:04 warming request fails; session is unchanged
00:08 next warming attempt
```
## Costs
Warming requests are real provider requests. They can consume tokens, incur
costs, count against rate limits, and fail like other model requests.
- Shorter intervals increase request volume.
- Longer durations allow more warming requests.
- Enable warming only when its provider-side benefit is worth the added usage.
For example, the defaults allow up to seven warming attempts during one
uninterrupted 30-minute window: at minutes 4, 8, 12, 16, 20, 24, and 28.
## Disabling
Set `warming` to `false` to disable it explicitly. Omitting the field also
leaves warming disabled.
```jsonc title="opencode.jsonc"
{
"warming": false,
}
```
## Request behavior
A warming request uses the session's current model, agent, instructions, and
conversation context. Tools are disabled. The configured prompt is appended as
a transient user message, and the response is discarded.
Warming does not admit input, add messages to session history, or otherwise
mutate durable session state. A warming request resets the idle interval but
does not extend the active duration; without new model activity, warming still
ends when the configured duration expires.
## Costs and limits
Warming requests are real provider requests. They can consume tokens, incur
costs, count against rate limits, and fail for the same reasons as other model
requests. OpenCode logs warming failures without failing or changing the
session, then waits for the next interval before trying again.
Enable warming only when the provider-side benefit is worth the additional
requests. A shorter interval or longer duration increases request volume.
@@ -0,0 +1,94 @@
---
title: "Websearch"
---
Ask OpenCode for current information and it can search the web, read the results, and include source links in its response.
```text
Find the latest Bun release and summarize the changes.
```
The first search asks you to allow web search and select a provider. OpenCode remembers your choice for later sessions.
## Providers
OpenCode includes four search providers:
| Provider | ID | Environment variable |
| --- | --- | --- |
| Exa | `exa` | `EXA_API_KEY` |
| Firecrawl | `firecrawl` | `FIRECRAWL_API_KEY` |
| Parallel | `parallel` | `PARALLEL_API_KEY` |
| Tavily | `tavily` | `TAVILY_API_KEY` |
Connect an account from the TUI with `/connect`, or set the provider's environment variable before starting OpenCode.
```bash
$ TAVILY_API_KEY=your-key opencode2
```
## Selection
Set a provider in `opencode.jsonc` to skip the selection prompt.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"websearch": {
"provider": "tavily",
},
}
```
Use `"random"` to choose an available provider automatically. Each session keeps using its selected provider until that provider is rate limited.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"websearch": {
"provider": "random",
},
}
```
## Limits
When a provider returns HTTP 429, random selection retries the search with another available provider. The rate-limited provider waits for its `Retry-After` period, or 60 seconds when the response does not include a valid period.
```text
Exa returns HTTP 429
→ Exa enters cooldown
→ OpenCode retries with another provider
```
If every provider is cooling down, the search fails immediately. Moving a session or restarting its server clears its remembered provider; cooldowns are shared by sessions in the same workspace.
## Permissions
Web searches use the `websearch` permission action and the search query as the resource. Ask before searches that are not explicitly allowed.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"permissions": [
{
"action": "websearch",
"resource": "*",
"effect": "ask",
},
],
}
```
See [Permissions](/permissions) for rule order and matching.
## Disable
Set `websearch` to `false` to remove the web search tool from model requests.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"websearch": false,
}
```
+7 -11
View File
@@ -30,7 +30,6 @@ export const docsSections: DocsSection[] = [
{
title: "Configure",
items: [
{ title: "LSP", slug: "lsp" },
{ title: "Agents", slug: "agents" },
{ title: "Models", slug: "models" },
{ title: "Skills", slug: "skills" },
@@ -38,6 +37,7 @@ export const docsSections: DocsSection[] = [
{ title: "Commands", slug: "commands" },
{ title: "Plugins", slug: "plugins" },
{ title: "Providers", slug: "providers" },
{ title: "Websearch", slug: "websearch" },
{ title: "Snapshots", slug: "snapshots" },
{ title: "Compaction", slug: "compaction" },
{ title: "Formatters", slug: "formatters" },
@@ -46,8 +46,8 @@ export const docsSections: DocsSection[] = [
{ title: "MCP servers", slug: "mcp-servers" },
{ title: "Permissions", slug: "permissions" },
{ title: "Instructions", slug: "instructions" },
{ title: "Session sharing", slug: "sharing" },
{ title: "Session warming", slug: "warming" },
{ title: "Sharing", slug: "sharing" },
{ title: "Warming", slug: "warming" },
],
},
{
@@ -67,19 +67,14 @@ export const docsSections: DocsSection[] = [
items: [
{ title: "Intro", slug: "cli" },
{ title: "Config", slug: "cli/config" },
],
},
{
title: "Configure",
items: [
{ title: "Web", slug: "cli/web" },
{ title: "Providers", slug: "cli/providers" },
{ title: "Commands", slug: "cli/commands" },
{ title: "Theme", slug: "cli/theme" },
{ title: "Plugins", slug: "cli/plugins" },
{ title: "Keybinds", slug: "cli/keybinds" },
],
},
{
items: [{ title: "Providers", slug: "cli/providers" }],
},
],
},
{
@@ -140,6 +135,7 @@ export const docsSections: DocsSection[] = [
items: [
{ title: "Intro", slug: "console" },
{ title: "Models", slug: "console/models" },
{ title: "Websearch", slug: "console/websearch" },
{ title: "Go", slug: "console/go" },
],
},