Compare commits

..

22 Commits

Author SHA1 Message Date
Kit Langton 275b10c843 fix(core): tighten MCP transport shutdown 2026-08-12 12:40:48 -04:00
Kit Langton be4d9b4c38 fix(core): harden MCP transport lifecycle 2026-08-12 12:25:51 -04:00
Kit Langton d715e3568a refactor(core): spawn MCP servers through environment 2026-08-12 12:06:15 -04:00
Matt Robinson f06a86eeac feat(client): support service version ranges (#42023)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-12 09:01:37 -07:00
Kit Langton 653b7d79cd fix(tui): restore navigation keybind defaults (#42066) 2026-08-12 11:57:39 -04:00
Kit Langton 70ce0d0970 feat(tui): jump between open menu sections (#42061) 2026-08-12 15:52:51 +00:00
opencode-agent[bot] 0777e84598 fix(tui): fill image message background (#42062)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-12 15:51:19 +00:00
Kit Langton bef795b2fe fix(tui): smooth session tab marquees (#42055) 2026-08-12 15:49:17 +00:00
Kit Langton 70853b1e5b feat(tui): surface plugin failures (#41940) 2026-08-12 10:51:00 -04:00
opencode-agent[bot] 1fea1c2ebc fix(core): route Muse models to Meta prompt (#42036)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 09:31:59 -05:00
opencode-agent[bot] 1da591b84d fix(core): return content-only Code Mode results (#41954)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 09:14:41 -05:00
opencode-agent[bot] b990f9a5c1 chore: generate 2026-08-12 13:32:36 +00:00
Dax Raad 9769e7012c feat(core): configure package publishing 2026-08-12 09:30:34 -04:00
opencode-agent[bot] 5fd28cffc5 refactor(tui): use command IDs for v2 keybinds (#41882)
Requested by Sebastian Herrlinger (@kommander). Rebased onto current v2 with conflicts resolved and all required checks passing.
2026-08-12 12:53:24 +00:00
Luke Parker c8dffb6893 test(app): deflake cached tab paint probe (#41965) 2026-08-12 20:21:27 +10:00
opencode-agent[bot] 01cbdcb8e4 fix(ai): batch Anthropic parallel tool results (#41948)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 00:18:50 -05:00
opencode-agent[bot] bf751a907d fix(core): jitter session retry delays (#41942)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-11 23:58:20 -05:00
opencode-agent[bot] 9d63ca8f90 chore: generate 2026-08-12 04:19:09 +00:00
opencode-agent[bot] e3bd82013c fix(ai): preserve stream transport failures (#41779)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-11 23:17:48 -05:00
Kit Langton 90b6fa0eab fix(core): expose local attachment paths (#41789) 2026-08-11 23:32:49 -04:00
Kit Langton 5b7b1830d2 refactor(server): resume suspended sessions unconditionally in the fetch entry (#41938) 2026-08-12 03:31:30 +00:00
Kit Langton a8fc664b6d fix(tui): pad markdown table cells (#41937) 2026-08-11 23:21:00 -04:00
116 changed files with 4444 additions and 1881 deletions
+2 -5
View File
@@ -345,9 +345,6 @@
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.4",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
@@ -367,8 +364,6 @@
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@lydell/node-pty": "catalog:",
@@ -404,6 +399,8 @@
"zod": "catalog:",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -573,7 +573,10 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
cache_control: cacheControl(breakpoints, part.cache),
})
}
messages.push({ role: "user", content })
const previous = messages.at(-1)
if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result"))
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
}
return messages
-2
View File
@@ -157,7 +157,6 @@ const OpenAIChatUsage = Schema.StructWithRest(
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
cost: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
@@ -596,7 +595,6 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
cost: usage.cost ?? undefined,
providerMetadata: { openai: usage },
})
}
+73 -30
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
import {
FetchHttpClient,
Headers,
@@ -297,44 +297,86 @@ export const classifyHttpFailure = (input: {
})
}
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
readonly message: string
readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) =>
type HttpOperation = "request" | "read"
const NativeTransportFailure = Schema.Struct({
message: Schema.String,
code: Schema.optionalKey(Schema.String),
cause: Schema.optionalKey(Schema.Unknown),
})
const decodeNativeTransportFailure = Schema.decodeUnknownOption(NativeTransportFailure)
const nativeTransportFailure = (error: unknown) => {
const failure = Option.getOrUndefined(decodeNativeTransportFailure(error))
if (!failure) return undefined
if (failure.code !== undefined) return failure
const cause = Option.getOrUndefined(decodeNativeTransportFailure(failure.cause))
if (cause?.code !== undefined) return cause
return failure
}
const httpError = (input: {
readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
new AIError({
module: "RequestExecutor",
method: "execute",
method: input.operation,
reason: new TransportReason({
message: input.message,
kind: input.kind,
url: input.request ? redactUrl(input.request.url) : undefined,
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
message: failure.message,
transport: "http",
operation: input.operation,
code: failure.code,
url: redactUrl(request.url),
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
}),
})
if (Cause.isTimeoutError(error)) {
return transportError({ message: error.message, kind: "Timeout" })
}
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
const source =
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
? input.error.reason.cause
: input.error
const native = nativeTransportFailure(source)
const code = native?.code
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
if (!HttpClientError.isHttpClientError(input.error))
return transportError({ message: message ?? "HTTP transport failed", code })
if (input.error.reason._tag === "TransportError") {
return transportError({
message: error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag,
request,
message: message ?? input.error.reason.description ?? "HTTP transport failed",
code: code ?? input.error.reason._tag,
})
}
return transportError({
message: `HTTP transport failed: ${error.reason._tag}`,
kind: error.reason._tag,
request,
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`,
code: code ?? input.error.reason._tag,
})
}
export const stream = (
executor: Interface,
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
): Stream.Stream<Uint8Array, AIError> =>
Stream.unwrap(
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* executor.execute(request, middleware)
return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
)
}),
)
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -343,15 +385,16 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware)
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
return yield* http.execute(request).pipe(
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
Effect.flatMap(statusError(request, redactedNames)),
)
const response = yield* middleware(request, (input) =>
http
.execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError(toHttpError(redactedNames)))
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
return yield* statusError(response.request, redactedNames)(response)
})
return Service.of({
+4 -21
View File
@@ -1,4 +1,4 @@
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth.js"
import { render as renderEndpoint } from "../endpoint.js"
@@ -6,6 +6,7 @@ import { Framing } from "../framing.js"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
import * as ProviderShared from "../../protocols/shared.js"
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
import { RequestExecutor } from "../executor.js"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -86,26 +87,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware,
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request, prepared.middleware)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
),
),
),
frames: (prepared, _request, runtime) =>
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
})
export const sseJson = {
+36 -13
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { AIError, TransportReason } from "../../schema/index.js"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js"
@@ -29,12 +29,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
const transportError = (
method: string,
message: string,
input: { readonly url?: string; readonly kind?: string } = {},
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
) =>
new AIError({
module: "WebSocketExecutor",
method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
reason: new TransportReason({
message,
transport: "websocket",
operation: input.operation,
url: input.url,
code: input.code,
}),
})
const eventMessage = (event: Event) => {
@@ -55,7 +61,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
return Effect.fail(
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url,
kind: "open",
operation: "request",
code: "closed",
}),
)
}
@@ -79,7 +86,10 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup()
resume(
Effect.fail(
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url,
operation: "request",
}),
),
)
}
@@ -89,7 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
Effect.fail(
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url,
kind: "open",
operation: "request",
code: String(event.code),
}),
),
)
@@ -118,7 +129,8 @@ const webSocketUrl = (value: string) =>
catch: (error) =>
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value,
kind: "websocket",
operation: "request",
code: "invalid-url",
}),
})
@@ -129,7 +141,7 @@ export const open = (input: WebSocketRequest) =>
catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
kind: "open",
operation: "request",
}),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
@@ -150,7 +162,10 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
transportError("message", "Unsupported WebSocket message payload", {
url: input.url,
operation: "read",
}),
),
)
}
@@ -158,7 +173,10 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url,
operation: "read",
}),
),
)
}
@@ -167,7 +185,11 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
transportError("message", `WebSocket closed with code ${event.code}`, {
url: input.url,
operation: "read",
code: String(event.code),
}),
),
)
}
@@ -188,7 +210,7 @@ export const fromWebSocket = (
catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
kind: "write",
operation: "write",
}),
}),
messages: Stream.fromQueue(messages),
@@ -243,7 +265,8 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
return Stream.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url,
kind: "websocket",
operation: "request",
code: "unavailable",
}),
)
}
+9 -1
View File
@@ -92,10 +92,18 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext),
}) {}
export const TransportType = Schema.Literals(["http", "websocket"])
export type TransportType = typeof TransportType.Type
export const TransportOperation = Schema.Literals(["request", "read", "write"])
export type TransportOperation = typeof TransportOperation.Type
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"),
message: Schema.String,
kind: Schema.optional(Schema.String),
transport: TransportType,
operation: TransportOperation,
code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
}) {}
-2
View File
@@ -56,8 +56,6 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
cacheWriteInputTokens: Schema.optional(Schema.Number),
reasoningTokens: Schema.optional(Schema.Number),
totalTokens: Schema.optional(Schema.Number),
/** Provider-reported cost for this physical request, normalized to USD. */
cost: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
}) {
/**
+101 -3
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { dynamicResponse } from "./lib/http.js"
import { dynamicResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js"
@@ -67,6 +67,62 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
it.effect("unwraps native transport failure causes", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed",
operation: "read",
code: "ECONNRESET",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
),
),
)
it.effect("preserves middleware error messages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
@@ -79,6 +135,48 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("reports the request sent by middleware", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, (original, handler) =>
handler(
original.pipe(
HttpClientRequest.setUrl("https://proxy.test/v1/chat?api_key=proxy-secret"),
HttpClientRequest.setHeader("authorization", "Bearer proxy-secret"),
),
),
)
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: proxy disconnected <redacted>",
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
http: {
request: {
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
headers: { authorization: "<redacted>" },
},
},
})
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: input.request,
cause: systemError("ECONNRESET", "proxy disconnected proxy-secret"),
}),
}),
),
),
),
),
)
it.effect("classifies context overflow responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+20 -6
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js"
import type { Service as RequestExecutorService } from "../../src/route/executor.js"
@@ -14,7 +14,9 @@ export type HandlerInput = {
) => HttpClientResponse.HttpClientResponse
}
export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
export type Handler = (
input: HandlerInput,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
Layer.succeed(
@@ -34,6 +36,12 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export interface SystemError extends Error {
readonly code: string
}
export const systemError = (code: string, message: string): SystemError => Object.assign(new Error(message), { code })
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
@@ -63,14 +71,20 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing.
*/
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
dynamicResponse((input) =>
Effect.sync(() => {
const encoder = new TextEncoder()
let index = 0
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
controller.error(new Error("connection reset"))
pull(controller) {
const chunk = chunks[index]
if (chunk !== undefined) {
index++
controller.enqueue(encoder.encode(chunk))
return
}
controller.error(error)
},
})
return input.respond(stream, { headers: SSE_HEADERS })
@@ -271,6 +271,47 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Check both cities."),
Message.assistant([
{ type: "text", text: "I'll check both." },
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
]),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Check both cities." }] },
{
role: "assistant",
content: [
{ type: "text", text: "I'll check both." },
{ type: "tool_use", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool_use", id: "call_london", name: "weather", input: { city: "London" } },
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
],
},
])
expect(prepared.body.messages).toHaveLength(3)
}),
)
it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -915,6 +956,54 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_paris", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "call_london", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"city":"London"}' },
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
{ id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.message.content).toMatchObject([
{ type: "tool-call", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool-call", id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () {
const body = sseEvents(
+39 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { Effect, Ref, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
HttpOptions,
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared.js"
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http.js"
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js"
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
import { sseEvents } from "../lib/sse.js"
@@ -1221,12 +1221,44 @@ describe("OpenAI Chat route", () => {
it.effect("surfaces transport errors that occur mid-stream", () =>
Effect.gen(function* () {
const layer = truncatedStream([
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
])
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
const layer = truncatedStream(
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
systemError("ECONNRESET", "socket closed unexpectedly"),
)
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
const error = yield* LLMClient.stream(request).pipe(
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
Stream.runDrain,
Effect.provide(layer),
Effect.flip,
)
expect(error.message).toContain("Failed to read openai/openai-chat stream")
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed unexpectedly",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://api.openai.test/v1/chat/completions",
})
}),
)
it.effect("surfaces transport errors before the first stream frame", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(truncatedStream([], systemError("ECONNRESET", "socket closed before output"))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed before output",
transport: "http",
operation: "read",
code: "ECONNRESET",
})
}),
)
@@ -246,24 +246,6 @@ describe("OpenRouter", () => {
}),
)
it.effect("reports OpenRouter's streamed USD cost", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12, cost: 0.00123 },
}),
),
),
)
expect(response.usage?.cost).toBe(0.00123)
}),
)
it.effect("fails on a mid-stream provider error", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
@@ -189,7 +189,12 @@ test.describe("smoke: session timeline", () => {
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
if (
!firstPaint &&
visible.includes(last) &&
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
!root.querySelector('[data-markdown-key="initial"]')
) {
firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect()
@@ -204,10 +209,16 @@ test.describe("smoke: session timeline", () => {
}
;(
window as Window & {
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
__sessionTabPaint?: {
samples: typeof samples
painted: () => boolean
removed: () => number
stop: () => void
}
}
).__sessionTabPaint = {
samples,
painted: () => firstPaint,
removed: () => removedFirstPaintNodes,
stop: () => {
running = false
@@ -219,17 +230,19 @@ test.describe("smoke: session timeline", () => {
)
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() =>
(
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
)
await page.waitForFunction(() => {
const probe = (
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } }
).__sessionTabPaint
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0)
})
await page.waitForTimeout(200)
const first = await page.evaluate(() => {
const probe = (
window as Window & {
__sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
painted: () => boolean
removed: () => number
stop: () => void
}
+53 -31
View File
@@ -1,7 +1,8 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/util/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { Flock } from "@opencode-ai/util/flock"
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
@@ -28,7 +29,6 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const lock = yield* Semaphore.make(1)
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
@@ -49,38 +49,60 @@ export const layer = Layer.effect(
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
const withLock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.scoped(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const lock = yield* restore(
Effect.promise((signal) => Flock.acquire(file, { dir: path.join(global.state, "locks"), signal })),
)
yield* Effect.addFinalizer(() => Effect.promise(() => lock.release()))
return yield* restore(effect)
}),
),
)
const get = Effect.fn("cli.config.get")(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
return Option.getOrElse(decode(yield* readJson()), () => empty)
})
const get = Effect.fn("cli.config.get")(() =>
withLock(
Effect.gen(function* () {
const migration = yield* migrate.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to migrate cli config", { cause }).pipe(Effect.as(undefined)),
),
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
}),
),
)
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
lock
.withPermits(1)(
Effect.gen(function* () {
yield* migrate
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
)
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
withLock(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
return Service.of({ path: file, get, update })
+123 -15
View File
@@ -1,13 +1,18 @@
export * as ConfigMigration from "./migrate"
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
import { Definitions } from "@opencode-ai/tui/config/keybind"
import { Effect, FileSystem, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import { randomUUID } from "crypto"
import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import type { Info } from "./schema"
import { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const LegacyKeybindTargets = new Set<string>(Object.values(TuiKeybind.CommandMap))
export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly file: string
@@ -15,7 +20,60 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly state: string
}) {
const fs = yield* FileSystem.FileSystem
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
const persist = Effect.fnUntraced(function* (text: string, info: Info) {
const temp = `${input.file}.${process.pid}.${randomUUID()}.tmp`
const cause = yield* Effect.gen(function* () {
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, input.file)
}).pipe(
Effect.as(undefined),
Effect.catchCause((cause) => Effect.succeed(cause)),
Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)),
)
return cause === undefined ? { info } : { info, cause }
})
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
const text = yield* fs.readFileString(input.file)
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
const config = Option.getOrUndefined(decodeRecord(value))
if (config === undefined) return
const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds))
if (keybinds === undefined) return
const deduped = findKeybindObjects(text)
.slice(0, -1)
.reduce((text) => {
const property = findKeybindObjects(text)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
const updated = Object.keys(keybinds).reduce((text, name) => {
const target =
TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ??
(name in Definitions || LegacyKeybindTargets.has(name) ? name : undefined)
if (target === undefined) return text
const properties = findKeybindProperties(text, name)
if (!properties.length) return text
const remove = !(target in Definitions) || (target !== name && target in keybinds)
// The parser gives the final duplicate precedence, so remove earlier properties before renaming it.
const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => {
const property = findKeybindProperties(text, name)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
if (remove) return updated
if (target === name) return updated
const key = findKeybindProperties(updated, name)[0]?.children?.[0]
if (key === undefined) return text
return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length)
}, deduped)
if (updated === text) return
const updatedErrors: ParseError[] = []
const info = Option.getOrUndefined(decodeInfo(parse(updated, updatedErrors, { allowTrailingComma: true })))
if (updatedErrors.length || info === undefined) return
return yield* persist(updated, info)
}
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
@@ -23,19 +81,59 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const temp = input.file + ".tmp"
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, input.file)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
return result
})
function findKeybindProperties(text: string, name: string) {
const keybinds = findKeybindObjects(text).at(-1)?.children?.[1]
return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
}
function findKeybindObjects(text: string) {
const tree = parseTree(text)
if (tree === undefined) return []
return tree.children?.filter((property) => property.children?.[0]?.value === "keybinds") ?? []
}
function removeProperty(text: string, property: Node) {
const properties = property.parent?.children ?? []
const index = properties.indexOf(property)
const end = property.offset + property.length
const next = properties[index + 1]
if (next) {
const comma = findComma(text, end, next.offset)
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
}
const previous = properties[index - 1]
if (previous) {
const comma = findComma(text, previous.offset + previous.length, property.offset)
if (comma !== undefined) return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end)
}
const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0))
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
return text.slice(0, property.offset) + text.slice(end)
}
function findComma(text: string, start: number, end: number) {
const scanner = createScanner(text, false)
scanner.setPosition(start)
while (true) {
scanner.scan()
const offset = scanner.getTokenOffset()
if (scanner.getTokenLength() === 0 || offset >= end) return
if (text[offset] === ",") return offset
}
}
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
const plugins = [
...(legacy?.plugin?.map((plugin) =>
@@ -49,6 +147,16 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking =
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
const keybinds =
legacy?.keybinds === undefined
? undefined
: Object.fromEntries(
Object.entries(legacy.keybinds).flatMap(([name, value]) => {
const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name
if (!(target in Definitions)) return []
return [[target, value]]
}),
)
return {
...(themeName !== undefined || themeMode !== undefined
@@ -59,7 +167,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
},
}
: {}),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(keybinds === undefined ? {} : { keybinds }),
...(plugins.length ? { plugins } : {}),
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
+288 -4
View File
@@ -1,7 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { Effect, FileSystem, Option } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
import { Config } from "../src/config"
@@ -21,7 +23,14 @@ test("migrates tui and kv config into cli.json", async () => {
path.join(directory, "tui.json"),
JSON.stringify({
theme: "legacy",
keybinds: { leader: "ctrl+o" },
keybinds: {
leader: "ctrl+o",
app_exit: "ctrl+q",
app_heap_snapshot: "ctrl+h",
input_paste: { key: "ctrl+v", preventDefault: false },
session_delete: false,
"dialog.select.next": "ctrl+n",
},
plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false },
leader_timeout: 500,
@@ -65,7 +74,13 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).toMatchObject({
theme: { name: "legacy", mode: "light" },
keybinds: { leader: "ctrl+o" },
keybinds: {
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
},
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
@@ -80,7 +95,13 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
})
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
@@ -141,6 +162,257 @@ test("preserves legacy cursor settings", async () => {
}
})
test("migrates legacy keybind names in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
// Preserve this comment
"keybinds": {
// Session list shortcut
"session_list": "ctrl+l",
"app_heap_snapshot": "ctrl+h",
// Legacy delete shortcut
"session_delete": "ctrl+d",
// Canonical delete shortcut
"session.delete": "ctrl+x",
"app.heap_snapshot": "ctrl+shift+h"
}
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
const text = await Bun.file(file).text()
expect(text).toContain("// Preserve this comment")
expect(text).toContain("// Session list shortcut")
expect(text).toContain("// Legacy delete shortcut")
expect(text).toContain("// Canonical delete shortcut")
expect(parse(text).keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("uses migrated keybinds when persistence fails", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_list":"ctrl+l"}}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "rename") return () => Effect.die(new Error("read-only config"))
return Reflect.get(target, property, receiver)
},
})
try {
const config = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory))).toEqual([])
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the effective value when migrating duplicate legacy keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates and updates the effective duplicate top-level keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("serializes migration and updates across processes", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const migrateReady = path.join(directory, "migrate-ready")
const updateReady = path.join(directory, "update-ready")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
const worker = path.join(import.meta.dir, "fixture/config-concurrency.ts")
const migrate = Bun.spawn([process.execPath, worker, "migrate", directory, started, release, migrateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(started, migrate.exited)
const update = Bun.spawn([process.execPath, worker, "update", directory, started, release, updateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(updateReady, update.exited)
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
await Bun.write(release, "")
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
expect(await new Response(migrate.stderr).text()).toBe("")
expect(await new Response(update.stderr).text()).toBe("")
expect([migrateCode, updateCode]).toEqual([0, 0])
expect(await Bun.file(file).json()).toEqual({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
} finally {
update.kill()
await update.exited
}
} finally {
await Bun.write(release, "")
migrate.kill()
await migrate.exited
await Bun.$`rm -rf ${directory}`
}
})
test("config reads remain interruptible while waiting for the file lock", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const locks = path.join(directory, "locks")
const held = await Flock.acquire(file, { dir: locks })
try {
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provide(NodeFileSystem.layer),
),
)
const result = Effect.runPromise(service.get().pipe(Effect.timeoutOption("50 millis")))
expect(await Promise.race([result, Bun.sleep(250).then(() => "blocked" as const)])).toEqual(Option.none())
} finally {
await held.release()
await Bun.$`rm -rf ${directory}`
}
})
test("updates effective duplicate canonical keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({
"session.delete": "changed",
"permission.mode": "changed",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("removes orphaned keybinds without deleting trailing comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
"keybinds": {
"app_heap_snapshot": "ctrl+h" /* Keep legacy explanation */,
"app.heap_snapshot": "ctrl+shift+h" /* Keep canonical explanation */,
},
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({})
const text = await Bun.file(file).text()
expect(text).toContain("/* Keep legacy explanation */")
expect(text).toContain("/* Keep canonical explanation */")
expect(parse(text).keybinds).toEqual({})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
@@ -167,3 +439,15 @@ test("updates a config draft while preserving JSONC comments", async () => {
await Bun.$`rm -rf ${directory}`
}
})
async function waitForFile(file: string, exited: Promise<number>) {
const found = await Promise.race([
(async () => {
while (!(await Bun.file(file).exists())) await Bun.sleep(10)
return true
})(),
exited.then(() => false),
Bun.sleep(5000).then(() => false),
])
if (!found) throw new Error(`timed out waiting for ${file}`)
}
@@ -0,0 +1,42 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { Config } from "../../src/config"
const [mode, directory, started, release, ready] = process.argv.slice(2)
if (!mode || !directory || !started || !release || !ready) throw new Error("missing config concurrency arguments")
if (mode !== "migrate" && mode !== "update") throw new Error(`unknown mode: ${mode}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const state = { writes: 0 }
const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => {
state.writes++
if (mode !== "migrate" || state.writes !== 1) return node.writeFileString(target, data, options)
return Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
while (!(yield* Effect.promise(() => Bun.file(release).exists()))) yield* Effect.sleep("10 millis")
yield* node.writeFileString(target, data, options)
})
}
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "writeFileString") return writeFileString
return Reflect.get(target, property, receiver)
},
})
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
await Bun.write(ready, "")
if (mode === "migrate") await Effect.runPromise(service.get())
if (mode === "update")
await Effect.runPromise(
service.update((draft) => {
draft.mouse = false
}),
)
+69
View File
@@ -0,0 +1,69 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, Option } from "effect"
import { expect, mock, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { Config } from "../src/config"
import type { MiniCommandInput } from "../src/mini"
import { OPENCODE_VERSION } from "../src/version"
test("mini handler passes resolved CLI keybinds to the runtime", async () => {
const root = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const configDirectory = path.join(root, "config")
const stateDirectory = path.join(root, "state")
await mkdir(configDirectory, { recursive: true })
await Bun.write(
path.join(configDirectory, "cli.json"),
JSON.stringify({
keybinds: { "composer.subagent.interrupt": "ctrl+i" },
leader: { timeout: 321 },
}),
)
let received: MiniCommandInput["tuiConfig"]
const mini = await import("../src/mini")
mock.module("../src/mini", () => ({
...mini,
validateMiniTerminal() {},
runMini(input: Pick<MiniCommandInput, "tuiConfig">) {
received = input.tuiConfig
return Promise.resolve()
},
}))
const handler = (await import("../src/commands/handlers/mini")).default
const server = Bun.serve({
port: 0,
fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }),
})
try {
await Effect.runPromise(
handler({
server: Option.some(server.url.toString()),
standalone: false,
continue: false,
session: Option.none(),
fork: false,
replay: true as never,
replayLimit: Option.none(),
model: Option.none(),
agent: Option.none(),
prompt: Option.none(),
demo: false,
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
)
const config = await received
expect(config?.leader.timeout).toBe(321)
expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }])
} finally {
server.stop(true)
mock.restore()
await rm(root, { recursive: true, force: true })
}
})
+4 -3
View File
@@ -10,6 +10,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -37,14 +38,14 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
if (found === undefined || found.legacy) return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return { endpoint: found.endpoint, state: found.state }
})
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
})
@@ -93,7 +94,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
+3 -2
View File
@@ -9,6 +9,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -27,7 +28,7 @@ export async function discover(options: DiscoverOptions = {}) {
async function discoverLocal(options: DiscoverOptions) {
const found = (await registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
}
@@ -76,7 +77,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
+8
View File
@@ -0,0 +1,8 @@
import type { DiscoverOptions } from "./service.js"
export function matchesVersion(version: string | undefined, options: DiscoverOptions) {
if (options.version === undefined) return true
if (version === undefined) return false
if (typeof options.version === "function") return options.version(version)
return version === options.version
}
+2 -2
View File
@@ -17,8 +17,8 @@ export type Endpoint = {
export type DiscoverOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** Required service version. */
readonly version?: string
/** Required exact service version or compatibility predicate. */
readonly version?: string | ((version: string) => boolean)
}
/** Reason ensuring the service requires a new process. */
+15 -1
View File
@@ -1,6 +1,7 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
import type { Session } from "@opencode-ai/schema/session"
import type { DiscoverOptions } from "../src/service"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
@@ -8,6 +9,9 @@ type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
declare const effectClient: EffectClient
declare const promiseClient: PromiseClient
const exactVersion: DiscoverOptions = { version: "2.0.0" }
const compatibleVersion: DiscoverOptions = { version: (version) => version.startsWith("2.") }
const effectApi: EffectApi<unknown> = effectClient
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
@@ -42,4 +46,14 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
key: "review-notes",
})
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
void [
effectSession,
effectList,
effectPut,
effectRemove,
promiseList,
promisePut,
promiseRemove,
exactVersion,
compatibleVersion,
]
+4 -1
View File
@@ -27,7 +27,10 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
}
let requests = 0
const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
const server = Bun.serve({
port: 0,
@@ -25,6 +25,19 @@ test("discovers a registered service", async () => {
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
})
test("discovers a compatible registered service", async () => {
const registration = await setup("compatible")
expect(await Service.discover({ file: registration, version: "2.1.0" })).toBeUndefined()
expect(await Service.discover({ file: registration, version: "2.1.0-next.1" })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("2.") })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
})
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+46
View File
@@ -47,6 +47,52 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
})
test("reuses a compatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "compatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [],
onStart: (reason) => starts.push(reason),
}),
)
expect(endpoint.url).toBe((await Bun.file(registration).json()).url)
expect(starts).toEqual([])
expect(existing.exitCode).toBe(null)
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "incompatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [process.execPath, fixture, registration, "delayed-compatible", "10"],
onStart: (reason) => starts.push(reason),
}),
)
const replacement = await Bun.file(registration).json()
expect(await existing.exited).toBe(0)
expect(replacement.version).toBe("2.1.0-next.1")
expect(endpoint.url).toBe(replacement.url)
expect(starts).toEqual(["version-mismatch"])
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("waits for a registered service to finish starting", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+3
View File
@@ -0,0 +1,3 @@
# @opencode-ai/core
Core runtime services for OpenCode.
+21 -13
View File
@@ -4,24 +4,28 @@
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/core"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
},
"bin": {
"opencode": "./bin/opencode"
},
"exports": {
"./environment": "./src/environment/index.ts",
"./testing/environment-conformance": "./test/lib/environment-conformance.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
},
"imports": {
@@ -55,9 +59,18 @@
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
"default": "./src/util/process-lock-ffi.bun.ts"
},
"#v1-migration": {
"types": "./src/database/v1-migration.bun.ts",
"bun": "./src/database/v1-migration.bun.ts",
"node": "./src/database/v1-migration.noop.ts",
"workerd": "./src/database/v1-migration.noop.ts",
"default": "./src/database/v1-migration.noop.ts"
}
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
@@ -93,8 +106,6 @@
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.1",
@@ -128,8 +139,5 @@
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
},
"overrides": {
"drizzle-orm": "catalog:"
}
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const root = path.resolve("src")
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true }))
const result = await Bun.build({
entrypoints: files.filter((file) => !file.endsWith(".d.ts")),
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
splitting: true,
loader: {
".txt": "text",
".md": "text",
},
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
asset: "assets/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
value.replace("./src/", types ? "./dist/types/" : "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [key, { import: output(value), types: output(value, true) }]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(
Object.entries(conditions).map(([condition, value]) => [condition, output(value, condition === "types")]),
),
]),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+11 -37
View File
@@ -343,8 +343,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
model: (input) =>
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) =>
streamLanguage(language, prepared as LanguageModelV3CallOptions, info.providerID === Provider.ID.githubCopilot),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return LanguageModel.make({
id: info.modelID ?? info.id,
@@ -428,7 +427,6 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
toolChoice: toolChoice(request.toolChoice),
headers: request.http?.headers,
providerOptions: providerOptions(request.providerOptions),
includeRawChunks: request.model.provider === ProviderID.make(Provider.ID.githubCopilot),
}
}
@@ -549,15 +547,8 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
interface StreamState {
step: number
toolNames: Record<string, string>
copilot: boolean
cost?: number
}
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions, copilot: boolean) {
const state: StreamState = { step: 0, toolNames: {}, copilot }
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
const state = { step: 0, toolNames: {} as Record<string, string> }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
@@ -580,19 +571,17 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
}
function streamPartEvents(
state: StreamState,
state: { step: number; toolNames: Record<string, string> },
event: LanguageModelV3StreamPart,
): Effect.Effect<ReadonlyArray<LLMEvent>, AIError> {
switch (event.type) {
case "stream-start":
case "response-metadata":
case "raw":
case "file":
case "source":
case "tool-approval-request":
return Effect.succeed([])
case "raw":
if (state.copilot) state.cost = copilotCost(event.rawValue) ?? state.cost
return Effect.succeed([])
case "text-start":
return Effect.succeed([
LLMEvent.textStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
@@ -683,18 +672,16 @@ function streamPartEvents(
}),
])
case "finish":
const normalized = usage(event.usage, state.cost)
state.cost = undefined
return Effect.succeed([
LLMEvent.stepFinish({
index: state.step++,
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
usage: normalized,
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),
LLMEvent.finish({
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
usage: normalized,
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
@@ -703,10 +690,7 @@ function streamPartEvents(
}
}
function usage(
input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"],
cost?: number,
): UsageInput | undefined {
function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"]): UsageInput | undefined {
const output = {
inputTokens: input.inputTokens.total,
nonCachedInputTokens: input.inputTokens.noCache,
@@ -718,22 +702,10 @@ function usage(
input.inputTokens.total === undefined || input.outputTokens.total === undefined
? undefined
: input.inputTokens.total + input.outputTokens.total,
cost,
}
return Object.values(output).some((value) => value !== undefined) ? output : undefined
}
function copilotCost(input: unknown): number | undefined {
if (!ProviderShared.isRecord(input)) return undefined
const raw = input
const response = ProviderShared.isRecord(raw.response) ? raw.response : undefined
const usage = raw.copilot_usage ?? response?.copilot_usage
if (!ProviderShared.isRecord(usage)) return undefined
const total = usage.total_nano_aiu
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return undefined
return total / 100_000_000_000
}
function finishReason(value: LanguageModelV3FinishReason): FinishReason {
return value.unified === "other" ? "unknown" : value.unified
}
@@ -791,7 +763,9 @@ function apiCallErrorReason(error: APICallError) {
if (error.statusCode !== undefined || !error.isRetryable) return reason
return new TransportReason({
message: reason.message,
kind: error.name,
transport: "http",
operation: "request",
code: error.name,
url: error.url,
http: "http" in reason ? reason.http : undefined,
})
+4 -2
View File
@@ -75,7 +75,9 @@ export const create = (
const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
return executed.output
if (executed.output !== undefined) return executed.output
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}),
{
onToolCallStart: ({ index, name, input }) => {
@@ -155,7 +157,7 @@ function runtime(
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
}
+1 -11
View File
@@ -1,5 +1,4 @@
import { Database, type SQLQueryBindings } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
@@ -98,16 +97,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as Database })
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
+1 -11
View File
@@ -1,5 +1,4 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
@@ -95,16 +94,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as DatabaseSync }) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
-3
View File
@@ -5,11 +5,8 @@ import { identity } from "effect/Function"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { drizzle } from "drizzle-orm/bun-sqlite"
export type DrizzleClient = ReturnType<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
export interface ClientConfig {
readonly spanAttributes?: Record<string, unknown>
+1 -12
View File
@@ -1,4 +1,3 @@
import { drizzle } from "drizzle-orm/durable-sqlite"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
@@ -238,17 +237,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
return drizzle(native) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
@@ -0,0 +1,997 @@
export * as V1Migration from "./v1-migration.js"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message.js"
import { SessionSchema } from "../session/schema.js"
import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
type Progress = {
readonly label: string
readonly numerator?: number
readonly denominator?: number
}
export type Status =
| { readonly status: "required" | "completed" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let runtimeState: RuntimeState = { status: "idle" }
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
const cause = input.cause
return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}`
}
function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, 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
)
SELECT
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
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, 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, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function readState(db: Database.Interface["db"]): Effect.Effect<MigrationState | undefined> {
return db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, MIGRATION_STATE_KEY))
.get()
.pipe(
Effect.map((row) => parseState(row?.value)),
Effect.orDie,
)
}
function parseState(input: unknown): MigrationState | undefined {
if (!input || typeof input !== "object" || !("phase" in input)) return
if (input.phase === "completed") return { phase: "completed" }
if (input.phase !== "sessions") return
if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" }
if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor }
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
@@ -0,0 +1,11 @@
import { Effect, Layer } from "effect"
export type Status = { readonly status: "completed" }
export const layer = Layer.empty
export function status() {
return Effect.succeed({ status: "completed" } as const)
}
export function run() {
return Effect.succeed({ status: "completed" } as const)
}
+2 -997
View File
@@ -1,997 +1,2 @@
export * as V1Migration from "./v1-migration.js"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message.js"
import { SessionSchema } from "../session/schema.js"
import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
type Progress = {
readonly label: string
readonly numerator?: number
readonly denominator?: number
}
export type Status =
| { readonly status: "required" | "completed" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let runtimeState: RuntimeState = { status: "idle" }
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
const cause = input.cause
return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}`
}
function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, 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
)
SELECT
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
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, 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, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function readState(db: Database.Interface["db"]): Effect.Effect<MigrationState | undefined> {
return db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, MIGRATION_STATE_KEY))
.get()
.pipe(
Effect.map((row) => parseState(row?.value)),
Effect.orDie,
)
}
function parseState(input: unknown): MigrationState | undefined {
if (!input || typeof input !== "object" || !("phase" in input)) return
if (input.phase === "completed") return { phase: "completed" }
if (input.phase !== "sessions") return
if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" }
if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor }
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
export * as V1Migration from "#v1-migration"
export * from "#v1-migration"
+15 -59
View File
@@ -1,11 +1,10 @@
export * as MCPClient from "./client.js"
import path from "node:path"
import { execFile } from "node:child_process"
import { pathToFileURL } from "node:url"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
@@ -30,13 +29,12 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Cause, Effect, Exit, Schema } from "effect"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { MCPStdio } from "./stdio.js"
const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
type Transport = StdioClientTransport | StreamableHTTPClientTransport
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
// only that field so a single bad schema doesn't blank out the whole tool list.
const TolerantListToolsResult = ListToolsResultSchema.extend({
@@ -176,7 +174,12 @@ export interface Connection {
readonly onResourcesChanged: (callback: () => void) => void
}
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
/**
* Connects an MCP server; closing the calling scope tears down the transport and any spawned process.
*
* A stdio server is spawned through the location's `Environment`, so it runs on the same execution
* plane as the location's shell commands rather than always on the host.
*/
export const connect = Effect.fnUntraced(function* (
server: string,
config: typeof ConfigMCP.Server.Type,
@@ -190,13 +193,12 @@ export const connect = Effect.fnUntraced(function* (
const transport: Transport = yield* Effect.gen(function* () {
if (config.type === "local") {
const [command, ...args] = config.command
return new StdioClientTransport({
return yield* MCPStdio.make({
server,
command,
args,
cwd: config.cwd ? path.resolve(directory, config.cwd) : directory,
stderr: "pipe",
env: {
...(process.env as Record<string, string>),
environment: {
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
...config.environment,
},
@@ -233,9 +235,9 @@ export const connect = Effect.fnUntraced(function* (
catch: (error) => error,
}).pipe(Effect.exit)
if (Exit.isSuccess(exit)) {
yield* Effect.addFinalizer(() =>
cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore),
)
// Closing the client closes the transport, which ends stdin and then kills through the spawner
// handle if the server does not exit cleanly. The process scope remains a final backstop.
yield* Effect.addFinalizer(() => Effect.promise(() => client.close()).pipe(Effect.ignore))
const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
return {
@@ -434,58 +436,12 @@ export const connect = Effect.fnUntraced(function* (
} satisfies Connection
}
yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore)
yield* Effect.promise(() => transport.close()).pipe(Effect.ignore)
const error = Cause.squash(exit.cause)
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
})
// SDK close stops the MCP process, but not child processes it spawned.
const cleanupStdioDescendants = (transport: Transport) =>
Effect.gen(function* () {
if (!(transport instanceof StdioClientTransport)) return
const pid = transport.pid
if (typeof pid !== "number") return
yield* Effect.forEach(
yield* descendantPids(pid),
(pid) =>
Effect.try({
try: () => process.kill(pid, "SIGTERM"),
catch: () => undefined,
}).pipe(Effect.ignore),
{ discard: true },
)
})
const descendantPids = Effect.fnUntraced(function* (root: number) {
if (process.platform === "win32") return []
const result: number[] = []
const queue = [root]
for (let index = 0; index < queue.length; index++) {
const parent = queue[index]
if (parent === undefined) return result
const children = (yield* childPids(parent)).filter((pid) => !result.includes(pid))
result.push(...children)
queue.push(...children)
}
return result
})
const childPids = (pid: number) =>
Effect.promise(
() =>
new Promise<number[]>((resolve) => {
execFile("pgrep", ["-P", String(pid)], { encoding: "utf8" }, (_error, stdout) => {
resolve(
stdout
.split("\n")
.map((line) => Number.parseInt(line, 10))
.filter((pid) => Number.isInteger(pid)),
)
})
}),
)
async function paginate<R extends { nextCursor?: string }, T>(
list: (cursor: string | undefined) => Promise<R>,
items: (result: R) => T[],
+7 -9
View File
@@ -12,6 +12,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { Form } from "../form.js"
import { Integration } from "../integration.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
@@ -173,13 +174,13 @@ export const layer = (options?: Options) =>
Effect.gen(function* () {
const config = yield* Config.Service
const location = yield* Location.Service
const environment = yield* Environment.Service
const bus = yield* Bus.Service
const forms = yield* Form.Service
const integration = yield* Integration.Service
const credentials = yield* Credential.Service
const root = yield* Scope.make()
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
const loadConfig = (entries: readonly Entry[]) => {
const documents = entries.filter((entry): entry is Document => entry.type === "document")
@@ -459,13 +460,8 @@ export const layer = (options?: Options) =>
connection.onClose(() =>
live(
Effect.gen(function* () {
entry.client = undefined
entry.tools = undefined
entry.prompts = undefined
entry.status = { status: "failed", error: "Connection closed" }
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
yield* stopServer(name, entry)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}),
),
@@ -520,6 +516,8 @@ export const layer = (options?: Options) =>
options?.clientInfo,
).pipe(
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
// A stdio server is spawned on this location's execution plane, not the host's.
Effect.provideService(Environment.Service, environment),
Scope.provide(scope),
Effect.exit,
)
@@ -828,7 +826,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Config.node, Location.node, Bus.node, Form.node, Integration.node, Credential.node],
deps: [Config.node, Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
})
}
+181
View File
@@ -0,0 +1,181 @@
export * as MCPStdio from "./stdio.js"
import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"
import { Cause, Duration, Effect, Queue, Scope, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import { Environment } from "../environment/index.js"
/** Mirrors StdioClientTransport: wait this long for a graceful exit after stdin closes. */
const CLOSE_GRACE = Duration.seconds(2)
/** Mirrors StdioClientTransport: escalate SIGTERM to SIGKILL after this long. */
const FORCE_KILL_AFTER = Duration.seconds(2)
const OUTGOING_CAPACITY = 64
const MAX_FRAME_BYTES = 16 * 1024 * 1024
export interface Options {
/** Server name; only used to attribute logs. */
readonly server: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
/**
* Environment declared by the server config, and nothing else.
*
* The host environment is merged in by the spawner via `extendEnv`, which keeps the merge on the
* side that actually runs the process: the local driver extends with the host's `process.env`
* (what the MCP SDK's transport did), while a workspace driver extends with the sandbox's own
* environment. Host variables therefore never cross the seam into a remote workspace.
*/
readonly environment: Record<string, string>
}
/**
* MCP stdio transport that spawns its server through the location's `Environment` instead of the
* SDK's host-bound `StdioClientTransport`, so a workspace-backed location runs its MCP servers
* wherever the rest of its execution happens.
*
* The process is acquired in the calling scope: closing the scope kills it (the spawner kills the
* whole process group, so descendants go too) regardless of whether the transport was closed.
*/
export const make = Effect.fnUntraced(function* (options: Options) {
const environment = yield* Environment.Service
const scope = yield* Effect.scope
// Outgoing frames are queued rather than written to `handle.stdin` directly: the sink closes the
// stream it is run with, and stdin must stay open across the whole session.
const outgoing = yield* Queue.bounded<string, Cause.Done>(OUTGOING_CAPACITY)
const buffer = new ReadBuffer()
const state: { phase: "ready" | "starting" | "open" | "closed"; handle?: ChildProcessHandle } = { phase: "ready" }
let startup: Promise<void> | undefined
let closing: Promise<void> | undefined
let trailingBytes = 0
const stop = (handle: ChildProcessHandle) =>
Effect.gen(function* () {
const exit = yield* Effect.timeoutOption(handle.exitCode, CLOSE_GRACE)
if (exit._tag === "Some") return
const terminated = yield* Effect.timeoutOption(handle.kill({ killSignal: "SIGTERM" }), FORCE_KILL_AFTER)
if (terminated._tag === "None") yield* handle.kill({ killSignal: "SIGKILL" })
}).pipe(Effect.ignore)
const close = () =>
(closing ??= Effect.runPromise(
Effect.gen(function* () {
state.phase = "closed"
Queue.endUnsafe(outgoing)
if (startup) yield* Effect.promise(() => startup!.catch(() => undefined))
const handle = state.handle
if (!handle) return
state.handle = undefined
yield* stop(handle)
}).pipe(Effect.ensuring(Queue.shutdown(outgoing)), Effect.ensuring(Effect.sync(() => buffer.clear()))),
))
const transport: Transport = {
start: () => {
if (state.phase !== "ready") return Promise.reject(new Error("Stdio transport already started"))
state.phase = "starting"
startup = Effect.runPromise(
Effect.gen(function* () {
const handle = yield* environment.spawner.spawn(
ChildProcess.make(options.command, [...options.args], {
cwd: options.cwd,
env: options.environment,
extendEnv: true,
stdin: { stream: Stream.encodeText(Stream.fromQueue(outgoing)), endOnDone: true },
stdout: "pipe",
stderr: "pipe",
forceKillAfter: FORCE_KILL_AFTER,
}),
)
state.handle = handle
if (state.phase === "closed") {
state.handle = undefined
return yield* stop(handle)
}
state.phase = "open"
yield* startOutput(handle)
}).pipe(Scope.provide(scope)),
)
return startup
},
send: (message: JSONRPCMessage) =>
state.phase !== "open"
? Promise.reject(new Error("Not connected"))
: Effect.runPromise(
Queue.offer(outgoing, serializeMessage(message)).pipe(
Effect.flatMap((offered) => (offered ? Effect.void : Effect.fail(new Error("Not connected")))),
),
),
close,
}
const deliver = (chunk: Uint8Array) =>
Effect.gen(function* () {
for (const byte of chunk) {
trailingBytes = byte === 10 ? 0 : trailingBytes + 1
if (trailingBytes > MAX_FRAME_BYTES) return yield* Effect.fail(new Error("MCP stdio frame exceeded 16 MiB"))
}
buffer.append(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength))
while (true) {
// `undefined` means the frame failed to parse: the buffer has already advanced past it, so
// keep draining. `null` means the buffer holds no complete frame yet.
const message = yield* Effect.try({
try: () => buffer.readMessage(),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}).pipe(
Effect.catch((error) =>
Effect.sync(() => {
transport.onerror?.(error)
return undefined
}),
),
)
if (message === undefined) continue
if (message === null) return
transport.onmessage?.(message)
}
})
const startOutput = (handle: ChildProcessHandle) =>
Effect.gen(function* () {
yield* Effect.forkScoped(
Stream.runForEach(handle.stdout, deliver).pipe(
Effect.tapCause((cause) =>
Effect.sync(() => {
const error = Cause.squash(cause)
transport.onerror?.(error instanceof Error ? error : new Error(String(error)))
}),
),
Effect.ignore,
// stdout ending means the server is gone; the SDK transport reports that the same way.
Effect.ensuring(
Effect.gen(function* () {
const unexpected = state.phase !== "closed"
if (unexpected) yield* Effect.promise(close)
transport.onclose?.()
}),
),
),
)
// StdioClientTransport pipes stderr into a stream nobody reads. Drain chunks into the debug
// log so chatty servers cannot stall and newline-free output is not buffered without bound.
yield* Effect.forkScoped(
handle.stderr.pipe(
Stream.decodeText(),
Stream.runForEach((output) =>
output.trim() === ""
? Effect.void
: Effect.logDebug("mcp server stderr", { server: options.server, output }),
),
Effect.ignore,
),
)
})
return transport
})
+5 -1
View File
@@ -23,7 +23,11 @@ export const GooglePlugin = make("google", (id) => (id.includes("gemini-") ? PRO
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined))
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined))
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined))
export const MetaPlugin = make("meta", (id) => (id.includes("muse-spark") ? PROMPT_META : undefined))
export const MetaPlugin = make("meta", (id) => {
if (!id.includes("muse")) return
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
})
export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
@@ -1,4 +1,4 @@
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by Muse Spark, a large language model trained by Meta MSL.
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by {{MODEL_NAME}}, a large language model trained by Meta MSL.
Use the instructions below and the tools available to assist the user.
@@ -55,5 +55,5 @@ Use the instructions below and the tools available to assist the user.
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
# User Help & Feedback
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta Muse Spark.
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta {{MODEL_NAME}}.
- When users ask directly about OpenCode (eg. "can OpenCode do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the `webfetch` tool to gather information to answer the question from the V2 OpenCode docs at https://opencode.ai/v2/docs/.
+4 -2
View File
@@ -272,8 +272,10 @@ const layer = Layer.effect(
snapshot: startSnapshot,
assistantMessageID,
})
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) =>
SessionUsage.record(finish.usage, resolved.cost)
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
})
const captureStepEnd = Effect.fnUntraced(function* () {
const snapshot = yield* snapshots.capture()
@@ -35,7 +35,7 @@ export interface StepRecord {
/** Present once the provider finished the step normally. */
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly usage: Extract<LLMEvent, { type: "step-finish" }>["usage"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly calls: ReadonlyArray<{
readonly id: string
@@ -495,7 +495,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason.normalized, usage: event.usage }
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+3 -1
View File
@@ -48,10 +48,12 @@ export const schedule = (
assistantMessageID: () => SessionMessage.ID,
) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<RetryableFailure>(),
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = retryAfter(failure)
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
Schedule.tap((metadata) =>
bus.publish(SessionEvent.RetryScheduled, {
@@ -1,5 +1,6 @@
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import type { Model } from "../../model.js"
import { SessionMessage } from "../message.js"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
@@ -14,6 +15,17 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description },
})
const attachmentLocation = (file: FileAttachment) => {
if (file.source.type !== "uri") return undefined
const url = URL.parse(file.source.uri)
if (url?.protocol !== "file:") return undefined
try {
return fileURLToPath(url)
} catch {
return undefined
}
}
const textAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
@@ -36,7 +48,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
const directoryAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
@@ -55,7 +67,10 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
const attachmentContent = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime)) return [media(file)]
if (imageMimes.has(file.mime)) {
const location = attachmentLocation(file)
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
}
return []
}
+2 -8
View File
@@ -17,6 +17,7 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
},
})
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) {
const context = usage.input + usage.cache.read + usage.cache.write
const tier = costs
@@ -37,14 +38,7 @@ export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.
export const record = (usage: Usage | undefined, costs: Model.Info["cost"]): Recorded => {
const normalized = tokens(usage)
const reported = usage?.cost
return {
tokens: normalized,
cost:
reported !== undefined && Number.isFinite(reported) && reported >= 0
? Money.USD.make(reported)
: calculateCost(costs, normalized),
}
return { tokens: normalized, cost: calculateCost(costs, normalized) }
}
export const add = (a: Recorded, b: Recorded): Recorded => ({
+14 -47
View File
@@ -1,5 +1,5 @@
import { APICallError } from "@ai-sdk/provider"
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { AISDK } from "@opencode-ai/core/aisdk"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
@@ -23,26 +23,21 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
limit: { context: 100, output: 20 },
})
const streamModel = (
events: ReadonlyArray<LanguageModelV3StreamPart>,
inspect?: (options: LanguageModelV3CallOptions) => void,
): LanguageModelV3 => ({
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
specificationVersion: "v3",
provider: "test",
modelId: "test",
supportedUrls: {},
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
doStream: (options) => {
inspect?.(options)
return Promise.resolve({
doStream: () =>
Promise.resolve({
stream: new ReadableStream({
start(controller) {
events.forEach((event) => controller.enqueue(event))
controller.close()
},
}),
})
},
}),
})
const usage = {
@@ -54,7 +49,9 @@ const client = LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
RequestExecutor.Service.of({
execute: () => Effect.die("Unexpected HTTP request"),
}),
),
),
)
@@ -380,41 +377,6 @@ it.effect("emits malformed AI SDK tool input without executing it", () =>
}),
)
it.effect("normalizes Copilot billed usage to USD", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let options: LanguageModelV3CallOptions | undefined
yield* aisdk.hook.sdk((event) => {
event.sdk = {
languageModel: () =>
streamModel(
[
{
type: "raw",
rawValue: { type: "message_delta", copilot_usage: { total_nano_aiu: 4_473_525_000 } },
},
{ type: "finish", finishReason: { unified: "stop", raw: "end_turn" }, usage },
],
(input) => {
options = input
},
),
}
})
const resolved = yield* aisdk.model({
...model("@ai-sdk/github-copilot"),
providerID: Provider.ID.githubCopilot,
})
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
Effect.provide(client),
)
expect(options?.includeRawChunks).toBeTrue()
expect(response.usage?.cost).toBeCloseTo(0.04473525)
}),
)
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
@@ -582,7 +544,12 @@ it.effect("retries status-less AI SDK transport failures", () =>
isRetryable: true,
}),
)
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
expect(error.reason).toMatchObject({
_tag: "Transport",
transport: "http",
operation: "request",
code: "AI_APICallError",
})
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
}),
+1 -1
View File
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
+36 -1
View File
@@ -1,7 +1,42 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, Layer } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
/**
* The host environment, without the workspace machinery: what a location with no `workspaceID`
* resolves to.
*/
export const hostEnvironmentLayer = Layer.effect(
Environment.Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const driver = Environment.makeLocalDriver(spawner)
return Environment.Service.of({ files: Environment.makeFiles(driver), spawner: driver.spawner })
}),
).pipe(Layer.provide(LayerNode.compile(CrossSpawnSpawner.node)))
/**
* The host environment with its spawner wrapped so a test can assert on every command that crosses
* the seam. Spawning still really happens, so the process under test behaves normally.
*/
export const recordingEnvironmentLayer = (spawns: Array<ChildProcess.Command>) =>
Layer.effect(
Environment.Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Environment.Service.of({
...environment,
spawner: ChildProcessSpawner.make((command) => {
spawns.push(command)
return environment.spawner.spawn(command)
}),
})
}),
).pipe(Layer.provide(hostEnvironmentLayer))
export type EnvironmentFilesTransform = (files: Environment.Files) => Partial<Environment.Files>
@@ -7,7 +7,7 @@ import { Bus } from "@opencode-ai/core/bus"
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
+1 -1
View File
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { it } from "../lib/effect"
const key = (value: string) => Instructions.Key.make(value)
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
import { it } from "./effect"
import { Failed, NotFound, WrongKind, type Files } from "@opencode-ai/core/environment/index"
export interface EnvironmentHarness {
readonly files: Files
@@ -16,17 +15,21 @@ export const environmentConformance = <E>(
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
it.live(title, () =>
Effect.gen(function* () {
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
test(title, () =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
),
),
)
const bytes = (value: string) => new TextEncoder().encode(value)
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
export interface State {
readonly values: Readonly<Record<string, Schema.Json>>
+185 -9
View File
@@ -22,19 +22,24 @@ import { Bus } from "@opencode-ai/core/bus"
import { ID, type Payload } from "@opencode-ai/schema/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
import { MCPStdio } from "@opencode-ai/core/mcp/stdio"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Stream } from "effect"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Sink, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
@@ -167,6 +172,7 @@ function resourceMcpLayer(
overrides?: {
entries?: Config.Interface["entries"]
subscribe?: Bus.Interface["subscribe"]
environment?: Layer.Layer<Environment.Service>
},
) {
const directory = AbsolutePath.make(import.meta.dir)
@@ -229,11 +235,15 @@ function resourceMcpLayer(
},
}),
Layer.mock(Credential.Service, {}),
overrides?.environment ?? hostEnvironmentLayer,
),
),
)
}
const connect = (server: string, config: typeof ConfigMCP.Server.Type, directory: string) =>
MCPClient.connect(server, config, directory).pipe(Effect.provide(hostEnvironmentLayer))
const mcp = Layer.mock(MCP.Service, {
tools: () =>
Effect.succeed([
@@ -248,6 +258,12 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"],
},
}),
new MCP.Tool({
server: MCP.ServerName.make("demo"),
name: "status",
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
name: "lookup",
@@ -290,6 +306,13 @@ const mcp = Layer.mock(MCP.Service, {
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
],
})
if (input.name === "status")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "hello" }],
})
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
@@ -394,7 +417,7 @@ test("retains output schemas across paginated MCP discovery", async () => {
const tools = await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"pagination",
new ConfigMCP.Local({
type: "local",
@@ -427,11 +450,139 @@ test("retains output schemas across paginated MCP discovery", async () => {
])
})
test("spawns local MCP servers through the location environment", async () => {
const spawns: Array<ChildProcess.Command> = []
const cwd = path.join(import.meta.dir, "fixture")
const config = new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
cwd: "fixture",
environment: { MCP_LOCATION_TEST: "configured" },
})
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect("environment", config, import.meta.dir)
yield* connection.tools()
}),
).pipe(Effect.provide(recordingEnvironmentLayer(spawns))),
)
expect(spawns).toHaveLength(1)
const command = spawns[0]
if (!command || !ChildProcess.isStandardCommand(command)) throw new Error("Expected a standard process command")
expect(command.command).toBe(process.execPath)
expect(command.options.cwd).toBe(cwd)
expect(command.options.extendEnv).toBe(true)
expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" })
})
test("rejects sends before the stdio transport is started", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
server: "not-started",
command: process.execPath,
args: [path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
cwd: import.meta.dir,
environment: {},
})
yield* Effect.tryPromise({
try: () => transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}).pipe(
Effect.flip,
Effect.tap((error) => Effect.sync(() => expect(error.message).toBe("Not connected"))),
)
}).pipe(Effect.provide(hostEnvironmentLayer)),
),
)
})
test("joins concurrent stdio transport closes", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
server: "concurrent-close",
command: "unused",
args: [],
cwd: import.meta.dir,
environment: {},
})
const first = transport.close()
expect(transport.close()).toBe(first)
yield* Effect.promise(() => first)
}).pipe(Effect.provide(hostEnvironmentLayer)),
),
)
})
test("closes a stdio process that finishes spawning after close", async () => {
const spawning = Deferred.makeUnsafe<void>()
const release = Deferred.makeUnsafe<void>()
const exited = Deferred.makeUnsafe<ExitCode>()
const signals: Array<string> = []
const driver = Environment.makeMemoryDriver()
const environment = Layer.succeed(
Environment.Service,
Environment.Service.of({
files: Environment.makeFiles(driver),
spawner: ChildProcessSpawner.make(() =>
Effect.gen(function* () {
yield* Deferred.succeed(spawning, undefined)
yield* Deferred.await(release)
return makeHandle({
pid: ProcessId(1),
exitCode: Deferred.await(exited),
isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
kill: (options) =>
Effect.gen(function* () {
signals.push(options?.killSignal ?? "SIGTERM")
yield* Deferred.succeed(exited, ExitCode(143))
}),
stdin: Sink.drain,
stdout: Stream.never,
stderr: Stream.empty,
all: Stream.never,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
})
}),
),
}),
)
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* MCPStdio.make({
server: "close-during-spawn",
command: "unused",
args: [],
cwd: import.meta.dir,
environment: {},
})
const start = transport.start()
yield* Deferred.await(spawning)
const close = transport.close()
yield* Deferred.succeed(release, undefined)
yield* Effect.promise(() => Promise.all([start, close]))
}).pipe(Effect.provide(environment)),
),
)
expect(signals).toEqual(["SIGTERM"])
})
test("applies the configured MCP catalog timeout", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"catalog-timeout",
new ConfigMCP.Local({
type: "local",
@@ -453,7 +604,7 @@ test("applies the configured MCP execution timeout", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"execution-timeout",
new ConfigMCP.Local({
type: "local",
@@ -474,7 +625,7 @@ test("applies the configured MCP execution timeout to prompts", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"prompt-timeout",
new ConfigMCP.Local({
type: "local",
@@ -495,7 +646,7 @@ test("applies configured MCP timeouts to resource operations", async () => {
const catalog = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"resource-catalog-timeout",
new ConfigMCP.Local({
type: "local",
@@ -514,7 +665,7 @@ test("applies configured MCP timeouts to resource operations", async () => {
const read = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"resource-read-timeout",
new ConfigMCP.Local({
type: "local",
@@ -549,7 +700,7 @@ test("lists, reads, and reports MCP resource changes", async () => {
},
"templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] },
}
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
@@ -620,7 +771,7 @@ test("skips MCP resource requests when the capability is absent", async () => {
Effect.scoped(
Effect.gen(function* () {
const server = yield* resourceServer({ resources: false })
const connection = yield* MCPClient.connect(
const connection = yield* connect(
"resources",
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
import.meta.dir,
@@ -984,6 +1135,31 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_content_only",
name: "execute",
input: { code: "return await tools.demo.status({})" },
},
})
expect(execution).toMatchObject({
output: { output: "hello", toolCalls: [{ tool: "demo.status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+41
View File
@@ -393,4 +393,45 @@ describe("fromPromise", () => {
expect(progress).toEqual([{ phase: "greeting" }])
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const promisePlugin = define({
id: "content-only-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "demo_status",
description: "Returns a status string",
input: Schema.Struct({}),
execute: async () => ({ content: [{ type: "text", text: "hello" }] }),
options: { codemode: true },
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
const toolSet = yield* registry.snapshot()
const throughCodeMode = yield* toolSet.execute({
sessionID: Session.ID.make("ses_content_only_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_content_only_tool"),
call: {
type: "tool-call",
id: "call_content_only_tool",
name: "execute",
input: { code: "return await tools.demo_status({})" },
},
})
expect(throughCodeMode).toMatchObject({
output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
}),
)
})
@@ -90,6 +90,36 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("selects the Meta prompt for Muse family model IDs", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.MetaPlugin.effect(pluginHost)
yield* Effect.forEach(
[
["meta/muse-spark-preview", "Muse Spark"],
["muse-spark-1.2", "Muse Spark"],
["meta/muse-glimmer-30b", "Muse Glimmer"],
["muse-glimmer-30b", "Muse Glimmer"],
] as const,
([id, name]) => {
const event = context(id)
return hooks.trigger("session", "context", event).pipe(
Effect.tap(() =>
Effect.sync(() => {
expect(event.system[0]?.text).toContain(`powered by ${name},`)
expect(event.system[0]?.text).toContain(`using Meta ${name}.`)
expect(event.system[0]?.text).not.toContain("{{MODEL_NAME}}")
}),
),
)
},
{ discard: true },
)
}),
)
it.effect("preserves an explicit agent system prompt", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
+4 -2
View File
@@ -39,7 +39,9 @@ describe("toSessionError", () => {
)
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
expect(
toSessionError(llm(new TransportReason({ message: "transport", transport: "http", operation: "request" }))).type,
).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
"provider.internal",
)
@@ -111,7 +113,7 @@ describe("toSessionError", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
llm(new TransportReason({ message: "transport" })),
llm(new TransportReason({ message: "transport", transport: "http", operation: "request" })),
]
const ineligible = [
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
+2 -2
View File
@@ -14,7 +14,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
@@ -32,7 +32,7 @@ describe("SessionExecution lifecycle", () => {
new AIError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
reason: new TransportReason({ message: "Disconnected", transport: "http", operation: "request" }),
}),
),
),
+1 -1
View File
@@ -17,7 +17,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
+119 -10
View File
@@ -11,6 +11,8 @@ import { Shell } from "@opencode-ai/schema/shell"
import { Location } from "@opencode-ai/schema/location"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { DateTime } from "effect"
import path from "path"
import { pathToFileURL } from "url"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
@@ -267,12 +269,13 @@ Recent work
])
})
test("lowers directory attachments as directory context", () => {
test("exposes admitted reference directory source paths in model context", () => {
const location = path.resolve("/references/harness-engineering")
const directory = FileAttachment.make({
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
source: { type: "uri", uri: pathToFileURL(location).href },
name: "harness-engineering",
})
const messages = toLLMMessages(
[
@@ -295,14 +298,15 @@ Recent work
{ type: "text", text: "Review this directory" },
{
type: "text",
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
metadata: { attachment: { source: directory.source, name: "src/" } },
text: `\n\nAttached directory: ${location}\n\nlib/\nindex.ts`,
metadata: { attachment: { source: directory.source, name: "harness-engineering" } },
},
],
})
})
test("preserves attachment order after the prompt", () => {
const directory = path.resolve("/project/src")
const messages = toLLMMessages(
[
SessionMessage.User.make({
@@ -313,7 +317,7 @@ Recent work
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
source: { type: "uri", uri: pathToFileURL(directory).href },
name: "src/",
}),
FileAttachment.make({
@@ -332,12 +336,13 @@ Recent work
expect(messages).toHaveLength(1)
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
"Review these attachments",
"\n\nAttached directory: src/\n\nindex.ts",
`\n\nAttached directory: ${directory}\n\nindex.ts`,
"\n\nAttached file: main.ts\n\nexport const value = 1",
])
})
test("omits empty prompt text before an attachment", () => {
const directory = path.resolve("/project/src")
const messages = toLLMMessages(
[
SessionMessage.User.make({
@@ -348,7 +353,7 @@ Recent work
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
source: { type: "uri", uri: pathToFileURL(directory).href },
name: "src/",
}),
],
@@ -359,7 +364,9 @@ Recent work
)
expect(messages).toHaveLength(1)
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
expect(messages[0]?.content).toMatchObject([
{ type: "text", text: `\n\nAttached directory: ${directory}\n\nindex.ts` },
])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => {
@@ -391,6 +398,108 @@ Recent work
])
})
test("exposes admitted local image source paths before provider media", () => {
const data = Base64.make("AAECAw==")
const location = path.resolve("/project/IMG_3480.JPG")
const image = FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: pathToFileURL(location).href },
name: "IMG_3480.JPG",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-local-image-path"),
type: "user",
text: "Inspect this image",
files: [image],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "text", text: `Attached file: ${location}` },
{ type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" },
])
})
test("falls back to attachment names for invalid local source paths", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-invalid-local-paths"),
type: "user",
text: "Inspect these attachments",
files: [
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src%2Flib" },
name: "src/",
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image%2Fpreview.png" },
name: "preview.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect these attachments" },
{
type: "text",
text: "\n\nAttached directory: src/\n\nindex.ts",
metadata: {
attachment: {
source: { type: "uri", uri: "file:///project/src%2Flib" },
name: "src/",
},
},
},
{ type: "media", mediaType: "image/png", data, filename: "preview.png" },
])
})
test("does not add attachment location text for non-local provider media", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-remote-image"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "https://example.com/image.png" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
])
})
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
@@ -468,7 +577,7 @@ Recent work
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image.png" },
source: { type: "uri", uri: pathToFileURL(path.resolve("/project/image.png")).href },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
@@ -22,7 +22,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Tool } from "@opencode-ai/core/tool"
@@ -31,7 +31,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { Cause, Effect, Exit, Schema } from "effect"
import { LLMEvent } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Agent } from "@opencode-ai/core/agent"
@@ -12,7 +13,6 @@ import { Provider } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
import { SessionUsage } from "@opencode-ai/core/session/usage"
const sessionID = Session.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@@ -280,7 +280,6 @@ test("content-filter finish retains failure evidence until step closeout", async
nonCachedInputTokens: 8,
outputTokens: 3,
reasoningTokens: 1,
cost: 1.25,
},
}),
),
@@ -290,13 +289,13 @@ test("content-filter finish retains failure evidence until step closeout", async
const settlement = publisher.record().finish
expect(settlement).toMatchObject({
finish: "content-filter",
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1, cost: 1.25 },
tokens: { input: 8, output: 2, reasoning: 1 },
})
if (!settlement) throw new Error("Expected content-filter settlement")
const recorded = SessionUsage.record(settlement.usage, [])
await Effect.runPromise(
publisher.publishStepFailure({
...recorded,
cost: Money.USD.make(1.25),
tokens: settlement.tokens,
snapshot: Snapshot.ID.make("tree-end"),
files: [RelativePath.make("src/changed.ts")],
}),
+27 -20
View File
@@ -38,7 +38,7 @@ import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
@@ -61,7 +61,7 @@ import {
} from "@opencode-ai/core/session/sql"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
@@ -515,7 +515,11 @@ const providerUnavailable = () =>
new AIError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Provider unavailable" }),
reason: new TransportReason({
message: "Provider unavailable",
transport: "http",
operation: "request",
}),
})
const incompleteStream = () =>
@@ -3947,7 +3951,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries eligible pre-output failures after exponential backoff", () =>
it.effect("bounds jittered exponential backoff for eligible pre-output failures", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry transport")
@@ -3956,9 +3960,9 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("1999 millis")
yield* TestClock.adjust("1599 millis")
expect(requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
yield* TestClock.adjust("801 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -3983,7 +3987,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -4028,7 +4032,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -4085,7 +4089,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests[1]?.messages.at(-2)).toMatchObject({
@@ -4126,7 +4130,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(executions).toEqual(["settled"])
@@ -4165,7 +4169,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool", "user"])
@@ -4203,7 +4207,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
yield* TestClock.adjust(delay)
yield* TestLLM.wait(index + 2)
}
@@ -4224,7 +4228,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
yield* TestClock.adjust(delay)
yield* TestLLM.wait(index + 2)
}
@@ -4239,12 +4243,15 @@ describe("SessionRunnerLLM", () => {
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(retries.map((event) => event.data)).toMatchObject([
{ attempt: 2, at: 2_000 },
{ attempt: 3, at: 6_000 },
{ attempt: 4, at: 14_000 },
{ attempt: 5, at: 30_000 },
])
for (const [index, range] of [
[1_600, 2_400],
[4_800, 7_200],
[11_200, 16_800],
[24_000, 36_000],
].entries()) {
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(range[0]!)
expect(retries[index]?.data.at).toBeLessThanOrEqual(range[1]!)
}
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
const assistant = requireAssistant(yield* session.context(sessionID))
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
@@ -4274,7 +4281,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(3)
-28
View File
@@ -1,28 +0,0 @@
import { expect, test } from "bun:test"
import { Usage } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import { SessionUsage } from "@opencode-ai/core/session/usage"
const costs = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
},
]
test("prefers provider-reported cost", () => {
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: 0.25 }), costs).cost).toBe(
Money.USD.make(0.25),
)
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: 0 }), costs).cost).toBe(Money.USD.zero)
})
test("falls back to catalog pricing for invalid reported cost", () => {
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: Number.NaN }), costs).cost).toBe(
Money.USD.make(1),
)
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: -1 }), costs).cost).toBe(
Money.USD.make(1),
)
})
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
+1 -1
View File
@@ -21,7 +21,7 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
+1 -1
View File
@@ -5,7 +5,7 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
+1 -1
View File
@@ -12,7 +12,7 @@ import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
+1 -1
View File
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
+1 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { makeMemoryDriver } from "@opencode-ai/core/environment"
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { WorkspaceTable } from "@opencode-ai/core/workspace/sql"
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"incremental": false,
"outDir": "dist/types",
"tsBuildInfoFile": null
},
"include": ["src"]
}
+7 -12
View File
@@ -7,15 +7,6 @@ import { isAllowedCorsOrigin } from "./cors"
import { createRoutes } from "./routes"
import type { ServerOptions } from "./options"
export interface BootOptions {
/**
* Resumes execution-journaled Sessions once the application layer boots. Pair with
* `SessionExecution.configured({ suspendOnStart: true })` on runtimes that can die without
* teardown, so turns orphaned by a hard death replay on the next boot.
*/
readonly resumeSuspendedSessions?: boolean
}
/**
* Builds a web-standard fetch handler `(request: Request) => Promise<Response>` serving the
* same HttpApi routes as the Node server process without binding a port, owning a listener, or
@@ -32,13 +23,17 @@ export interface BootOptions {
* Auth follows `createRoutes` semantics: `options.password` enforces Basic auth; omitting it
* serves unauthenticated, so an embedder without a password must front the handler with its own
* access control.
*
* Sessions whose execution claim was never released resume once the layer is built, exactly as
* the Node server process does: a runtime that dies without teardown an evicted Durable
* Object leaves the same durable signature as a killed process replays orphaned turns on the
* next boot, and the sweep is a no-op when nothing is suspended.
*/
export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}, boot: BootOptions = {}) {
export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}) {
const context = yield* Layer.build(createRoutes(options, () => []).pipe(Layer.provide(HttpServer.layerServices)))
// Forked so the returned handler is never delayed; resumed drains are already
// logged and durably recorded by the execution layer.
if (boot.resumeSuspendedSessions)
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
return Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(
+1 -1
View File
@@ -2,7 +2,7 @@ import { Effect, Sink, Stream } from "effect"
import { systemError } from "effect/PlatformError"
import type { Command, KillOptions } from "effect/unstable/process/ChildProcess"
import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "@opencode-ai/core/environment"
import type { Driver } from "@opencode-ai/core/environment/index"
import type { App, Image, ModalClient, ModalClientParams, Sandbox, SandboxCreateParams } from "modal"
const INNER_WRAPPER = `
@@ -4,8 +4,8 @@ import path from "node:path"
import { afterAll, expect, test } from "bun:test"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Failed, makeFiles } from "@opencode-ai/core/environment"
import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance"
import { Failed, makeFiles } from "@opencode-ai/core/environment/index"
import { environmentConformance } from "../../core/test/lib/environment-conformance.js"
import { createModalSandbox } from "../src/workspace/modal"
const enabled =
+1 -1
View File
@@ -3,7 +3,7 @@ import os from "node:os"
import path from "node:path"
import { expect, test } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeFiles } from "@opencode-ai/core/environment"
import { makeFiles } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect, Layer } from "effect"
+3 -1
View File
@@ -150,6 +150,7 @@ const appBindingCommands = [
"variant.cycle",
"variant.list",
"provider.connect",
"opencode.settings",
"opencode.status",
"server.pair",
"service.restart",
@@ -168,6 +169,7 @@ const appBindingCommands = [
"app.toggle.file_context",
"app.toggle.diffwrap",
"app.toggle.paste_summary",
"permission.mode",
] as const
export type TuiInput = {
@@ -499,7 +501,7 @@ function App(props: { pair?: DialogPairCredentials }) {
toast.show({
variant: "error",
title: `MCP server failed: ${server.name}`,
message: "Open MCP servers to view details.",
message: "Run /mcps to view details.",
})
}
})
@@ -0,0 +1,115 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
import { getScrollAcceleration } from "../util/scroll"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [scrollable, setScrollable] = createSignal(false)
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
let measure: (() => void) | undefined
onMount(() => dialog.setSize("large"))
createEffect(() => {
dimensions()
props.error
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
measure = () => {
measure = undefined
setScrollable(Boolean(scroll && scroll.scrollHeight > scroll.viewport.height))
}
renderer.once(CliRenderEvents.FRAME, measure)
renderer.requestRender()
})
onCleanup(() => {
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
})
const copy = () => {
void clipboard
.write(props.error)
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack },
{ bind: "c", title: "Copy details", group: "Dialog", run: copy },
],
}))
useKeyboard((event) => {
if (!scrollable()) return
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{props.error}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text>
<span style={{ fg: theme.text.default }}>
<b>{scrollable() ? "↑/↓" : ""}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{scrollable() ? " scroll" : ""}</span>
</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() ? "✓ copied" : "c"}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
</box>
</box>
)
}
+6 -84
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
@@ -6,13 +6,10 @@ import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { TextAttributes } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import { DialogErrorDetails } from "./dialog-error-details"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
@@ -143,8 +140,9 @@ export function DialogMcp() {
}
>
{(server) => (
<DialogMcpError
server={server()}
<DialogErrorDetails
title={`MCP server: ${server().name}`}
error={statusError(server().status) ?? "Unknown MCP connection error"}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -155,79 +153,3 @@ export function DialogMcp() {
</box>
)
}
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
void clipboard
.write(error())
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
MCP server: {props.server.name}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{error()}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
@@ -151,6 +151,7 @@ export function DialogOpen() {
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
+102 -23
View File
@@ -23,7 +23,7 @@ import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
import { marqueeText } from "../util/marquee"
import { marqueeCycleWidth, marqueeText } from "../util/marquee"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -60,27 +60,98 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
function createMarquee(animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const [active, setActive] = createSignal<string>()
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
let delay: ReturnType<typeof setTimeout> | undefined
let interval: ReturnType<typeof setInterval> | undefined
let cycleWidth = 0
let returning = false
createEffect(() => {
const clear = () => {
if (delay) clearTimeout(delay)
if (interval) clearInterval(interval)
delay = undefined
interval = undefined
}
const scroll = () => {
interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL)
}
const enter = (sessionID: string, title: string, width: number) => {
if (active() === sessionID && !returning) return
clear()
if (active() === sessionID) {
returning = false
return scroll()
}
if (stringWidth(title) <= width) return
cycleWidth = marqueeCycleWidth(title)
setActive(sessionID)
setOffset(0)
returning = false
leading.jump({ opacity: 0 })
if (!hovered()) return
let interval: ReturnType<typeof setInterval> | undefined
const delay = setTimeout(() => {
delay = setTimeout(() => {
setOffset(1)
leading.animate({ opacity: 1 })
interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL)
scroll()
}, MARQUEE_DELAY)
onCleanup(() => {
clearTimeout(delay)
if (interval) clearInterval(interval)
}
const leave = (sessionID: string) => {
if (active() !== sessionID) return
clear()
if (offset() === 0) {
setActive(undefined)
return
}
returning = true
interval = setInterval(() => {
setOffset((value) => {
const next = (value + 1) % cycleWidth
if (next !== 0) return next
clear()
returning = false
setActive(undefined)
leading.animate({ opacity: 0 })
return 0
})
}, MARQUEE_INTERVAL)
}
const reset = () => {
clear()
returning = false
setActive(undefined)
setOffset(0)
leading.jump({ opacity: 0 })
}
onCleanup(clear)
return { offset, active, enter, leave, reset, leading: () => leading.value().opacity }
}
function createTabMarquee(animations: () => boolean) {
const [hovered, setHovered] = createSignal<string>()
const marquee = createMarquee(animations)
let hoverClear: ReturnType<typeof setTimeout> | undefined
const enter = (sessionID: string, title: string, width: number) => {
if (hoverClear) clearTimeout(hoverClear)
setHovered(sessionID)
marquee.enter(sessionID, title, width)
}
const leave = (sessionID: string) => {
if (hoverClear) clearTimeout(hoverClear)
hoverClear = setTimeout(() => {
if (hovered() !== sessionID) return
setHovered(undefined)
marquee.leave(sessionID)
})
}
onCleanup(() => {
if (hoverClear) clearTimeout(hoverClear)
})
return { offset, leading: () => leading.value().opacity }
return { ...marquee, hovered, enter, leave }
}
export function SessionTabs(
@@ -105,9 +176,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const newTab = () => tabs.newTab?.() ?? false
@@ -118,6 +189,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = ordered
createEffect(() => {
const active = marquee.active()
if (active && !items().some((tab) => tab.sessionID === active)) marquee.reset()
})
const statuses = createMemo(
() =>
new Map(
@@ -185,7 +260,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
@@ -274,10 +349,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseOver={() => marquee.enter(tab.sessionID, title(), titleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={() => {
setHovered(tab.sessionID)
marquee.enter(tab.sessionID, title(), titleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={release}
@@ -492,9 +567,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
@@ -530,6 +605,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
previous?.start,
),
)
createEffect(() => {
const active = marquee.active()
if (active && !layout().tabs.some((tab) => tab.sessionID === active)) marquee.reset()
})
const statuses = createMemo(
() =>
new Map(
@@ -682,7 +761,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// Hovering reveals the close mark, so the title's right bound shifts left of it.
const availableTitleWidth = () =>
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
@@ -741,10 +820,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
position="relative"
flexDirection="row"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseOver={() => marquee.enter(tab.sessionID, title(), availableTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={() => {
setHovered(tab.sessionID)
marquee.enter(tab.sessionID, title(), availableTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={release}
+4 -5
View File
@@ -234,10 +234,10 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
if (!options.terminalSuspend) {
keybinds.terminal_suspend = "none"
if (keybinds.input_undo === undefined) {
const inputUndo = TuiKeybind.defaultValue("input_undo")
keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
keybinds["terminal.suspend"] = "none"
if (keybinds["input.undo"] === undefined) {
const inputUndo = TuiKeybind.defaultValue("input.undo")
keybinds["input.undo"] = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
.filter((value, index, values) => values.indexOf(value) === index)
.join(",")
}
@@ -254,7 +254,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
sounds: input.attention?.sounds ?? {},
},
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader: { timeout: input.leader?.timeout ?? 2000 },
+336 -2
View File
@@ -1,2 +1,336 @@
export * from "./v1/keybind"
export * as TuiKeybind from "./v1/keybind"
export * as TuiKeybind from "./keybind"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
import { Schema } from "effect"
const KeyStroke = Schema.Struct({
name: Schema.String,
ctrl: Schema.optional(Schema.Boolean),
shift: Schema.optional(Schema.Boolean),
meta: Schema.optional(Schema.Boolean),
super: Schema.optional(Schema.Boolean),
hyper: Schema.optional(Schema.Boolean),
})
const BindingObject = Schema.StructWithRest(
Schema.Struct({
key: Schema.Union([Schema.String, KeyStroke]),
event: Schema.optional(Schema.Literals(["press", "release"])),
preventDefault: Schema.optional(Schema.Boolean),
fallthrough: Schema.optional(Schema.Boolean),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
export const BindingValueSchema = Schema.Union([
Schema.Literal(false),
Schema.Literal("none"),
BindingItem,
Schema.Array(BindingItem),
])
export type BindingValueSchema = Schema.Schema.Type<typeof BindingValueSchema>
type Definition = {
default: BindingValueSchema
description: string
}
export const LeaderDefault = "ctrl+x"
const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
export const Definitions = {
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
"app.exit": keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
"app.debug": keybind("none", "Toggle debug panel"),
"app.console": keybind("none", "Toggle console"),
"app.scrap": keybind("none", "Open scrap screen"),
"app.toggle.animations": keybind("none", "Toggle animations"),
"app.toggle.file_context": keybind("none", "Toggle file context"),
"app.toggle.diffwrap": keybind("none", "Toggle diff wrapping"),
"app.toggle.paste_summary": keybind("none", "Toggle paste summary"),
"command.palette.show": keybind("ctrl+p", "List available commands"),
"help.show": keybind("none", "Open help dialog"),
"docs.open": keybind("none", "Open documentation"),
"opencode.settings": keybind("none", "Open settings"),
"server.pair": keybind("none", "Pair device"),
"service.restart": keybind("none", "Restart service"),
"permission.mode": keybind("none", "Toggle auto-approve permissions"),
"diff.open": keybind("none", "Open diff viewer"),
"diff.close": keybind("escape,q", "Close diff viewer"),
"diff.down": keybind("j,down", "Move diff viewer down"),
"diff.up": keybind("k,up", "Move diff viewer up"),
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
"diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
"diff.expand": keybind("right", "Expand diff viewer item"),
"diff.expand_all": keybind("E", "Expand all diff viewer folders"),
"diff.collapse": keybind("left", "Collapse diff viewer item"),
"diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
"diff.next_hunk": keybind("]", "Jump to next diff hunk"),
"diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
"diff.next_file": keybind("n", "Jump to next diff file"),
"diff.previous_file": keybind("p", "Jump to previous diff file"),
"diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"),
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?", "Show more diff viewer shortcuts"),
"prompt.editor": keybind("<leader>e", "Open external editor"),
"theme.switch": keybind("<leader>t", "List available themes"),
"theme.switch_mode": keybind("none", "Switch between light and dark theme mode"),
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
"opencode.status": keybind("<leader>s", "View status"),
"opencode.debug": keybind("none", "View debug info"),
"session.export": keybind("<leader>x", "Export session to editor"),
"session.copy": keybind("none", "Copy session transcript"),
"session.move": keybind("none", "Move session"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
"session.tab.history.back": keybind("none", "Go back in session tab history"),
"session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
"session.tab.next_unread": keybind("alt+shift+down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("alt+shift+up", "Switch to previous unread session tab"),
"session.tab.close": keybind("<leader>w", "Close current session tab"),
"session.tab.reopen": keybind("ctrl+shift+t", "Reopen last closed session tab"),
"session.timeline": keybind("<leader>g", "Show session timeline"),
"session.fork": keybind("none", "Fork session from message"),
"session.rename": keybind("ctrl+r", "Rename session"),
"session.delete": keybind("ctrl+d", "Delete session"),
"session.share": keybind("none", "Share current session"),
"session.unshare": keybind("none", "Unshare current session"),
"session.interrupt": keybind("escape", "Interrupt current session"),
"session.background": keybind("ctrl+b", "Background blocking session tools"),
"session.compact": keybind("<leader>c", "Compact the session"),
"session.cd": keybind("none", "Change working directory"),
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
"session.child.first": keybind("down", "Toggle subagent picker"),
"session.child.next": keybind("right", "Go to next child session"),
"session.child.previous": keybind("left", "Go to previous child session"),
"session.parent": keybind("up", "Go to parent session"),
"session.pin.toggle": keybind("ctrl+f", "Pin or unpin session in the session list"),
"session.quick_switch.1": keybind("<leader>1", "Switch to session in quick slot 1"),
"session.quick_switch.2": keybind("<leader>2", "Switch to session in quick slot 2"),
"session.quick_switch.3": keybind("<leader>3", "Switch to session in quick slot 3"),
"session.quick_switch.4": keybind("<leader>4", "Switch to session in quick slot 4"),
"session.quick_switch.5": keybind("<leader>5", "Switch to session in quick slot 5"),
"session.quick_switch.6": keybind("<leader>6", "Switch to session in quick slot 6"),
"session.quick_switch.7": keybind("<leader>7", "Switch to session in quick slot 7"),
"session.quick_switch.8": keybind("<leader>8", "Switch to session in quick slot 8"),
"session.quick_switch.9": keybind("<leader>9", "Switch to session in quick slot 9"),
"session.tab.select.1": keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
"session.tab.select.2": keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
"session.tab.select.3": keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
"session.tab.select.4": keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
"session.tab.select.5": keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
"session.tab.select.6": keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
"session.tab.select.7": keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
"session.tab.select.8": keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
"session.tab.select.9": keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
"session.tab.select.10": keybind("<leader>0,ctrl+0", "Switch to session tab 10"),
"stash.delete": keybind("ctrl+d", "Delete stash entry"),
"model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
"model.dialog.favorite": keybind("ctrl+f", "Toggle model favorite status"),
"model.list": keybind("<leader>m", "List available models"),
"model.cycle_recent": keybind("f2", "Next recently used model"),
"model.cycle_recent_reverse": keybind("shift+f2", "Previous recently used model"),
"model.cycle_favorite": keybind("none", "Next favorite model"),
"model.cycle_favorite_reverse": keybind("none", "Previous favorite model"),
"mcp.list": keybind("none", "List MCP servers"),
"provider.connect": keybind("none", "Connect integration"),
"agent.list": keybind("<leader>a", "List agents"),
"agent.cycle": keybind("shift+tab", "Next agent"),
"agent.cycle.reverse": keybind("none", "Previous agent"),
"variant.cycle": keybind("ctrl+t", "Cycle model variants"),
"variant.list": keybind("none", "List model variants"),
"session.page.up": keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
"session.page.down": keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
"session.line.up": keybind("ctrl+alt+y", "Scroll messages up by one line"),
"session.line.down": keybind("ctrl+alt+e", "Scroll messages down by one line"),
"session.half.page.up": keybind("ctrl+alt+u", "Scroll messages up by half page"),
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
"session.message.next": keybind("none", "Navigate to next message"),
"session.message.previous": keybind("none", "Navigate to previous message"),
"session.message.user.next": keybind("none", "Navigate to next user message"),
"session.message.user.previous": keybind("none", "Navigate to previous user message"),
"session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
"messages.copy": keybind("<leader>y", "Copy message"),
"session.undo": keybind("<leader>u", "Undo message"),
"session.redo": keybind("<leader>r", "Redo message"),
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.images.view": keybind("<leader>i", "View image attachments"),
"prompt.skills": keybind("none", "Open skill selector"),
"prompt.stash": keybind("none", "Stash prompt"),
"prompt.stash.pop": keybind("none", "Pop stashed prompt"),
"prompt.stash.list": keybind("none", "List stashed prompts"),
"prompt.clear": keybind("ctrl+c", "Clear input field"),
"prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
"input.submit": keybind("return", "Submit input"),
"input.newline": keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
"input.move.left": keybind("left,ctrl+b", "Move cursor left in input"),
"input.move.right": keybind("right,ctrl+f", "Move cursor right in input"),
"input.move.up": keybind("up", "Move cursor up in input"),
"input.move.down": keybind("down", "Move cursor down in input"),
"input.select.left": keybind("shift+left", "Select left in input"),
"input.select.right": keybind("shift+right", "Select right in input"),
"input.select.up": keybind("shift+up", "Select up in input"),
"input.select.down": keybind("shift+down", "Select down in input"),
"input.line.home": keybind("ctrl+a", "Move to start of line in input"),
"input.line.end": keybind("ctrl+e", "Move to end of line in input"),
"input.select.line.home": keybind("ctrl+shift+a", "Select to start of line in input"),
"input.select.line.end": keybind("ctrl+shift+e", "Select to end of line in input"),
"input.visual.line.home": keybind("alt+a", "Move to start of visual line in input"),
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
"input.buffer.home": keybind("none", "Move to start of buffer in input"),
"input.buffer.end": keybind("none", "Move to end of buffer in input"),
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
"input.delete.to.line.end": keybind("ctrl+k", "Delete to end of line in input"),
"input.delete.to.line.start": keybind("ctrl+u", "Delete to start of line in input"),
"input.backspace": keybind("backspace,shift+backspace", "Backspace in input"),
"input.delete": keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
"input.undo": keybind("ctrl+-,super+z", "Undo in input"),
"input.redo": keybind("ctrl+.,super+shift+z", "Redo in input"),
"input.word.forward": keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
"input.word.backward": keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
"input.select.word.forward": keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
"input.select.word.backward": keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
"input.delete.word.forward": keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
"input.delete.word.backward": keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
"input.select.all": keybind("super+a", "Select all in input"),
"prompt.history.previous": keybind("up", "Previous history item"),
"prompt.history.next": keybind("down", "Next history item"),
"composer.subagent.up": keybind("up", "Previous subagent"),
"composer.subagent.down": keybind("down", "Next subagent"),
"composer.subagent.select": keybind("return", "Navigate to subagent"),
"composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
"composer.shell.up": keybind("up", "Previous shell"),
"composer.shell.down": keybind("down", "Next shell"),
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
"plugins.list": keybind("none", "Open plugin manager dialog"),
"plugins.install": keybind("none", "Install plugin"),
"which-key.toggle": keybind("ctrl+alt+k", "Toggle which-key panel"),
"which-key.layout.toggle": keybind("ctrl+alt+shift+k", "Switch which-key layout"),
"which-key.pending.toggle": keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
"which-key.group.previous": keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
"which-key.group.next": keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
"which-key.scroll.up": keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
"which-key.scroll.down": keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
"which-key.page.up": keybind("ctrl+alt+pageup", "Page which-key up"),
"which-key.page.down": keybind("ctrl+alt+pagedown", "Page which-key down"),
"which-key.home": keybind("ctrl+alt+home", "Jump to first which-key binding"),
"which-key.end": keybind("ctrl+alt+end", "Jump to last which-key binding"),
} satisfies Record<string, Definition>
type KeybindName = keyof typeof Definitions
const KeybindNames = new Set<string>(Object.keys(Definitions))
export const KeybindOverrides = Schema.Struct(
Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
Schema.optional(BindingValueSchema).annotate({ description: item.description }),
]),
),
).annotate({ description: "TUI keybinding overrides" })
export const Descriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
) as Record<KeybindName, string>
export type Keybinds = { [K in KeybindName]: BindingValueSchema }
export type KeybindOverrides = Partial<Keybinds>
export type BindingLookupView = {
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
get(command: string): readonly Binding<Renderable, KeyEvent>[]
has(command: string): boolean
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
pick(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
omit(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
}
export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {
return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
}
const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema)
export function defaultValue(name: KeybindName) {
return Definitions[name].default
}
export function parse(keybinds: KeybindOverrides): Keybinds {
const invalid = unknownKeys(keybinds)
if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`)
return Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
decodeBindingValue(keybinds[name as KeybindName] ?? item.default),
]),
) as Keybinds
}
export const Keybinds = { parse }
export function unknownKeys(input: object) {
return Object.keys(input).filter((key) => !KeybindNames.has(key))
}
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
return ({ command, binding }) => {
if (binding.desc !== undefined) return
return { desc: Descriptions[command as KeybindName] }
}
}
@@ -1,10 +1,21 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { usePlugin } from "../../plugin/context"
export function homeFooterVisibility(width: number) {
return {
mcpCommand: width >= 64,
pluginCommand: width >= 80,
version: width >= 64,
}
}
function Mcp(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const failed = createMemo(() => list().filter((item) => item.status.status === "failed").length)
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
return (
@@ -14,6 +25,7 @@ function Mcp(props: { context: Plugin.Context }) {
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} MCP failed
</Match>
<Match when={true}>
<span
@@ -24,11 +36,34 @@ function Mcp(props: { context: Plugin.Context }) {
>
{" "}
</span>
{count()} MCP
</Match>
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.text.subdued}>/status</text>
<Show when={visibility().mcpCommand}>
<text fg={props.context.theme.text.subdued}>/mcps</text>
</Show>
</box>
</Show>
)
}
function Plugins(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const plugins = usePlugin()
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
return (
<Show when={failed()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.text.default}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} plugin{failed() === 1 ? "" : "s"} failed
</text>
<Show when={visibility().pluginCommand}>
<text fg={props.context.theme.text.subdued}>/plugins</text>
</Show>
</box>
</Show>
)
@@ -36,6 +71,7 @@ function Mcp(props: { context: Plugin.Context }) {
function View(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
return (
<Show when={dimensions().height >= 12 && dimensions().width >= 44}>
@@ -50,10 +86,13 @@ function View(props: { context: Plugin.Context }) {
gap={2}
>
<Mcp context={props.context} />
<Plugins context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
<Show when={visibility().version}>
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
</Show>
</box>
</Show>
)
@@ -427,7 +427,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.down",
title: "Move diff viewer down",
group: "VCS",
bind: "j,down",
run: focusRunner({
files() {
moveFileSelection(1)
@@ -442,7 +441,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.up",
title: "Move diff viewer up",
group: "VCS",
bind: "k,up",
run: focusRunner({
files() {
moveFileSelection(-1)
@@ -457,7 +455,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.page.down",
title: "Page diff viewer down",
group: "VCS",
bind: "pagedown,ctrl+f",
run: focusRunner({
files() {
moveFileSelection(8)
@@ -472,7 +469,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.page.up",
title: "Page diff viewer up",
group: "VCS",
bind: "pageup,ctrl+b",
run: focusRunner({
files() {
moveFileSelection(-8)
@@ -578,7 +574,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.mark_reviewed",
title: "Toggle selected diff file reviewed",
group: "VCS",
bind: "m",
run() {
toggleSelectedFileReviewed()
},
@@ -1,22 +1,26 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { DialogErrorDetails } from "../../component/dialog-error-details"
const id = "opencode.plugins"
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
const [locked, setLocked] = createSignal(false)
const options = createMemo(() =>
props.plugins
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
const dialog = useDialog()
const options = createMemo(() => {
const builtins = props.plugins
.registered()
.filter((plugin) => plugin.id !== id)
.sort((a, b) => a.id.localeCompare(b.id))
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id,
value: plugin.id,
category: plugin.source === "builtin" ? "Built-in" : "External",
category: "Built-in",
footer: (
<span
style={{
@@ -29,8 +33,46 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
</span>
),
}),
),
)
)
const external = props.plugins
.list()
.filter((plugin) => plugin.status !== "unsupported")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id ?? plugin.target,
value: plugin.id ?? plugin.target,
category: "External",
searchText: plugin.target,
footer: (
<span
style={{
fg:
plugin.status === "active"
? props.context.theme.text.feedback.success.default
: plugin.status === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
}}
>
{plugin.status}
</span>
),
}),
)
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
})
const failure = (value: string | undefined) =>
props.plugins.list().find((plugin) => {
if (plugin.status !== "failed") return false
return (plugin.id ?? plugin.target) === value
})
createEffect(() => {
if (focused()) return
const first = options()[0]
if (first) setFocused(first.value)
})
const toggle = (plugin: DialogSelectOption<string>) => {
if (locked()) return
@@ -51,15 +93,56 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
.finally(() => setLocked(false))
}
const select = (plugin: DialogSelectOption<string>) => {
const failed = failure(plugin.value)
if (!failed || failed.status !== "failed") return toggle(plugin)
setDetail({ title: failed.target, error: failed.error })
}
return (
<DialogSelect
title="Plugins"
options={options()}
locked={locked()}
preserveSelection={true}
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
onSelect={toggle}
/>
<box>
<Show
when={detail()}
fallback={
<DialogSelect
title="Plugins"
options={options()}
current={focused()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
disabled: (option) => {
const failed = failure(option?.value)
return Boolean(failed && !("id" in failed && failed.id))
},
onTrigger: toggle,
},
]}
onSelect={select}
footer={
<Show when={failure(focused())}>
<text fg={props.context.theme.text.subdued}>enter to view error</text>
</Show>
}
/>
}
>
{(item) => (
<DialogErrorDetails
title={`Plugin: ${item().title}`}
error={item().error}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
)
}
@@ -72,6 +155,7 @@ function Commands(props: { context: Plugin.Context }) {
id: "plugins.list",
title: "Plugins",
group: "System",
slash: { name: "plugins" },
palette: true,
run() {
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
+2 -3
View File
@@ -191,7 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const subagentShortcut = () => shortcut("session.child.first")
const queuedShortcut = () => shortcut("session.queued_prompts")
const backgroundShortcut = () => shortcut("session.background")
const subagentInterruptShortcut = () => shortcut("subagent.interrupt")
const subagentInterruptShortcut = () => shortcut("composer.subagent.interrupt")
const interrupt = () => shortcut("session.interrupt")
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
const clearShortcut = () => shortcut("prompt.clear")
@@ -610,10 +610,9 @@ export function RunFooterView(props: RunFooterViewProps) {
priority: 1,
commands: [
{
id: "subagent.interrupt",
id: "composer.subagent.interrupt",
title: "Interrupt subagent",
group: "Session",
bind: "ctrl+d",
run: () => {
const current = selectedTab()
if (current?.status !== "running") {
+8 -3
View File
@@ -34,7 +34,7 @@ export interface PackageResolver {
type State =
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
| { readonly target: string; readonly id?: string; readonly status: "failed"; readonly error: string }
type RegisteredPlugin = {
readonly id: string
@@ -271,6 +271,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
if (!local && !previous) npmFailures.set(target, resolved.error)
failures.push({
target,
id: previous?.plugin.id,
status: "failed",
error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
})
@@ -376,7 +377,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
// A failed reload keeps this item running; the failure entry covers it.
if (failedTargets.has(item.target)) return []
const error = errors.get(item.plugin.id)
if (error) return [{ target: item.target, status: "failed", error }]
if (error) return [{ target: item.target, id: item.plugin.id, status: "failed", error }]
const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
return [{ target: item.target, id: item.plugin.id, status }]
}),
@@ -390,7 +391,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
)
)
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
host.toast.show({
variant: "error",
title: `Plugin failed: ${state.target}`,
message: "Run /plugins to view details.",
})
setStore("states", reconcileStore(states))
}
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
@@ -55,7 +55,6 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.up",
title: "Previous shell",
group: "Composer",
bind: "up",
run() {
if (store.selected === 0) {
composer.close()
@@ -68,7 +67,6 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.down",
title: "Next shell",
group: "Composer",
bind: "down",
run() {
const list = entries()
if (list.length === 0) return
@@ -79,7 +77,6 @@ export function ShellTab(props: { sessionID: string }) {
id: "composer.shell.kill",
title: "Kill shell command",
group: "Composer",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
if (!entry) return
@@ -169,7 +169,6 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.up",
title: "Previous subagent",
group: "Composer",
bind: "up",
run() {
if (store.selected === 0) {
composer.close()
@@ -182,7 +181,6 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.down",
title: "Next subagent",
group: "Composer",
bind: "down",
run() {
const list = entries()
if (list.length === 0) return
@@ -193,7 +191,6 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.select",
title: "Navigate to subagent",
group: "Composer",
bind: "return",
run() {
const entry = entries()[store.selected]
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
@@ -213,7 +210,6 @@ export function SubagentsTab(props: { sessionID: string }) {
id: "composer.subagent.interrupt",
title: "Interrupt subagent",
group: "Composer",
bind: "ctrl+d",
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return
+3 -2
View File
@@ -1814,7 +1814,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
streaming={true}
internalBlockMode="top-level"
content={content()}
tableOptions={{ style: "grid" }}
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
@@ -1983,6 +1983,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
border={["left"]}
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={theme.background.default}
>
<SessionImages images={images()} paddingLeft={2} />
<box
@@ -2264,7 +2265,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
tableOptions={{ style: "grid", cellPaddingX: 1 }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
-1
View File
@@ -40,7 +40,6 @@ export function DialogPrompt(props: DialogPromptProps) {
{
id: "dialog.prompt.submit",
title: "Submit dialog prompt",
bind: "return",
group: "Dialog",
run: confirm,
},
+26
View File
@@ -40,6 +40,7 @@ export interface DialogSelectProps<T> {
bindings?: readonly KeymapCommand[]
current?: T
focusCurrent?: boolean
sectionNavigation?: boolean
}
type DialogSelectActionBase<T> = {
@@ -327,6 +328,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true)
}
function moveSection(direction: 1 | -1) {
if (props.locked) return
const sections = grouped().filter(([_, options]) => options.length > 0)
if (sections.length === 0) return
const current = sections.findIndex(([category]) => category === selected()?.category)
const section = sections[(current + direction + sections.length) % sections.length]
moveTo(flat().indexOf(section[1][0]), true)
}
function moveTo(next: number, center = false, preserve = true) {
setFocusedAction(undefined)
setStore("selected", next)
@@ -488,6 +498,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
]
: []),
...(props.bindings ?? []),
...(props.sectionNavigation
? [
{
bind: "alt+up",
title: "Previous section",
group: "Dialog",
run: () => moveSection(-1),
},
{
bind: "alt+down",
title: "Next section",
group: "Dialog",
run: () => moveSection(1),
},
]
: []),
],
}
})
+6 -2
View File
@@ -1,14 +1,18 @@
import { Locale } from "./locale"
import { stringWidth } from "./string-width"
const GAP = " "
const GAP = " · "
export function marqueeCycleWidth(value: string) {
return stringWidth(value + GAP)
}
export function marqueeText(value: string, width: number, offset: number) {
if (width <= 0) return ""
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
const loop = value + GAP
const cursor = offset % stringWidth(loop)
const cursor = offset % marqueeCycleWidth(value)
const segments = Locale.graphemes(loop + loop)
const start = segments.reduce(
(state, segment, index) =>

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