mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 03:16:23 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5518553b5 | ||
|
|
6eb2042acd | ||
|
|
54504ab3a5 | ||
|
|
cc5086d127 | ||
|
|
b20482461c | ||
|
|
5b5368fe98 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 & 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`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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." }))
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)] })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user