Compare commits

..
Author SHA1 Message Date
James Long 089612b759 feat(tui): add recursive session grouping tree 2026-09-10 22:12:46 +00:00
James Long 0c1dfa9186 refactor(tui): extract shared session rendering primitives (#48393) 2026-09-10 18:07:14 -04:00
Dax Raad 8f4d706647 feat(cli): add command docs and simplify session list 2026-09-10 16:27:12 -04:00
Aiden Cline 929374cdfd feat(core): parse JSON text results from MCP tools without an output schema (#48357) 2026-09-10 15:20:13 -05:00
opencode-agent[bot] cfa5ba700e fix(stats): canonicalize DeepSeek Flash usage (#48373) 2026-09-10 13:58:51 -05:00
Shoubhit Dash 45a2ed9a97 feat(core): per-session permission rules (#48351) 2026-09-10 22:30:53 +05:30
opencode-agent[bot]andnexxeln f6333546f8 fix(tui): disambiguate plugin actions (#48354)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-10 22:10:01 +05:30
opencode-agent[bot]andnexxeln 9e153ce7b3 fix(core): raise instruction entry limit (#48350)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-09-10 20:59:38 +05:30
Simon Klee eb357f17cf fix(tui): strip NUL characters before clipboard writes (#48337) 2026-09-10 15:47:03 +02:00
Shoubhit Dash 573d76933f fix(ai): make xAI Responses websockets work and enable them (#48318) 2026-09-10 17:27:43 +05:30
usrnk1andBrendonovich bb8194395a feat(desktop): respect follow-up behavior for slash commands (#48169)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-10 19:24:23 +08:00
81 changed files with 2267 additions and 1930 deletions
@@ -18,7 +18,6 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
@@ -27,6 +26,7 @@ export interface Options {
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly continuation?: OpenResponsesContinuation.Shape
}
export interface Prepared {
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,6 +163,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -6,7 +6,6 @@ import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "open-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
@@ -15,12 +14,19 @@ interface CheckpointValue {
readonly output: ReadonlyArray<unknown>
}
/**
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
*/
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
readonly continuation?: Shape
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
@@ -127,22 +133,26 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
const fields = previous ? shape(request) : undefined
const delta = previous && fields ? incremental(request, previous) : undefined
if (!previous || !fields || !delta)
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -195,4 +205,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export const OpenResponsesContinuation = { driver } as const
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
@@ -405,6 +405,24 @@ export const Event = Schema.StructWithRest(
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
),
)
export interface ProviderAdapter {
readonly id: string
readonly name: string
+4
View File
@@ -41,6 +41,10 @@ const responsesRoute = Route.make({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
}),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
@@ -90,7 +90,11 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -98,6 +102,7 @@ const continuationDriver = (request: Readonly<Record<string, unknown>>, base = b
request,
message,
base: base(message),
continuation,
})
}
@@ -921,6 +926,58 @@ describe("OpenAI Responses route", () => {
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
const internal = ProviderShared.encodeJson({
type: "error",
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
})
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "ProviderInternal" } },
})
}),
)
it.effect("shapes the incremental send with the route continuation", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "grok-4.6",
store: true,
instructions: "You are terse.",
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const secondRequest = {
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
yield* continuationDriver(firstRequest).create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const trimmed = yield* continuationDriver(
secondRequest,
baseChannelDriver,
({ instructions: _, ...rest }) => rest,
).create(saved)
expect(trimmed.mode).toBe("incremental")
expect(JSON.parse(trimmed.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// Declining the continuation sends the step in full and never sends a previous_response_id.
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
expect(declined.mode).toBe("full")
expect(JSON.parse(declined.message)).toEqual(secondRequest)
}),
)
+110 -2
View File
@@ -1,11 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer, Stream } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import { LLMClient } from "../../src/route.js"
import {
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -13,6 +20,35 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
Effect.gen(function* () {
let driver: WebSocketChannelDriver | undefined
yield* LLMClient.generate(request, {
webSocket: {
execute: (exchange) =>
Effect.sync(() => {
driver = exchange.driver
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
if (!driver) throw new Error("Expected a WebSocket channel driver")
return driver
})
const completed = (driver: WebSocketChannelDriver, id: string) =>
Effect.gen(function* () {
const create = yield* driver.create(undefined)
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
const observation = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
)
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
return observation.checkpoint
})
describe("xAI Responses route", () => {
it.effect("composes the Open Responses baseline with xAI extensions", () =>
Effect.gen(function* () {
@@ -162,6 +198,78 @@ describe("xAI Responses route", () => {
}),
)
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
Effect.gen(function* () {
// xAI answers a rejected response.create with an error envelope that carries no event type.
const envelope = ProviderShared.encodeJson({
error: {
message:
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
type: "api_error",
},
})
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
})
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
expect(error.reason.body).toBe(envelope)
}),
)
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
Effect.gen(function* () {
const step = (store: boolean, ...prompts: string[]) =>
LLM.request({
model,
system: "You are terse.",
messages: prompts.map((prompt) => Message.user(prompt)),
providerOptions: { store },
})
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
expect(stored.mode).toBe("incremental")
expect(JSON.parse(stored.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
include: ["reasoning.encrypted_content"],
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// The connection cache only serves stored responses, so the default store: false never chains.
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
expect(unstored.mode).toBe("full")
expect(JSON.parse(unstored.message)).toMatchObject({
instructions: "You are terse.",
store: false,
input: [
{ role: "user", content: [{ type: "input_text", text: "First" }] },
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
],
})
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
+5 -10
View File
@@ -126,15 +126,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -326,7 +320,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
null,
2,
)
: formatTable(page.data)) + EOL
: formatList(page.data)) + EOL
const write = Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
@@ -96,18 +96,14 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
),
)
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
const rows = sessions.map((session) => ({
id: session.id,
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
updated: new Date(session.time.updated).toLocaleString(),
}))
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
return [
header,
"─".repeat(header.length),
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
].join(EOL)
function formatList(sessions: ReadonlyArray<SessionInfo>) {
return sessions
.map((session) =>
[
session.id,
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
new Date(session.time.updated).toLocaleString(),
].join("\t"),
)
.join(EOL)
}
+20 -12
View File
@@ -11,13 +11,13 @@ import type { RelativePath } from "@opencode/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode/schema/model"
import type { DateTime } from "effect"
import type { Permission } from "@opencode/schema/permission"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -27,7 +27,6 @@ import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { Permission } from "@opencode/schema/permission"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
import type { Command } from "@opencode/schema/command"
@@ -37,6 +36,7 @@ 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,6 +209,7 @@ export type SessionCreateInput = {
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionCreateOutput = Session.Info
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
@@ -360,15 +361,6 @@ 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> = (
@@ -446,6 +438,7 @@ export type SessionLogOutput =
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly metadata?: Session.Metadata | undefined
readonly permissions?: Permission.Ruleset | undefined
readonly version: string
}
}
@@ -498,6 +491,15 @@ export type SessionLogOutput =
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly title: string }
}
| {
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
}
| {
readonly id: Event.ID
readonly created: number
@@ -1148,7 +1150,6 @@ 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>
@@ -1595,6 +1596,12 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1602,6 +1609,7 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
+12 -14
View File
@@ -68,8 +68,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -183,6 +181,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -397,6 +397,7 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
}).pipe(
Effect.mapError(mapClientError),
@@ -594,17 +595,6 @@ 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(
@@ -744,7 +734,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -1159,6 +1148,14 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
request: { list: EndpointPermissionRequestList(raw) },
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
@@ -1166,6 +1163,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
+15 -14
View File
@@ -62,8 +62,6 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -177,6 +175,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -567,6 +567,7 @@ export function make(options: ClientOptions) {
model: input?.["model"],
location: input?.["location"],
metadata: input?.["metadata"],
permissions: input?.["permissions"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -844,18 +845,6 @@ 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 }>(
@@ -1580,6 +1569,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
},
file: {
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
+127 -93
View File
@@ -147,14 +147,6 @@ 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"
@@ -559,28 +551,6 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
version: string
}
}
export type SessionAgentSelected = {
id: string
created: number
@@ -1659,24 +1629,6 @@ export type SessionInboxMove = {
delivery: SessionInboxDelivery
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
revert?: SessionRevert
}
export type SessionRevertStaged = {
id: string
created: number
@@ -1920,6 +1872,58 @@ export type AgentInfo = {
permissions: PermissionRuleset
}
export type SessionPermissionsUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.permissions.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; permissions: PermissionRuleset }
}
export type SessionInfo = {
id: string
parentID?: string
fork?: { sessionID: string; boundary: SessionForkBoundary }
projectID: string
agent?: string
model?: ModelRef
cost: MoneyUSD
tokens: TokenUsageInfo
outcome?: "succeeded" | "failed" | "interrupted"
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string
location: LocationRef
subpath?: string
metadata?: SessionMetadata
permissions?: PermissionRuleset
revert?: SessionRevert
}
export type SessionCreated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.created"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
projectID: string
location: LocationRef
subpath?: string
parentID?: string
slug: string
title?: string
agent?: string
model?: ModelRef
metadata?: SessionMetadata
permissions?: PermissionRuleset
version: string
}
}
export type ConfigEntry =
| {
type: "document"
@@ -2092,8 +2096,6 @@ export type ConfigEntry =
| { type: "agents"; path: string }
| { type: "claude"; path: string }
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = {
id: string
sessionID: string
@@ -2148,6 +2150,8 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields2 = [FormField1, ...Array<FormField1>]
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionInboxEnqueued = {
@@ -2202,7 +2206,6 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -2242,6 +2245,7 @@ export type SessionEventDurable =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionDeleted
| SessionForked
@@ -2301,6 +2305,7 @@ export type V2Event =
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionPermissionsUpdated
| SessionViewed
| SessionUsageUpdated
| SessionDeleted
@@ -2813,6 +2818,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["id"]
readonly title?: {
readonly id?: string | null
@@ -2821,6 +2831,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["title"]
readonly agent?: {
readonly id?: string | null
@@ -2829,6 +2844,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["agent"]
readonly model?: {
readonly id?: string | null
@@ -2837,6 +2857,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["model"]
readonly location?: {
readonly id?: string | null
@@ -2845,6 +2870,11 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["location"]
readonly metadata?: {
readonly id?: string | null
@@ -2853,7 +2883,25 @@ export type SessionCreateInput = {
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["metadata"]
readonly permissions?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}> | null
}["permissions"]
}
export type SessionCreateOutput = { data: SessionInfo }["data"]
@@ -2891,6 +2939,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3161,13 +3214,6 @@ 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"]
@@ -3203,6 +3249,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3473,13 +3524,6 @@ 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"]
@@ -3515,6 +3559,11 @@ export type SessionImportInput = {
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly permissions?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
@@ -3785,13 +3834,6 @@ 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"]
@@ -4281,27 +4323,6 @@ 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"]
@@ -5804,6 +5825,19 @@ export type PermissionReplyInput = {
export type PermissionReplyOutput = void
export type PermissionRulesInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly permissions: {
readonly permissions: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}["permissions"]
}
export type PermissionRulesOutput = void
export type FileReadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+4 -12
View File
@@ -695,6 +695,10 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
case "session.moved": {
const current = store.session.info[event.data.sessionID]
if (current) {
@@ -1024,18 +1028,6 @@ 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)
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260910120000_clear_v1_session_permission",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`UPDATE \`session_v2\` SET \`permission\` = NULL;`)
})
},
}
export default migration
@@ -600,7 +600,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
+64 -75
View File
@@ -9,7 +9,6 @@ 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,
@@ -309,7 +308,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
options?: { stdin?: string; env?: Record<string, string> },
) {
const result = yield* proc
.run(
@@ -318,7 +317,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
{ stdin: options?.stdin },
)
.pipe(
Effect.mapError(
@@ -332,8 +331,7 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -387,7 +385,9 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,7 +464,13 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -493,23 +499,19 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
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))
})
/**
* 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
@@ -517,57 +519,49 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
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(
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
"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,
]
"--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
}),
)
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) {
@@ -739,11 +733,6 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+1 -1
View File
@@ -154,7 +154,7 @@ const layer = Layer.effect(
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
+3
View File
@@ -404,6 +404,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
rules: sessions.setPermissions,
},
plugin: {
list: () => response(plugin.list()),
@@ -509,6 +510,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
title: input?.title,
agent: input?.agent,
model: input?.model,
metadata: input?.metadata,
permissions: input?.permissions,
location:
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
+1
View File
@@ -101,6 +101,7 @@ export const XAIPlugin = define({
for (const model of provider.models.values()) {
catalog.model.update(providerID, model.id, (draft) => {
draft.capabilities.responsesWebsockets = true
draft.websocket = true
})
}
})
+10 -26
View File
@@ -18,6 +18,7 @@ import { SessionMessageTable } from "./session/sql.js"
import { SessionSchema } from "./session/schema.js"
import { RelativePath } from "./schema.js"
import { Agent } from "@opencode/schema/agent"
import type { Permission } from "@opencode/schema/permission"
import { App } from "./app.js"
import { Slug } from "./util/slug.js"
import path from "path"
@@ -54,11 +55,8 @@ 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"
@@ -84,6 +82,7 @@ type CreateBaseInput = {
agent?: Agent.ID
model?: Model.Ref
metadata?: SessionSchema.Metadata
permissions?: Permission.Ruleset
}
type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
@@ -110,7 +109,6 @@ 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<{
@@ -137,13 +135,6 @@ 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
@@ -168,6 +159,10 @@ export interface Interface {
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly setPermissions: (input: {
sessionID: SessionSchema.ID
permissions: Permission.Ruleset
}) => Effect.Effect<void, NotFoundError>
readonly move: SessionMove.Interface["move"]
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
@@ -232,7 +227,6 @@ 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)
@@ -260,9 +254,10 @@ const layer = Layer.effect(
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
title: input.title,
agent: input.agent,
// Children inherit metadata the way they inherit location, so
// host policies that read it treat the family uniformly.
// Children inherit metadata and permissions the way they inherit
// location, so host policies that read them treat the family uniformly.
metadata: input.metadata ?? parent?.metadata,
permissions: input.permissions ?? parent?.permissions,
model: input.model
? {
id: Model.ID.make(input.model.id),
@@ -364,17 +359,6 @@ 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),
@@ -410,6 +394,7 @@ const layer = Layer.effect(
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
rename: (input) => sessions.forSession(input.sessionID).rename(input),
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
move: moves.move,
compact: (input) => sessions.forSession(input.sessionID).compact(input),
wait: (sessionID) => sessions.forSession(sessionID).wait(),
@@ -463,7 +448,6 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
+2 -1
View File
@@ -1,6 +1,7 @@
export * as SessionContext from "./context.js"
import { Model } from "@opencode/schema/model"
import { Permission } from "../permission.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
@@ -129,7 +130,7 @@ const layer = Layer.effect(
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(agent.info.permissions),
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
builtins: builtins.load(sessionID),
discovery: discovery.load(),
skills: skillInstructions.load(agent),
-138
View File
@@ -1,138 +0,0 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["messageID", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly messageID?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.messageID ? eq(SessionMessageTable.id, input.messageID) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "messageID" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.messageID ? yield* resolve("messageID", input.messageID) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+1
View File
@@ -50,6 +50,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
metadata: row.metadata ?? undefined,
permissions: row.permission ?? undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
outcome: row.idle_outcome ?? undefined,
time: {
+4 -20
View File
@@ -60,21 +60,6 @@ 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")({
@@ -131,6 +116,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.permissions.updated": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
@@ -138,11 +124,9 @@ 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": () => 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.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
+10
View File
@@ -160,6 +160,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
agent: parent.agent,
model: parent.model,
metadata: parent.metadata,
permission: parent.permission,
version: parent.version,
cost: 0,
tokens_input: 0,
@@ -450,6 +451,7 @@ const layer = Layer.effectDiscard(
agent: event.data.agent,
model: event.data.model,
metadata: event.data.metadata,
permission: event.data.permissions,
version: event.data.version,
time_created: event.created,
time_updated: event.created,
@@ -571,6 +573,14 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
db
.update(SessionTable)
.set({ permission: event.data.permissions, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) => {
const idle = event.data.idle
return db
@@ -226,7 +226,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+11
View File
@@ -3,6 +3,7 @@ export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Scope } from "effect"
import type { Agent } from "@opencode/schema/agent"
import type { Model } from "@opencode/schema/model"
import type { Permission } from "@opencode/schema/permission"
import { Event } from "@opencode/schema/event"
import { FSUtil } from "@opencode/util/fs-util"
import { Bus } from "../bus.js"
@@ -72,6 +73,13 @@ export const make = Effect.fn("Session.make")(function* () {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
})
const setPermissions = Effect.fn("Session.setPermissions")(function* (
sessionID: SessionSchema.ID,
input: { permissions: Permission.Ruleset },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
input: { agent: Agent.ID },
@@ -334,6 +342,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
@@ -356,6 +365,7 @@ export const make = Effect.fn("Session.make")(function* () {
const message = operations.message.bind(undefined, sessionID)
const view = operations.view.bind(undefined, sessionID)
const rename = operations.rename.bind(undefined, sessionID)
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
const switchModel = operations.switchModel.bind(undefined, sessionID)
const inbox = operations.inbox.bind(undefined, sessionID)
@@ -381,6 +391,7 @@ export const make = Effect.fn("Session.make")(function* () {
message,
view,
rename,
setPermissions,
switchAgent,
switchModel,
inbox,
+2 -2
View File
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { PermissionV1 } from "@opencode/schema/permission-v1"
import type { Permission } from "@opencode/schema/permission"
import type { Project } from "@opencode/schema/project"
import type { SessionSchema } from "./schema.js"
import type { Workspace } from "@opencode/schema/workspace"
@@ -49,7 +49,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
+1
View File
@@ -103,6 +103,7 @@ const layer = Layer.effect(
agent: input.data.info.agent,
model: input.data.info.model,
metadata: input.data.info.metadata,
permissions: input.data.info.permissions,
},
{
location: input.location,
+16 -33
View File
@@ -131,55 +131,38 @@ const layer = Layer.effect(
)
})
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
source: repo.source,
const comparison = {
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
})
// 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 })
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 })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
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 comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
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
const comparison = yield* compare("diff", input)
return yield* git.tree
.diff({
repository: compared.repository,
from: compared.from,
to: compared.to,
...comparison.input,
context: input.context,
paths: input.paths,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
})
.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) {
+12 -1
View File
@@ -99,8 +99,19 @@ export const layer = Layer.effect(
},
)
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
const output = () => {
if (result.structured !== undefined) return result.structured
if (text === "") return null
// Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.
if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) {
try {
return JSON.parse(text)
} catch {}
}
return text
}
return {
output: result.structured ?? (text === "" ? null : text),
output: output(),
...(content.length === 0 ? {} : { content }),
}
}).pipe(
-37
View File
@@ -6,7 +6,6 @@ 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"
@@ -197,42 +196,6 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+74
View File
@@ -324,6 +324,32 @@ const mcp = Layer.mock(Mcp.Service, {
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "issues",
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "count",
description: "Returns a number as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("demo"),
name: "typed",
description: "Declares a string output and returns JSON as text",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "string" },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "issues",
codemode: false,
description: "Returns JSON as text",
inputSchema: { type: "object", properties: {} },
}),
new Mcp.Tool({
server: Mcp.ServerName.make("direct"),
name: "lookup",
@@ -374,6 +400,20 @@ const mcp = Layer.mock(Mcp.Service, {
isError: false,
content: [{ type: "text", text: "hello" }],
})
if (input.name === "issues" || input.name === "typed")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
})
if (input.name === "count")
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "42" }],
})
return new Mcp.ToolResult({
server: Mcp.ServerName.make(input.server),
tool: input.name,
@@ -1943,6 +1983,7 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
"direct_fail",
"direct_issues",
"direct_lookup",
"direct_media",
"execute",
@@ -2033,6 +2074,39 @@ it.effect("returns content-only MCP results through Code Mode", () =>
}),
)
it.effect("parses JSON text results from MCP tools without an output schema", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
yield* registration.flush
const toolSet = yield* registry.snapshot()
const run = (code: string) =>
toolSet
.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
})
.pipe(Effect.map((execution) => execution.output.output))
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
// Outside Code Mode the content the model reads is the original text.
expect(
yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_json_text"),
...toolIdentity,
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
}),
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+28
View File
@@ -224,6 +224,34 @@ describe("Permission", () => {
}),
)
it.effect("merges session rules after agent rules and before saved approvals", () =>
Effect.gen(function* () {
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
const { db } = yield* Database.Service
const service = yield* Permission.Service
const setSession = (permission: Permission.Ruleset) =>
db
.update(SessionTable)
.set({ permission })
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
.run()
.pipe(Effect.orDie)
yield* setSession([{ action: "edit", resource: "/original/**", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "edit", resources: ["/original/src/index.ts"] }))).toMatchObject({
effect: "deny",
})
yield* setRules([])
const saved = yield* PermissionSaved.Service
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
yield* setSession([{ action: "bash", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "deny" })
yield* setSession([{ action: "bash", resource: "*", effect: "ask" }])
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "allow" })
}),
)
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
Effect.gen(function* () {
yield* setup()
+1
View File
@@ -108,6 +108,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
reply: () => Effect.die("unused permission.reply"),
rules: () => Effect.die("unused permission.rules"),
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
@@ -69,7 +69,7 @@ describe("XAIPlugin", () => {
}),
)
it.effect("keeps xAI Responses WebSockets opt-in", () =>
it.effect("enables xAI Responses WebSockets", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("xai")
@@ -84,7 +84,7 @@ describe("XAIPlugin", () => {
const model = yield* catalog.model.get(providerID, Model.ID.make("grok-4.6"))
expect(model?.capabilities.responsesWebsockets).toBe(true)
expect(model?.websocket).toBeUndefined()
expect(model?.websocket).toBe(true)
}),
)
})
+39 -2
View File
@@ -388,6 +388,32 @@ describe("Session.create", () => {
}),
)
it.effect("stores permission rules, inherits them through children and forks, and replaces them", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const permissions = [{ action: "edit", resource: "/original/**", effect: "deny" as const }]
const created = yield* session.create({ location, permissions })
expect(created.permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id })).permissions).toEqual(permissions)
expect((yield* session.create({ parentID: created.id, permissions: [] })).permissions).toEqual([])
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
expect(forked.permissions).toEqual(permissions)
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
yield* session.setPermissions({ sessionID: created.id, permissions: replaced })
expect((yield* session.get(created.id)).permissions).toEqual(replaced)
expect(
yield* session.setPermissions({ sessionID: Session.ID.create(), permissions: replaced }).pipe(Effect.flip),
).toBeInstanceOf(Session.NotFoundError)
}),
)
it.effect("inherits location from an existing parent when omitted", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -1330,7 +1356,12 @@ describe("SessionTransfer", () => {
const transfer = yield* SessionTransfer.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } })
const template = yield* session.create({
location,
title: "Exported",
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
const sessionID = Session.ID.create()
const sourceMessageID = SessionMessage.ID.create()
const errorMessageID = SessionMessage.ID.create()
@@ -1376,7 +1407,13 @@ describe("SessionTransfer", () => {
})
const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } })
expect(imported).toMatchObject({
id: sessionID,
title: "Exported",
location,
metadata: { channel: "C123" },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
})
expect(imported.time).toMatchObject({
updated: DateTime.makeUnsafe(1_000),
idle: DateTime.makeUnsafe(200),
-198
View File
@@ -1,198 +0,0 @@
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 },
)
})
+2 -3
View File
@@ -561,9 +561,7 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
expect(yield* sessions.messages({ sessionID })).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -571,6 +569,7 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
+10 -1
View File
@@ -1944,7 +1944,16 @@ describe("SessionRunnerLLM", () => {
expect(yield* entries.list(sessionID)).toEqual([{ key: "nullable", value: null }])
})
scenario("rejects API instruction entries larger than 8KB", function* () {
scenario("accepts API instruction entries up to 256 KiB", function* () {
const entries = yield* InstructionEntry.Service
const value = "x".repeat(InstructionEntry.MaxValueBytes - 2)
yield* entries.put({ sessionID, key: "large", value })
expect(yield* entries.list(sessionID)).toEqual([{ key: "large", value }])
})
scenario("rejects API instruction entries larger than 256 KiB", function* () {
const entries = yield* InstructionEntry.Service
const exit = yield* entries
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
readonly hook: Hooks<PermissionHooks>
}
+1
View File
@@ -438,6 +438,7 @@ export function fromPromise(plugin: Plugin) {
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
readonly hook: Hooks<PermissionHooks>
}
-181
View File
@@ -3242,152 +3242,6 @@
"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"],
@@ -18540,38 +18394,6 @@
"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": [
{
@@ -18603,9 +18425,6 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
@@ -132,4 +132,21 @@ export const makePermissionGroup = <
}),
),
)
.add(
HttpApiEndpoint.put("session.permission.rules", "/api/session/:sessionID/permission/rules", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ permissions: Permission.Ruleset }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.rules",
summary: "Replace session permission rules",
description:
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
+2 -26
View File
@@ -27,10 +27,10 @@ import {
import { Agent } from "@opencode/schema/agent"
import { Skill } from "@opencode/schema/skill"
import { Model } from "@opencode/schema/model"
import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
import { FileDiff } from "@opencode/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -176,6 +176,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
metadata: Session.Metadata.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: Schema.Struct({ data: Session.Info }),
}).annotateMerge(
@@ -522,31 +523,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
messageID: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `messageID` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
+1 -1
View File
@@ -27,7 +27,7 @@ export const Snapshot = Schema.Array(
).annotate({ identifier: "InstructionEntry.Snapshot" })
export type Snapshot = typeof Snapshot.Type
export const MaxValueBytes = 8 * 1024
export const MaxValueBytes = 256 * 1024
export class ValueTooLargeError extends Schema.TaggedError<ValueTooLargeError>()(
"InstructionEntryValueTooLargeError",
+13
View File
@@ -25,6 +25,7 @@ import { TokenUsage } from "./token-usage.js"
import { SessionInbox } from "./session-inbox.js"
import { Project } from "./project.js"
import { SessionFork } from "./session-fork.js"
import { Permission } from "./permission.js"
export { FileAttachment }
@@ -62,6 +63,7 @@ export const Created = Event.durable({
model: Model.Ref.pipe(optional),
/** Host-supplied annotations resolved at creation, including any inherited from a parent. */
metadata: SessionMetadata.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
version: Schema.String,
},
})
@@ -109,6 +111,16 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const PermissionsUpdated = Event.durable({
type: "session.permissions.updated",
...options,
schema: {
...Base,
permissions: Permission.Ruleset,
},
})
export type PermissionsUpdated = typeof PermissionsUpdated.Type
export const Viewed = Event.durable({
type: "session.viewed",
...options,
@@ -634,6 +646,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
PermissionsUpdated,
Viewed,
UsageUpdated,
Deleted,
-14
View File
@@ -280,18 +280,6 @@ 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,
@@ -303,7 +291,6 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -316,5 +303,4 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
+3
View File
@@ -10,6 +10,7 @@ import { SessionEvent } from "./session-event.js"
import { SessionID } from "./session-id.js"
import { SessionMetadata } from "./session-metadata.js"
import { Money } from "./money.js"
import { Permission } from "./permission.js"
import { TokenUsage } from "./token-usage.js"
import { Revert } from "./session-revert.js"
import { SessionFork } from "./session-fork.js"
@@ -54,6 +55,8 @@ export const Info = Schema.Struct({
location: Location.Ref,
subpath: RelativePath.pipe(optional),
metadata: Metadata.pipe(optional),
/** Evaluated after the agent's rules; the last matching rule wins. */
permissions: Permission.Ruleset.pipe(optional),
revert: Revert.pipe(optional),
}).annotate({ identifier: "Session.Info" })
@@ -115,6 +115,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.permissions.updated.1",
"session.viewed.1",
"session.message.content.updated.1",
"session.usage.recorded.1",
@@ -83,6 +83,15 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.permission.rules",
Effect.fn(function* (ctx) {
yield* sessions
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"permission.saved.list",
Effect.fn(function* (ctx) {
+1 -23
View File
@@ -1,6 +1,5 @@
import { Session } from "@opencode/core/session"
import type { Snapshot } from "@opencode/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -10,14 +9,6 @@ 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(
@@ -27,16 +18,3 @@ 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 })),
),
)
}
}
+54 -32
View File
@@ -17,9 +17,10 @@ import {
ServiceUnavailableError,
SessionBusyError,
SkillNotFoundError,
UnknownError,
} from "@opencode/protocol/errors"
import { AbsolutePath } from "@opencode/core/schema"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
import { failedMessageDecode, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -119,6 +120,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
agent: ctx.payload.agent,
model: ctx.payload.model,
metadata: ctx.payload.metadata,
permissions: ctx.payload.permissions,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(Effect.orDie),
@@ -211,7 +213,15 @@ 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", missingMessage),
Effect.catchTag(
"Session.MessageNotFoundError",
(error) =>
new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
}),
),
Effect.catchTag(
"Session.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
@@ -439,14 +449,32 @@ 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", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
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}`,
}),
),
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,
}),
),
),
)
}),
),
}
}),
)
@@ -454,13 +482,23 @@ 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", failedSnapshot("clear session revert", 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,
}),
),
),
)
}),
)
return HttpApiSchema.NoContent.make()
}),
)
@@ -490,22 +528,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.diff",
Effect.fn(function* (ctx) {
return {
data: yield* session.diff({ sessionID: ctx.params.sessionID, ...ctx.query }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
"Session.TurnRangeError",
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
),
}
}),
)
.handle(
"session.inbox.list",
Effect.fn(function* (ctx) {
-98
View File
@@ -1,98 +0,0 @@
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" | "idle" }>
type Notice = Exclude<SessionMessageInfo, { type: "user" | "assistant" | "shell" }>
type Entry = { type: "assistant"; message: SessionMessageAssistant } | { type: "notice"; message: Notice }
type Content = SessionMessageAssistant["content"][number]
type GroupRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
@@ -765,8 +765,7 @@ 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" || message.type === "idle")
return false
if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false
if (message.type !== "synthetic") return true
return !!message.description?.trim() || timelineNoticeRequired(message)
}
@@ -39,6 +39,7 @@ describe("inference stat normalization", () => {
})
test("merges renamed models under their current name", () => {
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5")
expect(toModelAggregate(aggregate("x-preview-f", "openai"))).toMatchObject([
@@ -14,6 +14,7 @@ export const MODEL_AUTHOR_RULES = [
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export const MODEL_NAME_ALIASES: Record<string, string> = {
"deepseek-flash": "deepseek-v4.1-flash",
"x-preview-f": "ox-alpha",
"xiaomi/mimo-v2.5": "mimo-v2.5",
}
+2 -1
View File
@@ -51,7 +51,8 @@ export function createClipboardAdapter(clipboard: CoreClipboardService): OwnedCl
throw new Error(`Unexpected clipboard MIME type: ${result.representation.mimeType}`)
},
async write(text) {
const result = await clipboard.writeText(text, {
// OpenTUI rejects NUL before any destination; host clipboard text cannot contain it.
const result = await clipboard.writeText(text.replaceAll("\0", ""), {
destination: "all-available",
selection: "clipboard",
})
+1
View File
@@ -274,6 +274,7 @@ export const Definitions = {
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("return", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.error": keybind("space", "View plugin error"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"),
"dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"),
@@ -223,6 +223,15 @@ export function PluginsDialog(props: {
disabled: checking(),
onTrigger: check,
},
{
title: "view error",
command: "dialog.plugins.error",
hidden: !pluginError(focusedTui()),
onTrigger: (option) => {
const entry = entries().find((entry) => entry.key === option.value)
if (pluginError(entry)) setDetail(entry)
},
},
{
title: toggleTitle(),
command: "plugins.toggle",
@@ -239,7 +248,7 @@ export function PluginsDialog(props: {
},
]}
footer={
<Show when={pluginError(focusedEntry())}>
<Show when={pluginError(focusedEntry()) && !focusedTui()}>
<text>
<span style={{ fg: props.context.theme.text.default }}>
<b>enter</b>
@@ -0,0 +1,103 @@
export type EntryNode<Entry> = {
type: "entry"
entry: Entry
size: 1
}
export type GroupNode<Kind extends PropertyKey, Entry> = {
type: "group"
kind: Kind
children: TimelineNode<Kind, Entry>[]
size: number
}
export type TimelineNode<Kind extends PropertyKey, Entry> = EntryNode<Entry> | GroupNode<Kind, Entry>
export type GroupPath<Kind extends PropertyKey, Entry> = (entry: Entry) => readonly Kind[]
export function groupEntries<Kind extends PropertyKey, Entry>(entries: readonly Entry[], path: GroupPath<Kind, Entry>) {
return entries.reduce<TimelineNode<Kind, Entry>[]>((nodes, entry) => {
appendEntry(nodes, entry, path(entry))
return nodes
}, [])
}
/** Merge ordered chunks at their shared seam without mutating either input. */
export function mergeGroups<Kind extends PropertyKey, Entry>(
left: readonly TimelineNode<Kind, Entry>[],
right: readonly TimelineNode<Kind, Entry>[],
): TimelineNode<Kind, Entry>[] {
if (left.length === 0) return [...right]
if (right.length === 0) return [...left]
const before = left.at(-1)!
const after = right[0]
if (before.type !== "group" || after.type !== "group" || before.kind !== after.kind) {
return [...left, ...right]
}
return [
...left.slice(0, -1),
{
type: "group",
kind: before.kind,
children: mergeGroups(before.children, after.children),
size: before.size + after.size,
},
...right.slice(1),
]
}
/** Split at a depth-first leaf offset, preserving the surrounding group paths. */
export function splitGroups<Kind extends PropertyKey, Entry>(
nodes: readonly TimelineNode<Kind, Entry>[],
count: number,
): [TimelineNode<Kind, Entry>[], TimelineNode<Kind, Entry>[]] {
if (!Number.isInteger(count) || count < 0) throw new RangeError("Group split must be a non-negative integer")
if (count === 0) return [[], [...nodes]]
const total = leafCount(nodes)
if (count > total) throw new RangeError("Group split exceeds leaf count")
if (count === total) 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 a leaf")
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 leaf count")
}
export function flattenGroups<Kind extends PropertyKey, Entry>(nodes: readonly TimelineNode<Kind, Entry>[]): Entry[] {
return nodes.flatMap((node) => (node.type === "entry" ? [node.entry] : flattenGroups(node.children)))
}
export function leafCount<Kind extends PropertyKey, Entry>(nodes: readonly TimelineNode<Kind, Entry>[]) {
return nodes.reduce((total, node) => total + node.size, 0)
}
function appendEntry<Kind extends PropertyKey, Entry>(
nodes: TimelineNode<Kind, Entry>[],
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)
const group: GroupNode<Kind, Entry> =
previous?.type === "group" && previous.kind === kind ? previous : { type: "group", kind, children: [], size: 0 }
if (group !== previous) nodes.push(group)
group.size++
appendEntry(group.children, entry, path, depth + 1)
}
+3 -280
View File
@@ -1,6 +1,5 @@
import {
batch,
createContext,
createEffect,
createMemo,
createSignal,
@@ -12,7 +11,6 @@ import {
onMount,
Show,
Switch,
useContext,
type Accessor,
} from "solid-js"
import path from "node:path"
@@ -29,7 +27,6 @@ import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
SessionMessageInfo,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
@@ -111,6 +108,9 @@ import { createDelayedPresence } from "../../util/delayed-presence"
import { SessionLocationMissing } from "./location-missing"
import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
export { InlineToolRow } from "./message-parts"
addDefaultParsers(parsers.parsers)
@@ -121,34 +121,6 @@ const BACKGROUND_TOOL_HINT_DELAY = 3_000
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
width: number
/**
* Shared reactive terminal size. Transcript-row components must read this
* instead of calling useTerminalDimensions(), which registers one renderer
* resize listener per mounted component and grows with transcript length.
*/
terminal: { width: number; height: number }
sessionID: string
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
messageIndex: (messageID: string) => number | undefined
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
}>()
function use() {
const ctx = useContext(context)
if (!ctx) throw new Error("useContext must be used within a Session component")
return ctx
}
export function Session(props: {
scrollRef?: (scroll: ScrollBoxRenderable | undefined) => void
@@ -2462,160 +2434,6 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
)
}
const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: {
last: boolean
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => reasoningContent(props.part))
const isDone = createMemo(
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time?.completed ?? props.message.time.completed
const start = props.part.time?.created ?? props.message.time.created
return end === undefined ? 0 : Math.max(0, end - start)
})
const summary = createMemo(() => reasoningSummary(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content()}>
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={inMinimal() && !expanded() ? summary().title : null}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
</box>
<Show when={!inMinimal() || expanded()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={inMinimal() ? 3 : 1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</box>
</Show>
</box>
</Show>
)
}
function reasoningContent(part: SessionMessageAssistantReasoning) {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return part.text.replace("[REDACTED]", "").trim()
}
function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
const theme = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
: theme.text.feedback.warning.default
return (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
)
}
function TextPart(props: { last: boolean; part: SessionMessageAssistantText; message: SessionMessageAssistant }) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
/>
</box>
</Show>
)
}
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
@@ -2918,101 +2736,6 @@ function InlineTool(props: {
)
}
export function InlineToolRow(props: {
icon: string
iconColor?: RGBA
color?: RGBA
errorColor?: RGBA
failed?: boolean
denied?: boolean
error?: string
errorExpanded?: boolean
complete: unknown
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
onMouseUp?: () => void
}) {
return (
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
<Switch>
<Match when={props.spinner}>
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
{(status) => (
<box flexDirection="row" gap={1}>
<Spinner color={props.color} />
<InlineToolLabel color={props.color} status={status()}>
{props.children}
</InlineToolLabel>
</box>
)}
</Show>
</Match>
<Match when={true}>
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
<box flexDirection="row">
<text
width={INLINE_TOOL_ICON_WIDTH}
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.icon}
</text>
<Show
when={props.status}
fallback={
<text
flexGrow={1}
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
}
>
{(status) => (
<InlineToolLabel
color={props.failed ? props.errorColor : props.color}
denied={props.denied}
status={status()}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</InlineToolLabel>
)}
</Show>
</box>
</Show>
</Match>
</Switch>
<Show when={props.failed && props.errorExpanded}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={props.errorColor}>{props.error}</text>
</box>
</Show>
</box>
)
}
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
return (
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
<text
maxWidth="100%"
flexShrink={0}
fg={props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
</text>
{props.status}
</box>
)
}
function StatusBadge(props: { children: string }) {
const theme = useTheme()
return (
@@ -0,0 +1,269 @@
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { RGBA, TextAttributes } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import type {
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
} from "@opencode/client"
import { Spinner } from "../../component/spinner"
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
import { reasoningSummary } from "../../context/thinking"
import { usePlugin } from "../../plugin/context"
import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { use } from "./render-context"
import { generateThinkingSyntax } from "./thinking-syntax"
export const INLINE_TOOL_ICON_WIDTH = 2
export function ReasoningPart(props: {
last: boolean
part: SessionMessageAssistantReasoning
message: SessionMessageAssistant
}) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)
const content = createMemo(() => reasoningContent(props.part))
const isDone = createMemo(
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
)
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time?.completed ?? props.message.time.completed
const start = props.part.time?.created ?? props.message.time.created
return end === undefined ? 0 : Math.max(0, end - start)
})
const summary = createMemo(() => reasoningSummary(content()))
const toggle = () => {
if (!inMinimal()) return
setExpanded((prev) => !prev)
}
return (
<Show when={content()}>
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
<box
border={!inMinimal() || expanded() ? ["left"] : undefined}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
title={inMinimal() && !expanded() ? summary().title : null}
duration={isDone() ? Locale.duration(duration()) : undefined}
/>
</box>
</box>
<Show when={!inMinimal() || expanded()}>
<box marginTop={1}>
<box
border={["left"]}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.raise(theme.background.default)}
paddingLeft={inMinimal() ? 3 : 1}
>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={true}
syntaxStyle={thinkingSyntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.text.subdued}
/>
</box>
</box>
</Show>
</box>
</Show>
)
}
export function reasoningContent(part: SessionMessageAssistantReasoning) {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return part.text.replace("[REDACTED]", "").trim()
}
function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
title: string | null
duration?: string
}) {
const theme = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(
theme.text.feedback.warning.default.r,
theme.text.feedback.warning.default.g,
theme.text.feedback.warning.default.b,
0.6,
)
: theme.text.feedback.warning.default
return (
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
)
}
export function TextPart(props: {
last: boolean
part: SessionMessageAssistantText
message: SessionMessageAssistant
}) {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} flexShrink={0}>
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
<markdown
syntaxStyle={syntax()}
renderNode={plugins.markdown()}
content={props.part.text.trim()}
streaming={props.message.time.completed === undefined}
internalBlockMode="top-level"
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
/>
</box>
</Show>
)
}
export function InlineToolRow(props: {
icon: string
iconColor?: RGBA
color?: RGBA
errorColor?: RGBA
failed?: boolean
denied?: boolean
error?: string
errorExpanded?: boolean
complete: unknown
pending: string
failure?: string
spinner?: boolean
status?: JSX.Element
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
onMouseUp?: () => void
}) {
return (
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
<Switch>
<Match when={props.spinner}>
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
{(status) => (
<box flexDirection="row" gap={1}>
<Spinner color={props.color} />
<InlineToolLabel color={props.color} status={status()}>
{props.children}
</InlineToolLabel>
</box>
)}
</Show>
</Match>
<Match when={true}>
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
<box flexDirection="row">
<text
width={INLINE_TOOL_ICON_WIDTH}
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.icon}
</text>
<Show
when={props.status}
fallback={
<text
flexGrow={1}
fg={props.failed ? props.errorColor : props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</text>
}
>
{(status) => (
<InlineToolLabel
color={props.failed ? props.errorColor : props.color}
denied={props.denied}
status={status()}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
</InlineToolLabel>
)}
</Show>
</box>
</Show>
</Match>
</Switch>
<Show when={props.failed && props.errorExpanded}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={props.errorColor}>{props.error}</text>
</box>
</Show>
</box>
)
}
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
return (
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
<text
maxWidth="100%"
flexShrink={0}
fg={props.color}
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.children}
</text>
{props.status}
</box>
)
}
@@ -0,0 +1,34 @@
import { createContext, useContext } from "solid-js"
import type { ModelInfo } from "@opencode/client"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { useConfig } from "../../config"
import type { ThinkingMode } from "../../context/thinking"
export type PendingAction = "steer" | "queue" | "cancel"
export const context = createContext<{
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
width: number
/**
* Shared reactive terminal size. Transcript-row components must read this
* instead of calling useTerminalDimensions(), which registers one renderer
* resize listener per mounted component and grows with transcript length.
*/
terminal: { width: number; height: number }
sessionID: string
thinkingMode: () => ThinkingMode
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
models: () => ModelInfo[]
messageIndex: (messageID: string) => number | undefined
config: ReturnType<typeof useConfig>["data"]
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
}>()
export function use() {
const ctx = useContext(context)
if (!ctx) throw new Error("useContext must be used within a Session component")
return ctx
}
-1
View File
@@ -305,7 +305,6 @@ 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)
@@ -13,6 +13,10 @@ import { ThemeProvider, useThemes } from "../../../src/context/theme"
// the context back, so the context must load first exactly as it does in the app.
import type { usePlugin } from "../../../src/plugin/context"
import "../../../src/plugin/context"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider } from "../../../src/context/data"
import { LocationProvider } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { PluginsDialog } from "../../../src/feature-plugins/system/plugins"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
@@ -32,11 +36,19 @@ function packagePlugin(outdated: boolean): PluginInfo {
}
}
async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) {
async function renderPlugins(
root: string,
inventory: { list: PluginInfo[]; check: PluginInfo[] },
tui?: {
registered: { id: string; source: "builtin" | "external"; active: boolean }[]
list: { target: string; id?: string; status: "active" | "inactive" | "failed"; error?: string }[]
},
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const requests: { path: string; body: unknown }[] = []
const toasts: ToastOptions[] = []
const activations: string[] = []
const location = { directory: root, project: { id: "proj_test", directory: root, canonical: root } }
const transport = createFetch(async (url, request) => {
if (url.pathname === "/api/plugin") return json({ location, data: inventory.list })
@@ -49,13 +61,14 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
return new Response(null, { status: 204 })
}
})
const api = createApi(transport.fetch)
function Harness() {
function Content() {
onCleanup(Keymap.use().mode.push("modal"))
const theme = useThemes().currentTokens()
const context = {
client: createApi(transport.fetch),
client: api,
data: { location: { default: () => ({ directory: root }) }, on: () => () => {} },
get theme() {
return theme
@@ -66,9 +79,12 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
},
} as unknown as Context
const plugins = {
registered: () => [],
list: () => [],
activate: async () => true,
registered: () => tui?.registered ?? [],
list: () => tui?.list ?? [],
activate: async (id: string) => {
activations.push(id)
return true
},
deactivate: async () => true,
} as unknown as ReturnType<typeof usePlugin>
return <PluginsDialog context={context} plugins={plugins} />
@@ -77,15 +93,23 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={api}>
<DataProvider directory={root}>
<LocationProvider>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</TestTuiContexts>
)
@@ -93,10 +117,46 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("team.plugins") || frame.includes("local.plugin"))
return { app, requests, toasts }
const expected = tui?.list[0]?.id ?? inventory.list[0]?.id ?? "local.plugin"
await app.waitForFrame((frame) => frame.includes(expected))
return { app, requests, toasts, activations }
}
test("failed TUI plugins keep enter to enable and use space to show the error", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(
tmp.path,
{ list: [], check: [] },
{
registered: [{ id: "broken.plugin", source: "external", active: false }],
list: [
{
target: "./broken.ts",
id: "broken.plugin",
status: "failed",
error: "Plugin setup failed",
},
],
},
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("broken.plugin") && frame.includes("view error"))
expect(fixture.app.captureCharFrame()).toContain("enable")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.activations.length === 1)
expect(fixture.activations).toEqual(["broken.plugin"])
fixture.app.mockInput.pressKey(" ")
await fixture.app.waitForFrame(
(frame) => frame.includes("TUI plugin error") && frame.includes("Plugin setup failed"),
)
} finally {
fixture.app.renderer.destroy()
}
})
test("checking for updates refreshes the inventory and reveals the update action", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
@@ -0,0 +1,291 @@
import { describe, expect, test } from "bun:test"
import {
flattenGroups,
groupEntries,
leafCount,
mergeGroups,
splitGroups,
type TimelineNode,
} from "../../../src/routes/session/grouping/tree"
type Kind = "activity" | "exploration" | "reasoning" | "instructions"
type Entry = { id: string; path: Kind[] }
const entry = (id: string, ...path: Kind[]): Entry => ({ id, path })
const group = (entries: readonly Entry[]) => groupEntries(entries, (item) => item.path)
const ids = (nodes: readonly TimelineNode<Kind, Entry>[]) => flattenGroups(nodes).map((item) => item.id)
describe("session grouping tree", () => {
test("leaves standalone entries unwrapped", () => {
const entries = [entry("a"), entry("b")]
expect(group(entries)).toEqual([
{ type: "entry", entry: entries[0], size: 1 },
{ type: "entry", entry: entries[1], size: 1 },
])
})
test("groups consecutive entries with the same path", () => {
expect(group([entry("a", "exploration"), entry("b", "exploration")])).toMatchObject([
{
type: "group",
kind: "exploration",
size: 2,
children: [
{ type: "entry", entry: { id: "a" }, size: 1 },
{ type: "entry", entry: { id: "b" }, size: 1 },
],
},
])
})
test("creates nested groups from configured paths", () => {
const nodes = group([
entry("read", "activity", "exploration"),
entry("thought", "activity", "reasoning"),
entry("notice", "activity", "instructions"),
entry("shell", "activity"),
])
expect(nodes).toMatchObject([
{
type: "group",
kind: "activity",
size: 4,
children: [
{ type: "group", kind: "exploration", size: 1 },
{ type: "group", kind: "reasoning", size: 1 },
{ type: "group", kind: "instructions", size: 1 },
{ type: "entry", entry: { id: "shell" }, size: 1 },
],
},
])
expect(ids(nodes)).toEqual(["read", "thought", "notice", "shell"])
})
test("standalone entries delimit compatible groups", () => {
const nodes = group([entry("a", "exploration"), entry("text"), entry("b", "exploration")])
expect(nodes.map((node) => (node.type === "group" ? node.kind : node.entry.id))).toEqual([
"exploration",
"text",
"exploration",
])
})
test("direct children delimit nested subgroups without ending their outer group", () => {
const nodes = group([
entry("read-a", "activity", "exploration"),
entry("shell", "activity"),
entry("read-b", "activity", "exploration"),
])
expect(nodes).toHaveLength(1)
expect(nodes[0]).toMatchObject({
type: "group",
kind: "activity",
size: 3,
children: [
{ type: "group", kind: "exploration", size: 1 },
{ type: "entry", entry: { id: "shell" } },
{ type: "group", kind: "exploration", size: 1 },
],
})
})
test("counts depth-first leaves and never counts group wrappers", () => {
const nodes = group([
entry("a", "activity", "exploration"),
entry("b", "activity", "exploration"),
entry("c", "activity", "reasoning"),
entry("d"),
])
expect(leafCount(nodes)).toBe(4)
expect(nodes[0]?.size).toBe(3)
if (nodes[0]?.type !== "group") throw new Error("Expected activity group")
expect(nodes[0].children[0]?.size).toBe(2)
})
test("merges both levels at a recursive seam", () => {
const left = group([entry("shell", "activity"), entry("read-a", "activity", "exploration")])
const right = group([entry("read-b", "activity", "exploration"), entry("thought", "activity", "reasoning")])
const merged = mergeGroups(left, right)
expect(merged).toMatchObject([
{
type: "group",
kind: "activity",
size: 4,
children: [
{ type: "entry", entry: { id: "shell" } },
{ type: "group", kind: "exploration", size: 2 },
{ type: "group", kind: "reasoning", size: 1 },
],
},
])
expect(ids(merged)).toEqual(["shell", "read-a", "read-b", "thought"])
})
test("does not merge incompatible outer or inner seams", () => {
expect(mergeGroups(group([entry("a", "exploration")]), group([entry("b", "reasoning")]))).toHaveLength(2)
const merged = mergeGroups(
group([entry("a", "activity", "exploration")]),
group([entry("b", "activity", "reasoning")]),
)
expect(merged).toHaveLength(1)
if (merged[0]?.type !== "group") throw new Error("Expected activity group")
expect(merged[0].children).toHaveLength(2)
})
test("retains identities outside the recursive seam", () => {
const left = group([entry("before"), entry("shell", "activity"), entry("read-a", "activity", "exploration")])
const right = group([
entry("read-b", "activity", "exploration"),
entry("thought", "activity", "reasoning"),
entry("after"),
])
if (left[1]?.type !== "group" || right[0]?.type !== "group") throw new Error("Expected activity groups")
const leftShell = left[1].children[0]
const rightReasoning = right[0].children[1]
const merged = mergeGroups(left, right)
expect(merged[0]).toBe(left[0])
expect(merged[2]).toBe(right[1])
if (merged[1]?.type !== "group") throw new Error("Expected merged activity group")
expect(merged[1].children[0]).toBe(leftShell)
expect(merged[1].children[2]).toBe(rightReasoning)
})
test("does not mutate chunks while recursively merging", () => {
const left = group([entry("a", "activity", "exploration")])
const right = group([entry("b", "activity", "exploration")])
const saved = structuredClone([left, right])
mergeGroups(left, right)
expect([left, right]).toEqual(saved)
})
test("merges empty chunks without sharing their root arrays", () => {
const nodes = group([entry("a", "exploration")])
expect(mergeGroups([], nodes)).toEqual(nodes)
expect(mergeGroups([], nodes)).not.toBe(nodes)
expect(mergeGroups(nodes, [])).toEqual(nodes)
expect(mergeGroups(nodes, [])).not.toBe(nodes)
})
test("splits at every depth-first leaf boundary and rejoins canonically", () => {
const entries = [
entry("before"),
entry("read-a", "activity", "exploration"),
entry("read-b", "activity", "exploration"),
entry("thought", "activity", "reasoning"),
entry("shell", "activity"),
entry("after"),
]
const nodes = group(entries)
for (let count = 0; count <= entries.length; count++) {
const [left, right] = splitGroups(nodes, count)
expect(ids(left)).toEqual(entries.slice(0, count).map((item) => item.id))
expect(ids(right)).toEqual(entries.slice(count).map((item) => item.id))
expect(leafCount(left)).toBe(count)
expect(leafCount(right)).toBe(entries.length - count)
expect(mergeGroups(left, right)).toEqual(nodes)
}
})
test("rejects invalid split offsets", () => {
const nodes = group([entry("a")])
for (const count of [-1, 0.5, 2, Number.NaN]) expect(() => splitGroups(nodes, count)).toThrow(RangeError)
})
test("all two-way partitions reproduce a fresh projection", () => {
const entries = [
entry("a", "activity", "exploration"),
entry("b", "activity", "exploration"),
entry("c", "activity", "reasoning"),
entry("d", "activity"),
entry("e"),
entry("f", "instructions"),
entry("g", "instructions"),
]
const whole = group(entries)
for (let index = 0; index <= entries.length; index++) {
expect(mergeGroups(group(entries.slice(0, index)), group(entries.slice(index)))).toEqual(whole)
}
})
test("all three-way partitions merge associatively", () => {
const entries = [
entry("a", "activity", "exploration"),
entry("b", "activity", "exploration"),
entry("c", "activity", "reasoning"),
entry("d", "activity"),
entry("e"),
entry("f", "instructions"),
entry("g", "instructions"),
]
const whole = group(entries)
for (let first = 0; first <= entries.length; first++) {
for (let second = first; second <= entries.length; second++) {
const a = group(entries.slice(0, first))
const b = group(entries.slice(first, second))
const c = group(entries.slice(second))
expect(mergeGroups(mergeGroups(a, b), c)).toEqual(whole)
expect(mergeGroups(a, mergeGroups(b, c))).toEqual(whole)
}
}
})
test("all short path sequences preserve order, sizes, splits, and associative seams", () => {
const paths: Kind[][] = [
[],
["exploration"],
["reasoning"],
["activity"],
["activity", "exploration"],
["activity", "reasoning"],
]
const sequences = paths.flatMap((first) =>
paths.flatMap((second) => paths.flatMap((third) => paths.map((fourth) => [first, second, third, fourth]))),
)
sequences.forEach((sequence) => {
const entries = sequence.map((path, index) => entry(String(index), ...path))
const whole = group(entries)
expect(ids(whole)).toEqual(["0", "1", "2", "3"])
expect(leafCount(whole)).toBe(4)
for (let first = 0; first <= entries.length; first++) {
const [left, right] = splitGroups(whole, first)
expect(mergeGroups(left, right)).toEqual(whole)
for (let second = first; second <= entries.length; second++) {
const a = group(entries.slice(0, first))
const b = group(entries.slice(first, second))
const c = group(entries.slice(second))
expect(mergeGroups(mergeGroups(a, b), c)).toEqual(whole)
expect(mergeGroups(a, mergeGroups(b, c))).toEqual(whole)
}
}
})
})
test("supports deeper paths without special-casing two phases", () => {
type DeepKind = "outer" | "middle" | "inner"
const entries = [
{ id: "a", path: ["outer", "middle", "inner"] as DeepKind[] },
{ id: "b", path: ["outer", "middle", "inner"] as DeepKind[] },
]
const nodes = groupEntries(entries, (item) => item.path)
expect(nodes).toMatchObject([
{
type: "group",
kind: "outer",
size: 2,
children: [
{
type: "group",
kind: "middle",
size: 2,
children: [{ type: "group", kind: "inner", size: 2 }],
},
],
},
])
})
test("preserves duplicate leaves because ingestion identity belongs to the projection layer", () => {
const duplicate = entry("same", "exploration")
expect(ids(group([duplicate, duplicate]))).toEqual(["same", "same"])
})
})
+13
View File
@@ -102,6 +102,19 @@ test("uses all available routes but skips the process host remotely", async () =
expect(writes).toEqual({ host: 0, terminal: 1 })
})
test("removes NUL characters before writing", async () => {
const writes: string[] = []
const clipboard = createClipboardAdapter(
coreClipboard({
onWrite: (text) => writes.push(text),
}),
)
expect(await clipboard.write("before\0after")).toBeUndefined()
expect(await clipboard.write("clean")).toBeUndefined()
expect(writes).toEqual(["beforeafter", "clean"])
})
test("rejects only when no clipboard route accepted the write", async () => {
const writes: [string, ClipboardWriteOptions][] = []
const failure = new Error("native clipboard failed")
-181
View File
@@ -3242,152 +3242,6 @@
"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"],
@@ -18540,38 +18394,6 @@
"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": [
{
@@ -18603,9 +18425,6 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
-181
View File
@@ -3242,152 +3242,6 @@
"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"],
@@ -18540,38 +18394,6 @@
"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": [
{
@@ -18603,9 +18425,6 @@
},
{
"$ref": "#/components/schemas/Session.Message.Compaction"
},
{
"$ref": "#/components/schemas/Session.Message.Idle"
}
]
},
@@ -656,6 +656,16 @@ const request = await ctx.permission.get({ sessionID, requestID })
await ctx.permission.reply({ sessionID, requestID, reply: "once" })
```
Replace the session-scoped permission rules. They are evaluated after the agent's rules, and the
last matching rule wins. Child sessions inherit the rules in effect when they are created.
```ts
await ctx.permission.rules({
sessionID,
permissions: [{ action: "edit", resource: "/path/to/original/checkout/**", effect: "deny" }],
})
```
### Sessions
Create or read a session.
@@ -0,0 +1,548 @@
---
title: "commands"
description: "Reference for the opencode2 command line."
---
Every command accepts `--help` for its full flag list, for example `opencode2 run --help`. Commands that talk to a server also accept `--standalone` to run a private server and `--server <url>` to target a specific one.
## run
`opencode2 run` sends a message and prints the reply without opening the interactive interface.
```bash
$ opencode2 run "Explain this repository"
```
Choose a model.
```bash
$ opencode2 run --model anthropic/claude-sonnet-4-5 "Refactor parseToken"
```
Continue the last session.
```bash
$ opencode2 run --continue "Now handle the expired case"
```
Emit newline-delimited JSON for scripts.
```bash
$ opencode2 run --format json "List the TODO comments"
```
Attach files to the message.
```bash
$ opencode2 run --file src/server.ts --file src/client.ts "Review these for bugs"
```
Run with a specific agent.
```bash
$ opencode2 run --agent build "Fix the failing test"
```
View all subcommands and flags.
```bash
$ opencode2 run --help
```
## mini
`opencode2 mini` starts the minimal interactive interface instead of the full-screen TUI.
```bash
$ opencode2 mini
```
Continue the last session.
```bash
$ opencode2 mini --continue
```
Start with a model and an initial prompt.
```bash
$ opencode2 mini --model anthropic/claude-sonnet-4-5 --prompt "Summarize this repository"
```
View all subcommands and flags.
```bash
$ opencode2 mini --help
```
## session
`opencode2 session` manages sessions.
```bash
$ opencode2 session list
```
Limit the list and print JSON.
```bash
$ opencode2 session list --max-count 20 --format json
```
Delete a session and its child sessions.
```bash
$ opencode2 session delete ses_9c1b08
```
Export session data as JSON.
```bash
$ opencode2 session export ses_4f2a1c
```
Redact sensitive transcript and file data when exporting.
```bash
$ opencode2 session export ses_4f2a1c --sanitize
```
Import session data from a JSON file or URL.
```bash
$ opencode2 session import session.json
```
Import into a specific directory.
```bash
$ opencode2 session import session.json --directory ~/code/project
```
View all subcommands and flags.
```bash
$ opencode2 session --help
```
## auth
`opencode2 auth` manages AI providers and credentials.
```bash
$ opencode2 auth list
```
List them as JSON.
```bash
$ opencode2 auth list --format json
```
Log in to a provider.
```bash
$ opencode2 auth login anthropic
```
Log in with a specific authentication method.
```bash
$ opencode2 auth login anthropic --method api-key
```
Log out of a saved account.
```bash
$ opencode2 auth logout anthropic work
```
Switch the active account for an integration.
```bash
$ opencode2 auth switch anthropic work
```
View all subcommands and flags.
```bash
$ opencode2 auth --help
```
## models
`opencode2 models` lists every available model.
```bash
$ opencode2 models
```
View all subcommands and flags.
```bash
$ opencode2 models --help
```
## mcp
`opencode2 mcp` manages MCP (Model Context Protocol) servers.
```bash
$ opencode2 mcp list
```
Add a remote server.
```bash
$ opencode2 mcp add context7 --url https://mcp.context7.com/mcp
```
Add a local server to the global config.
```bash
$ opencode2 mcp add everything --global -- npx -y @modelcontextprotocol/server-everything
```
Add a local server with an environment variable.
```bash
$ opencode2 mcp add everything --env LOG_LEVEL=debug -- npx -y @modelcontextprotocol/server-everything
```
Add a remote server with a header.
```bash
$ opencode2 mcp add context7 --url https://mcp.context7.com/mcp --header CONTEXT7_API_KEY=secret
```
Authenticate with an OAuth-capable remote server.
```bash
$ opencode2 mcp auth sentry
```
Remove stored OAuth credentials for a server.
```bash
$ opencode2 mcp logout sentry
```
View all subcommands and flags.
```bash
$ opencode2 mcp --help
```
## plugin
`opencode2 plugin` manages plugins.
```bash
$ opencode2 plugin list
```
Include built-in server plugins.
```bash
$ opencode2 plugin list --builtin
```
Install a plugin and add it to the global configuration.
```bash
$ opencode2 plugin add @example/opencode-tui
```
Check package plugins for updates.
```bash
$ opencode2 plugin check
```
Update package plugins.
```bash
$ opencode2 plugin update
```
Remove a plugin from global configuration.
```bash
$ opencode2 plugin remove @example/opencode-tui
```
View all subcommands and flags.
```bash
$ opencode2 plugin --help
```
## stats
`opencode2 stats` shows shareable usage statistics.
```bash
$ opencode2 stats
```
Show the last 7 days.
```bash
$ opencode2 stats --days 7
```
Show model usage.
```bash
$ opencode2 stats --models
```
Show cost and token details.
```bash
$ opencode2 stats --cost
```
Print JSON instead of a report.
```bash
$ opencode2 stats --json
```
View all subcommands and flags.
```bash
$ opencode2 stats --help
```
## serve
`opencode2 serve` starts the API and web server. See [Web](/cli/web).
```bash
$ opencode2 serve
```
Bind to all interfaces on a fixed port.
```bash
$ opencode2 serve --hostname 0.0.0.0 --port 4096
```
Allow a browser client from another origin.
```bash
$ opencode2 serve --cors https://app.example.com
```
View all subcommands and flags.
```bash
$ opencode2 serve --help
```
## pair
`opencode2 pair` shows server pairing information, including URLs, credentials, and a QR code.
```bash
$ opencode2 pair
```
Advertise an external URL in the QR code.
```bash
$ opencode2 pair --url https://dev.example.com
```
View all subcommands and flags.
```bash
$ opencode2 pair --help
```
## service
`opencode2 service` manages the background server. See [Web](/cli/web).
```bash
$ opencode2 service start
```
Restart it.
```bash
$ opencode2 service restart
```
Show its status.
```bash
$ opencode2 service status
```
Stop it.
```bash
$ opencode2 service stop
```
Read a setting.
```bash
$ opencode2 service get hostname
```
Set a setting.
```bash
$ opencode2 service set hostname 0.0.0.0
```
Allow an extra CORS origin.
```bash
$ opencode2 service set cors https://app.example.com
```
Pass an environment variable to the server process.
```bash
$ opencode2 service set env OPENCODE_LOG_LEVEL DEBUG
```
Reset a setting to its default.
```bash
$ opencode2 service unset hostname
```
View all subcommands and flags.
```bash
$ opencode2 service --help
```
## api
`opencode2 api` makes a request to the running server.
```bash
$ opencode2 api GET /api/session
```
Call an operation ID with a query parameter.
```bash
$ opencode2 api v2.session.list --param limit=10
```
Send a JSON body.
```bash
$ opencode2 api v2.session.create --data '{"title": "New session"}'
```
Add a request header.
```bash
$ opencode2 api GET /api/session -H "accept: application/json"
```
View all subcommands and flags.
```bash
$ opencode2 api --help
```
## acp
`opencode2 acp` starts an Agent Client Protocol server over stdin and stdout for editor integrations. It runs until the client closes the connection.
```bash
$ opencode2 acp
```
View all subcommands and flags.
```bash
$ opencode2 acp --help
```
## debug
`opencode2 debug` provides debugging and troubleshooting tools.
```bash
$ opencode2 debug agents
```
List configuration sources.
```bash
$ opencode2 debug config
```
Show global paths.
```bash
$ opencode2 debug paths
```
Print a single path.
```bash
$ opencode2 debug paths db
```
View all subcommands and flags.
```bash
$ opencode2 debug --help
```
## upgrade
`opencode2 upgrade` upgrades OpenCode to the latest or a specific version. Alias: `update`.
```bash
$ opencode2 upgrade
```
Upgrade to a specific version with a specific package manager.
```bash
$ opencode2 upgrade 1.18.15 --method bun
```
View all subcommands and flags.
```bash
$ opencode2 upgrade --help
```
## uninstall
`opencode2 uninstall` removes OpenCode and all related files.
```bash
$ opencode2 uninstall
```
Preview what would be removed.
```bash
$ opencode2 uninstall --dry-run
```
Keep configuration and session data.
```bash
$ opencode2 uninstall --keep-config --keep-data
```
View all subcommands and flags.
```bash
$ opencode2 uninstall --help
```
+74
View File
@@ -0,0 +1,74 @@
---
title: "Web"
description: "Run OpenCode in the browser."
---
OpenCode ships with a web ui that is served from the same server that powers the
TUI. It's available by default and password protected.
## Access
```bash
$ opencode2 pair
URLs http://127.0.0.1:49374
Username opencode
Password ********
```
By default the server runs on port 49374 and listens only on localhost. You can
change this config with the `opencode2 service` command.
## Configure
Set any option with `opencode2 service set`:
```bash
# Listen on every network interface
$ opencode2 service set hostname 0.0.0.0
# Use a fixed port instead of the channel default
$ opencode2 service set port 49374
# Replace the generated password
$ opencode2 service set password "a-long-secret"
# Allow a web client served from another origin
$ opencode2 service set cors https://app.example.com,https://other.example.com
# Pass an environment variable to the server process
$ opencode2 service set env OPENCODE_LOG_LEVEL DEBUG
```
Changing a setting stops the background server. To apply the new config
```bash
$ opencode2 service start
```
## Standalone
`opencode2 serve` runs the same server in the foreground instead of through the
shared background service.
```bash
$ opencode2 serve --hostname 0.0.0.0 --port 4096
server listening on http://0.0.0.0:4096
server password <password>
```
Use it when you want to:
- Run OpenCode on a shared, always-on, or remote host, then connect clients with
`opencode2 --server <url>`.
- Control the hostname, port, and CORS origins for a single process.
- Run under a supervisor like systemd, Docker, or another environment that expects
a foreground process.
- Keep a dedicated server instead of the shared background service.
Connect a client to it with `--server`:
```bash
$ opencode2 --server http://127.0.0.1:4096
```
-18
View File
@@ -247,24 +247,6 @@ field, but it does not run formatters yet.
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
Control how oversized images loaded by the `read` tool are resized or rejected
-103
View File
@@ -1,103 +0,0 @@
---
title: "LSP"
---
Language Server Protocol (LSP) integrations can provide code diagnostics,
symbols, definitions, references, and other language-aware context.
<Callout type="warning">
OpenCode V2 does not yet have an LSP runtime or built-in language servers. The `lsp` configuration is accepted and
preserved, but it does not currently start or download servers, expose an LSP tool, or add diagnostics to file tool
results.
</Callout>
## Built-in servers
There are no built-in LSP servers in the current V2 implementation. Setting
`lsp` to `true` declares that built-ins should be enabled, but has no runtime
effect until V2 provides a server registry and LSP runtime.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": true,
}
```
## Configuration
The `lsp` field accepts a boolean or an object keyed by server name:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": {
"custom-typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
"env": {
"TSS_LOG": "-level verbose",
},
"initialization": {
"preferences": {
"importModuleSpecifierPreference": "relative",
},
},
},
},
}
```
Each enabled server entry has this shape:
| Property | Type | Required | Description |
| ---------------- | ------------------------- | -------- | --------------------------------------------------------------------------------------------- |
| `command` | `string[]` | Yes | Executable followed by any arguments. |
| `extensions` | `string[]` | No | File extensions associated with the server, including the leading dot. |
| `disabled` | `boolean` | No | Disables the entry when `true`. |
| `env` | `Record<string, string>` | No | Environment variables for the server process. The property is named `env`, not `environment`. |
| `initialization` | `Record<string, unknown>` | No | Server-specific options for the LSP `initialize` request. |
The only entry that may omit `command` is the disable-only form:
```jsonc
{
"lsp": {
"typescript": {
"disabled": true,
},
},
}
```
Server names are arbitrary. The V2 schema permits `extensions` to be omitted,
including for a custom server, although a future runtime will need a way to
associate that server with files.
## Disable LSP
Omit `lsp` when no configuration is needed. Set it to `false` to explicitly
disable the whole integration, including when a lower-priority configuration
set it to `true` or supplied an object:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"lsp": false,
}
```
Use `{ "disabled": true }` under a server name to disable one server while
retaining the object form. `OPENCODE_DISABLE_LSP_DOWNLOAD` is not used by V2;
V2 currently performs no automatic LSP downloads.
## Current usage
V2 loads and validates the configuration shape for compatibility and future
integration. It does not currently use LSP when reading, writing, editing, or
patching files, and those tools do not notify a language server or return LSP
diagnostics.
For reliable feedback today, have the agent run the project's lint, typecheck,
test, or compiler commands. Record those commands in an `AGENTS.md` file or a
skill so the agent knows when and where to run them.
+5 -4
View File
@@ -110,10 +110,11 @@ role for other Foundry models. If a request fails because the token belongs to a
## WebSocket transport
OpenAI and supported Azure Responses models keep one WebSocket connection open per session and send each step over it
instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit what was
added since the previous response, which cuts upload volume on long sessions. Provider compaction runs over the same
connection.
OpenAI, xAI, and supported Azure Responses models keep one WebSocket connection open per session and send each step
over it instead of opening a new HTTP request. While the request prefix is unchanged, consecutive steps only transmit
what was added since the previous response, which cuts upload volume on long sessions. OpenAI provider compaction runs
over the same connection. xAI continues a chain only from stored responses, so with its default `store: false` each step
is sent in full over the reused connection.
The connection is transparent. When the provider closes the socket, the next step reconnects; when a connection cannot
be opened at all, the session continues over HTTP. Plugins that register `http.request` or `http.response` hooks for a
+2 -32
View File
@@ -1,35 +1,5 @@
---
title: "Session sharing"
title: "Sharing"
---
Session sharing is not yet available in OpenCode V2. V2 does not currently
publish sessions, upload conversation history to a sharing service, or create
public links.
There is no functional share or unshare server API endpoint.
## Configuration
The V2 configuration schema accepts a `share` field with three values:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"share": "manual",
}
```
- `"manual"` represents sharing only when explicitly requested.
- `"auto"` represents automatically sharing new sessions.
- `"disabled"` represents preventing session sharing.
These values are parsed but are not acted on by the current V2 runtime. In
particular, setting `"auto"` does not publish sessions. If `share` is omitted,
V2 leaves the sharing policy unspecified.
## Beta limitations
V2 currently provides no public session viewer, share URL, history sync,
retention controls, or unshare/delete operation. Until those surfaces are
implemented in the V2 server and protocol, keep using sessions locally and do
not treat the `share` configuration field as a privacy or publishing control.
OpenCode V2 does not support session sharing yet.
+1 -1
View File
@@ -1,5 +1,5 @@
---
title: "Session warming"
title: "Warming"
---
Session warming sends periodic model requests for recently active sessions.
+4 -8
View File
@@ -30,7 +30,6 @@ export const docsSections: DocsSection[] = [
{
title: "Configure",
items: [
{ title: "LSP", slug: "lsp" },
{ title: "Agents", slug: "agents" },
{ title: "Models", slug: "models" },
{ title: "Skills", slug: "skills" },
@@ -46,8 +45,8 @@ export const docsSections: DocsSection[] = [
{ title: "MCP servers", slug: "mcp-servers" },
{ title: "Permissions", slug: "permissions" },
{ title: "Instructions", slug: "instructions" },
{ title: "Session sharing", slug: "sharing" },
{ title: "Session warming", slug: "warming" },
{ title: "Sharing", slug: "sharing" },
{ title: "Warming", slug: "warming" },
],
},
{
@@ -67,11 +66,8 @@ export const docsSections: DocsSection[] = [
items: [
{ title: "Intro", slug: "cli" },
{ title: "Config", slug: "cli/config" },
],
},
{
title: "Configure",
items: [
{ title: "Web", slug: "cli/web" },
{ title: "Commands", slug: "cli/commands" },
{ title: "Theme", slug: "cli/theme" },
{ title: "Plugins", slug: "cli/plugins" },
{ title: "Keybinds", slug: "cli/keybinds" },