mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-22 21:28:38 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f945b57a5 | |||
| 181428a2f3 | |||
| 9b1891fb7e | |||
| d4bf78b348 | |||
| d4ceffe787 | |||
| 872e38055e | |||
| 0c1dfa9186 | |||
| 8f4d706647 | |||
| 929374cdfd | |||
| cfa5ba700e | |||
| 45a2ed9a97 | |||
| f6333546f8 | |||
| 9e153ce7b3 | |||
| eb357f17cf | |||
| 573d76933f | |||
| bb8194395a | |||
| 2e8ed86658 | |||
| 2695607fbc |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
|
||||
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Observe optimistic prompt spacing.")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { prompt?: number; working: boolean }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
|
||||
row.textContent?.includes("Observe optimistic prompt spacing."),
|
||||
)
|
||||
frames.push({
|
||||
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
|
||||
working: !!document.querySelector('[data-component="session-working"]'),
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
const requested = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
await requested
|
||||
const prompt = page
|
||||
.locator('[data-timeline-row="UserMessage"]')
|
||||
.filter({ hasText: "Observe optimistic prompt spacing." })
|
||||
await expect(prompt).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
const frames = await observation.evaluate((value) => value.stop())
|
||||
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
|
||||
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
|
||||
expect(positions.length).toBeGreaterThan(0)
|
||||
expect(new Set(positions).size).toBe(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
await observation.dispose()
|
||||
}
|
||||
})
|
||||
@@ -122,7 +122,15 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
|
||||
@@ -88,8 +88,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
}).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -122,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 {
|
||||
@@ -322,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,
|
||||
@@ -359,7 +358,8 @@ async function applySelection(
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
@@ -389,7 +389,9 @@ async function sendPrompt(
|
||||
},
|
||||
},
|
||||
}
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit()
|
||||
await sending
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
|
||||
@@ -515,6 +515,19 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<div
|
||||
ref={(value) => {
|
||||
element = value
|
||||
if (row()._tag !== "UserMessage" || !addedKeys.has(rowProps.rowKey) || !input.pinned() || coldPending)
|
||||
return
|
||||
// The optimistic row can paint before ResizeObserver corrects the tail estimates.
|
||||
// Measure the mounted tail and pin it in this render's microtask instead.
|
||||
queueMicrotask(() => {
|
||||
if (!input.pinned() || !virtualContent?.isConnected) return
|
||||
virtualizer.elementsCache.forEach((item) => {
|
||||
if (item.isConnected) virtualizer.resizeItem(virtualizer.indexFromElement(item), item.offsetHeight)
|
||||
})
|
||||
virtualizer.resizeItem(item().index, element.offsetHeight)
|
||||
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}}
|
||||
data-index={item().index}
|
||||
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
@@ -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}
|
||||
`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
export * as GptDelegationPlugin from "./gpt-delegation.js"
|
||||
|
||||
import { Message } from "@opencode/ai"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import type { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
const proactive = `<system-reminder>
|
||||
Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning subagents no longer applies. This mode remains active until a later reminder changes it. User requests override this hint.
|
||||
|
||||
If at any point you can parallelize work by delegating tasks to a subagent, you should do so if it could save time or improve quality.
|
||||
</system-reminder>`
|
||||
const explicit = `<system-reminder>
|
||||
Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
|
||||
</system-reminder>`
|
||||
const removed = `<system-reminder>
|
||||
The previous delegation-mode instructions no longer apply.
|
||||
</system-reminder>`
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.gpt-delegation",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const provider of catalog.provider.list()) {
|
||||
for (const model of provider.models.values()) {
|
||||
const selected = effort(model)
|
||||
if (!selected) continue
|
||||
const variant = model.variants.find((item) => item.id === selected)
|
||||
if (!variant) continue
|
||||
catalog.model.update(model.providerID, model.id, (draft) => {
|
||||
draft.variants = [
|
||||
...draft.variants.filter((item) => item.id !== "ultra"),
|
||||
{ ...variant, id: Model.VariantID.make("ultra") },
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Synthetic reminders are durable. Render them in place as chronological system
|
||||
// messages, and restore the selected policy if compaction or revert removed it.
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = (yield* ctx.catalog.model.list()).data
|
||||
const last = event.messages.reduce<string | undefined>((last, message, index) => {
|
||||
const text = reminder(message)
|
||||
if (!text) return last
|
||||
event.messages[index] = Message.make({ id: message.id, role: "system", content: text })
|
||||
return text
|
||||
}, undefined)
|
||||
const text = policy(event.model, catalog) ?? (last ? removed : undefined)
|
||||
if (!text || text === last) return
|
||||
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
|
||||
event.messages.splice(at, 0, Message.system(text))
|
||||
yield* ctx.session
|
||||
.synthetic({ sessionID: event.sessionID, text, resume: false })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to persist GPT delegation reminder", { sessionID: event.sessionID, cause }),
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("failed to reconcile GPT delegation policy", { sessionID: event.sessionID, error }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter(
|
||||
(event): event is SessionEvent.Created | SessionEvent.ModelSelected =>
|
||||
event.type === "session.created" || event.type === "session.model.selected",
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = (yield* ctx.catalog.model.list()).data
|
||||
const current = policy(event.data.model, catalog)
|
||||
const previous = event.type === "session.model.selected" ? policy(event.data.previous, catalog) : undefined
|
||||
if (current === previous) return
|
||||
yield* ctx.session
|
||||
.synthetic({ sessionID: event.data.sessionID, text: current ?? removed, resume: false })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to inject GPT delegation reminder", {
|
||||
sessionID: event.data.sessionID,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function effort(model: Model.Info) {
|
||||
const family = model.family?.toLowerCase()
|
||||
const id = model.modelID.toLowerCase()
|
||||
if (family === "gpt-astra" || id.includes("gpt-6-astra")) return "xhigh"
|
||||
if (family === "gpt-sol" || family === "gpt-terra" || id.includes("gpt-5.6-sol") || id.includes("gpt-5.6-terra"))
|
||||
return "max"
|
||||
}
|
||||
|
||||
function policy(ref: Model.Ref | undefined, catalog: ReadonlyArray<Model.Info>) {
|
||||
if (!ref) return
|
||||
const model = catalog.find((model) => model.providerID === ref.providerID && model.id === ref.id)
|
||||
if (!model || ![model.modelID, model.family].some((id) => id?.toLowerCase().includes("gpt"))) return
|
||||
return ref.variant === "ultra" && effort(model) && model.variants.some((item) => item.id === "ultra")
|
||||
? proactive
|
||||
: explicit
|
||||
}
|
||||
|
||||
function reminder(message: Message) {
|
||||
if (message.role !== "user" && message.role !== "system") return
|
||||
if (message.content.length !== 1) return
|
||||
const part = message.content[0]
|
||||
if (part?.type !== "text") return
|
||||
return part.text === proactive || part.text === explicit || part.text === removed ? part.text : undefined
|
||||
}
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -83,6 +83,7 @@ import BrowserPlugin from "@opencode/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
import { GptDelegationPlugin } from "./gpt-delegation.js"
|
||||
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
import { ProviderPlugins } from "./provider.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
@@ -241,6 +242,7 @@ const post = [
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
GptDelegationPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -38,8 +38,3 @@ In your final answer back to the user, focus on the most important information.
|
||||
- Do not introduce unsolicited warnings, disclaimers, approval flows, or safety/compliance checklists due to hypothetical risk.
|
||||
- Do not write tests for reversible, low-impact changes or that mirror the implementation. If you do choose to verify your work with tests, make sure that the tests are meaningful and necessary to verify implementation.
|
||||
- Run tests appropriate to the change and complete required checks. Once those pass, broaden or repeat testing only when new changes, failures, or unresolved concerns justify it; otherwise, continue toward completing the task.
|
||||
|
||||
|
||||
# Delegation
|
||||
|
||||
Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
|
||||
|
||||
@@ -26,10 +26,6 @@ In the final answer, lead with the outcome, not the steps you took to reach it.
|
||||
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
|
||||
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
|
||||
|
||||
# Delegation
|
||||
|
||||
Do not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.
|
||||
|
||||
# Destructive actions
|
||||
|
||||
Do not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Delegation\\n\\nDo not spawn subagents unless the user or applicable AGENTS.md/skill instructions explicitly ask for subagents, delegation, or parallel agent work.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.\\n\\n# Harness\\n- Responses are rendered as GitHub-flavored Markdown.\\n- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.\\n- Prefer parallelizing independent tool calls.\\n\\n\\n# Communication\\n\\nUse clear file paths when referring to files. Keep responses clear and concise, and avoid unnecessary technical jargon.\\n\\n## Intermediate Commentary\\n\\nAs you work, you send messages to the commentary channel. These are how you collaborate with the user while you work: stating assumptions and providing updates. Keep them concise and quickly scannable, and send them only when they add real information, such as a discovery, a tradeoff, or a blocker. Do not narrate routine reads, searches, or edits.\\n\\nBy default, treat new messages received during ongoing work as steering the active task rather than replacing it. Incorporate corrections and constraints, and answer questions briefly in commentary before continuing. Replace the task only when the user clearly cancels it or requests an incompatible objective.\\n\\nDo not put a final response, such as a blocking or clarifying question, in the commentary channel. The final answer must always be fully self-contained.\\n\\n## Final Answer\\n\\nIn the final answer, lead with the outcome, not the steps you took to reach it. Cover the most important information, use only as much structure as the answer needs, and avoid long-winded explanations unless necessary. Include technical detail only where it helps.\\n\\n# Working in codebases\\n- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.\\n- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.\\n\\n# Destructive actions\\n\\nDo not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.\\n\\n# Autonomy\\n\\nDo not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"prompt_cache_key\":\"ses_runner_recorded\",\"max_completion_tokens\":20,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message, SystemPart } from "@opencode/ai"
|
||||
import { Catalog } from "@opencode/core/catalog"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { GptDelegationPlugin } from "@opencode/core/plugin/gpt-delegation"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import type { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionContext } from "@opencode/plugin/effect/session"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { DateTime, Deferred, Effect, Stream } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { catalogHost, host } from "./host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, PluginHooks.node]), [
|
||||
Config.node.replace(Config.testLayer()),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
]),
|
||||
)
|
||||
const provider = Provider.ID.make("test")
|
||||
const sessionID = Session.ID.make("ses_gpt_delegation")
|
||||
const max = { id: Model.VariantID.make("max"), settings: { reasoningEffort: "max" } }
|
||||
const xhigh = { id: Model.VariantID.make("xhigh"), settings: { reasoningEffort: "xhigh" } }
|
||||
const model = (id: string, variant: string | undefined) =>
|
||||
Model.Ref.make({
|
||||
providerID: provider,
|
||||
id: Model.ID.make(id),
|
||||
...(variant ? { variant: Model.VariantID.make(variant) } : {}),
|
||||
})
|
||||
|
||||
const selected = (current: Model.Ref, previous: Model.Ref): SessionEvent.ModelSelected => ({
|
||||
id: Event.ID.create(),
|
||||
created: 0,
|
||||
durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) },
|
||||
type: "session.model.selected",
|
||||
data: { sessionID, model: current, previous },
|
||||
})
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const persisted: SessionInbox.Synthetic[] = []
|
||||
const completed = yield* Deferred.make<void>()
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.model.update(provider, Model.ID.make("sol"), (item) => {
|
||||
item.modelID = Model.ID.make("gpt-5.6-sol")
|
||||
item.family = Model.Family.make("custom-family")
|
||||
item.variants = [
|
||||
{ ...max, headers: { "x-reasoning": "max" }, body: { include: ["reasoning.encrypted_content"] } },
|
||||
{ id: Model.VariantID.make("ultra"), settings: { reasoningEffort: "stale" } },
|
||||
]
|
||||
})
|
||||
editor.model.update(provider, Model.ID.make("terra"), (item) => {
|
||||
item.family = Model.Family.make("gpt-terra")
|
||||
item.variants = [max]
|
||||
})
|
||||
editor.model.update(provider, Model.ID.make("astra"), (item) => {
|
||||
item.modelID = Model.ID.make("gpt-6-astra")
|
||||
item.variants = [max, { ...xhigh, headers: { "x-reasoning": "xhigh" } }]
|
||||
})
|
||||
editor.model.update(provider, Model.ID.make("no-xhigh"), (item) => {
|
||||
item.family = Model.Family.make("gpt-astra")
|
||||
item.variants = [max]
|
||||
})
|
||||
editor.model.update(provider, Model.ID.make("claude"), (item) => {
|
||||
item.variants = [{ id: Model.VariantID.make("ultra") }]
|
||||
})
|
||||
})
|
||||
return {
|
||||
catalog,
|
||||
persisted,
|
||||
completed: Deferred.await(completed),
|
||||
activate: (events: ReadonlyArray<SessionEvent.Created | SessionEvent.ModelSelected>) =>
|
||||
GptDelegationPlugin.Plugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
event: {
|
||||
subscribe: () => Stream.fromIterable(events).pipe(Stream.ensuring(Deferred.succeed(completed, undefined))),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback, options) => hooks.register("session", name, callback, options),
|
||||
synthetic: (input) =>
|
||||
Effect.sync(() => {
|
||||
expect(input.resume).toBe(false)
|
||||
const item = SessionInbox.Synthetic.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "synthetic",
|
||||
payload: { text: input.text },
|
||||
delivery: "steer",
|
||||
})
|
||||
persisted.push(item)
|
||||
return item
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
prepare: (ref: Model.Ref, messages: Message[]) => {
|
||||
const event: SessionContext = {
|
||||
sessionID,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: ref,
|
||||
system: [SystemPart.make("baseline")],
|
||||
messages,
|
||||
tools: {},
|
||||
options: {},
|
||||
}
|
||||
return hooks.trigger("session", "context", event)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe("GptDelegationPlugin", () => {
|
||||
it.effect("copies complete max or xhigh variants using either model identity signal", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.activate([])
|
||||
const sol = yield* fixture.catalog.model.get(provider, Model.ID.make("sol"))
|
||||
const terra = yield* fixture.catalog.model.get(provider, Model.ID.make("terra"))
|
||||
const astra = yield* fixture.catalog.model.get(provider, Model.ID.make("astra"))
|
||||
const noXhigh = yield* fixture.catalog.model.get(provider, Model.ID.make("no-xhigh"))
|
||||
expect(sol?.variants.filter((item) => item.id === "ultra")).toEqual([
|
||||
{
|
||||
...max,
|
||||
headers: { "x-reasoning": "max" },
|
||||
body: { include: ["reasoning.encrypted_content"] },
|
||||
id: Model.VariantID.make("ultra"),
|
||||
},
|
||||
])
|
||||
expect(terra?.variants.find((item) => item.id === "ultra")).toEqual({ ...max, id: Model.VariantID.make("ultra") })
|
||||
expect(astra?.variants.find((item) => item.id === "ultra")).toEqual({
|
||||
...xhigh,
|
||||
headers: { "x-reasoning": "xhigh" },
|
||||
id: Model.VariantID.make("ultra"),
|
||||
})
|
||||
expect(noXhigh?.variants.some((item) => item.id === "ultra")).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles missing and stale policies as system messages without changing the baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.activate([])
|
||||
expect((yield* fixture.prepare(model("claude", "ultra"), [Message.user("hello")])).messages).toEqual([
|
||||
Message.user("hello"),
|
||||
])
|
||||
const normal = yield* fixture.prepare(model("sol", undefined), [Message.user("hello")])
|
||||
const explicit = fixture.persisted[0]
|
||||
if (!explicit) throw new Error("Missing explicit-only reminder")
|
||||
expect(normal.messages.map((message) => message.role)).toEqual(["system", "user"])
|
||||
expect(explicit.payload.text).toContain("Do not spawn subagents unless")
|
||||
|
||||
const ultra = yield* fixture.prepare(model("sol", "ultra"), [
|
||||
Message.user(explicit.payload.text),
|
||||
Message.user("continue"),
|
||||
])
|
||||
const proactive = fixture.persisted[1]
|
||||
if (!proactive) throw new Error("Missing proactive reminder")
|
||||
expect(ultra.system).toEqual(normal.system)
|
||||
expect(ultra.messages.map((message) => message.role)).toEqual(["system", "system", "user"])
|
||||
expect(proactive.payload.text).toContain("Proactive multi-agent delegation is active")
|
||||
expect((yield* fixture.prepare(model("astra", "ultra"), [...ultra.messages])).messages).toEqual(ultra.messages)
|
||||
expect(fixture.persisted).toHaveLength(2)
|
||||
|
||||
const other = yield* fixture.prepare(model("claude", undefined), [...ultra.messages])
|
||||
expect(fixture.persisted[2]?.payload.text).toContain("previous delegation-mode instructions no longer apply")
|
||||
expect((yield* fixture.prepare(model("claude", undefined), [...other.messages])).messages).toEqual(other.messages)
|
||||
expect(fixture.persisted).toHaveLength(3)
|
||||
|
||||
yield* fixture.prepare(model("sol", undefined), [Message.user(proactive.payload.text), Message.user("continue")])
|
||||
expect(fixture.persisted.at(-1)?.payload.text).toBe(explicit.payload.text)
|
||||
// A checkpoint quote is history, not a live mode reminder.
|
||||
const compacted = yield* fixture.prepare(model("sol", "ultra"), [
|
||||
Message.user(`<conversation-checkpoint>\n${proactive.payload.text}\n</conversation-checkpoint>`),
|
||||
])
|
||||
expect(compacted.messages[0]).toEqual(Message.system(proactive.payload.text))
|
||||
expect(fixture.persisted).toHaveLength(5)
|
||||
yield* fixture.prepare(model("sol", undefined), [Message.user("reverted history")])
|
||||
expect(fixture.persisted.at(-1)?.payload.text).toBe(explicit.payload.text)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists model-switch reminders and renders them in place on later requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.activate([
|
||||
selected(model("sol", undefined), model("claude", undefined)),
|
||||
selected(model("sol", "ultra"), model("sol", undefined)),
|
||||
selected(model("astra", "ultra"), model("sol", "ultra")),
|
||||
selected(model("claude", undefined), model("astra", "ultra")),
|
||||
])
|
||||
yield* fixture.completed
|
||||
expect(fixture.persisted.map((item) => item.payload.text)).toEqual([
|
||||
expect.stringContaining("Do not spawn subagents unless"),
|
||||
expect.stringContaining("Proactive multi-agent delegation is active"),
|
||||
expect.stringContaining("previous delegation-mode instructions no longer apply"),
|
||||
])
|
||||
const request = yield* fixture.prepare(model("claude", undefined), [
|
||||
...fixture.persisted.map((item) => Message.make({ id: item.id, role: "user", content: item.payload.text })),
|
||||
Message.user("continue"),
|
||||
])
|
||||
expect(request.messages).toEqual([
|
||||
...fixture.persisted.map((item) => Message.make({ id: item.id, role: "system", content: item.payload.text })),
|
||||
Message.user("continue"),
|
||||
])
|
||||
expect(fixture.persisted).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -63,12 +63,7 @@ import { Document, Info } from "@opencode/schema/config"
|
||||
import { ConfigCompaction } from "@opencode/schema/config/compaction"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import type { Info as ToolInfo } from "@opencode/schema/tool"
|
||||
import {
|
||||
InstructionStateTable,
|
||||
SessionInboxTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "@opencode/core/session/sql"
|
||||
import { InstructionStateTable, SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode/core/session/sql"
|
||||
import { InstructionEntry } from "@opencode/core/session/instruction-entry"
|
||||
import { SessionStore } from "@opencode/core/session/store"
|
||||
import { Instructions } from "@opencode/core/instructions/index"
|
||||
@@ -1700,7 +1695,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
expect.stringContaining("# Delegation"),
|
||||
expect.stringContaining("## Final Answer"),
|
||||
"Initial context",
|
||||
])
|
||||
})
|
||||
@@ -1720,7 +1715,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toEqual([
|
||||
expect.stringContaining("# Delegation"),
|
||||
expect.stringContaining("## Final Answer"),
|
||||
"Initial context",
|
||||
])
|
||||
})
|
||||
@@ -1944,7 +1939,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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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." }))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -427,17 +427,6 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={i18n.t("ui.messagePart.compaction.started")} />
|
||||
</div>
|
||||
<Show when={props.message.status === "running"}>
|
||||
<div role="status" class="py-2">
|
||||
<BasicTool
|
||||
icon="archive"
|
||||
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
|
||||
status="running"
|
||||
locked
|
||||
hideDetails
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
<div data-slot="text-part-body">
|
||||
@@ -449,6 +438,17 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status === "running"}>
|
||||
<div role="status" class="py-2">
|
||||
<BasicTool
|
||||
icon="archive"
|
||||
trigger={{ title: i18n.t("ui.messagePart.compaction.running") }}
|
||||
status="running"
|
||||
locked
|
||||
hideDetails
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.message.status !== "running"}>
|
||||
<div class="py-2">
|
||||
<TimelineSeparator label={label()} />
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)] })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
{
|
||||
"formatter": false,
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// Reserved for enabling all built-ins once a V2 runtime provides them.
|
||||
{
|
||||
"formatter": true,
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// Configure named entries or mark one as disabled.
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"formatter": {
|
||||
"prettier": {
|
||||
"extensions": [".md", ".mdx"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 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 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,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 1–64 characters and keep it aligned with the directory name:
|
||||
|
||||
```text
|
||||
^[a-z0-9]+(-[a-z0-9]+)*$
|
||||
```
|
||||
|
||||
V2 currently does not enforce this pattern, the 1–64 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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user