Compare commits

..
Author SHA1 Message Date
Shoubhit Dash d5518553b5 Merge remote-tracking branch 'origin/v2' into session-diff
# Conflicts:
#	packages/server/src/handlers/session.ts
2026-09-10 16:52:26 +05:30
Shoubhit Dash 6eb2042acd Merge remote-tracking branch 'origin/v2' into session-diff
# Conflicts:
#	packages/client/src/effect/api/api.ts
#	packages/core/src/session.ts
#	packages/core/test/git.test.ts
#	packages/protocol/src/groups/session.ts
#	packages/server/src/handlers/session-error.ts
#	packages/server/src/handlers/session.ts
2026-09-08 19:30:14 +05:30
Shoubhit Dash 54504ab3a5 fix(client): synthesize idle messages live
The solid data layer mirrors every projected marker message from its event so the in-memory transcript matches the server before the next read; do the same for the idle marker on execution succeeded, failed, and non-shutdown interrupted.
2026-09-07 23:57:17 +05:30
Shoubhit Dash cc5086d127 feat(session): add turn diff route
GET /api/session/:sessionID/diff?messageID&to&context returns FileDiff.Info[] for the turn containing a user message (default: the newest one), or the contiguous range through a later user message's turn. A turn runs from the first prompt after the Session was last idle until its idle marker, so steers belong to the turn they interrupted; Sessions without markers fall back to prompt-to-next-prompt. The diff compares the range's first recorded step snapshot with its last recorded one, or with the working copy only while the Session is actively executing, resolves the snapshot repository from the Location in effect at the range (rejecting ranges that span a move), and defaults to full-file patches like vcs.diff. Shared missingMessage and failedSnapshot handler helpers replace the inlined mappings in the session handlers.
2026-09-07 22:08:19 +05:30
Shoubhit Dash b20482461c feat(session): record idle boundaries as messages
Project an idle message when a busy period ends (execution succeeded, failed, or interrupted for any reason other than shutdown, which resumes the same turn). Every step since the previous marker is one turn, including prompts steered in while the Session was busy, so turns are derivable from session_message alone without persisting events or a separate table. The marker is invisible to the model and to the TUI and web transcripts.
2026-09-07 22:00:36 +05:30
Shoubhit Dash 5b5368fe98 perf(core): batch snapshot tree diffs
Git.tree.diff ran --name-status, --numstat, and a patch once per changed file, sequentially, so a turn or revert touching N files cost 1 + 3N git processes (~50ms per file). Run the three once over the tree pair, split the patch with VcsPatch.chunksByFile, cap patch output at MAX_TOTAL_PATCH_BYTES like VCS diffs (capped files get an empty patch, stats stay exact), keep core.quotepath=false so non-ASCII paths still match their chunk, and pass --no-ext-diff. Snapshot.diff diffs first and filters ignored paths from the result instead of listing changed files twice and passing every path as a pathspec.
2026-09-07 21:53:19 +05:30
111 changed files with 3378 additions and 5279 deletions
@@ -18,6 +18,7 @@ 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
@@ -26,7 +27,6 @@ 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* OpenResponses.decodeChannelEvent(frame).pipe(
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,7 +163,6 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -6,6 +6,7 @@ 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
@@ -14,19 +15,12 @@ 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 => {
@@ -133,26 +127,22 @@ 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)
// 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 }
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -205,4 +195,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
export const OpenResponsesContinuation = { driver } as const
+4 -32
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -325,8 +325,9 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// 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.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -400,39 +401,10 @@ export const Event = Schema.StructWithRest(
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
),
)
export interface ProviderAdapter {
readonly id: string
readonly name: string
-4
View File
@@ -41,10 +41,6 @@ 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"] } },
})
@@ -1,78 +0,0 @@
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,11 +90,7 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -102,7 +98,6 @@ const continuationDriver = (
request,
message,
base: base(message),
continuation,
})
}
@@ -926,58 +921,6 @@ 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)
}),
)
+2 -110
View File
@@ -1,18 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { Effect } 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,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -20,35 +13,6 @@ 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* () {
@@ -198,78 +162,6 @@ 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" } }
+10 -5
View File
@@ -126,9 +126,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
// 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))
return
}
} finally {
@@ -320,8 +326,7 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// 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 applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
null,
2,
)
: formatList(page.data)) + EOL
: formatTable(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
@@ -96,14 +96,18 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
),
)
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)
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)
}
+12 -20
View File
@@ -11,13 +11,13 @@ 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"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -27,6 +27,7 @@ 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"
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Reference } from "@opencode/schema/reference"
import type { Worktree } from "@opencode/schema/worktree"
import type { Vcs } from "@opencode/schema/vcs"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
@@ -209,7 +209,6 @@ 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>
@@ -361,6 +360,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly messageID?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -438,7 +446,6 @@ 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
}
}
@@ -491,15 +498,6 @@ 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
@@ -1150,6 +1148,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -1596,12 +1595,6 @@ 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> }
@@ -1609,7 +1602,6 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
+14 -12
View File
@@ -68,6 +68,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -181,8 +183,6 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -397,7 +397,6 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -595,6 +594,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -734,6 +744,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -1148,14 +1159,6 @@ 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) },
@@ -1163,7 +1166,6 @@ 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) =>
+14 -15
View File
@@ -62,6 +62,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -175,8 +177,6 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -567,7 +567,6 @@ export function make(options: ClientOptions) {
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -845,6 +844,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { messageID: input["messageID"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -1569,18 +1580,6 @@ 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) =>
+93 -127
View File
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -551,6 +559,28 @@ 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
@@ -1629,6 +1659,24 @@ 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
@@ -1872,58 +1920,6 @@ 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"
@@ -2096,6 +2092,8 @@ 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
@@ -2150,8 +2148,6 @@ 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 = {
@@ -2206,6 +2202,7 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -2245,7 +2242,6 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionDeleted
| SessionForked
@@ -2305,7 +2301,6 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
@@ -2818,11 +2813,6 @@ 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
@@ -2831,11 +2821,6 @@ 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
@@ -2844,11 +2829,6 @@ 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
@@ -2857,11 +2837,6 @@ 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
@@ -2870,11 +2845,6 @@ 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
@@ -2883,25 +2853,7 @@ 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"]
@@ -2939,11 +2891,6 @@ 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
@@ -3214,6 +3161,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3249,11 +3203,6 @@ 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
@@ -3524,6 +3473,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3559,11 +3515,6 @@ 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
@@ -3834,6 +3785,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4323,6 +4281,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["messageID"]
readonly to?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly messageID?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
@@ -5825,19 +5804,6 @@ 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
+12 -4
View File
@@ -695,10 +695,6 @@ 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) {
@@ -1028,6 +1024,18 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+3 -2
View File
@@ -5,8 +5,9 @@ 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 (a string)`, node).as("TypeError")
if (args.length === 0) {
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
}
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
-2
View File
@@ -45,7 +45,6 @@ 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,
@@ -94,5 +93,4 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -1,13 +0,0 @@
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, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
+75 -64
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -308,7 +309,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string> },
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
) {
const result = yield* proc
.run(
@@ -317,7 +318,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin },
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
)
.pipe(
Effect.mapError(
@@ -331,7 +332,8 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -385,9 +387,7 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,13 +464,7 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -499,19 +493,23 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -519,49 +517,57 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
"diff",
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+1 -1
View File
@@ -154,7 +154,7 @@ const layer = Layer.effect(
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
return agent?.permissions ?? missingAgentPermissions
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
-3
View File
@@ -404,7 +404,6 @@ 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()),
@@ -510,8 +509,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
title: input?.title,
agent: input?.agent,
model: input?.model,
metadata: input?.metadata,
permissions: input?.permissions,
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
-1
View File
@@ -101,7 +101,6 @@ 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
})
}
})
+26 -10
View File
@@ -18,7 +18,6 @@ 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"
@@ -55,8 +54,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode/util/fs-util"
import type { EventLog } from "@opencode/schema/event-log"
import type { FileDiff } from "@opencode/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -82,7 +84,6 @@ 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 })
@@ -109,6 +110,7 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -135,6 +137,13 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -159,10 +168,6 @@ 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 },
@@ -227,6 +232,7 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -254,10 +260,9 @@ const layer = Layer.effect(
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
// Children inherit metadata and permissions the way they inherit
// location, so host policies that read them treat the family uniformly.
// Children inherit metadata the way they inherit location, so
// host policies that read it treat the family uniformly.
metadata: input.metadata ?? parent?.metadata,
permissions: input.permissions ?? parent?.permissions,
model: input.model
? {
id: Model.ID.make(input.model.id),
@@ -359,6 +364,17 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
messageID: input.messageID,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -394,7 +410,6 @@ 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(),
@@ -448,6 +463,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
+1 -2
View File
@@ -1,7 +1,6 @@
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"
@@ -130,7 +129,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(Permission.merge(agent.info.permissions, session.permissions ?? [])),
tools: registry.snapshot(agent.info.permissions),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
+138
View File
@@ -0,0 +1,138 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["messageID", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
-1
View File
@@ -50,7 +50,6 @@ 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: {
+20 -4
View File
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -116,7 +131,6 @@ 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,
@@ -124,9 +138,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
-10
View File
@@ -160,7 +160,6 @@ 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,
@@ -451,7 +450,6 @@ 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,
@@ -573,14 +571,6 @@ 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
@@ -226,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
-11
View File
@@ -3,7 +3,6 @@ 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"
@@ -73,13 +72,6 @@ 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 },
@@ -342,7 +334,6 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
@@ -365,7 +356,6 @@ 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)
@@ -391,7 +381,6 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
+2 -2
View File
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { Permission } from "@opencode/schema/permission"
import type { PermissionV1 } from "@opencode/schema/permission-v1"
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<Permission.Ruleset>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
-1
View File
@@ -103,7 +103,6 @@ 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,
+33 -16
View File
@@ -131,38 +131,55 @@ const layer = Layer.effect(
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
return {
source: repo.source,
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
return yield* git.tree
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
.diff({
...comparison.input,
repository: compared.repository,
from: compared.from,
to: compared.to,
context: input.context,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
paths: input.paths,
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
+1 -12
View File
@@ -99,19 +99,8 @@ 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: output(),
output: result.structured ?? (text === "" ? null : text),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
+37
View File
@@ -6,6 +6,7 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Git } from "@opencode/core/git"
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { VcsPatch } from "@opencode/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -196,6 +197,42 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
-74
View File
@@ -324,32 +324,6 @@ 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",
@@ -400,20 +374,6 @@ 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,
@@ -1983,7 +1943,6 @@ 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",
@@ -2074,39 +2033,6 @@ 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
-28
View File
@@ -224,34 +224,6 @@ describe("Permission", () => {
}),
)
it.effect("merges session rules after agent rules and before saved approvals", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const { db } = yield* Database.Service
const service = yield* Permission.Service
const setSession = (permission: Permission.Ruleset) =>
db
.update(SessionTable)
.set({ permission })
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
yield* setSession([{ action: "edit", resource: "/original/**", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "edit", resources: ["/original/src/index.ts"] }))).toMatchObject({
effect: "deny",
})
yield* setRules([])
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
yield* setSession([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "deny" })
yield* setSession([{ action: "bash", resource: "*", effect: "ask" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "allow" })
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
-1
View File
@@ -108,7 +108,6 @@ 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("enables xAI Responses WebSockets", () =>
it.effect("keeps xAI Responses WebSockets opt-in", () =>
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).toBe(true)
expect(model?.websocket).toBeUndefined()
}),
)
})
+2 -39
View File
@@ -388,32 +388,6 @@ 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
@@ -1356,12 +1330,7 @@ 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" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } })
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
@@ -1407,13 +1376,7 @@ describe("SessionTransfer", () => {
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({
id: sessionID,
title: "Exported",
location,
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } })
expect(imported.time).toMatchObject({
updated: DateTime.makeUnsafe(1_000),
idle: DateTime.makeUnsafe(200),
+198
View File
@@ -0,0 +1,198 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionDiff } from "@opencode/core/session/diff"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionProjector } from "@opencode/core/session/projector"
import { Snapshot } from "@opencode/core/snapshot"
import { Money } from "@opencode/schema/money"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { messageID?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ messageID: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ messageID: steer })).toEqual(busy)
expect(yield* diff({ messageID: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ messageID: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, messageID: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ messageID: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ messageID: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "messageID",
})
expect(yield* diff({ messageID: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ messageID: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+3 -2
View File
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* sessions.messages({ sessionID })).toMatchObject([
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
+1 -10
View File
@@ -1944,16 +1944,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* entries.list(sessionID)).toEqual([{ key: "nullable", value: null }])
})
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* () {
scenario("rejects API instruction entries larger than 8KB", function* () {
const entries = yield* InstructionEntry.Service
const exit = yield* entries
@@ -1280,91 +1280,6 @@ flowchart TD
])
})
test("expands & node groups into fan-in and fan-out edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
N[Native] & M[Mapped] & O --> LM["LanguageModel"]
LM -->|prepare| REQ & LOG`)
expect(diagram.nodes).toEqual([
{ id: "N", label: "Native", shape: "box" },
{ id: "M", label: "Mapped", shape: "box" },
{ id: "O", label: "O", shape: "box" },
{ id: "LM", label: "LanguageModel", shape: "box" },
{ id: "REQ", label: "REQ", shape: "box" },
{ id: "LOG", label: "LOG", shape: "box" },
])
expect(diagram.edges).toEqual([
{ from: "N", to: "LM", label: "" },
{ from: "M", to: "LM", label: "" },
{ from: "O", to: "LM", label: "" },
{ from: "LM", to: "REQ", label: "prepare" },
{ from: "LM", to: "LOG", label: "prepare" },
])
})
test("expands & groups on both sides of an edge and through a chain", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A & B --> C & D --> E`)
expect(diagram.edges).toEqual([
{ from: "A", to: "C", label: "" },
{ from: "A", to: "D", label: "" },
{ from: "B", to: "C", label: "" },
{ from: "B", to: "D", label: "" },
{ from: "C", to: "E", label: "" },
{ from: "D", to: "E", label: "" },
])
})
test("declares every node of a bare & group inside the current subgraph", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
subgraph Runtime
A[Alpha] & B[Beta]:::focus
end
A --> B`)
expect(diagram.nodes).toEqual([
{ id: "A", label: "Alpha", shape: "box" },
{ id: "B", label: "Beta", shape: "box" },
])
expect(diagram.subgraphs?.[0]?.nodeIds).toEqual(["A", "B"])
})
test("keeps & inside quoted or bracketed labels as label text", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A["Fetch & parse"] & B[R&D] --> C[Done &amp; dusted]`)
expect(diagram.nodes).toEqual([
{ id: "A", label: "Fetch & parse", shape: "box" },
{ id: "B", label: "R&D", shape: "box" },
{ id: "C", label: "Done & dusted", shape: "box" },
])
expect(diagram.edges).toEqual([
{ from: "A", to: "C", label: "" },
{ from: "B", to: "C", label: "" },
])
})
test("rejects an empty & group member", () => {
expect(() =>
parseMermaidFlowchartDiagram(`flowchart LR
A & --> B`),
).toThrow('Unsupported syntax in flowchart diagram at line 2: "A & --> B"')
})
test("renders a fan-in expressed with & the same as separate edge statements", () => {
const grouped = renderFlowchartDiagram(`flowchart LR
N & M & O --> LM[LanguageModel] --> REQ[LLMRequest]`)
const separate = renderFlowchartDiagram(`flowchart LR
N --> LM[LanguageModel]
M --> LM
O --> LM
LM --> REQ[LLMRequest]`)
expect(grouped).toBe(separate)
expect(grouped).toContain("LanguageModel")
})
test("parses chained undirected solid edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A --- B --- C`)
+25 -66
View File
@@ -127,41 +127,6 @@ function stripNodeToken(token: string): string {
.trim()
}
/** Split an `&`-joined node group, leaving `&` inside labels (brackets or quotes) untouched. */
function splitNodeGroup(token: string): string[] {
const groups: string[] = []
const stack: string[] = []
let quote: '"' | "'" | undefined
let start = 0
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
for (let index = 0; index < token.length; index++) {
const character = token[index]!
if (quote) {
if (character === quote && token[index - 1] !== "\\") quote = undefined
continue
}
if (character === '"' || character === "'") {
quote = character
continue
}
if (character in closes) {
stack.push(character)
continue
}
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
stack.pop()
continue
}
if (stack.length === 0 && character === "&") {
groups.push(token.slice(start, index))
start = index + 1
}
}
groups.push(token.slice(start))
return groups
}
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
@@ -340,57 +305,51 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
const edgeOperators = parseEdgeOperators(line)
if (edgeOperators.length > 0) {
// Each chain position may be an `&` group (`A & B --> C`), so endpoints are lists of node tokens.
const nodeGroups = [
const nodeTokens = [
line.slice(0, edgeOperators[0]!.index),
...edgeOperators.map((operator, index) =>
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
),
].map((group) => splitNodeGroup(group).map(stripNodeToken))
]
if (nodeGroups.every((group) => group.every((token) => token.length > 0))) {
const unsupportedEndpoint = nodeGroups.find((group, index) => {
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
const unsupportedEndpoint = nodeTokens.find((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
return group.some(
(stripped) =>
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped),
return (
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped)
)
})
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
const chainNodeIds = nodeGroups.map((group, index) => {
const chainNodeIds = nodeTokens.map((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
return group.map((stripped) => {
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
})
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
})
for (const nodeId of chainNodeIds.flat()) {
for (const nodeId of chainNodeIds) {
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
}
for (let index = 0; index < edgeOperators.length; index++) {
const operator = edgeOperators[index]!
for (const from of chainNodeIds[index]!) {
for (const to of chainNodeIds[index + 1]!) {
const edge = createEdge(
from,
to,
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
}
const edge = createEdge(
chainNodeIds[index]!,
chainNodeIds[index + 1]!,
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
continue
}
}
const nodeGroup = splitNodeGroup(line)
if (nodeGroup.every(isSupportedNodeToken)) {
for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, ensureNode(nodes, stripNodeToken(token)).id)
if (isSupportedNodeToken(line)) {
const node = ensureNode(nodes, line)
addNodeToSubgraph(currentSubgraph, node.id)
continue
}
+1 -1
View File
@@ -29,7 +29,7 @@ describe("parser diagnostics", () => {
})
test("does not partially parse unsupported flowchart syntax", () => {
for (const statement of ["A & --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
for (const statement of ["A & B --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(MermaidSyntaxError)
}
})
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
readonly hook: Hooks<PermissionHooks>
}
-1
View File
@@ -438,7 +438,6 @@ export function fromPromise(plugin: Plugin) {
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
readonly hook: Hooks<PermissionHooks>
}
+181
View File
@@ -3242,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18394,6 +18540,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18425,6 +18603,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
@@ -132,21 +132,4 @@ 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." }))
+26 -2
View File
@@ -27,10 +27,10 @@ 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"
import { FileDiff } from "@opencode/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -176,7 +176,6 @@ 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(
@@ -523,6 +522,31 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
messageID: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
+1 -1
View File
@@ -27,7 +27,7 @@ export const Snapshot = Schema.Array(
).annotate({ identifier: "InstructionEntry.Snapshot" })
export type Snapshot = typeof Snapshot.Type
export const MaxValueBytes = 256 * 1024
export const MaxValueBytes = 8 * 1024
export class ValueTooLargeError extends Schema.TaggedError<ValueTooLargeError>()(
"InstructionEntryValueTooLargeError",
-13
View File
@@ -25,7 +25,6 @@ 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 }
@@ -63,7 +62,6 @@ 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,
},
})
@@ -111,16 +109,6 @@ 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,
@@ -646,7 +634,6 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
PermissionsUpdated,
Viewed,
UsageUpdated,
Deleted,
+14
View File
@@ -280,6 +280,18 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
)
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
/**
* Marks the Session going idle: every step since the previous marker belongs to
* one turn, including prompts steered in while it was busy. A shutdown does not
* record one, since the resumed execution continues the same turn.
*/
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
export const Idle = Schema.Struct({
...Base,
type: Schema.tag("idle"),
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
}).annotate({ identifier: "Session.Message.Idle" })
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
@@ -291,6 +303,7 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -303,4 +316,5 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
-3
View File
@@ -10,7 +10,6 @@ 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"
@@ -55,8 +54,6 @@ 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,7 +115,6 @@ 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,15 +83,6 @@ 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) {
+23 -1
View File
@@ -1,5 +1,6 @@
import { Session } from "@opencode/core/session"
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import type { Snapshot } from "@opencode/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -9,6 +10,14 @@ export function missingSession(error: Session.NotFoundError) {
})
}
export function missingMessage(error: Session.MessageNotFoundError) {
return new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
})
}
export function failedMessageDecode(error: Session.MessageDecodeError) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
@@ -18,3 +27,16 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
),
)
}
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
export function failedSnapshot(operation: string, sessionID: Session.ID) {
return (error: Snapshot.Error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
Effect.annotateLogs({ ref, sessionID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
}
+32 -54
View File
@@ -17,10 +17,9 @@ import {
ServiceUnavailableError,
SessionBusyError,
SkillNotFoundError,
UnknownError,
} from "@opencode/protocol/errors"
import { AbsolutePath } from "@opencode/core/schema"
import { failedMessageDecode, missingSession } from "./session-error"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -120,7 +119,6 @@ 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),
@@ -213,15 +211,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
@@ -449,32 +439,14 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
files: ctx.payload.files,
})
return {
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
data: yield* session.revert
.stage({ ...ctx.params, ...ctx.payload })
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
),
}
}),
)
@@ -482,23 +454,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.revert.clear",
Effect.fn(function* (ctx) {
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
yield* session.revert.clear(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", (error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
Effect.andThen(
Effect.fail(
new UnknownError({
message: "Unexpected server error. Check server logs for details.",
ref,
}),
),
),
)
}),
)
yield* session.revert
.clear(ctx.params.sessionID)
.pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -528,6 +490,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.diff",
Effect.fn(function* (ctx) {
return {
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.TurnRangeError",
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
),
}
}),
)
.handle(
"session.inbox.list",
Effect.fn(function* (ctx) {
+98
View File
@@ -0,0 +1,98 @@
import { expect, setDefaultTimeout } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionMessage } from "@opencode/core/session/message"
import { Money } from "@opencode/schema/money"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
setDefaultTimeout(30_000)
it.live("serves turn diffs by user message with range validation", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-session-diff-")))
const ids = { user: SessionMessage.ID.create(), assistant: SessionMessage.ID.create() }
// Deliver the prompt and one step the way the runner would, without a model.
const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
isActive: () => Effect.succeed(false),
resume: () => Effect.void,
wake: (sessionID) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: ids.user })
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: ids.assistant,
agent: Agent.defaultID,
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: ids.assistant,
finish: "stop",
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
}),
interrupt: () => Effect.succeed(false),
awaitIdle: () => Effect.void,
})
}),
)
const handler = yield* ServerFetch.make(
{
app: { version: "test-version" },
database: { path: ":memory:" },
fs: { filewatcher: false },
models: { fetch: false },
},
{
overrides: [
SessionExecution.node.replace(
makeGlobalNode({ service: SessionExecution.Service, layer: execution, deps: [Bus.node] }),
),
],
},
)
const request = (path: string, body?: unknown) =>
Effect.promise(async () => {
const response = await handler(
new Request(`http://opencode.local${path}`, {
method: body === undefined ? "GET" : "POST",
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
}),
)
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
})
const created = yield* request("/api/session", { location: { directory: tmp.path } })
const sessionID = Session.ID.make((created.body.data as { id: string }).id)
const diff = (query = "") => request(`/api/session/${sessionID}/diff${query}`)
expect(yield* diff()).toEqual({ status: 200, body: { data: [] } })
expect((yield* request(`/api/session/${sessionID}/prompt`, { id: ids.user, text: "prompt" })).status).toBe(200)
// Not a git repository, so steps record no snapshots and the turn has no diff.
expect(yield* diff(`?messageID=${ids.user}&context=3`)).toEqual({ status: 200, body: { data: [] } })
expect(yield* diff(`?messageID=${ids.assistant}`)).toMatchObject({
status: 400,
body: { _tag: "InvalidRequestError", field: "messageID" },
})
expect(yield* diff(`?messageID=${SessionMessage.ID.create()}`)).toMatchObject({
status: 404,
body: { _tag: "MessageNotFoundError" },
})
expect((yield* request(`/api/session/${Session.ID.create()}/diff`)).status).toBe(404)
}),
)
@@ -16,7 +16,7 @@ export { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap }
export type ReasoningMode = "hidden" | "compact" | "full"
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" | "idle" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
@@ -765,7 +765,8 @@ function record(value: unknown): value is Record<string, unknown> {
}
function isNotice(message: SessionMessageInfo): message is Notice {
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type === "user" || message.type === "assistant" || message.type === "shell" || message.type === "idle")
return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
}
@@ -39,7 +39,6 @@ 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,7 +14,6 @@ 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",
}
+1 -2
View File
@@ -51,8 +51,7 @@ export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedCl
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
// OpenTUI rejects NUL before any destination; host clipboard text cannot contain it.
const result = await clipboard.writeText(text.replaceAll("\0", ""), {
const result = await clipboard.writeText(text, {
destination: "all-available",
selection: "clipboard",
})
-1
View File
@@ -274,7 +274,6 @@ 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,15 +223,6 @@ 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",
@@ -248,7 +239,7 @@ export function PluginsDialog(props: {
},
]}
footer={
<Show when={pluginError(focusedEntry()) && !focusedTui()}>
<Show when={pluginError(focusedEntry())}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
@@ -1,111 +0,0 @@
export type GroupNode<Entry, Kind extends string> =
| { readonly type: "entry"; readonly entry: Entry; readonly size: 1 }
| {
readonly type: "group"
readonly kind: Kind
readonly children: readonly GroupNode<Entry, Kind>[]
/** Number of descendant leaves, independent of disclosure state. */
readonly size: number
}
/**
* Group adjacent entries by their configured nesting paths. For example, a read
* can use ["exploration"] today or ["activity", "exploration"] in Low.
* Entries are opaque: message/part identity, visibility and live state remain
* owned by the session projection. A path of [] creates a standalone leaf.
*/
export function groupEntries<Entry, Kind extends string>(
entries: readonly Entry[],
path: (entry: Entry) => readonly Kind[],
): readonly GroupNode<Entry, Kind>[] {
const result: BuildingNode<Entry, Kind>[] = []
entries.forEach((entry) => {
appendEntry(result, entry, path(entry))
})
return result
}
// Only freshly constructed nodes are writable; the published tree is readonly.
type BuildingNode<Entry, Kind extends string> =
| { type: "entry"; entry: Entry; size: 1 }
| { type: "group"; kind: Kind; children: BuildingNode<Entry, Kind>[]; size: number }
function appendEntry<Entry, Kind extends string>(
nodes: BuildingNode<Entry, Kind>[],
entry: Entry,
path: readonly Kind[],
depth = 0,
) {
const kind = path[depth]
if (kind === undefined) {
nodes.push({ type: "entry", entry, size: 1 })
return
}
const previous = nodes.at(-1)
if (previous?.type === "group" && previous.kind === kind) {
previous.size++
appendEntry(previous.children, entry, path, depth + 1)
return
}
const children: BuildingNode<Entry, Kind>[] = []
appendEntry(children, entry, path, depth + 1)
nodes.push({ type: "group", kind, children, size: 1 })
}
/**
* Concatenate ordered, disjoint chunks, recursively merging compatible groups
* at their seam. Untouched subtrees retain their object identity.
*
* This is concatenation, not ingestion: callers must reconcile overlapping
* pages/replayed message IDs before merging. Equal payloads may be distinct
* entries and must not be silently deduplicated here.
*/
export function mergeGroups<Entry, Kind extends string>(
left: readonly GroupNode<Entry, Kind>[],
right: readonly GroupNode<Entry, Kind>[],
): readonly GroupNode<Entry, Kind>[] {
if (!left.length) return right
if (!right.length) return left
const a = left[left.length - 1]
const b = right[0]
if (a.type !== "group" || b.type !== "group" || a.kind !== b.kind) return [...left, ...right]
return [
...left.slice(0, -1),
{
type: "group",
kind: a.kind,
size: a.size + b.size,
children: mergeGroups(a.children, b.children),
},
...right.slice(1),
]
}
/**
* Split at a depth-first leaf offset. Group headers count as zero. Cached sizes
* skip whole subtrees; only the ancestors crossing the cut are reconstructed.
* The returned halves can be seam-merged again without changing their meaning.
*/
export function splitGroups<Entry, Kind extends string>(
nodes: readonly GroupNode<Entry, Kind>[],
count: number,
): readonly [readonly GroupNode<Entry, Kind>[], readonly GroupNode<Entry, Kind>[]] {
if (!Number.isInteger(count) || count < 0) throw new RangeError("Group split requires a non-negative integer")
if (count === 0) return [[], nodes]
let offset = 0
for (const [index, node] of nodes.entries()) {
const end = offset + node.size
if (count === end) return [nodes.slice(0, index + 1), nodes.slice(index + 1)]
if (count < end) {
if (node.type !== "group") throw new RangeError("Cannot split inside an entry")
const size = count - offset
const [left, right] = splitGroups(node.children, size)
return [
[...nodes.slice(0, index), { ...node, children: left, size }],
[{ ...node, children: right, size: node.size - size }, ...nodes.slice(index + 1)],
]
}
offset = end
}
throw new RangeError("Group split exceeds entry count")
}
+280 -3
View File
@@ -1,5 +1,6 @@
import {
batch,
createContext,
createEffect,
createMemo,
createSignal,
@@ -11,6 +12,7 @@ import {
onMount,
Show,
Switch,
useContext,
type Accessor,
} from "solid-js"
import path from "node:path"
@@ -27,6 +29,7 @@ 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,
@@ -108,9 +111,6 @@ 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,6 +121,34 @@ 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
@@ -2434,6 +2462,160 @@ 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 }) {
@@ -2736,6 +2918,101 @@ 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 (
@@ -1,269 +0,0 @@
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>
)
}
@@ -1,34 +0,0 @@
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
}
+1
View File
@@ -305,6 +305,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "idle") return rows
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
if (!pending.has(message.id)) completePrevious(rows)
@@ -1,82 +0,0 @@
import { expect, test } from "bun:test"
import { groupEntries, mergeGroups, splitGroups } from "../../../src/routes/session/grouping/tree"
const path = (entry: { path: string[] }) => entry.path
const read = { id: "read", path: ["activity", "exploration"] }
const search = { id: "search", path: ["activity", "exploration"] }
const thought = { id: "thought", path: ["activity", "reasoning"] }
const text = { id: "text", path: [] }
const leaf = (entry: typeof read) => ({ type: "entry" as const, entry, size: 1 as const })
test("groups adjacent entries by path and counts leaves, not wrappers", () => {
expect(groupEntries([read, search, thought, text, read], path)).toEqual([
{
type: "group",
kind: "activity",
size: 3,
children: [
{ type: "group", kind: "exploration", size: 2, children: [leaf(read), leaf(search)] },
{ type: "group", kind: "reasoning", size: 1, children: [leaf(thought)] },
],
},
leaf(text),
{
type: "group",
kind: "activity",
size: 1,
children: [{ type: "group", kind: "exploration", size: 1, children: [leaf(read)] }],
},
])
})
test("a direct child breaks a subgroup without ending the outer group", () => {
const shell = { id: "shell", path: ["activity"] }
expect(groupEntries([read, shell, search], path)).toEqual([
{
type: "group",
kind: "activity",
size: 3,
children: [
{ type: "group", kind: "exploration", size: 1, children: [leaf(read)] },
leaf(shell),
{ type: "group", kind: "exploration", size: 1, children: [leaf(search)] },
],
},
])
})
test("merges both grouping levels at a page seam without changing the inputs", () => {
const left = groupEntries([text, read], path)
const right = groupEntries([search, thought], path)
const saved = structuredClone([left, right])
const merged = mergeGroups(left, right)
expect(merged).toEqual(groupEntries([text, read, search, thought], path))
expect([left, right]).toEqual(saved)
expect(merged[0]).toBe(left[0])
})
test("splits at each leaf boundary and merges back to the original tree", () => {
const entries = [read, search, thought, text]
const tree = groupEntries(entries, path)
for (let count = 0; count <= entries.length; count++) {
const [left, right] = splitGroups(tree, count)
expect(left).toEqual(groupEntries(entries.slice(0, count), path))
expect(right).toEqual(groupEntries(entries.slice(count), path))
expect(mergeGroups(left, right)).toEqual(tree)
}
})
test("handles empty chunks", () => {
const tree = groupEntries([read], path)
expect(groupEntries([], path)).toEqual([])
expect(mergeGroups([], tree)).toBe(tree)
expect(mergeGroups(tree, [])).toBe(tree)
expect(splitGroups([], 0)).toEqual([[], []])
})
test("rejects invalid split offsets", () => {
const tree = groupEntries([read], path)
for (const count of [-1, 0.5, 2, NaN]) {
expect(() => splitGroups(tree, count)).toThrow(RangeError)
}
})
@@ -13,10 +13,6 @@ 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"
@@ -36,19 +32,11 @@ function packagePlugin(outdated: boolean): 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 }[]
},
) {
async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) {
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 })
@@ -61,14 +49,13 @@ async function renderPlugins(
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: api,
client: createApi(transport.fetch),
data: { location: { default: () => ({ directory: root }) }, on: () => () => {} },
get theme() {
return theme
@@ -79,12 +66,9 @@ async function renderPlugins(
},
} as unknown as Context
const plugins = {
registered: () => tui?.registered ?? [],
list: () => tui?.list ?? [],
activate: async (id: string) => {
activations.push(id)
return true
},
registered: () => [],
list: () => [],
activate: async () => true,
deactivate: async () => true,
} as unknown as ReturnType<typeof usePlugin>
return <PluginsDialog context={context} plugins={plugins} />
@@ -93,23 +77,15 @@ async function renderPlugins(
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<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>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
)
@@ -117,46 +93,10 @@ async function renderPlugins(
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
const expected = tui?.list[0]?.id ?? inventory.list[0]?.id ?? "local.plugin"
await app.waitForFrame((frame) => frame.includes(expected))
return { app, requests, toasts, activations }
await app.waitForFrame((frame) => frame.includes("team.plugins") || frame.includes("local.plugin"))
return { app, requests, toasts }
}
test("failed TUI plugins keep enter to enable and use space to show the error", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(
tmp.path,
{ list: [], check: [] },
{
registered: [{ id: "broken.plugin", source: "external", active: false }],
list: [
{
target: "./broken.ts",
id: "broken.plugin",
status: "failed",
error: "Plugin setup failed",
},
],
},
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("broken.plugin") && frame.includes("view error"))
expect(fixture.app.captureCharFrame()).toContain("enable")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.activations.length === 1)
expect(fixture.activations).toEqual(["broken.plugin"])
fixture.app.mockInput.pressKey(" ")
await fixture.app.waitForFrame(
(frame) => frame.includes("TUI plugin error") && frame.includes("Plugin setup failed"),
)
} finally {
fixture.app.renderer.destroy()
}
})
test("checking for updates refreshes the inventory and reveals the update action", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
-13
View File
@@ -102,19 +102,6 @@ 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")
+181
View File
@@ -3242,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18394,6 +18540,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18425,6 +18603,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+181
View File
@@ -3242,6 +3242,152 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/diff": {
"get": {
"tags": ["session"],
"operationId": "v2.session.diff",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "User message whose turn to diff. Defaults to the turn of the newest user message."
},
"required": false
},
{
"name": "to",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone."
},
"required": false
},
{
"name": "context",
"in": "query",
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Unchanged lines around each hunk. Omit for full-file patches."
},
"required": false
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff.Info"
}
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "MessageNotFoundError | SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
}
]
}
}
}
},
"500": {
"description": "UnknownError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnknownErrorEncoded"
}
}
}
}
},
"description": "Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
"summary": "Diff session turns"
}
},
"/api/session/{sessionID}/inbox": {
"get": {
"tags": ["session"],
@@ -18394,6 +18540,38 @@
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
"additionalProperties": false
},
"Session.Message.Idle": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^msg_"
},
"metadata": {
"type": "object"
},
"time": {
"type": "object",
"properties": {
"created": {
"type": "number"
}
},
"required": ["created"],
"additionalProperties": false
},
"type": {
"type": "string",
"enum": ["idle"]
},
"outcome": {
"type": "string",
"enum": ["succeeded", "failed", "interrupted"]
}
},
"required": ["id", "time", "type", "outcome"],
"additionalProperties": false
},
"Session.Message.Info": {
"anyOf": [
{
@@ -18425,6 +18603,9 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
+167 -240
View File
@@ -2,13 +2,85 @@
title: "Agents"
---
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:
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`:
```md title=".opencode/agents/reviewer.md"
---
description: Reviews changes for correctness and regressions
description: Reviews changes without modifying files
mode: subagent
model: anthropic/claude-sonnet-4-5#high
color: "#ff6b6b"
steps: 8
permissions:
- action: edit
resource: "*"
@@ -18,191 +90,70 @@ permissions:
effect: deny
---
Review the current changes. List findings in severity order with file and line references.
Review for correctness, security, regressions, and missing tests.
List findings in severity order with file and line references.
```
Ask your primary agent to use it:
### JSON or JSONC
```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):
Use the `agents` field in any [OpenCode configuration file](/config):
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "reviewer",
"agents": {
"reviewer": {
"description": "Reviews current changes",
"mode": "subagent",
"system": "Report findings in severity order.",
"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,
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
],
},
},
}
```
## Selection
Set the primary agent used when a session has not selected one:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "writer",
"agents": {
"writer": { "mode": "primary" },
},
}
```
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.
## Modes
Set `mode` according to where the agent should run:
```jsonc
{
"agents": {
"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" },
],
},
},
}
```
## 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" },
],
"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.
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.
## Options
### Description
### `description`
`description` explains the agent's purpose. Add it to subagents because OpenCode shows it to the model choosing which agent to launch:
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.
```yaml
description: Reviews database migrations for safety
### `mode`
Accepts `primary`, `subagent`, or `all`. The default is `all`.
### `model`
Selects a model using `provider/model` with an optional `#variant`:
```jsonc
{
"agents": {
"reviewer": {
"model": "anthropic/claude-sonnet-4-5#high",
},
},
}
```
### 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:
The equivalent expanded form is:
```jsonc
{
@@ -218,108 +169,84 @@ JSON configuration also accepts the expanded form:
}
```
- 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.
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.
### System
### `system`
`system` sets the agent's system prompt. A non-empty value replaces the provider's base prompt for that agent:
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:
```jsonc
{
"agents": {
"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": {
"orchestrator": {
"permissions": [
{ "action": "*", "resource": "*", "effect": "deny" },
{ "action": "read", "resource": "src/**", "effect": "allow" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "explore", "effect": "allow" },
{ "action": "shell", "resource": "git *", "effect": "ask" },
],
},
},
}
```
| 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`. |
Each rule has:
The last matching rule wins, so put broad rules before exceptions. Common actions include:
| 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`. |
| 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 |
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`.
For `read`, `edit`, and `external_directory` resources, OpenCode expands `~` and `$HOME`:
<Callout type="tip">
Put broad wildcard rules first and exceptions afterward. For example, deny all subagents first, then allow `explore`.
</Callout>
```jsonc
{ "action": "read", "resource": "~/notes/**", "effect": "allow" }
```
`~` and `$HOME` are expanded in filesystem resources for `read`, `edit`, and
`external_directory`. Shell resources are raw command text and are not
expanded.
Shell resources remain raw command text and do not expand those values.
### `steps`
### Steps
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` sets a positive maximum number of model steps:
### `hidden`
```yaml
steps: 8
```
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.
On the final step, OpenCode removes tools and asks the model to summarize in text. New user input resets the allowance.
### `color`
### Hidden
Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`.
`hidden` removes an agent from normal listings, interactive discovery, and the subagent catalog:
### `disabled`
```yaml
hidden: true
```
When `true`, removes the agent definition at that point in configuration
loading. This works for built-in and custom agents.
This controls visibility, not security. Use permissions to restrict behavior.
### `request`
### 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:
The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
```jsonc
{
@@ -335,8 +262,8 @@ color: "#ff6b6b"
```
<Callout type="warning">
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.
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.
</Callout>
Do not use legacy top-level fields such as `temperature`, `top_p`, `prompt`, `permission`, `tools`, `disable`, or `maxSteps` in new V2 agent configuration.
+63 -118
View File
@@ -2,78 +2,52 @@
title: "Attachments"
---
## Attach
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 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.
| 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 |
```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.
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.
<Callout type="warning">
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.
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.
</Callout>
## Syntax
## Add attachments
V2 prompt and command inputs describe an attachment with a `uri` and optional
`name` and `description`:
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.
```json
{
"uri": "file:///home/me/project/src/server.ts",
"name": "server.ts",
"description": "HTTP server entrypoint"
}
```
Attachment controls and client-side limits depend on the interface. For programmatic submission, see the generated
[API reference](/api).
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.
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:
```text
file:///home/me/project/src/server.ts?start=20&end=60
```
Use a `data:` URL to send content inline:
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.
```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 processing
Configure image normalization in `opencode.json` or `opencode.jsonc`:
@@ -93,70 +67,41 @@ Configure image normalization in `opencode.json` or `opencode.jsonc`:
All fields are optional:
| 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. |
| 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. |
For example, this rejects rather than resizes an image wider than 1200 pixels:
<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>
```jsonc title="opencode.jsonc"
{
"media": {
"image": {
"auto_resize": false,
"max_width": 1200,
},
},
}
```
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.
These settings apply both to supported images attached to prompts and to images
returned by the built-in `read` tool.
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.
## Processing
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.
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.
## Limits and provider behavior
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.
- 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.
@@ -7,6 +7,10 @@ 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": "latest"
"@opencode/plugin": "beta"
},
"peerDependencies": {
"@opentui/core": ">=0.5.8",
@@ -1326,7 +1326,7 @@ entrypoint and declare both runtime dependencies.
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "latest",
"@opencode/plugin": "beta",
"effect": "4.0.0-rc.111"
}
}
@@ -656,16 +656,6 @@ 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.
@@ -1521,7 +1511,7 @@ manifest is:
"./rpc": "./src/rpc.ts"
},
"dependencies": {
"@opencode/plugin": "latest"
"@opencode/plugin": "beta"
}
}
```
@@ -1531,8 +1521,9 @@ 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. Publish a compatible
plugin update when you adopt a newer API contract.
installed package, not only a workspace-linked copy. Because the plugin API is
beta, publish compatible plugin updates when V2 entrypoints or contracts
change.
## 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@beta
bun add @opencode/sdk@dev
```
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@beta effect
bun add @opencode/sdk@dev effect
```
## Create a host
@@ -9,11 +9,10 @@ network hop between the client and server.
For Cloudflare Durable Objects, see the [Cloudflare guide](/build/sdk/cloudflare).
Install the SDK:
```sh
bun add @opencode/sdk@beta
```
<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>
## Create a host
@@ -1,548 +0,0 @@
---
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,140 +1,3 @@
---
title: "Providers"
description: "Connect provider accounts and manage credentials from the CLI and TUI."
---
Connect a provider in the TUI, then choose one of its models:
```text
/connect
/models
```
`/connect` lists the integrations available from the current server and project. Select a provider, choose an
authentication method when it offers more than one, and follow the prompts.
## TUI
The TUI supports API keys, OAuth, and provider authentication commands. OAuth opens an authorization URL or shows a
code to enter; press `o` to open the URL and `c` to copy the authorization details.
```text
/connect
# Select OpenAI, then ChatGPT Pro/Plus (headless).
```
Run `/connect` again to add another account. Selecting an already connected provider opens its account list, where you
can activate, rename, or delete a saved account.
## CLI
Use `auth login` for the same provider methods without opening the TUI. With no provider argument, the command opens an
interactive provider picker.
```bash
opencode2 auth login
```
Pass an integration ID or name to skip the first picker. Use `--method key` to select API-key entry explicitly.
```bash
opencode2 auth login anthropic --method key
```
Method IDs are provider-specific. Run the command without `--method` to see the available methods when a provider has
more than one.
```bash
opencode2 auth login openai
```
API-key entry and provider forms require an interactive terminal. OAuth methods that ask you to paste an authorization
code also require one.
## Methods
OpenCode receives provider credentials through four integration methods:
| Method | Behavior |
| --- | --- |
| API key | Prompts for a secret and saves it as a provider account. |
| OAuth | Provides a browser URL, device code, or authorization-code prompt, then saves tokens and refreshes them when supported. |
| Command | Runs a provider-supplied authentication command and saves its standard output as a key. |
| Environment | Reads a supported variable from the server process without saving it. |
Providers can add forms to API-key and OAuth methods for required details such as an Azure resource name or a GitHub
Enterprise domain. The CLI and TUI render those forms before starting authentication.
## Environment
Set a provider's supported environment variable on the server process that runs model requests. For a one-off private
server, pass it when starting standalone mode.
```bash
ANTHROPIC_API_KEY=sk-ant-... opencode2 --standalone
```
For the shared background server, add the variable to its managed environment. This stops a running service; the next
OpenCode command starts it with the new value.
```bash
opencode2 service set env ANTHROPIC_API_KEY sk-ant-...
opencode2 auth list
```
Environment connections appear in `auth list` with type `environment`. They are not accounts: `auth logout` cannot
remove them, so unset the variable to disconnect. A saved account takes precedence over an environment connection for
the same integration.
```bash
opencode2 auth list
opencode2 service unset env ANTHROPIC_API_KEY
```
Some cloud providers also use their native ambient credential chain instead of an API-key variable:
- Amazon Bedrock supports the AWS default credential chain, including profiles, access-key environments, web identity,
and container credentials. It also supports `AWS_BEARER_TOKEN_BEDROCK`.
- Google Vertex uses Application Default Credentials and a resolvable project. For example, authenticate with
`gcloud auth application-default login` and set `GOOGLE_CLOUD_PROJECT`.
- Azure exposes **Microsoft Entra ID (Azure CLI)** as a connect method when `az` is installed. Run `az login` first, then
select that method in `/connect` or `auth login azure`.
```bash
gcloud auth application-default login
GOOGLE_CLOUD_PROJECT=my-project opencode2
```
See [Providers](/providers) for provider-specific and server-side setup.
## Accounts
Each successful API-key, OAuth, or command login creates a saved account. The newest account becomes active; switch the
active account by its label or credential ID.
```bash
opencode2 auth list
opencode2 auth switch anthropic work
```
Remove a saved account with `auth logout`. Both commands open pickers when their arguments are omitted.
```bash
opencode2 auth logout anthropic work
```
In the TUI, `/connect` provides the same add, activate, rename, and delete operations for saved accounts.
## Storage
Saved API keys and OAuth tokens live in the server's SQLite database. For the local server, print its database path with:
```bash
opencode2 debug paths db
```
The usual release path is `~/.local/share/opencode/opencode.db`; `XDG_DATA_HOME`, the release channel, and `OPENCODE_DB`
can change it. Do not edit the database to manage credentials; use `/connect` or the `auth` commands.
V2 imports supported credentials from the legacy `auth.json` in the OpenCode data directory during its database
migration. New and updated credentials are stored in SQLite rather than written back to that file.
-74
View File
@@ -1,74 +0,0 @@
---
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
```
+87 -186
View File
@@ -2,62 +2,52 @@
title: "Commands"
---
Create `.opencode/commands/review.md` to turn a prompt into `/review`:
Custom commands turn a named prompt template into a reusable command.
```md title=".opencode/commands/review.md"
Review $ARGUMENTS for bugs and missing tests.
```
## Configure with Markdown
Run it from the TUI with a target:
OpenCode discovers `.md` command files in `commands/` directories:
```text
/review src/auth.ts
~/.config/opencode/commands/ # Global
.opencode/commands/ # Project
```
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.
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.
```md title=".opencode/commands/review.md"
---
description: Review code for correctness
description: Review code for correctness and missing tests
agent: plan
model: anthropic/claude-sonnet-4-5#high
---
Review $ARGUMENTS. Report bugs first.
Review $ARGUMENTS. Report bugs first, then missing tests.
```
## JSON
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.
Define commands under `commands` in any OpenCode JSON or JSONC [configuration file](/config). Each command requires a
`template`.
## 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.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"commands": {
"review": {
"description": "Review code for correctness",
"template": "Review $ARGUMENTS. Report bugs first.",
"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",
},
},
}
@@ -65,196 +55,107 @@ Define commands under `commands` in any OpenCode JSON or JSONC [configuration fi
## Fields
Markdown frontmatter and JSON entries accept the same fields, except that Markdown gets `template` from the file body.
| 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`. |
| 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.
The optional fields can be used in JSON or YAML frontmatter. Do not put
`template` in frontmatter because the Markdown body always supplies it.
## Arguments
Use `$ARGUMENTS` for the complete argument string exactly as entered.
Use `$ARGUMENTS` for the complete argument string:
```md title=".opencode/commands/component.md"
---
description: Create a component
---
Create a typed React component named $ARGUMENTS.
```
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.
Use `$1`, `$2`, and higher numbers for parsed positional arguments. Single and
double quotes group text containing spaces and are removed during parsing.
```md title=".opencode/commands/check.md"
---
description: Check one area with a specific focus
---
Check $1. Focus on $2.
```
For `/check src/auth.ts "error handling"`, `$1` becomes `src/auth.ts` and `$2` becomes `error handling`.
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.
The highest-numbered placeholder consumes its argument and everything after it. Missing positions become empty strings.
If a template contains neither positional placeholders nor `$ARGUMENTS`,
OpenCode appends non-empty arguments to the template after a blank line.
```md title=".opencode/commands/compare.md"
Compare $1 with $2.
```
## Shell interpolation
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.
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 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.
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.
<Callout type="warning">
Shell blocks run when OpenCode evaluates the command, outside the agent's tool permission flow. Only use commands from
sources you trust.
Shell interpolations run when the command is evaluated, outside the agent's tool permission flow. Only use commands
from sources you trust.
</Callout>
## Attachments
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.
Stored templates do not expand `@path`; it remains ordinary prompt text.
## Agent, model, and execution
```md title=".opencode/commands/readme.md"
Review @README.md.
```
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.
To attach a file, add it through the composer when invoking the command. OpenCode preserves those composer attachments
when it submits the expanded prompt.
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.
## Execution
### Background subagents
Commands expand arguments and shell blocks before submitting a durable user prompt. They run in the current session by
default.
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.
```md title=".opencode/commands/plan.md"
```md title=".opencode/commands/review.md"
---
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
description: Review changes in the background
agent: general
subagent: true
---
Audit $ARGUMENTS for bugs and missing tests.
Review $ARGUMENTS for bugs and missing tests.
```
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.
- `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.
+130 -237
View File
@@ -2,118 +2,54 @@
title: "Compaction"
---
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 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.
## Start
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.
Automatic compaction is enabled by default. This minimal configuration keeps
about 15,000 tokens of recent conversation beside the generated summary:
## Automatic compaction
```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:
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:
```text
estimated tokens >= min(input limit - buffer, context limit - max(output reserve, buffer))
```
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.
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.
```text
128,000 - 20,000 = 108,000
```
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.
The estimate follows these rules:
## Manual compaction
- 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.
Manual compaction is available through session interfaces. See the generated [API reference](/api) for the server
operation.
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:
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.
```text
model call → context overflow → compact → retry same 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.
This recovery requires `compaction.auto: true`. A second overflow is returned
as an error.
## Settings
## Configuration
Add `compaction` to any [OpenCode configuration file](/config):
@@ -121,27 +57,30 @@ Add `compaction` to any [OpenCode configuration file](/config):
{
"$schema": "https://opencode.ai/config.json",
"compaction": {
"auto": false,
"keep": { "tokens": 24000 },
"buffer": 16000,
"auto": true,
"keep": {
"tokens": 15000,
},
"buffer": 20000,
},
}
```
| 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. |
| 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. |
`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.
`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.
## Providers
## Provider compaction
Local text summaries are the default. Use a provider policy to request the
selected provider's native compaction for both automatic and manual requests:
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:
```jsonc title="opencode.jsonc"
{
@@ -158,140 +97,94 @@ selected provider's native compaction for both automatic and manual requests:
}
```
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.
- `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.
| 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. |
## Local checkpoint contents
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:
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.
```text
15,000 retained + 8,000 prompt and tools > 20,000 threshold
```
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.
Otherwise each fresh usage report can immediately trigger another checkpoint.
Disabling automatic compaction does not remove a checkpoint already installed.
The summary records the objective, requirements, decisions, completed and active
work, blockers, next moves, relevant files, and additional context.
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 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.
On a recognized automatic context overflow, OpenCode attempts local recovery
from the original stored history, expanding native checkpoints first:
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.
```text
native request rejected for overflow → rebuild stored history → local checkpoint
```
## Compaction advances the instruction epoch
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.
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.
## Checkpoints
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.
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:
## Current limitations
```md
## Objective
Finish the authentication migration.
- 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.
## 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.
V1 used additional tail-turn and pruning behavior. Those V1 details are only
migration context; the settings and behavior on this page describe V2.
+75 -42
View File
@@ -2,51 +2,56 @@
title: "Config"
---
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": "anthropic/claude-sonnet-4-5",
}
```
You can also ask OpenCode to update this file for you.
You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you.
## Format
OpenCode supports JSON and JSONC. Use JSONC when you want comments or trailing commas.
OpenCode supports both **JSON** and **JSONC** (JSON with Comments) configuration files.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
// Use this model by default.
"model": "anthropic/claude-sonnet-4-5",
"model": "openai/gpt-5.2-custom",
"providers": {
"openai": {
"models": {
"gpt-5.2-custom": {
"modelID": "gpt-5.2",
"name": "GPT-5.2 Custom",
},
},
},
},
}
```
## Locations
Put settings for every project in the global configuration:
OpenCode loads global configuration from:
```text
~/.config/opencode/opencode.json(c)
```
Put project settings in either of these files:
Project-specific configuration can use either form:
```text
/home/user/projects/my-app/opencode.json(c)
/home/user/projects/my-app/.opencode/opencode.json(c)
```
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.
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.
For example, start OpenCode from `/home/user/projects/acme/packages/web`:
For example, consider a monorepo with OpenCode started from
`/home/user/projects/acme/packages/web`:
```text
~/.config/opencode/opencode.json
@@ -65,8 +70,9 @@ 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`
The package config overrides matching settings from the repository config,
which overrides the global config. Settings that do not conflict are preserved.
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.
## Schema
@@ -108,7 +114,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.
### Agent
### Default agent
Choose the primary agent used when a session does not select one explicitly.
@@ -138,8 +144,8 @@ Project-level values are ignored.
### Sharing
Set the session sharing policy. OpenCode accepts this field, but session sharing
is not supported yet.
Set the intended session sharing policy. V2 accepts this field, but session
sharing is not implemented yet.
```jsonc
{
@@ -151,8 +157,8 @@ See the [sharing guide](/sharing) for more details.
### Username
Set a username. OpenCode accepts this field but does not display it in
conversations.
Set a username for future display behavior. V2 accepts this field but does not
currently display it in conversations.
```jsonc
{
@@ -225,16 +231,39 @@ Ignore files and directories that should not trigger filesystem updates.
### Formatter
Format files after the `write`, `edit`, or `patch` tools change them. Set
`formatter` to `true` to enable available built-in formatters.
Define formatter settings for compatibility and future use. V2 accepts this
field, but it does not run formatters yet.
```jsonc
{
"formatter": true,
"formatter": {
"prettier": {
"command": ["bunx", "prettier", "--write", "$FILE"],
"extensions": [".js", ".ts", ".tsx"],
},
},
}
```
See the [formatters guide](/formatters) for built-ins and custom formatters.
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.
### Media
@@ -256,7 +285,7 @@ before they are sent to a model.
See the [attachments guide](/attachments) for image processing and limits.
### Output
### Tool output
Set the maximum number of lines and bytes retained from a tool result.
@@ -269,10 +298,10 @@ Set the maximum number of lines and bytes retained from a tool result.
}
```
### Search
### Web search
Choose how OpenCode searches the web. Use `"random"` to select an available
provider automatically.
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.
```jsonc
{
@@ -282,8 +311,12 @@ provider automatically.
}
```
See the [websearch guide](/websearch) for providers, credentials, selection,
rate limits, and disabling search.
- 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.
### MCP
@@ -345,7 +378,7 @@ Top-level `compaction.auto: false` disables new automatic compaction without
discarding installed checkpoints. See the [compaction guide](/compaction) for
budgeting and overflow recovery.
### Warming
### Session 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
@@ -361,7 +394,7 @@ interval and 30-minute active window.
}
```
See the [warming guide](/warming) for request behavior, customization,
See the [session warming guide](/warming) for request behavior, customization,
and cost considerations.
### Skills
@@ -395,8 +428,8 @@ See the [commands guide](/commands) for arguments, models, agents, and file-base
### Instructions
Declare additional instruction files, globs, or URLs. OpenCode accepts this
field but does not load its entries; use `AGENTS.md` for 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.
```jsonc
{
@@ -1,44 +0,0 @@
---
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.
+57 -166
View File
@@ -2,200 +2,91 @@
title: "Formatters"
---
OpenCode can format files after its `write`, `edit`, or `patch` tools change
them. Formatters are disabled by default, so enable them in your configuration:
OpenCode V2 accepts formatter configuration, but it does not yet include a
formatter runtime. File writes and edits are not automatically formatted.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": true,
}
```
<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>
## Enable
## Configuration
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.
The `formatter` field accepts a boolean or an object keyed by formatter name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": {
"prettier": {
"disabled": false,
"command": ["prettier", "--write", "$FILE"],
"environment": {
"NODE_ENV": "development",
},
"extensions": [".js", ".ts"],
},
"deno-markdown": {
"command": ["deno", "fmt", "$FILE"],
"extensions": [".md"],
"extensions": [".js", ".jsx", ".ts", ".tsx"],
},
},
}
```
| 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. |
This example is valid V2 configuration, but V2 does not currently execute the
command.
All fields are optional. A built-in entry inherits omitted values, while a new
entry without a command or extensions cannot run.
Each named formatter entry supports these optional fields:
```jsonc title="opencode.jsonc"
| 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": {
"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": {
"custom": {
"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"],
},
},
}
```
## Disable
Omit `formatter` or set it to `false` to disable all formatting. An explicit
`false` can override a lower-priority configuration that enabled formatters.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"formatter": false,
}
```
To disable one built-in while leaving the others enabled, mark its named entry
as disabled.
```jsonc
// Reserved for enabling all built-ins once a V2 runtime provides them.
{
"formatter": true,
}
```
```jsonc title="opencode.jsonc"
```jsonc
// Configure named entries or mark one as disabled.
{
"formatter": {
"prettier": {
"disabled": true,
"prettier": { "disabled": true },
"custom": {
"command": ["custom-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.
## Commands and placeholders
`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.
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.
+3 -2
View File
@@ -2,7 +2,8 @@
title: "Intro"
---
These docs describe OpenCode 2 and its released APIs, configuration, and plugin system.
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.
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.
@@ -26,7 +27,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 in V2.
Homebrew, Windows package managers, Docker, and standalone binaries are not supported during the beta.
---
+98 -83
View File
@@ -2,111 +2,126 @@
title: "Instructions"
---
Add an `AGENTS.md` file to give OpenCode persistent project guidance. Use it for build commands, architecture notes, code conventions, and verification requirements.
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.
```md title="AGENTS.md"
# Project instructions
## AGENTS.md
- Run `bun typecheck` after changing TypeScript.
- Keep database queries in `src/database`.
- Do not edit generated files directly.
```
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.
Commit project instruction files so everyone working in the repository receives the same guidance.
V2 loads:
## Scope
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.
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.
For example, when the Location is `packages/web`, OpenCode can load all three
project files below:
```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
my-project/
├── AGENTS.md
└── packages/
├── AGENTS.md
└── web/
└── AGENTS.md
```
In this example, all four files are loaded. They are combined in this order:
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.
```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.
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.
<Callout type="note">
OpenCode V2 recognizes `AGENTS.md` only. It does not use `CLAUDE.md` as a fallback.
Current V2 discovery only recognizes `AGENTS.md`. The `CLAUDE.md` fallback and related precedence described by older
OpenCode documentation do not apply.
</Callout>
## Discovery
### Nested instructions
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.
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.
```text
my-project/ ← current workspace
├── AGENTS.md loaded initially
└── packages/
└── web/
├── AGENTS.md loaded when this area is read
└── src/
└── app.ts read target
```
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.
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.
## Config entries
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:
The V2 config schema accepts an `instructions` array of strings:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md", "https://example.com/instructions.md"],
"instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md", "https://example.com/shared-instructions.md"],
}
```
Use `AGENTS.md` for active V2 instructions. See [Config](/config) for configuration locations and precedence.
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.
+103
View File
@@ -0,0 +1,103 @@
---
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.
+66 -203
View File
@@ -2,32 +2,11 @@
title: "MCP servers"
---
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.
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.
## Setup
## Configure servers
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`.
Define each server by a unique name under `mcp.servers` in your [OpenCode configuration](/config). V2 does not place server names directly under `mcp`.
```jsonc title="opencode.jsonc"
{
@@ -43,7 +22,7 @@ To configure a server by hand, give it a unique name under `mcp.servers`. V2 doe
}
```
Servers connect automatically. Use `disabled`, not an `enabled` field, to keep one configured without connecting it:
Servers connect automatically unless `disabled` is `true`. There is no V2 `enabled` field.
```jsonc
{
@@ -59,30 +38,11 @@ Servers connect automatically. Use `disabled`, not an `enabled` field, to keep o
}
```
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:
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.
```jsonc title="opencode.jsonc"
{
"mcp": {
"servers": {
"my-server": {
"type": "remote",
"url": "https://mcp.example.com/mcp",
},
},
},
}
```
## Local servers
## 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:
A local server is a command that OpenCode starts using the MCP stdio transport.
```jsonc title="opencode.jsonc"
{
@@ -103,35 +63,21 @@ Use configuration for process options such as a working directory or environment
}
```
| 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. |
| 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. |
Use `{env:NAME}` for environment substitution. Shell expressions such as `$NAME` are not expanded in JSON strings:
Use `{env:NAME}` to substitute an environment variable while loading config. Shell expressions such as `$NAME` are not expanded in JSON strings.
```jsonc
{
"environment": {
"MCP_API_KEY": "{env:MCP_API_KEY}",
},
}
```
## Remote servers
## 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:
A remote server uses the MCP Streamable HTTP transport. Its `url` must be a valid absolute URL.
```jsonc title="opencode.jsonc"
{
@@ -151,32 +97,23 @@ Use configuration when the server needs headers or other options. Store secrets
}
```
| 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. |
| 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. |
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}" },
}
```
Use `oauth: false` for a server that exclusively uses an API key or another header-based credential.
## OAuth
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.
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.
For dynamic registration, configure only the server URL:
For a server that supports dynamic client registration, only the remote server is required:
```jsonc title="opencode.jsonc"
{
@@ -192,13 +129,10 @@ For dynamic registration, configure only the server URL:
}
```
If the server needs authentication, run `/mcps`, select it, and complete authorization in the browser. The CLI can start the same flow:
When a server reports that it needs authentication, start its OAuth flow using
an MCP management interface and complete authorization in the browser.
```sh
opencode2 mcp auth sentry
```
When a provider gives you client credentials, use V2's snake_case OAuth fields:
If the provider issued client credentials, configure them using V2's snake_case field names:
```jsonc title="opencode.jsonc"
{
@@ -221,23 +155,17 @@ When a provider gives you client credentials, use V2's snake_case OAuth fields:
}
```
| 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
```
| 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. |
## Timeouts
Timeouts are positive integer milliseconds. Set defaults under `mcp.timeout`; a server's `timeout` object overrides matching defaults.
Timeouts are positive integer milliseconds. Configure defaults under `mcp.timeout`; a server's `timeout` fields override matching defaults.
```jsonc title="opencode.jsonc"
{
@@ -261,37 +189,19 @@ Timeouts are positive integer milliseconds. Set defaults under `mcp.timeout`; a
}
```
| 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. |
| 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. |
## Names
## Names and permissions
OpenCode names a tool `<server>_<tool>`. It replaces characters other than letters, numbers, `_`, and `-` with `_`:
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.
```text
server: context 7
tool: resolve.library/id
name: context_7_resolve_library_id
```
Choose short server names that remain unique after normalization. Under the default Code Mode, MCP tools are grouped by the normalized server name.
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:
Set `codemode` to `false` on a server when its tools should remain on the provider's native tool list:
```jsonc
{
@@ -307,7 +217,7 @@ Code Mode is the default. Set `codemode` to `false` when a server's tools must s
}
```
Use permission actions to hide or deny tools without disconnecting their server. Match the normalized `<server>_<tool>` name:
Use permission actions to hide or deny a server's tools without stopping its connection:
```jsonc
{
@@ -321,68 +231,21 @@ Use permission actions to hide or deny tools without disconnecting their server.
}
```
## Context
## Session context
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:
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.
```json
{
"method": "tools/call",
"params": {
"name": "lookup",
"arguments": { "query": "example" },
"_meta": { "sessionID": "ses_..." }
}
}
```
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.
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:
## Manage servers
| 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,
},
},
},
}
```
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.
+18 -10
View File
@@ -20,16 +20,20 @@ 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
compatibility bug rather than an expected migration requirement.
beta 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>
## Install V2
<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 the V2 terminal client with the [terminal startup guide](/cli).
## Install the beta
The V2 terminal client is published on the `beta` distribution tag. See the [terminal startup guide](/cli).
## Configuration
@@ -542,20 +546,24 @@ 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 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.
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).
## 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 released V2 API. See the generated [API reference](/api) for its
endpoints, request types, and responses.
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.
## Verify your setup
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
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
configuration that you have converted to the native V2 shape.
+143 -234
View File
@@ -2,44 +2,34 @@
title: "Models"
---
Choose a model for the current session with `/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.
```text
/models
```
Configure provider availability in [Providers](/providers).
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).
## Choose a model
## Select
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.
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.
## Per-run model
```text
anthropic/claude-sonnet-4-5
```
Command-line runs can select a model without changing the configured default.
Availability is project-specific:
Agents and commands can also select their own model. See [Agents](/agents) and [Commands](/commands).
- 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
## Runs
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.
Use `--model` to choose a model for one command-line run without changing the configured default:
## Configure
```bash
opencode2 run --model anthropic/claude-sonnet-4-5 "Refactor parseToken"
```
### Default model
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:
Set `model` in `opencode.json` or `opencode.jsonc`:
```jsonc title="opencode.jsonc"
{
@@ -48,28 +38,87 @@ Set `model` in `opencode.json` or `opencode.jsonc` to choose the default for new
}
```
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.
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.
See [Config](/config) for configuration locations and precedence.
## Variants
### Model settings
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:
Provider and model entries can supply three kinds of request configuration:
```bash
opencode2 run --model openai/gpt-5.2#high "Review this migration plan"
- `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,
},
},
},
},
},
}
```
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.
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:
Define new variants, or replace catalog variants with the same ID, in the model's `variants` array:
- 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:
```jsonc title="opencode.jsonc"
{
@@ -103,143 +152,15 @@ Define new variants, or replace catalog variants with the same ID, in the model'
}
```
Each variant can contain `settings`, `headers`, and `body`. Its values are applied after provider and model values.
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.
## Options
### Local models
Provider and model entries can customize requests with `settings`, `headers`, and `body`:
#### Ollama
```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:
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"
{
@@ -249,13 +170,15 @@ With Ollama listening at `http://127.0.0.1:11434`, select a discovered model wit
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from Ollama.
Embedding-only models are excluded.
Embedding-only models are excluded because they cannot drive a session. Disable discovery with
`"plugins": ["-opencode.provider.ollama"]`.
For another host or port, set Ollama's OpenAI-compatible URL. Discovery still uses the native Ollama API at the same
path prefix:
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:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"ollama": {
"settings": {
@@ -267,13 +190,12 @@ path prefix:
}
```
- Omit `apiKey` when the endpoint does not require bearer authentication.
- Disable discovery with `"plugins": ["-opencode.provider.ollama"]`.
Omit `apiKey` when the Ollama endpoint does not require bearer authentication.
### LMStudio
#### LM Studio
With an unauthenticated LM Studio server at `http://127.0.0.1:1234`, select a discovered model with the `lmstudio`
provider ID:
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:
```jsonc title="opencode.jsonc"
{
@@ -282,13 +204,15 @@ provider ID:
}
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM Studio.
Embedding models are excluded.
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"]`.
For another host or port, set the OpenAI-compatible URL. Models are still discovered automatically:
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"lmstudio": {
"settings": {
@@ -300,12 +224,12 @@ For another host or port, set the OpenAI-compatible URL. Models are still discov
}
```
- Omit `apiKey` when LM Studio authentication is disabled.
- Disable discovery with `"plugins": ["-opencode.provider.lmstudio"]`.
Omit `apiKey` when LM Studio authentication is disabled.
### vLLM
#### vLLM
With vLLM listening at `http://127.0.0.1:8000`, select a discovered model with the `vllm` provider ID:
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:
```jsonc title="opencode.jsonc"
{
@@ -314,16 +238,17 @@ With vLLM listening at `http://127.0.0.1:8000`, select a discovered model with t
}
```
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`.
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"]`.
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:
For a different endpoint or an authenticated server, configure its OpenAI-compatible base URL:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"providers": {
"vllm": {
"settings": {
@@ -335,14 +260,10 @@ For another endpoint or an authenticated server, set its OpenAI-compatible URL:
}
```
- 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`.
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`.
### Compatible
For another OpenAI-compatible server, define its provider package, endpoint, and at least one model:
For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
```jsonc title="opencode.jsonc"
{
@@ -374,13 +295,14 @@ For another OpenAI-compatible server, define its provider package, endpoint, and
}
```
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.
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.
## References
### Model references
Model selectors use `provider/model` with an optional `#variant`:
Configuration fields and model-selection inputs identify a model as `provider/model`, with an optional `#variant`:
```text
openai/gpt-5.2
@@ -388,15 +310,10 @@ openai/gpt-5.2#high
openrouter/anthropic/claude-sonnet-4.5#high
```
| 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. |
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 `#`.
Use catalog IDs rather than provider display names. Root, agent, and command `model` fields accept the string form above
or an expanded object:
The expanded config form is equivalent when generated or programmatic configuration is more convenient:
```jsonc
{
@@ -407,23 +324,15 @@ or an expanded object:
}
```
The expanded form is useful for generated or programmatic configuration.
Root, agent, and command `model` fields accept both forms. Use IDs from the available catalog, not provider display names.
## Caveats
### Caveats
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.
- 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.

Some files were not shown because too many files have changed in this diff Show More