From a8fc664b6dc8888d9fc5f535b58a363ed90c8a38 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 23:21:00 -0400 Subject: [PATCH 01/61] fix(tui): pad markdown table cells (#41937) --- packages/tui/src/routes/session/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 28affa1d74..2b0af18e7f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1814,7 +1814,7 @@ function CompactionMessage(props: { message: Extract Date: Tue, 11 Aug 2026 23:31:30 -0400 Subject: [PATCH 02/61] refactor(server): resume suspended sessions unconditionally in the fetch entry (#41938) --- packages/server/src/fetch.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/server/src/fetch.ts b/packages/server/src/fetch.ts index f410524606..0918d2d43f 100644 --- a/packages/server/src/fetch.ts +++ b/packages/server/src/fetch.ts @@ -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` — 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( From 90b6fa0eab6ef4bd031950d86c502ed1c144e9aa Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 23:32:49 -0400 Subject: [PATCH 03/61] fix(core): expose local attachment paths (#41789) --- .../core/src/session/runner/to-llm-message.ts | 19 ++- .../core/test/session-runner-message.test.ts | 129 ++++++++++++++++-- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index ed9f558aea..967f56bedc 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -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 [] } diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index ce49a72714..f624c402de 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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]" }, }), From e3bd82013cd4452629f147fcc95ad5db82d96985 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:17:48 -0500 Subject: [PATCH 04/61] fix(ai): preserve stream transport failures (#41779) Co-authored-by: Aiden Cline Co-authored-by: Aiden Cline --- packages/ai/src/route/executor.ts | 105 ++++++++++++----- packages/ai/src/route/transport/http.ts | 25 +---- packages/ai/src/route/transport/websocket.ts | 49 +++++--- packages/ai/src/schema/errors.ts | 10 +- packages/ai/test/executor.test.ts | 106 +++++++++++++++++- packages/ai/test/lib/http.ts | 26 ++++- packages/ai/test/provider/openai-chat.test.ts | 46 ++++++-- packages/core/src/aisdk.ts | 4 +- packages/core/test/aisdk.test.ts | 11 +- packages/core/test/session-error.test.ts | 6 +- packages/core/test/session-execution.test.ts | 2 +- packages/core/test/session-runner.test.ts | 6 +- 12 files changed, 308 insertions(+), 88 deletions(-) diff --git a/packages/ai/src/route/executor.ts b/packages/ai/src/route/executor.ts index 388fd4198e..6786b9f2a2 100644 --- a/packages/ai/src/route/executor.ts +++ b/packages/ai/src/route/executor.ts @@ -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,88 @@ export const classifyHttpFailure = (input: { }) } -const toHttpError = (redactedNames: ReadonlyArray) => (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 +}) => { + 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 => + 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 = Layer.effect( Service, Effect.gen(function* () { @@ -343,15 +387,16 @@ export const layer: Layer.Layer = 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({ diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index 12482f3918..06cb2fa48c 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -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 = TransportPrepareInput @@ -86,26 +87,8 @@ export const httpJson = (input: HttpJsonInput): 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 = { diff --git a/packages/ai/src/route/transport/websocket.ts b/packages/ai/src/route/transport/websocket.ts index 0198deb5c6..b1d8854de1 100644 --- a/packages/ai/src/route/transport/websocket.ts +++ b/packages/ai/src/route/transport/websocket.ts @@ -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()("@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 = (input: JsonInput): JsonTransp return Stream.fail( transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { url: prepared.url, - kind: "websocket", + operation: "request", + code: "unavailable", }), ) } diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index 0c201450fd..d03f2f3cf7 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -92,10 +92,18 @@ export class ProviderInternalReason extends Schema.Class 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("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), }) {} diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 1d3353e44b..7a483bbbd4 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -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,64 @@ 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 ", + 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 +137,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 ", + url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E", + http: { + request: { + url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E", + headers: { authorization: "" }, + }, + }, + }) + }).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 diff --git a/packages/ai/test/lib/http.ts b/packages/ai/test/lib/http.ts index e53794aa87..7c98d687ce 100644 --- a/packages/ai/test/lib/http.ts +++ b/packages/ai/test/lib/http.ts @@ -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 +export type Handler = ( + input: HandlerInput, +) => Effect.Effect const handlerLayer = (handler: Handler): Layer.Layer => Layer.succeed( @@ -34,6 +36,12 @@ const handlerLayer = (handler: Handler): Layer.Layer => 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): Layer.Layer => { 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) => +export const truncatedStream = (chunks: ReadonlyArray, 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 }) diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 910e57d26a..ea327aa123 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -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>([]) + 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", + }) }), ) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 1234e1053b..9652c16921 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -763,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, }) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 9decce67b8..a762075944 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -49,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"), + }), ), ), ) @@ -542,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") }), diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 625427d65c..d0e14b8fa7 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -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" })), diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index e71cd12ade..e6a6d498d5 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -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" }), }), ), ), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index fa068812bb..5bfbedf4d6 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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 = () => From 9d63ca8f90c876a8faff4cd0a9fff5c62fe3b456 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 12 Aug 2026 04:19:09 +0000 Subject: [PATCH 05/61] chore: generate --- packages/ai/src/route/executor.ts | 4 +--- packages/ai/test/executor.test.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/route/executor.ts b/packages/ai/src/route/executor.ts index 6786b9f2a2..b962895486 100644 --- a/packages/ai/src/route/executor.ts +++ b/packages/ai/src/route/executor.ts @@ -372,9 +372,7 @@ export const stream = ( 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 }), - ), + Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })), ) }), ) diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 7a483bbbd4..25750d174f 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -114,9 +114,7 @@ describe("RequestExecutor", () => { new Response( new ReadableStream({ pull(controller) { - controller.error( - new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }), - ) + controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") })) }, }), ), From bf751a907d84e958b5afcb935ac4970ae92a6c2d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:58:20 -0500 Subject: [PATCH 06/61] fix(core): jitter session retry delays (#41942) Co-authored-by: Aiden Cline --- packages/core/src/session/runner/retry.ts | 4 ++- packages/core/test/session-runner.test.ts | 37 ++++++++++++----------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts index 35f0b06ead..efc2004433 100644 --- a/packages/core/src/session/runner/retry.ts +++ b/packages/core/src/session/runner/retry.ts @@ -48,10 +48,12 @@ export const schedule = ( assistantMessageID: () => SessionMessage.ID, ) => Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe( + Schedule.jittered, Schedule.setInputType(), 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, { diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 5bfbedf4d6..40efdbb4bb 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3951,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") @@ -3960,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) @@ -3987,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) @@ -4032,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) @@ -4089,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({ @@ -4130,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"]) @@ -4169,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"]) @@ -4207,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) } @@ -4228,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) } @@ -4243,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([ @@ -4278,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) From 01cbdcb8e4de17052260b464ec719f70487f89e0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:18:50 -0500 Subject: [PATCH 07/61] fix(ai): batch Anthropic parallel tool results (#41948) Co-authored-by: Aiden Cline --- .../ai/src/protocols/anthropic-messages.ts | 5 +- .../test/provider/anthropic-messages.test.ts | 89 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 7539e54d09..78c76be5e4 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -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 diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index ab1047f2c9..d8792c3d9d 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -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( From c8dffb68932c2cdb83f6e6d82ae6e0c46428200d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:21:27 +1000 Subject: [PATCH 08/61] test(app): deflake cached tab paint probe (#41965) --- .../app/e2e/smoke/session-timeline.spec.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index d5330ac7d9..225a722893 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -189,7 +189,12 @@ test.describe("smoke: session timeline", () => { .querySelector('[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("[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 } From 5fd28cffc574b9937421f477f3409bc02309b1f9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:53:24 +0000 Subject: [PATCH 09/61] 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. --- packages/cli/src/config/config.ts | 84 +++-- packages/cli/src/config/migrate.ts | 138 +++++++- packages/cli/test/config.test.ts | 292 ++++++++++++++- .../cli/test/fixture/config-concurrency.ts | 42 +++ packages/cli/test/mini-config.test.ts | 69 ++++ packages/tui/src/app.tsx | 2 + packages/tui/src/config/index.tsx | 9 +- packages/tui/src/config/keybind.ts | 334 +++++++++++++++++- .../feature-plugins/system/diff-viewer.tsx | 5 - packages/tui/src/mini/footer.view.tsx | 5 +- .../src/routes/session/composer/shell-tab.tsx | 3 - .../routes/session/composer/subagents-tab.tsx | 4 - packages/tui/src/ui/dialog-prompt.tsx | 1 - packages/tui/test/app-lifecycle.test.tsx | 60 ++++ .../tui/test/cli/tui/composer-keymap.test.tsx | 190 ++++++++++ .../tui/test/cli/tui/dialog-prompt.test.tsx | 32 +- .../tui/test/cli/tui/diff-viewer.test.tsx | 259 ++++++++------ packages/tui/test/config-v2.test.tsx | 111 +++++- packages/tui/test/keybind.test.ts | 4 +- packages/tui/test/keymap.test.tsx | 4 +- packages/tui/test/mini/footer-keymap.test.tsx | 51 ++- packages/tui/test/mini/footer.view.test.tsx | 8 +- packages/tui/test/mini/runtime.boot.test.ts | 67 ---- packages/tui/test/mini/runtime.test.ts | 9 +- 24 files changed, 1510 insertions(+), 273 deletions(-) create mode 100644 packages/cli/test/fixture/config-concurrency.ts create mode 100644 packages/cli/test/mini-config.test.ts create mode 100644 packages/tui/test/cli/tui/composer-keymap.test.tsx diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index e655802b7b..6ee62f2d5a 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 = (effect: Effect.Effect) => + 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) => 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 }) diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index f9677225b1..44cb7b6e98 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -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(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): Info { const plugins = [ ...(legacy?.plugin?.map((plugin) => @@ -49,6 +147,16 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record { + 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 { 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) { + 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}`) +} diff --git a/packages/cli/test/fixture/config-concurrency.ts b/packages/cli/test/fixture/config-concurrency.ts new file mode 100644 index 0000000000..43e84d94ea --- /dev/null +++ b/packages/cli/test/fixture/config-concurrency.ts @@ -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 + }), + ) diff --git a/packages/cli/test/mini-config.test.ts b/packages/cli/test/mini-config.test.ts new file mode 100644 index 0000000000..a93990d033 --- /dev/null +++ b/packages/cli/test/mini-config.test.ts @@ -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) { + 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 }) + } +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c5a299b94b..88bb8ab40c 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 = { diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index b0bfbab5b0..52f52ffeb9 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -234,10 +234,10 @@ export type Resolved = Omit 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 }, diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 475f5120f8..bef9a0f053 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -1,2 +1,332 @@ -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 + +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,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("e", "Open external editor"), + "theme.switch": keybind("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("b", "Toggle sidebar"), + "session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"), + "opencode.status": keybind("s", "View status"), + "opencode.debug": keybind("none", "View debug info"), + + "session.export": keybind("x", "Export session to editor"), + "session.copy": keybind("none", "Copy session transcript"), + "session.move": keybind("none", "Move session"), + "session.new": keybind("n", "Create a new session"), + "session.list": keybind("l", "List all sessions"), + "session.tab.next": keybind("ctrl+tab,right", "Switch to next open session tab"), + "session.tab.previous": keybind("ctrl+shift+tab,left", "Switch to previous open session tab"), + "session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"), + "session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"), + "session.tab.next_unread": keybind("down", "Switch to next unread session tab"), + "session.tab.previous_unread": keybind("up", "Switch to previous unread session tab"), + "session.tab.close": keybind("w", "Close current session tab"), + "session.timeline": keybind("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("c", "Compact the session"), + "session.cd": keybind("none", "Change working directory"), + "session.queued_prompts": keybind("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("1", "Switch to session in quick slot 1"), + "session.quick_switch.2": keybind("2", "Switch to session in quick slot 2"), + "session.quick_switch.3": keybind("3", "Switch to session in quick slot 3"), + "session.quick_switch.4": keybind("4", "Switch to session in quick slot 4"), + "session.quick_switch.5": keybind("5", "Switch to session in quick slot 5"), + "session.quick_switch.6": keybind("6", "Switch to session in quick slot 6"), + "session.quick_switch.7": keybind("7", "Switch to session in quick slot 7"), + "session.quick_switch.8": keybind("8", "Switch to session in quick slot 8"), + "session.quick_switch.9": keybind("9", "Switch to session in quick slot 9"), + "session.tab.select.1": keybind("1,ctrl+1", "Switch to session tab 1"), + "session.tab.select.2": keybind("2,ctrl+2", "Switch to session tab 2"), + "session.tab.select.3": keybind("3,ctrl+3", "Switch to session tab 3"), + "session.tab.select.4": keybind("4,ctrl+4", "Switch to session tab 4"), + "session.tab.select.5": keybind("5,ctrl+5", "Switch to session tab 5"), + "session.tab.select.6": keybind("6,ctrl+6", "Switch to session tab 6"), + "session.tab.select.7": keybind("7,ctrl+7", "Switch to session tab 7"), + "session.tab.select.8": keybind("8,ctrl+8", "Switch to session tab 8"), + "session.tab.select.9": keybind("9,ctrl+9", "Switch to session tab 9"), + + "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("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("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("alt+down", "Navigate to next message"), + "session.message.previous": keybind("alt+up", "Navigate to previous message"), + "session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"), + "session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"), + "session.messages_last_user": keybind("alt+end", "Navigate to last user message"), + "messages.copy": keybind("y", "Copy message"), + "session.undo": keybind("u", "Undo message"), + "session.redo": keybind("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.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("home", "Move to start of buffer in input"), + "input.buffer.end": keybind("end", "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 + +type KeybindName = keyof typeof Definitions +const KeybindNames = new Set(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 + +export type Keybinds = { [K in KeybindName]: BindingValueSchema } +export type KeybindOverrides = Partial +export type BindingLookupView = { + readonly bindings: readonly Binding[] + get(command: string): readonly Binding[] + has(command: string): boolean + gather(name: string, commands: readonly string[]): readonly Binding[] + pick(name: string, commands: readonly string[]): Binding[] + omit(name: string, commands: readonly string[]): Binding[] +} + +export function toBindingConfig(keybinds: Keybinds): BindingConfig { + return Object.fromEntries(Object.entries(keybinds)) as BindingConfig +} + +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 { + return ({ command, binding }) => { + if (binding.desc !== undefined) return + return { desc: Descriptions[command as KeybindName] } + } +} diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index f5b06c4f5e..f71d8604ea 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -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() }, diff --git a/packages/tui/src/mini/footer.view.tsx b/packages/tui/src/mini/footer.view.tsx index 2af744f47b..1770758cbf 100644 --- a/packages/tui/src/mini/footer.view.tsx +++ b/packages/tui/src/mini/footer.view.tsx @@ -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") { diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx index 6c0f02ce2a..9a5092399f 100644 --- a/packages/tui/src/routes/session/composer/shell-tab.tsx +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -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 diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index 70cbd39df0..964003e494 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -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 diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index e6a57e8cf2..1e1c8cb9cf 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -40,7 +40,6 @@ export function DialogPrompt(props: DialogPromptProps) { { id: "dialog.prompt.submit", title: "Submit dialog prompt", - bind: "return", group: "Dialog", run: confirm, }, diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index ef31b87954..1ac5cde0c2 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -294,3 +294,63 @@ test("session startup prompt is submitted exactly once", async () => { await server.stop() } }) + +test("configured app bindings execute settings and permission commands", async () => { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const ready = Promise.withResolvers() + const events = createEventStream() + const calls = createFetch(undefined, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ + animations: false, + keybinds: { "opencode.settings": "f6", "permission.mode": "f7" }, + }), + update: async () => ({}), + }, + packages: { resolve: async () => undefined }, + args: {}, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }), + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + await ready.promise + await setup.waitForFrame((frame) => frame.includes("commands")) + + setup.mockInput.pressKey("F6") + const settings = await setup.waitForFrame((frame) => frame.includes("Settings")) + expect(settings).toContain("Color mode") + expect(settings).toContain("Animations") + + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("Settings")) + setup.mockInput.pressKey("F7") + await setup.renderOnce() + setup.mockInput.pressKey("p", { ctrl: true }) + await setup.waitForFrame((frame) => frame.includes("Commands")) + setup.mockInput.pressKey("END") + const commands = await setup.waitForFrame( + (frame) => { + if (frame.includes("Disable auto-approve permissions")) return true + setup.mockInput.pressArrow("up") + return false + }, + { maxPasses: 100 }, + ) + expect(commands).not.toContain("Enable auto-approve permissions") + + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + } +}) diff --git a/packages/tui/test/cli/tui/composer-keymap.test.tsx b/packages/tui/test/cli/tui/composer-keymap.test.tsx new file mode 100644 index 0000000000..fbccd5b3f2 --- /dev/null +++ b/packages/tui/test/cli/tui/composer-keymap.test.tsx @@ -0,0 +1,190 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { onMount } from "solid-js" +import { ConfigProvider } from "../../../src/config" +import type { TuiKeybind } from "../../../src/config/keybind" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider, useData } from "../../../src/context/data" +import { Keymap } from "../../../src/context/keymap" +import { LocationProvider } from "../../../src/context/location" +import { RouteProvider, useRoute } from "../../../src/context/route" +import { ThemeProvider } from "../../../src/context/theme" +import { Composer } from "../../../src/routes/session/composer" +import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const sessions = { + parent: session("parent", "Parent"), + "child-a": session("child-a", "First", "parent"), + "child-b": session("child-b", "Second", "parent"), +} + +const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")] + +async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial) { + const events = createEventStream() + const interrupted: string[] = [] + const removed: string[] = [] + const ready = Promise.withResolvers() + let closed = 0 + let dispatch!: ReturnType["dispatch"] + let route!: ReturnType + const calls = createFetch((url, request) => { + if (url.pathname === "/api/session/active") + return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } }) + const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1] + if (sessionID && sessionID in sessions) return json({ data: sessions[sessionID as keyof typeof sessions] }) + const interruptID = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/)?.[1] + if (interruptID && request.method === "POST") { + interrupted.push(interruptID) + return new Response(null, { status: 204 }) + } + if (url.pathname === "/api/shell" && request.method === "GET") { + const requestDirectory = url.searchParams.get("location[directory]") ?? directory + return json({ + location: { directory: requestDirectory, project: { id: "proj_test", directory: requestDirectory } }, + data: shells, + }) + } + const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1] + if (shellID && request.method === "DELETE") { + removed.push(shellID) + return new Response(null, { status: 204 }) + } + }, events) + + function Content() { + const data = useData() + route = useRoute() + dispatch = Keymap.use().dispatch + onMount(() => { + void Promise.all([ + data.session.sync("parent"), + data.session.sync("child-a"), + data.session.sync("child-b"), + data.shell.sync(), + ]) + .then(() => wait(() => data.session.status("child-a") === "running")) + .then(() => ready.resolve(), ready.reject) + }) + return closed++} /> + } + + const app = await testRender( + () => ( + + + + + + + + ({}) }}> + + + + + + + + + + ), + { width: 100, height: 20, kittyKeyboard: true }, + ) + await ready.promise + await app.renderOnce() + return { + app, + interrupted, + removed, + route: () => route.data, + dispatch: (command: string) => dispatch(command), + closed: () => closed, + } +} + +test("disabled subagent bindings have no component fallbacks", async () => { + const composer = await renderComposer("subagents", { + "composer.subagent.up": "none", + "composer.subagent.down": "none", + "composer.subagent.select": "none", + "composer.subagent.interrupt": "none", + }) + try { + expect(composer.app.captureCharFrame()).toContain("First") + composer.app.mockInput.pressArrow("up") + composer.app.mockInput.pressEnter() + composer.app.mockInput.pressKey("d", { ctrl: true }) + await composer.app.renderOnce() + expect(composer.closed()).toBe(0) + expect(composer.route()).toMatchObject({ type: "session", sessionID: "parent" }) + expect(composer.interrupted).toEqual([]) + + composer.app.mockInput.pressArrow("down") + composer.dispatch("composer.subagent.select") + expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-a" }) + } finally { + composer.app.renderer.destroy() + } +}) + +test("disabled shell bindings have no component fallbacks", async () => { + const composer = await renderComposer("shell", { + "composer.shell.up": "none", + "composer.shell.down": "none", + "composer.shell.kill": "none", + }) + try { + expect(composer.app.captureCharFrame()).toContain("bun test") + composer.app.mockInput.pressArrow("up") + composer.app.mockInput.pressKey("d", { ctrl: true }) + await composer.app.renderOnce() + expect(composer.closed()).toBe(0) + expect(composer.removed).toEqual([]) + + composer.app.mockInput.pressArrow("down") + composer.dispatch("composer.shell.kill") + await wait(() => composer.removed.length === 1) + expect(composer.removed).toEqual(["sh-a"]) + } finally { + composer.app.renderer.destroy() + } +}) + +function session(id: string, title: string, parentID?: string) { + return { + id, + projectID: "proj_test", + title, + agent: "build", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + ...(parentID ? { parentID } : {}), + } +} + +function shell(id: string, command: string) { + return { + id, + status: "running" as const, + command, + cwd: directory, + shell: "/bin/sh", + file: `/tmp/${id}`, + metadata: { sessionID: "parent" }, + time: { started: 1 }, + } +} + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} diff --git a/packages/tui/test/cli/tui/dialog-prompt.test.tsx b/packages/tui/test/cli/tui/dialog-prompt.test.tsx index b2257860c9..135991d737 100644 --- a/packages/tui/test/cli/tui/dialog-prompt.test.tsx +++ b/packages/tui/test/cli/tui/dialog-prompt.test.tsx @@ -87,8 +87,8 @@ test("dialog prompt submit wins when return is also input newline", async () => const prompt = await mountPrompt({ root: tmp.path, keybinds: { - input_submit: "super+return", - input_newline: "return,shift+return,alt+return,ctrl+j", + "input.submit": "super+return", + "input.newline": "return,shift+return,alt+return,ctrl+j", }, onConfirm: (value) => confirmed.push(value), }) @@ -113,7 +113,7 @@ test("dialog prompt submit can be rebound separately from input submit", async ( const prompt = await mountPrompt({ root: tmp.path, keybinds: { - input_submit: "return", + "input.submit": "return", "dialog.prompt.submit": "ctrl+y", }, onConfirm: (value) => confirmed.push(value), @@ -135,3 +135,29 @@ test("dialog prompt submit can be rebound separately from input submit", async ( await prompt.cleanup() } }) + +test("dialog prompt submit can be disabled", async () => { + await using tmp = await tmpdir() + const confirmed: string[] = [] + const prompt = await mountPrompt({ + root: tmp.path, + keybinds: { + "input.submit": "return", + "dialog.prompt.submit": "none", + }, + onConfirm: (value) => confirmed.push(value), + }) + + try { + await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable) + const textarea = prompt.app.renderer.currentFocusedEditor + if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea") + + prompt.app.mockInput.pressEnter() + + expect(confirmed).toEqual([]) + expect(textarea.plainText).toBe("draft") + } finally { + await prompt.cleanup() + } +}) diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index e9b393683e..8588a77da3 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -14,7 +14,7 @@ import type { import { ThemeProvider, useThemes } from "../../../src/context/theme" import { emptyThemeSource } from "../../fixture/fixture" import { ConfigProvider } from "../../../src/config" -import { TuiKeybind } from "../../../src/config/keybind" +import type { TuiKeybind } from "../../../src/config/keybind" import { Keymap } from "../../../src/context/keymap" import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" @@ -22,6 +22,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment" import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" import { DialogProvider } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" +import { createSignal } from "solid-js" test("closing the diff viewer returns to the route it opened from", async () => { const viewer = await renderDiffViewer([]) @@ -49,7 +50,7 @@ test("closing the diff viewer returns to the route it opened from", async () => }) test("shows an error instead of an empty diff when loading fails", async () => { - const viewer = await renderDiffViewer([], 20, undefined, true) + const viewer = await renderDiffViewer([], { fail: true }) try { await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff")) expect(viewer.app.captureCharFrame()).not.toContain("No changes to show") @@ -59,7 +60,7 @@ test("shows an error instead of an empty diff when loading fails", async () => { }) test("uses the active location when opened outside a session", async () => { - const viewer = await renderDiffViewer([], 20, { type: "home" }) + const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } }) try { expect(viewer.vcsDiffInput()).toEqual({ location: { directory: "/repo/default" }, @@ -72,66 +73,35 @@ test("uses the active location when opened outside a session", async () => { }) test("brackets navigate diff hunks", async () => { - const viewer = await renderDiffViewer( - [ - { - file: "src/file.ts", - additions: 3, - deletions: 3, - status: "modified", - patch: `--- a/src/file.ts -+++ b/src/file.ts -@@ -1,3 +1,3 @@ - const first = true --const oldFirst = true -+const newFirst = true - const afterFirst = true -@@ -20,3 +20,3 @@ - const second = true --const oldSecond = true -+const newSecond = true - const afterSecond = true -@@ -40,3 +40,3 @@ - const third = true --const oldThird = true -+const newThird = true - const afterThird = true`, - }, - ], - 12, - ) + const viewer = await renderDiffViewer(hunkDiff, { height: 12 }) try { await viewer.app.waitForFrame((frame) => frame.includes("const first")) await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root))) await viewer.app.flush() - expect(viewer.app.captureCharFrame()).toContain("@@ -20,3 +20,3 @@") expect(countDiffs(viewer.app.renderer.root)).toBe(3) const scroll = findScrollBox(viewer.app.renderer.root)! const initial = scroll.scrollTop - expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]") - expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[") - - viewer.commands.get("diff.next_hunk")!.run() + viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() const first = scroll.scrollTop expect(first).toBeGreaterThan(initial) - viewer.commands.get("diff.next_hunk")!.run() + viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() const second = scroll.scrollTop expect(second).toBeGreaterThan(first) - viewer.commands.get("diff.previous_hunk")!.run() + viewer.app.mockInput.pressKey("[") await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(first) - viewer.commands.get("diff.next_hunk")!.run() + viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(second) scroll.scrollTo(initial) - viewer.commands.get("diff.next_hunk")!.run() + viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() expect(scroll.scrollTop).toBe(first) } finally { @@ -139,13 +109,49 @@ test("brackets navigate diff hunks", async () => { } }) -async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) { +test("disabled diff keybinds have no component fallbacks", async () => { + const viewer = await renderDiffViewer(hunkDiff, { + height: 12, + keybinds: disabledDiffKeybinds, + }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root))) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const initial = scroll.scrollTop + + Object.keys(disabledDiffKeybinds).forEach((command) => expect(viewer.shortcut(command)).toBe("")) + + viewer.app.mockInput.pressKey("j") + await viewer.app.renderOnce() + + expect(scroll.scrollTop).toBe(initial) + } finally { + viewer.app.renderer.destroy() + } +}) + +async function renderDiffViewer( + vcsDiff: unknown[], + options: { + height?: number + initialRoute?: Route + fail?: boolean + keybinds?: TuiKeybind.KeybindOverrides + } = {}, +) { const commands = new Map() - let current = initialRoute ?? startRoute + const [current, setCurrent] = createSignal(options.initialRoute ?? startRoute) + const currentData = () => { + const route = current() + return route.type === "plugin" ? route.data : undefined + } let renderDiff: Page["render"] | undefined let renderCommands: SlotClaim<"app">["render"] | undefined let vcsDiffInput: unknown - const config = createTuiResolvedConfig() + let shortcut: (command: string) => string | undefined = () => undefined + const config = createTuiResolvedConfig({ keybinds: options.keybinds }) const transport = createFetch((url) => { if (url.pathname !== "/api/vcs/diff") return vcsDiffInput = { @@ -153,7 +159,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: mode: url.searchParams.get("mode"), context: url.searchParams.get("context"), } - if (fail) return json({ message: "boom" }, { status: 500 }) + if (options.fail) return json({ message: "boom" }, { status: 500 }) return json({ location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } }, data: vcsDiff, @@ -161,61 +167,65 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: }, createEventStream()) function Harness() { let theme: ReturnType["currentTokens"]> - const context = { - options: {}, - client: createApi(transport.fetch), - data: { - session: { get: () => session }, - location: { default: () => ({ directory: "/repo/default" }) }, - }, - get theme() { - return theme - }, - keymap: { - layer(input: () => KeymapLayer) { - input().commands?.forEach((command) => { - if (command.id) commands.set(command.id, command) - }) + function Content() { + const keymap = Keymap.use() + const shortcuts = Keymap.useShortcuts() + shortcut = shortcuts.get + theme = useThemes().currentTokens() + const context = { + options: {}, + client: createApi(transport.fetch), + data: { + session: { get: () => session }, + location: { default: () => ({ directory: "/repo/default" }) }, }, - dispatch() {}, - shortcuts: () => [], - mode: { current: () => "base", push: () => () => {} }, - }, - ui: { - dialog: { - show: () => () => {}, - set() {}, - clear() {}, + get theme() { + return theme }, - router: { - register(page: Page) { - if (page.name === "diff") renderDiff = page.render + keymap: { + layer(input: () => KeymapLayer) { + input().commands?.forEach((command) => { + if (command.id) commands.set(command.id, command) + }) + Keymap.createLayer(input) + }, + dispatch: keymap.dispatch, + shortcuts: shortcuts.list, + mode: keymap.mode, + }, + ui: { + dialog: { + show: () => () => {}, + set() {}, + clear() {}, + }, + router: { + register(page: Page) { + if (page.name === "diff") renderDiff = page.render + return () => {} + }, + navigate(destination: Destination) { + setCurrent( + destination.type === "plugin" && !("id" in destination) + ? { ...destination, id: "diff-viewer" } + : destination, + ) + }, + current, + }, + slot(claim: SlotClaim<"app">) { + renderCommands = claim.render return () => {} }, - navigate(destination: Destination) { - current = - destination.type === "plugin" && !("id" in destination) - ? { ...destination, id: "diff-viewer" } - : destination - }, - current: () => current, }, - slot(claim: SlotClaim<"app">) { - renderCommands = claim.render - return () => {} - }, - }, - } as unknown as Context + } as unknown as Context - void diffViewerPlugin.setup(context) - function Content() { - theme = useThemes().currentTokens() + void diffViewerPlugin.setup(context) const commandView = renderCommands?.({}) - if (current.type !== "plugin") commands.get("diff.open")?.run() return ( <> {commandView} - {renderDiff?.({ data: current.type === "plugin" ? current.data : undefined })} + {renderDiff?.({ data: currentData() })} ) } @@ -237,19 +247,60 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: ) } - const app = await testRender(() => , { width: 80, height }) - await waitForCommand(app, commands, "diff.close") + const app = await testRender(() => , { width: 80, height: options.height ?? 20 }) + for (let attempt = 0; attempt < 100; attempt++) { + await app.renderOnce() + if (current().type !== "plugin") commands.get("diff.open")?.run() + if (commands.has("diff.close")) break + await Bun.sleep(25) + } + await app.waitFor(() => commands.has("diff.close"), { maxPasses: 1 }) await app.waitFor(() => vcsDiffInput !== undefined) return { app, commands, - current: () => current, + current, + shortcut: (command: string) => shortcut(command), vcsDiffInput: () => vcsDiffInput, } } const startRoute: Route = { type: "session", sessionID: "session-1" } +const disabledDiffKeybinds = { + "diff.down": "none", + "diff.up": "none", + "diff.page.down": "none", + "diff.page.up": "none", + "diff.mark_reviewed": "none", +} satisfies TuiKeybind.KeybindOverrides + +const hunkDiff = [ + { + file: "src/file.txt", + additions: 3, + deletions: 3, + status: "modified", + patch: `--- a/src/file.txt ++++ b/src/file.txt +@@ -1,3 +1,3 @@ + const first = true +-const oldFirst = true ++const newFirst = true + const afterFirst = true +@@ -20,3 +20,3 @@ + const second = true +-const oldSecond = true ++const newSecond = true + const afterSecond = true +@@ -40,3 +40,3 @@ + const third = true +-const oldThird = true ++const newThird = true + const afterThird = true`, + }, +] + function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined { if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root return root.getChildren().map(findScrollBox).find(Boolean) @@ -280,11 +331,13 @@ const session = { } test("branch diff source requests branch VCS diff", async () => { - const viewer = await renderDiffViewer([], 20, { - type: "plugin", - id: "diff-viewer", - name: "diff", - data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, + const viewer = await renderDiffViewer([], { + initialRoute: { + type: "plugin", + id: "diff-viewer", + name: "diff", + data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, + }, }) try { expect(viewer.current()).toEqual({ @@ -302,15 +355,3 @@ test("branch diff source requests branch VCS diff", async () => { viewer.app.renderer.destroy() } }) - -async function waitForCommand( - app: Awaited>, - commands: Map, - command: string, -) { - for (let attempt = 0; attempt < 10; attempt++) { - await app.renderOnce() - if (commands.has(command)) return - await new Promise((resolve) => setTimeout(resolve, 25)) - } -} diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index c774fb0484..55965adcd9 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -4,15 +4,17 @@ import { expect, test } from "bun:test" import { Schema } from "effect" import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config" import { settings } from "../src/component/dialog-config" +import { TuiKeybind } from "../src/config/keybind" +import { CommandMap, Definitions } from "../src/config/v1/keybind" + +const decodeInfo = Schema.decodeUnknownSync(Info) test("validates mini replay settings", () => { - const decode = Schema.decodeUnknownSync(Info) - - expect(decode({ mini: { replay: false, replay_limit: 50 } })).toEqual({ + expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({ mini: { replay: false, replay_limit: 50 }, }) - expect(() => decode({ mini: { replay_limit: 0 } })).toThrow() - expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow() + expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow() + expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow() }) test("validates the session tabs setting", () => { @@ -60,6 +62,105 @@ test("shows the new session location default in settings", () => { expect(settings.find((setting) => setting.path.join(".") === "session.new_location")?.default).toBe("launch") }) +test("uses command IDs as keybind keys", () => { + const config = resolve({ keybinds: { "session.list": "ctrl+l" } }, { terminalSuspend: true }) + + expect(config.keybinds.get("session.list")).toMatchObject([{ key: "ctrl+l" }]) + expect(TuiKeybind.unknownKeys({ session_list: "ctrl+l" })).toEqual(["session_list"]) + expect( + Object.keys(TuiKeybind.Definitions) + .filter((key) => key !== "leader") + .every((key) => key.includes(".")), + ).toBe(true) +}) + +test("preserves migrated v1 keybind defaults", () => { + const pairs = [ + ["app.exit", "app_exit"], + ["prompt.paste", "input_paste"], + ["session.delete", "session_delete"], + ["session.list", "session_list"], + ["agent.list", "agent_list"], + ] as const + + pairs.forEach(([command, name]) => { + expect(CommandMap[name]).toBe(command) + expect(TuiKeybind.Definitions[command].default).toEqual(Definitions[name].default) + }) +}) + +test("accepts every v2-only named command ID", () => { + const commands = [ + "server.pair", + "session.toggle.exploration_grouping", + "composer.subagent.up", + "composer.subagent.down", + "composer.subagent.select", + "composer.subagent.interrupt", + "composer.shell.up", + "composer.shell.down", + "composer.shell.kill", + "diff.down", + "diff.up", + "diff.page.down", + "diff.page.up", + "diff.mark_reviewed", + "opencode.settings", + "service.restart", + "permission.mode", + "session.cd", + "app.scrap", + ] + const config = resolve( + decodeInfo({ keybinds: Object.fromEntries(commands.map((command) => [command, "ctrl+alt+z"])) }), + { terminalSuspend: true }, + ) + + commands.forEach((command) => expect(config.keybinds.get(command)).toMatchObject([{ key: "ctrl+alt+z" }])) +}) + +test("centralizes named command defaults and resolves explicit none", () => { + const defaults = { + "composer.subagent.up": "up", + "composer.subagent.down": "down", + "composer.subagent.select": "return", + "composer.subagent.interrupt": "ctrl+d", + "composer.shell.up": "up", + "composer.shell.down": "down", + "composer.shell.kill": "ctrl+d", + "diff.down": "j,down", + "diff.up": "k,up", + "diff.page.down": "pagedown,ctrl+f", + "diff.page.up": "pageup,ctrl+b", + "diff.mark_reviewed": "m", + } + const config = resolve({}, { terminalSuspend: true }) + Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }])) + + const disabled = resolve( + decodeInfo({ keybinds: Object.fromEntries(Object.keys(defaults).map((command) => [command, "none"])) }), + { terminalSuspend: true }, + ) + Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([])) +}) + +test("rejects orphaned keybind definitions", () => { + expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} }) +}) + +test("uses ctrl+z for input undo when terminal suspend is unavailable", () => { + const config = resolve({}, { terminalSuspend: false }) + expect(config.keybinds.has("terminal.suspend")).toBe(false) + expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+z,ctrl+-,super+z" }]) + + const overridden = resolve( + { keybinds: { "terminal.suspend": "ctrl+s", "input.undo": "ctrl+u" } }, + { terminalSuspend: false }, + ) + expect(overridden.keybinds.has("terminal.suspend")).toBe(false) + expect(overridden.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }]) +}) + test("provides config and its host interface", async () => { const config = resolve({}, { terminalSuspend: true }) let current = {} diff --git a/packages/tui/test/keybind.test.ts b/packages/tui/test/keybind.test.ts index 1af999d9d3..64b007cb38 100644 --- a/packages/tui/test/keybind.test.ts +++ b/packages/tui/test/keybind.test.ts @@ -2,6 +2,6 @@ import { expect, test } from "bun:test" import { TuiKeybind } from "../src/config/keybind" test("binds agent cycling only to shift+tab by default", () => { - expect(TuiKeybind.Definitions.agent_cycle.default).toBe("shift+tab") - expect(TuiKeybind.Definitions.agent_cycle_reverse.default).toBe("none") + expect(TuiKeybind.Definitions["agent.cycle"].default).toBe("shift+tab") + expect(TuiKeybind.Definitions["agent.cycle.reverse"].default).toBe("none") }) diff --git a/packages/tui/test/keymap.test.tsx b/packages/tui/test/keymap.test.tsx index 11141ddf10..f07a4beebb 100644 --- a/packages/tui/test/keymap.test.tsx +++ b/packages/tui/test/keymap.test.tsx @@ -27,8 +27,8 @@ test("legacy page key aliases compile as page keys", async () => { diff --git a/packages/tui/test/mini/footer-keymap.test.tsx b/packages/tui/test/mini/footer-keymap.test.tsx index 40d7e09813..b5ff4ef13a 100644 --- a/packages/tui/test/mini/footer-keymap.test.tsx +++ b/packages/tui/test/mini/footer-keymap.test.tsx @@ -8,7 +8,7 @@ import { RunFooterView } from "../../src/mini/footer.view" import { RUN_THEME_FALLBACK } from "../../src/mini/theme" import type { FooterState, FooterSubagentState, FooterView } from "../../src/mini/types" -test("down opens subagents from an empty prompt", async () => { +async function renderSubagent(interrupt: "ctrl+i" | "none") { const [state] = createSignal({ phase: "idle", status: "", @@ -34,9 +34,17 @@ test("down opens subagents from an empty prompt", async () => { forms: [], }) const config = resolve( - { keybinds: { editor_open: "none", session_queued_prompts: "none" } }, + { + keybinds: { + "prompt.editor": "none", + "session.queued_prompts": "none", + "composer.subagent.interrupt": interrupt, + }, + }, { terminalSuspend: true }, ) + const interrupted: string[] = [] + function Harness() { return ( @@ -82,18 +90,47 @@ test("down opens subagents from an empty prompt", async () => { onLayout={() => {}} onStatus={() => {}} onMiniSettingChange={() => {}} + onSubagentInterrupt={(sessionID) => interrupted.push(sessionID)} /> ) } const app = await testRender(() => , { width: 100, height: 8, kittyKeyboard: true }) + return { app, interrupted } +} + +async function openSubagent(app: Awaited>) { + await app.renderOnce() + expect(app.renderer.currentFocusedEditor?.plainText).toBe("") + app.mockInput.pressArrow("down") + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Select subagent") + app.mockInput.pressEnter() + await app.renderOnce() +} + +test("configured subagent key updates its hint and action", async () => { + const { app, interrupted } = await renderSubagent("ctrl+i") try { - await app.renderOnce() - expect(app.renderer.currentFocusedEditor?.plainText).toBe("") - app.mockInput.pressArrow("down") - await app.renderOnce() - expect(app.captureCharFrame()).toContain("Select subagent") + await openSubagent(app) + expect(app.captureCharFrame()).toContain("ctrl+i") + app.mockInput.pressKey("i", { ctrl: true }) + expect(interrupted).toEqual(["subagent-1"]) + } finally { + app.renderer.currentFocusedRenderable?.blur() + app.renderer.currentFocusedEditor?.blur() + app.renderer.destroy() + } +}) + +test("disabled subagent interrupt has no component fallback", async () => { + const { app, interrupted } = await renderSubagent("none") + try { + await openSubagent(app) + expect(app.captureCharFrame()).not.toContain("ctrl+d") + app.mockInput.pressKey("d", { ctrl: true }) + expect(interrupted).toEqual([]) } finally { app.renderer.currentFocusedRenderable?.blur() app.renderer.currentFocusedEditor?.blur() diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 509e71b131..2988f76847 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -1072,7 +1072,7 @@ test.skip("direct footer recreates the frame across command panel transitions", test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => { const calls: string[] = [] const app = await renderFooter({ - tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "t" } }), + tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", "variant.cycle": "t" } }), onCycle: () => calls.push("cycle"), }) @@ -1092,7 +1092,7 @@ test.skip("direct footer dispatches leader variant binding only when leader is r test("direct footer keeps leader variant binding inactive when leader is disabled", async () => { const calls: string[] = [] const app = await renderFooter({ - tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "t" } }), + tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", "variant.cycle": "t" } }), onCycle: () => calls.push("cycle"), }) @@ -1603,7 +1603,7 @@ test("direct footer keeps the command hint at its minimum width", async () => { test("direct footer keeps complete status text ahead of the spinner", async () => { const app = await renderFooter({ - tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none" } }), + tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none" } }), state: { phase: "running" }, width: 22, }) @@ -1663,7 +1663,7 @@ test("direct footer hides the subagent hint when only completed subagents remain test("direct footer omits interrupt key hint when interrupt is unbound", async () => { const app = await renderFooter({ - tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }), + tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none", "prompt.clear": "ctrl+l" } }), state: { phase: "running" }, mono: true, }) diff --git a/packages/tui/test/mini/runtime.boot.test.ts b/packages/tui/test/mini/runtime.boot.test.ts index 067a4d7011..5a08ce02bf 100644 --- a/packages/tui/test/mini/runtime.boot.test.ts +++ b/packages/tui/test/mini/runtime.boot.test.ts @@ -1,75 +1,14 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpenCode } from "@opencode-ai/client/promise" -import type { Resolved } from "../../src/config" import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot" import { catalogModel, catalogProvider } from "./fixture/catalog" import { createTuiResolvedConfig } from "../fixture/tui-runtime" -function config(input?: { - leader?: string - leaderTimeout?: number - bindings?: Partial<{ - commandList: string[] - variantCycle: string[] - interrupt: string[] - historyPrevious: string[] - historyNext: string[] - inputClear: string[] - inputSubmit: string[] - inputNewline: string[] - }> -}): Resolved { - const bind = input?.bindings - return createTuiResolvedConfig({ - leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout }, - keybinds: { - ...(input?.leader && { leader: input.leader }), - ...(bind?.commandList && { command_list: bind.commandList }), - ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }), - ...(bind?.interrupt && { session_interrupt: bind.interrupt }), - ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }), - ...(bind?.historyNext && { history_next: bind.historyNext }), - ...(bind?.inputClear && { input_clear: bind.inputClear }), - ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }), - ...(bind?.inputNewline && { input_newline: bind.inputNewline }), - }, - }) -} - describe("run runtime boot", () => { afterEach(() => { mock.restore() }) - test("reads footer keybinds from resolved keybind config", async () => { - const input = config({ - leader: "ctrl+g", - bindings: { - commandList: ["ctrl+p"], - variantCycle: ["ctrl+t", "alt+t"], - interrupt: ["ctrl+c"], - historyPrevious: ["k"], - historyNext: ["j"], - inputClear: ["ctrl+l"], - inputSubmit: ["ctrl+s"], - inputNewline: ["alt+return"], - }, - }) - - const result = await resolveRunTuiConfig(input) - - expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g") - expect(result.leader.timeout).toBe(2000) - expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p") - expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"]) - expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c") - expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k") - expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j") - expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l") - expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s") - expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return") - }) - test("falls back to default tui keymap config when config load fails", async () => { const result = await resolveRunTuiConfig(Promise.reject(new Error("boom"))) @@ -86,12 +25,6 @@ describe("run runtime boot", () => { expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return") }) - test("preserves disabled leader from resolved tui config", async () => { - const result = await resolveRunTuiConfig(config({ leader: "none" })) - - expect(result.keybinds.get("leader")).toEqual([]) - }) - test("preserves shared config while resolving independent Mini defaults", async () => { const result = await resolveRunTuiConfig( createTuiResolvedConfig({ diff --git a/packages/tui/test/mini/runtime.test.ts b/packages/tui/test/mini/runtime.test.ts index ff661d6667..b125eddcb8 100644 --- a/packages/tui/test/mini/runtime.test.ts +++ b/packages/tui/test/mini/runtime.test.ts @@ -5,6 +5,7 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle" import type { FooterEvent, MiniHost } from "../../src/mini/types" import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog" import { createFooterApiFixture } from "./fixture/footer-api" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" function defer() { let resolve!: (value: T | PromiseLike) => void @@ -488,7 +489,7 @@ describe("run interactive runtime", () => { expect(closedTitle).toBe("Cached title") }) - test("adopts the deferred target location for catalogs, files, and runtime placement", async () => { + test("adopts deferred target placement and supplied TUI config", async () => { const sdk = OpenCode.make({ baseUrl: "https://opencode.test" }) const lifecycleStarted = defer() const painted = defer() @@ -498,6 +499,8 @@ describe("run interactive runtime", () => { let getDirectory: (() => string) | undefined let findFiles: ((query: string) => Promise) | undefined let transportLocation: unknown + let runtimeConfig: LifecycleInput["tuiConfig"] | undefined + const tuiConfig = createTuiResolvedConfig({ keybinds: { "variant.cycle": "ctrl+g" } }) const catalogs = stubCatalogLists(sdk, { location: { directory: "/session", workspaceID: "work-1" }, }) @@ -534,11 +537,13 @@ describe("run interactive runtime", () => { model: undefined, variant: undefined, files: [], + tuiConfig, }, { createRuntimeLifecycle: async (input) => { getDirectory = input.getDirectory findFiles = input.findFiles + runtimeConfig = input.tuiConfig lifecycleStarted.resolve() return { footer: api, @@ -577,6 +582,8 @@ describe("run interactive runtime", () => { const query = { location: { directory: "/session", workspace: "work-1" } } expect(getDirectory?.()).toBe("/session") + if (!runtimeConfig) throw new Error("runtime lifecycle did not receive TUI config") + expect(await runtimeConfig).toBe(tuiConfig) expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" }) expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) }) expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) }) From 9769e7012c9b72b6171d95fce338c2e94d467987 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Aug 2026 09:30:15 -0400 Subject: [PATCH 10/61] feat(core): configure package publishing --- bun.lock | 7 +- packages/core/README.md | 3 + packages/core/package.json | 34 +- packages/core/script/build.ts | 42 + packages/core/script/publish.ts | 51 + packages/core/src/database/sqlite.bun.ts | 10 +- packages/core/src/database/sqlite.node.ts | 10 +- packages/core/src/database/sqlite.ts | 3 - packages/core/src/database/sqlite.workerd.ts | 11 +- .../core/src/database/v1-migration.bun.ts | 997 +++++++++++++++++ .../core/src/database/v1-migration.noop.ts | 11 + packages/core/src/database/v1-migration.ts | 999 +----------------- packages/core/test/file-mutation.test.ts | 2 +- packages/core/test/fixture/environment.ts | 2 +- .../core/test/instruction-discovery.test.ts | 2 +- packages/core/test/instruction-state.test.ts | 2 +- packages/core/test/instructions/index.test.ts | 2 +- .../core/test/lib/environment-conformance.ts | 27 +- packages/core/test/lib/instructions.ts | 2 +- packages/core/test/session-execution.test.ts | 2 +- packages/core/test/session-generate.test.ts | 2 +- .../core/test/session-runner-recorded.test.ts | 4 +- packages/core/test/session-runner.test.ts | 4 +- packages/core/test/tool-edit.test.ts | 2 +- packages/core/test/tool-patch.test.ts | 2 +- .../core/test/tool-read-filesystem.test.ts | 2 +- packages/core/test/tool-read.test.ts | 2 +- packages/core/test/tool-search.test.ts | 2 +- packages/core/test/tool-shell.test.ts | 2 +- packages/core/test/tool-write.test.ts | 2 +- packages/core/test/workspace.test.ts | 2 +- packages/core/tsconfig.build.json | 11 + packages/server/src/workspace/modal.ts | 2 +- .../server/test/environment-modal.test.ts | 4 +- packages/server/test/workspace-modal.test.ts | 2 +- script/publish.ts | 3 + 36 files changed, 1185 insertions(+), 1082 deletions(-) create mode 100644 packages/core/README.md create mode 100644 packages/core/script/build.ts create mode 100644 packages/core/script/publish.ts create mode 100644 packages/core/src/database/v1-migration.bun.ts create mode 100644 packages/core/src/database/v1-migration.noop.ts create mode 100644 packages/core/tsconfig.build.json diff --git a/bun.lock b/bun.lock index f8814c3d07..2432c40f6f 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000000..6c80690fc0 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,3 @@ +# @opencode-ai/core + +Core runtime services for OpenCode. diff --git a/packages/core/package.json b/packages/core/package.json index 9844abe63d..1d98d93c6b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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:" } } diff --git a/packages/core/script/build.ts b/packages/core/script/build.ts new file mode 100644 index 0000000000..181e37c030 --- /dev/null +++ b/packages/core/script/build.ts @@ -0,0 +1,42 @@ +#!/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") diff --git a/packages/core/script/publish.ts b/packages/core/script/publish.ts new file mode 100644 index 0000000000..45f6f85795 --- /dev/null +++ b/packages/core/script/publish.ts @@ -0,0 +1,51 @@ +#!/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 + imports: Record> +} +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 }) +} diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index ac8f023878..c914b94a8e 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -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,9 @@ 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( + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( Layer.provide(Reactivity.layer), ) } diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 49e12de3b4..81f74a09d9 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -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,9 @@ 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( + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( Layer.provide(Reactivity.layer), ) } diff --git a/packages/core/src/database/sqlite.ts b/packages/core/src/database/sqlite.ts index 1add137b10..6ab22de8ca 100644 --- a/packages/core/src/database/sqlite.ts +++ b/packages/core/src/database/sqlite.ts @@ -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 export class Native extends Context.Service()("@opencode-ai/core/database/SqliteNative") {} -export class Drizzle extends Context.Service()("@opencode-ai/core/database/SqliteDrizzle") {} export interface ClientConfig { readonly spanAttributes?: Record diff --git a/packages/core/src/database/sqlite.workerd.ts b/packages/core/src/database/sqlite.workerd.ts index da7a860e06..fbe7d11bd5 100644 --- a/packages/core/src/database/sqlite.workerd.ts +++ b/packages/core/src/database/sqlite.workerd.ts @@ -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,9 @@ 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( + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( Layer.provide(Reactivity.layer), ) } diff --git a/packages/core/src/database/v1-migration.bun.ts b/packages/core/src/database/v1-migration.bun.ts new file mode 100644 index 0000000000..5c3150c77c --- /dev/null +++ b/packages/core/src/database/v1-migration.bun.ts @@ -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 + readonly parts: ReadonlyArray +} + +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 + }> + 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 +} + +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 => 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 => 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() + 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> => { + 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 { + 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 { + 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( + sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`, + ) + const sourceParts = yield* tx.all( + 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 { + 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("SELECT * FROM project") + .all() + .map((project) => [project.id, project]), + ) + const sessions = source.query("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) { + 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>, +) { + 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 { + 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, + ) +} diff --git a/packages/core/src/database/v1-migration.noop.ts b/packages/core/src/database/v1-migration.noop.ts new file mode 100644 index 0000000000..268eaf44e7 --- /dev/null +++ b/packages/core/src/database/v1-migration.noop.ts @@ -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) +} diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts index 5c3150c77c..55cb22797e 100644 --- a/packages/core/src/database/v1-migration.ts +++ b/packages/core/src/database/v1-migration.ts @@ -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 - readonly parts: ReadonlyArray -} - -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 - }> - 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 -} - -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 => 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 => 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() - 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> => { - 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 { - 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 { - 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( - sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`, - ) - const sourceParts = yield* tx.all( - 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 { - 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("SELECT * FROM project") - .all() - .map((project) => [project.id, project]), - ) - const sessions = source.query("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) { - 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>, -) { - 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 { - 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" diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 11e70c6529..7a7508d2f6 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -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" diff --git a/packages/core/test/fixture/environment.ts b/packages/core/test/fixture/environment.ts index 1b77e7b0af..67c4f858ac 100644 --- a/packages/core/test/fixture/environment.ts +++ b/packages/core/test/fixture/environment.ts @@ -1,5 +1,5 @@ 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 { Effect, Layer } from "effect" diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index 5c2706f2d1..de5005a38c 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -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" diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts index 36f9c61ef1..837d683b5c 100644 --- a/packages/core/test/instruction-state.test.ts +++ b/packages/core/test/instruction-state.test.ts @@ -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" diff --git a/packages/core/test/instructions/index.test.ts b/packages/core/test/instructions/index.test.ts index 1b74bd130c..842c5bb536 100644 --- a/packages/core/test/instructions/index.test.ts +++ b/packages/core/test/instructions/index.test.ts @@ -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) diff --git a/packages/core/test/lib/environment-conformance.ts b/packages/core/test/lib/environment-conformance.ts index 0264aec011..6d65669061 100644 --- a/packages/core/test/lib/environment-conformance.ts +++ b/packages/core/test/lib/environment-conformance.ts @@ -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 = ( skip = false, ) => { const check = (title: string, body: (harness: EnvironmentHarness) => Effect.Effect) => - 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) diff --git a/packages/core/test/lib/instructions.ts b/packages/core/test/lib/instructions.ts index e7a0f6b723..7a129d41eb 100644 --- a/packages/core/test/lib/instructions.ts +++ b/packages/core/test/lib/instructions.ts @@ -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> diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index e6a6d498d5..090bbb5432 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -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" diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 35f2998cc6..88d1f212bf 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -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" diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 02bec18ed2..891d9b61cb 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -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" diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 40efdbb4bb..22c4daf98e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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" diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index e730ebf53d..10bb94f817 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -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" diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 5a08fc8dfc..9c1cc59090 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -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" diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts index 624258e0ce..5c313f9229 100644 --- a/packages/core/test/tool-read-filesystem.test.ts +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -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" diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index b2d2f091b3..de490f57eb 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -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" diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 03d195a0e4..a87c45cd88 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -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" diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index ca98216481..e3170a406f 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -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" diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index d3ab2f0540..46d2ea6fab 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -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" diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts index f5de82fbda..262212e9c7 100644 --- a/packages/core/test/workspace.test.ts +++ b/packages/core/test/workspace.test.ts @@ -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" diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000000..94801488a5 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -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"] +} diff --git a/packages/server/src/workspace/modal.ts b/packages/server/src/workspace/modal.ts index 3699866d35..7a8c4b87bf 100644 --- a/packages/server/src/workspace/modal.ts +++ b/packages/server/src/workspace/modal.ts @@ -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 = ` diff --git a/packages/server/test/environment-modal.test.ts b/packages/server/test/environment-modal.test.ts index 621d3f5b6a..3dae4fe53b 100644 --- a/packages/server/test/environment-modal.test.ts +++ b/packages/server/test/environment-modal.test.ts @@ -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 = diff --git a/packages/server/test/workspace-modal.test.ts b/packages/server/test/workspace-modal.test.ts index 126e8a0400..cc6e54593e 100644 --- a/packages/server/test/workspace-modal.test.ts +++ b/packages/server/test/workspace-modal.test.ts @@ -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" diff --git a/script/publish.ts b/script/publish.ts index 3cd3599039..48fc95c257 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -63,6 +63,9 @@ if (Script.channel !== "beta") { console.log("\n=== plugin ===\n") await $`bun ./packages/plugin/script/publish.ts` + console.log("\n=== core ===\n") + await $`bun ./packages/core/script/publish.ts` + console.log("\n=== ui ===\n") await $`bun ./packages/ui/script/publish.ts` } From b990f9a5c11faa6f51a278fdb368e444e41d03d6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 12 Aug 2026 13:32:36 +0000 Subject: [PATCH 11/61] chore: generate --- packages/core/script/build.ts | 10 +--------- packages/core/script/publish.ts | 9 ++++----- packages/core/src/database/sqlite.bun.ts | 4 +--- packages/core/src/database/sqlite.node.ts | 4 +--- packages/core/src/database/sqlite.workerd.ts | 4 +--- 5 files changed, 8 insertions(+), 23 deletions(-) diff --git a/packages/core/script/build.ts b/packages/core/script/build.ts index 181e37c030..bf8eec926e 100644 --- a/packages/core/script/build.ts +++ b/packages/core/script/build.ts @@ -19,15 +19,7 @@ const result = await Bun.build({ target: "node", format: "esm", packages: "external", - external: [ - "#sqlite", - "#pty", - "#fff", - "#photon-wasm", - "#shell-parser-wasm", - "#process-lock-ffi", - "#v1-migration", - ], + external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"], splitting: true, loader: { ".txt": "text", diff --git a/packages/core/script/publish.ts b/packages/core/script/publish.ts index 45f6f85795..39c65f086a 100644 --- a/packages/core/script/publish.ts +++ b/packages/core/script/publish.ts @@ -27,11 +27,10 @@ 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) }] - }), + 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]) => [ diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index c914b94a8e..f44e9783d2 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -99,7 +99,5 @@ const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(c export const sqliteLayer = (config: Config) => { const native = nativeLayer(config) - return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( - Layer.provide(Reactivity.layer), - ) + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer)) } diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 81f74a09d9..837beaa1f8 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -96,7 +96,5 @@ const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(c export const sqliteLayer = (config: Config) => { const native = nativeLayer(config) - return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( - Layer.provide(Reactivity.layer), - ) + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer)) } diff --git a/packages/core/src/database/sqlite.workerd.ts b/packages/core/src/database/sqlite.workerd.ts index fbe7d11bd5..c0239bb07e 100644 --- a/packages/core/src/database/sqlite.workerd.ts +++ b/packages/core/src/database/sqlite.workerd.ts @@ -239,7 +239,5 @@ const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(c export const sqliteLayer = (config: Config) => { const native = nativeLayer(config) - return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( - Layer.provide(Reactivity.layer), - ) + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer)) } From 1da591b84d96fa0bddfd84c9e9ca05048397cf11 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:14:41 -0500 Subject: [PATCH 12/61] fix(core): return content-only Code Mode results (#41954) Co-authored-by: Aiden Cline --- packages/core/src/codemode/tool.ts | 6 ++-- packages/core/test/mcp.test.ts | 38 +++++++++++++++++++++ packages/core/test/plugin/promise.test.ts | 41 +++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index aa7f4af73b..23aa7a4708 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -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), }) } diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index ed295f75e2..1f2cf2d2d2 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -248,6 +248,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 +296,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, @@ -984,6 +997,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() + 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 diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index e5551e4340..efc25e07cb 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -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" }], + }) + }), + ) }) From 1fea1c2ebce283a245bc85c4f77dc85fdb1f2a49 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:31:59 -0500 Subject: [PATCH 13/61] fix(core): route Muse models to Meta prompt (#42036) Co-authored-by: Aiden Cline --- packages/core/src/plugin/system-prompt.ts | 6 +++- .../core/src/plugin/system-prompt/meta.txt | 4 +-- .../core/test/plugin/system-prompt.test.ts | 30 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/core/src/plugin/system-prompt.ts b/packages/core/src/plugin/system-prompt.ts index 4756d67ef8..dca093fbe4 100644 --- a/packages/core/src/plugin/system-prompt.ts +++ b/packages/core/src/plugin/system-prompt.ts @@ -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 diff --git a/packages/core/src/plugin/system-prompt/meta.txt b/packages/core/src/plugin/system-prompt/meta.txt index a8f92270bb..f31887a7d4 100644 --- a/packages/core/src/plugin/system-prompt/meta.txt +++ b/packages/core/src/plugin/system-prompt/meta.txt @@ -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/. diff --git a/packages/core/test/plugin/system-prompt.test.ts b/packages/core/test/plugin/system-prompt.test.ts index 78e98b575f..c324675291 100644 --- a/packages/core/test/plugin/system-prompt.test.ts +++ b/packages/core/test/plugin/system-prompt.test.ts @@ -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 From 70853b1e5b6f53a3f68ee555ba4ff63465b3b9e9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 10:51:00 -0400 Subject: [PATCH 14/61] feat(tui): surface plugin failures (#41940) --- packages/tui/src/app.tsx | 2 +- .../src/component/dialog-error-details.tsx | 115 +++++++++++++++++ packages/tui/src/component/dialog-mcp.tsx | 90 +------------- .../tui/src/feature-plugins/home/footer.tsx | 51 +++++++- .../src/feature-plugins/system/plugins.tsx | 116 +++++++++++++++--- packages/tui/src/plugin/context.tsx | 11 +- .../test/feature-plugins/home-footer.test.ts | 13 ++ 7 files changed, 288 insertions(+), 110 deletions(-) create mode 100644 packages/tui/src/component/dialog-error-details.tsx create mode 100644 packages/tui/test/feature-plugins/home-footer.test.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 88bb8ab40c..ebb2b6e5fb 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -501,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.", }) } }) diff --git a/packages/tui/src/component/dialog-error-details.tsx b/packages/tui/src/component/dialog-error-details.tsx new file mode 100644 index 0000000000..b0507bcdf6 --- /dev/null +++ b/packages/tui/src/component/dialog-error-details.tsx @@ -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 ( + + + + {props.title} + + + esc + + + ✗ Failed + + (scroll = element)} + height={height()} + scrollbarOptions={{ visible: false }} + scrollAcceleration={getScrollAcceleration(config)} + > + + {props.error} + + + + + + + {scrollable() ? "↑/↓" : ""} + + {scrollable() ? " scroll" : ""} + + + + {copied() ? "✓ copied" : "c"} + + {copied() ? "" : " copy details"} + + + + ) +} diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index a2999a081e..fe2e333181 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -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) => ( - { setDetail() dialog.setSize("medium") @@ -155,79 +153,3 @@ export function DialogMcp() { ) } - -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 ( - - - - MCP server: {props.server.name} - - - esc back - - - ✗ Failed - - (scroll = element)} - height={height()} - scrollbarOptions={{ visible: false }} - scrollAcceleration={getScrollAcceleration(config)} - > - - {error()} - - - - - ↑↓ scroll - - {copied() ? "✓ copied" : "c copy details"} - - - - ) -} diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 0c540692ee..ad09b01e8b 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -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 }) { + {failed()} MCP failed ⊙{" "} + {count()} MCP - {count()} MCP - /status + + /mcps + + + + ) +} + +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 ( + + + + + {failed()} plugin{failed() === 1 ? "" : "s"} failed + + + /plugins + ) @@ -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 ( = 12 && dimensions().width >= 44}> @@ -50,10 +86,13 @@ function View(props: { context: Plugin.Context }) { gap={2} > + - - {props.context.app.version} - + + + {props.context.app.version} + + ) diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index 676ee3e475..a539f8a0e7 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -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 }) { const [locked, setLocked] = createSignal(false) - const options = createMemo(() => - props.plugins + const [focused, setFocused] = createSignal() + 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 => ({ title: plugin.id, value: plugin.id, - category: plugin.source === "builtin" ? "Built-in" : "External", + category: "Built-in", footer: ( ), }), - ), - ) + ) + const external = props.plugins + .list() + .filter((plugin) => plugin.status !== "unsupported") + .map( + (plugin): DialogSelectOption => ({ + title: plugin.id ?? plugin.target, + value: plugin.id ?? plugin.target, + category: "External", + searchText: plugin.target, + footer: ( + + {plugin.status} + + ), + }), + ) + 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) => { if (locked()) return @@ -51,15 +93,56 @@ function View(props: { context: Plugin.Context; plugins: ReturnType setLocked(false)) } + const select = (plugin: DialogSelectOption) => { + const failed = failure(plugin.value) + if (!failed || failed.status !== "failed") return toggle(plugin) + setDetail({ title: failed.target, error: failed.error }) + } + return ( - + + 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={ + + enter to view error + + } + /> + } + > + {(item) => ( + { + setDetail() + dialog.setSize("medium") + }} + /> + )} + + ) } @@ -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(() => ) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a8053f4a25..ee96a4e8ba 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -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>() diff --git a/packages/tui/test/feature-plugins/home-footer.test.ts b/packages/tui/test/feature-plugins/home-footer.test.ts new file mode 100644 index 0000000000..d2c6544d40 --- /dev/null +++ b/packages/tui/test/feature-plugins/home-footer.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { homeFooterVisibility } from "../../src/feature-plugins/home/footer" + +describe("home footer visibility", () => { + test("keeps failure labels readable at the minimum supported width", () => { + expect(homeFooterVisibility(44)).toEqual({ mcpCommand: false, pluginCommand: false, version: false }) + }) + + test("adds secondary hints as space becomes available", () => { + expect(homeFooterVisibility(64)).toEqual({ mcpCommand: true, pluginCommand: false, version: true }) + expect(homeFooterVisibility(80)).toEqual({ mcpCommand: true, pluginCommand: true, version: true }) + }) +}) From bef795b2fe11d0d4b116f213191e9fd6a07be25e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 11:49:17 -0400 Subject: [PATCH 15/61] fix(tui): smooth session tab marquees (#42055) --- packages/tui/src/component/session-tabs.tsx | 125 ++++++++++++++++---- packages/tui/src/util/marquee.ts | 8 +- packages/tui/test/util/marquee.test.ts | 12 +- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index b111af5a89..722850b9e1 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -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() const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) }) + let delay: ReturnType | undefined + let interval: ReturnType | 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 | 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() + const marquee = createMarquee(animations) + let hoverClear: ReturnType | 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() const [addHovered, setAddHovered] = createSignal(false) - const marquee = createMarquee(hovered, animations) + const marquee = createTabMarquee(animations) + const hovered = marquee.hovered const [dragging, setDragging] = createSignal() 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() const [addHovered, setAddHovered] = createSignal(false) - const marquee = createMarquee(hovered, animations) + const marquee = createTabMarquee(animations) + const hovered = marquee.hovered const [dragging, setDragging] = createSignal() // 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} diff --git a/packages/tui/src/util/marquee.ts b/packages/tui/src/util/marquee.ts index b0c740a032..f1af221349 100644 --- a/packages/tui/src/util/marquee.ts +++ b/packages/tui/src/util/marquee.ts @@ -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) => diff --git a/packages/tui/test/util/marquee.test.ts b/packages/tui/test/util/marquee.test.ts index 60740d0c2a..f0b871ad6a 100644 --- a/packages/tui/test/util/marquee.test.ts +++ b/packages/tui/test/util/marquee.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { marqueeText } from "../../src/util/marquee" +import { marqueeCycleWidth, marqueeText } from "../../src/util/marquee" import { stringWidth } from "../../src/util/string-width" describe("marquee text", () => { @@ -10,8 +10,14 @@ describe("marquee text", () => { test("starts clipped and scrolls through a long title", () => { expect(marqueeText("A long session title", 8, 0)).toBe("A long s") expect(marqueeText("A long session title", 8, 2)).toBe("long ses") - expect(marqueeText("A long session title", 8, 15)).toBe("title ") - expect(marqueeText("A long session title", 8, 20)).toBe(" A lo") + expect(marqueeText("A long session title", 8, 15)).toBe("title · ") + expect(marqueeText("A long session title", 8, 20)).toBe(" · A lon") + }) + + test("loops after one spaced dot separator", () => { + const title = "A long session title" + expect(marqueeText(title, 8, marqueeCycleWidth(title) - 3)).toBe(" · A lon") + expect(marqueeText(title, 8, marqueeCycleWidth(title))).toBe("A long s") }) test("clips wide graphemes to terminal cells", () => { From 0777e845982a286e4c6dbc0f35fd43bd91cabd80 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:51:19 +0000 Subject: [PATCH 16/61] fix(tui): fill image message background (#42062) Co-authored-by: Simon Klee --- packages/tui/src/routes/session/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 2b0af18e7f..4e95a5094e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1983,6 +1983,7 @@ function UserMessage(props: { message: SessionMessageUser }) { border={["left"]} borderColor={delivery() ? theme.border.default : color()} customBorderChars={SplitBorder.customBorderChars} + backgroundColor={theme.background.default} > Date: Wed, 12 Aug 2026 11:52:51 -0400 Subject: [PATCH 17/61] feat(tui): jump between open menu sections (#42061) --- packages/tui/src/component/dialog-open.tsx | 1 + packages/tui/src/ui/dialog-select.tsx | 26 ++++++ .../tui/test/cli/tui/dialog-open.test.tsx | 88 +++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index 2102ea99ec..b19caf7f88 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -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} diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index fbcf9da936..03b10e3d2d 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -40,6 +40,7 @@ export interface DialogSelectProps { bindings?: readonly KeymapCommand[] current?: T focusCurrent?: boolean + sectionNavigation?: boolean } type DialogSelectActionBase = { @@ -327,6 +328,15 @@ export function DialogSelect(props: DialogSelectProps) { 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(props: DialogSelectProps) { ] : []), ...(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), + }, + ] + : []), ], } }) diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index a3e76726ff..0937178b6f 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -186,6 +186,94 @@ test("preserves a moved project when sessions arrive", async () => { } }) +test("option arrows jump between sections", async () => { + const handler: FetchHandler = (url) => { + if (url.pathname === "/api/session") + return json({ + data: [ + { + id: "ses_recent", + projectID: "proj_recent", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + title: "Recent session", + location: { directory: "/tmp/opencode/recent" }, + }, + ], + cursor: {}, + }) + if (url.pathname === "/api/project") + return json([ + { + id: "proj_recent", + canonical: "/tmp/opencode/recent", + name: "Recent project", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + ]) + return undefined + } + + const next = await renderOpen(handler) + try { + await next.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project")) + next.app.mockInput.pressArrow("down", { meta: true }) + next.app.mockInput.pressEnter() + await next.app.waitFor(() => next.route.data.type === "home") + expect(next.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } }) + } finally { + await next.dispose() + } + + const previous = await renderOpen(handler) + try { + await previous.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project")) + previous.app.mockInput.pressArrow("up", { meta: true }) + previous.app.mockInput.pressEnter() + await previous.app.waitFor(() => previous.route.data.type === "home") + expect(previous.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } }) + } finally { + await previous.dispose() + } +}) + +test("option arrows stay in the only visible section", async () => { + const fixture = await renderOpen((url) => { + if (url.pathname === "/api/session") return json({ data: [], cursor: {} }) + if (url.pathname !== "/api/project") return undefined + return json([ + { + id: "proj_effect", + canonical: "/tmp/effect", + name: "Effect", + time: { created: 1, updated: 2 }, + sandboxes: [], + }, + { + id: "proj_opencode", + canonical: "/tmp/opencode", + name: "OpenCode", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + ]) + }) + + try { + await fixture.app.waitForFrame((frame) => frame.includes("Effect") && frame.includes("OpenCode")) + await fixture.app.mockInput.typeText("Effect") + await fixture.app.waitForFrame((frame) => frame.includes("Effect") && !frame.includes("OpenCode")) + fixture.app.mockInput.pressArrow("down", { meta: true }) + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.route.data.type === "home") + expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/effect" } }) + } finally { + await fixture.dispose() + } +}) + async function renderOpen( handler: FetchHandler, beforeOpen?: (contexts: { From 653b7d79cd333a0f391cc14175feeb7075d11254 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 11:57:39 -0400 Subject: [PATCH 18/61] fix(tui): restore navigation keybind defaults (#42066) --- packages/tui/src/config/keybind.ts | 26 +++++++++++++++----------- packages/tui/test/config-v2.test.tsx | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index bef9a0f053..be007d182a 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -96,13 +96,15 @@ export const Definitions = { "session.move": keybind("none", "Move session"), "session.new": keybind("n", "Create a new session"), "session.list": keybind("l", "List all sessions"), - "session.tab.next": keybind("ctrl+tab,right", "Switch to next open session tab"), - "session.tab.previous": keybind("ctrl+shift+tab,left", "Switch to previous open session tab"), - "session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"), + "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("down", "Switch to next unread session tab"), - "session.tab.previous_unread": keybind("up", "Switch to previous unread session tab"), + "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("w", "Close current session tab"), + "session.tab.reopen": keybind("ctrl+shift+t", "Reopen last closed session tab"), "session.timeline": keybind("g", "Show session timeline"), "session.fork": keybind("none", "Fork session from message"), "session.rename": keybind("ctrl+r", "Rename session"), @@ -139,6 +141,7 @@ export const Definitions = { "session.tab.select.7": keybind("7,ctrl+7", "Switch to session tab 7"), "session.tab.select.8": keybind("8,ctrl+8", "Switch to session tab 8"), "session.tab.select.9": keybind("9,ctrl+9", "Switch to session tab 9"), + "session.tab.select.10": keybind("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"), @@ -164,10 +167,10 @@ export const Definitions = { "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("alt+down", "Navigate to next message"), - "session.message.previous": keybind("alt+up", "Navigate to previous message"), - "session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"), - "session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user 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("y", "Copy message"), "session.undo": keybind("u", "Undo message"), @@ -177,6 +180,7 @@ export const Definitions = { "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("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"), @@ -202,8 +206,8 @@ export const Definitions = { "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("home", "Move to start of buffer in input"), - "input.buffer.end": keybind("end", "Move to end of buffer 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"), diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 55965adcd9..57a1c2858d 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -74,6 +74,25 @@ test("uses command IDs as keybind keys", () => { ).toBe(true) }) +test("preserves current navigation defaults", () => { + const config = resolve({}, { terminalSuspend: true }) + + expect(config.keybinds.get("open.menu")).toMatchObject([{ key: "ctrl+o" }]) + expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }]) + expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }]) + expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }]) + expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }]) + expect(config.keybinds.get("session.tab.reopen")).toMatchObject([{ key: "ctrl+shift+t" }]) + expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "0,ctrl+0" }]) + expect(config.keybinds.get("session.message.next")).toEqual([]) + expect(config.keybinds.get("session.message.previous")).toEqual([]) + expect(config.keybinds.get("session.message.user.next")).toEqual([]) + expect(config.keybinds.get("session.message.user.previous")).toEqual([]) + expect(config.keybinds.get("input.buffer.home")).toEqual([]) + expect(config.keybinds.get("input.buffer.end")).toEqual([]) + expect(config.keybinds.get("prompt.images.view")).toMatchObject([{ key: "i" }]) +}) + test("preserves migrated v1 keybind defaults", () => { const pairs = [ ["app.exit", "app_exit"], From f06a86eeac2112dd872c275327d2c7a695b38eb9 Mon Sep 17 00:00:00 2001 From: Matt Robinson <33697516+mattrobrob@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:01:37 +0100 Subject: [PATCH 19/61] feat(client): support service version ranges (#42023) Co-authored-by: Dax Raad --- packages/client/src/effect/service.ts | 7 +-- packages/client/src/promise/service.ts | 5 ++- packages/client/src/service-version.ts | 8 ++++ packages/client/src/service.ts | 4 +- packages/client/test/api.types.ts | 16 ++++++- packages/client/test/fixture/service.ts | 5 ++- packages/client/test/promise-service.test.ts | 13 ++++++ packages/client/test/service.test.ts | 46 ++++++++++++++++++++ packages/www/content/docs/build/client.mdx | 7 +-- 9 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 packages/client/src/service-version.ts diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 7797a3f3a0..733bbf8a7a 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -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")) diff --git a/packages/client/src/promise/service.ts b/packages/client/src/promise/service.ts index 4a44c42a4d..d54da6c4e9 100644 --- a/packages/client/src/promise/service.ts +++ b/packages/client/src/promise/service.ts @@ -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 { 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) { diff --git a/packages/client/src/service-version.ts b/packages/client/src/service-version.ts new file mode 100644 index 0000000000..5586f111a9 --- /dev/null +++ b/packages/client/src/service-version.ts @@ -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 +} diff --git a/packages/client/src/service.ts b/packages/client/src/service.ts index 96d54871f6..65c3226643 100644 --- a/packages/client/src/service.ts +++ b/packages/client/src/service.ts @@ -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. */ diff --git a/packages/client/test/api.types.ts b/packages/client/test/api.types.ts index a4c3f0f003..1cb35e5dca 100644 --- a/packages/client/test/api.types.ts +++ b/packages/client/test/api.types.ts @@ -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> type PromiseClient = ReturnType @@ -8,6 +9,9 @@ type PromiseClient = ReturnType 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 = effectClient const effectSession: Effect.Effect = effectClient.session.get({ @@ -42,4 +46,14 @@ const promiseRemove: Promise = 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, +] diff --git a/packages/client/test/fixture/service.ts b/packages/client/test/fixture/service.ts index 0c5c9c666b..4faf13e693 100644 --- a/packages/client/test/fixture/service.ts +++ b/packages/client/test/fixture/service.ts @@ -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, diff --git a/packages/client/test/promise-service.test.ts b/packages/client/test/promise-service.test.ts index 75ae1b544f..78d871ab5f 100644 --- a/packages/client/test/promise-service.test.ts +++ b/packages/client/test/promise-service.test.ts @@ -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") diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts index 3b3e94b3ed..152f6cd15e 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -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") diff --git a/packages/www/content/docs/build/client.mdx b/packages/www/content/docs/build/client.mdx index 2e11ae1d2b..deaf29bb79 100644 --- a/packages/www/content/docs/build/client.mdx +++ b/packages/www/content/docs/build/client.mdx @@ -94,13 +94,14 @@ const client = OpenCode.make({ const health = await client.health.get() ``` -`Service.ensure()` accepts an optional registration file, required version, -service command, and `onStart` callback: +`Service.ensure()` accepts an optional registration file, version, service +command, and `onStart` callback. `version` accepts either an exact value or a +compatibility predicate: ```ts const endpoint = await Service.ensure({ file: "/var/run/opencode/service.json", - version: "2.0.0", + version: (version) => version.startsWith("2."), command: ["opencode", "serve", "--service"], onStart(reason, previousVersion) { console.log(reason, previousVersion) From 930b0751b191ae0d8676e18b1cdd0d2729995aef Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 12:08:24 -0400 Subject: [PATCH 20/61] fix(core): generate session titles before model execution (#42067) --- packages/core/src/session/runner/llm.ts | 6 ++-- packages/core/test/session-runner.test.ts | 42 ++++++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6b5bfebead..1c9fef2123 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -113,8 +113,8 @@ const layer = Layer.effect( const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service const toolOutput = yield* ToolOutput.Service - // Title generation is a side effect of a successful step; it must not delay continuation. - // The in-flight set coalesces overlapping steps while title presence records success durably. + // Title generation starts once input is visible and must not delay model execution. + // The in-flight set coalesces overlapping prompts while title presence records success durably. const titlesRunning = new Set() const forkTitle = yield* FiberSet.makeRuntime() /** @@ -144,7 +144,6 @@ const layer = Layer.effect( let step = 1 while (true) { const result = yield* runStep(sessionID, promotable, step) - if (step === 1) yield* startTitle(sessionID) yield* runPendingCompaction(sessionID) if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return promotable = "steer" @@ -236,6 +235,7 @@ const layer = Layer.effect( // a blocked first step leaves pending inputs untouched. yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id) const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0 + if (promoted > 0) yield* startTitle(sessionID) // Promoted input opens a fresh step allowance. const currentStep = promoted > 0 ? 1 : step const loaded = yield* context.load(selected) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 22c4daf98e..73eb2b77a5 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -816,7 +816,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => }) describe("SessionRunnerLLM", () => { - it.effect("retries title generation from the first prompt after execution and title failures", () => + it.effect("generates the title while the first model step is still running", () => Effect.gen(function* () { const session = yield* setup const agents = yield* Agent.Service @@ -831,16 +831,48 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "First prompt") - yield* TestLLM.push(Stream.fail(invalidRequest())) + yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never) + const bus = yield* Bus.Service + const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe( + Stream.filter((event) => event.data.sessionID === sessionID), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ) + const runner = yield* SessionRunner.Service + const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) + yield* Fiber.join(renamed) + + expect((yield* session.get(sessionID)).title).toBe("Generated title") + yield* Fiber.interrupt(fiber) + }), + ) + + it.effect("retries title generation from the first prompt after title and execution failures", () => + Effect.gen(function* () { + const session = yield* setup + const agents = yield* Agent.Service + const { db } = yield* Database.Service + yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie) + yield* agents.transform((draft) => + draft.update(Agent.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "Generate a title." + }), + ) + + yield* admit(session, "First prompt") + yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest())) expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure") yield* admit(session, "Second prompt") const titleFailed = yield* Deferred.make() yield* TestLLM.push( - TestLLM.text("Recovered", "text-recovered"), Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe( Stream.ensuring(Deferred.succeed(titleFailed, undefined)), ), + TestLLM.text("Recovered", "text-recovered"), ) yield* session.resume(sessionID) yield* Deferred.await(titleFailed) @@ -856,13 +888,13 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "Third prompt") yield* TestLLM.push( - TestLLM.text("Recovered again", "text-recovered-again"), TestLLM.text("Generated title", "text-title"), + TestLLM.text("Recovered again", "text-recovered-again"), ) yield* session.resume(sessionID) yield* Fiber.join(renamed) - expect(requests).toHaveLength(5) + expect(requests).toHaveLength(6) expect(requests[2]?.messages).toContainEqual(Message.user("First prompt")) expect(requests[4]?.messages).toContainEqual(Message.user("First prompt")) expect((yield* session.get(sessionID)).title).toBe("Generated title") From b24b1b3f16581610523a8abcdfcf8142e89049bf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 12:17:45 -0400 Subject: [PATCH 21/61] fix(tui): keep exact-fit tab titles stationary (#42073) --- packages/tui/src/component/session-tabs.tsx | 25 ++++++++++++--------- packages/tui/src/util/marquee.ts | 4 ++++ packages/tui/test/util/marquee.test.ts | 7 +++++- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 722850b9e1..548b0a26ae 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -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 { marqueeCycleWidth, marqueeText } from "../util/marquee" +import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 @@ -85,7 +85,7 @@ function createMarquee(animations: () => boolean) { returning = false return scroll() } - if (stringWidth(title) <= width) return + if (!marqueeOverflows(title, width)) return cycleWidth = marqueeCycleWidth(title) setActive(sessionID) setOffset(0) @@ -258,7 +258,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat return value ? data.project.get(value.projectID) : undefined }) const numberWidth = () => 2 - const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0)) + const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2) + const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0)) const title = () => tab.title ?? "Untitled session" const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0 const visibleTitle = createMemo(() => @@ -267,7 +268,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat : Locale.takeWidth(title(), titleWidth()), ) const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) - const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH) + const titleFades = createMemo( + () => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH, + ) const detail = createMemo(() => { const value = session() return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth()) @@ -349,10 +352,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat position="relative" flexDirection="column" backgroundColor={background()} - onMouseOver={() => marquee.enter(tab.sessionID, title(), titleWidth())} + onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())} onMouseOut={() => marquee.leave(tab.sessionID)} onMouseDown={() => { - marquee.enter(tab.sessionID, title(), titleWidth()) + marquee.enter(tab.sessionID, title(), restingTitleWidth()) setDragging(tab.sessionID) }} onMouseUp={release} @@ -759,8 +762,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim // Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot. const numberWidth = () => 2 // 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 restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth()) + const availableTitleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 2 : 0)) const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0 const visibleTitle = createMemo(() => scrolling() @@ -769,7 +772,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim ) const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) const titleFades = createMemo( - () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH, + () => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH, ) const foreground = () => { if (hovered() === tab.sessionID) return theme.text.default @@ -820,10 +823,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim position="relative" flexDirection="row" backgroundColor={background()} - onMouseOver={() => marquee.enter(tab.sessionID, title(), availableTitleWidth())} + onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())} onMouseOut={() => marquee.leave(tab.sessionID)} onMouseDown={() => { - marquee.enter(tab.sessionID, title(), availableTitleWidth()) + marquee.enter(tab.sessionID, title(), restingTitleWidth()) setDragging(tab.sessionID) }} onMouseUp={release} diff --git a/packages/tui/src/util/marquee.ts b/packages/tui/src/util/marquee.ts index f1af221349..91ecef79fd 100644 --- a/packages/tui/src/util/marquee.ts +++ b/packages/tui/src/util/marquee.ts @@ -7,6 +7,10 @@ export function marqueeCycleWidth(value: string) { return stringWidth(value + GAP) } +export function marqueeOverflows(value: string, width: number) { + return stringWidth(value) > width +} + export function marqueeText(value: string, width: number, offset: number) { if (width <= 0) return "" if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width) diff --git a/packages/tui/test/util/marquee.test.ts b/packages/tui/test/util/marquee.test.ts index f0b871ad6a..3ecdf9fdd7 100644 --- a/packages/tui/test/util/marquee.test.ts +++ b/packages/tui/test/util/marquee.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { marqueeCycleWidth, marqueeText } from "../../src/util/marquee" +import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../../src/util/marquee" import { stringWidth } from "../../src/util/string-width" describe("marquee text", () => { @@ -7,6 +7,11 @@ describe("marquee text", () => { expect(marqueeText("Short", 10, 8)).toBe("Short") }) + test("does not classify an exact fit as overflow", () => { + expect(marqueeOverflows("Exact fit", 9)).toBe(false) + expect(marqueeOverflows("Exact fit", 8)).toBe(true) + }) + test("starts clipped and scrolls through a long title", () => { expect(marqueeText("A long session title", 8, 0)).toBe("A long s") expect(marqueeText("A long session title", 8, 2)).toBe("long ses") From b50924b9936451f36ae6ca4c351d66694a9a3624 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:45:38 -0400 Subject: [PATCH 22/61] test: stabilize Windows integration coverage (#42079) Co-authored-by: Kit Langton --- packages/core/test/snapshot.test.ts | 75 ++++----- packages/core/test/tool-shell.test.ts | 149 ++++++++++-------- .../tui/test/component/tab-pulse.test.tsx | 48 ------ 3 files changed, 118 insertions(+), 154 deletions(-) diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 678f68313e..09269a9f60 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -143,44 +143,47 @@ describe("Snapshot", () => { ), ) - testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => - Effect.gen(function* () { - const project = path.join(tmp.path, "project") - const linked = path.join(tmp.path, "linked") - yield* Effect.promise(async () => { - await fs.mkdir(project) - await fs.writeFile(path.join(project, "tracked.txt"), "main\n") - await initGit(project, true) - await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet() - }) + testEffect(Layer.empty).live( + "isolates snapshot indexes by canonical Git worktree", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => + Effect.gen(function* () { + const project = path.join(tmp.path, "project") + const linked = path.join(tmp.path, "linked") + yield* Effect.promise(async () => { + await fs.mkdir(project) + await fs.writeFile(path.join(project, "tracked.txt"), "main\n") + await initGit(project, true) + await $`git -c core.fsmonitor=false worktree add --detach ${linked} HEAD`.cwd(project).quiet() + }) - const capture = (directory: string) => - Effect.gen(function* () { - const snapshot = yield* Snapshot.Service - return yield* snapshot.capture() - }).pipe(Effect.provide(snapshotLayer(tmp.path, directory))) - expect(yield* capture(project)).toBeDefined() - expect(yield* capture(linked)).toBeDefined() + const capture = (directory: string) => + Effect.gen(function* () { + const snapshot = yield* Snapshot.Service + return yield* snapshot.capture() + }).pipe(Effect.provide(snapshotLayer(tmp.path, directory))) + expect(yield* capture(project)).toBeDefined() + expect(yield* capture(linked)).toBeDefined() - const projectID = yield* Effect.gen(function* () { - return (yield* Location.Service).project.id - }).pipe( - Effect.provide( - AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), - ), - ) - expect( - yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), - ).toBeDefined() - expect( - yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))), - ).toBeDefined() - }), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + const projectID = yield* Effect.gen(function* () { + return (yield* Location.Service).project.id + }).pipe( + Effect.provide( + AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + ), + ) + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), + ).toBeDefined() + expect( + yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(linked)))), + ).toBeDefined() + }), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + { timeout: 15_000 }, ) }) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index e3170a406f..8b71371010 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -387,57 +387,63 @@ describe("ShellTool", () => { ), ) - it.live("approves an explicit external workdir before shell execution", () => - Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([active, outside]) => { - reset() - return withSession(active.path, (registry) => - executeTool(registry, call({ command: cwdCommand, workdir: outside.path })), - ).pipe( - Effect.andThen( - Effect.sync(() => { - expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) - expect(assertions[0]).toMatchObject({ - resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")], - }) - }), + it.live( + "approves an explicit external workdir before shell execution", + () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + return withSession(active.path, (registry) => + executeTool(registry, call({ command: cwdCommand, workdir: outside.path })), + ).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) + expect(assertions[0]).toMatchObject({ + resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")], + }) + }), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), - ) - }, - ([active, outside]) => - Effect.promise(() => - Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), - ), - ), + ), + { timeout: 15_000 }, ) - it.live("approves an external directory used by a directory-change command", () => - Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([active, outside]) => { - reset() - const command = isWindows - ? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path` - : `cd '${outside.path}' && pwd` - return withSession(active.path, (registry) => - executeTool(registry, call({ command }, "call-external-cd")), - ).pipe( - Effect.andThen( - Effect.sync(() => { - expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) - expect(assertions[0]).toMatchObject({ - resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")], - }) - }), + it.live( + "approves an external directory used by a directory-change command", + () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + const command = isWindows + ? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path` + : `cd '${outside.path}' && pwd` + return withSession(active.path, (registry) => + executeTool(registry, call({ command }, "call-external-cd")), + ).pipe( + Effect.andThen( + Effect.sync(() => { + expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) + expect(assertions[0]).toMatchObject({ + resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")], + }) + }), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), - ) - }, - ([active, outside]) => - Effect.promise(() => - Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), - ), - ), + ), + { timeout: 15_000 }, ) it.live("approves an expanded external home directory", () => @@ -459,28 +465,31 @@ describe("ShellTool", () => { ), ) - it.live("does not execute after external-directory or shell denial", () => - Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([active, outside]) => - Effect.gen(function* () { - reset() - denyAction = "external_directory" - yield* withSession(active.path, (registry) => - executeTool(registry, call({ command: cwdCommand, workdir: outside.path })), - ) - expect(assertions.map((item) => item.action)).toEqual(["external_directory"]) + it.live( + "does not execute after external-directory or shell denial", + () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => + Effect.gen(function* () { + reset() + denyAction = "external_directory" + yield* withSession(active.path, (registry) => + executeTool(registry, call({ command: cwdCommand, workdir: outside.path })), + ) + expect(assertions.map((item) => item.action)).toEqual(["external_directory"]) - reset() - denyAction = "shell" - yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand }))) - expect(assertions.map((item) => item.action)).toEqual(["shell"]) - }), - ([active, outside]) => - Effect.promise(() => - Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), - ), - ), + reset() + denyAction = "shell" + yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand }))) + expect(assertions.map((item) => item.action)).toEqual(["shell"]) + }), + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + { timeout: 15_000 }, ) it.live("keeps non-zero exits useful", () => @@ -619,7 +628,7 @@ describe("ShellTool", () => { }, (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), - { timeout: 10_000 }, + { timeout: 15_000 }, ) it.live( @@ -630,7 +639,7 @@ describe("ShellTool", () => { (tmp) => { reset() return withSession(tmp.path, (registry) => - executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })), + executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 500 })), ).pipe( Effect.andThen((settled) => Effect.sync(() => { diff --git a/packages/tui/test/component/tab-pulse.test.tsx b/packages/tui/test/component/tab-pulse.test.tsx index 6fceb27b2b..0e157f6ba3 100644 --- a/packages/tui/test/component/tab-pulse.test.tsx +++ b/packages/tui/test/component/tab-pulse.test.tsx @@ -1,10 +1,6 @@ -/** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" -import { testRender } from "@opentui/solid" -import { createSignal } from "solid-js" import { - TabPulse, blendTabPulseColor, completionPulseOpacity, glowIgnitionLevel, @@ -12,50 +8,6 @@ import { } from "../../src/component/tab-pulse" import { tint } from "../../src/theme/color" -test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => { - const background = RGBA.fromHex("#101010") - const flash = RGBA.fromHex("#f0f0f0") - const [promptPulse, setPromptPulse] = createSignal(0) - const app = await testRender( - () => ( - - - - ), - { width: 8, height: 1 }, - ) - - const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg - - try { - await app.renderOnce() - expect(firstBackground()?.equals(background)).toBeTrue() - - setPromptPulse(1) - await Bun.sleep(80) - await app.renderOnce() - expect(firstBackground()?.equals(background)).toBeFalse() - expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17) - - await Bun.sleep(800) - await app.renderOnce() - expect(firstBackground()?.equals(background)).toBeTrue() - - setPromptPulse(2) - await Bun.sleep(80) - await app.renderOnce() - expect(firstBackground()?.equals(background)).toBeFalse() - } finally { - app.renderer.destroy() - } -}) - test("completion pulse rises quickly and fades over the remaining duration", () => { expect(completionPulseOpacity(0)).toBe(0) expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5) From 10ebf70a075d14c4bcae414e403a236917411db5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 12:45:59 -0400 Subject: [PATCH 23/61] feat(tui): add session tab context menu (#42068) --- packages/tui/src/component/session-tabs.tsx | 182 ++++++++++++++++++-- 1 file changed, 172 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 548b0a26ae..49d2dffe60 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -1,4 +1,4 @@ -import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core" import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" @@ -18,12 +18,15 @@ import { } from "../context/session-tabs-model" import { createAnimatable, spring, tween } from "../ui/animation" import { Locale } from "../util/locale" -import { stringWidth } from "../util/string-width" import { TabPulse, unreadGlowIntensity } from "./tab-pulse" import { tint } from "../theme/color" import { SESSION_SIDEBAR_WIDTH } from "../ui/layout" import { projectName } from "../util/project" import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee" +import { useDialog } from "../ui/dialog" +import { DialogSessionRename } from "./dialog-session-rename" +import { Keymap } from "../context/keymap" +import { moveSelection } from "../ui/select-controller" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 @@ -31,6 +34,15 @@ const FADE_WIDTH = 4 const ADD_TAB_WIDTH = 3 const MARQUEE_DELAY = 600 const MARQUEE_INTERVAL = 100 +const CONTEXT_MENU_WIDTH = 16 +const RIGHT_MOUSE_BUTTON = 2 + +type TabContextMenuState = { + x: number + y: number + sessionID?: string + title?: string +} type ContextController = ReturnType export type SessionTabsStatus = Omit, "unread"> & { @@ -154,6 +166,96 @@ function createTabMarquee(animations: () => boolean) { return { ...marquee, hovered, enter, leave } } +function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) { + const dimensions = useTerminalDimensions() + const theme = useTheme("elevated") + const dialog = useDialog() + const keymap = Keymap.use() + const actions = createMemo(() => { + const sessionID = props.state.sessionID + return [ + ...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []), + ...(sessionID + ? [ + { + title: "Rename", + run: () => DialogSessionRename.show(dialog, sessionID, props.state.title), + }, + { title: "Close", run: () => props.tabs.close(sessionID) }, + ] + : []), + ] + }) + const [selected, setSelected] = createSignal(0) + const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length)) + const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH)) + const run = (index: number) => { + props.onClose() + actions()[index]?.run() + } + + createEffect(() => { + const popMode = keymap.mode.push("modal") + onCleanup(popMode) + }) + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "escape", title: "Close tab menu", group: "Tabs", run: props.onClose }, + { + bind: "up", + title: "Previous tab menu item", + group: "Tabs", + run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: -1, policy: "wrap" })), + }, + { + bind: "down", + title: "Next tab menu item", + group: "Tabs", + run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: 1, policy: "wrap" })), + }, + { bind: "return", title: "Select tab menu item", group: "Tabs", run: () => run(selected()) }, + ], + })) + return ( + { + event.preventDefault() + event.stopPropagation() + }} + > + + {(action, index) => ( + setSelected(index())} + onMouseUp={(event) => { + event.preventDefault() + event.stopPropagation() + run(index()) + }} + > + + {action.title} + + + )} + + + ) +} + export function SessionTabs( props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {}, ) { @@ -181,6 +283,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const hovered = marquee.hovered const [dragging, setDragging] = createSignal() const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() + const [contextMenu, setContextMenu] = createSignal() const newTab = () => tabs.newTab?.() ?? false const activeID = createMemo(() => (newTab() ? undefined : tabs.current())) const ordered = createMemo(() => { @@ -212,7 +315,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat ), ) const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)! - let rail: { screenY: number } | undefined + let rail: { screenX: number; screenY: number } | undefined let scroll: ScrollBoxRenderable | undefined createEffect(() => { @@ -242,6 +345,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat height="100%" flexShrink={0} flexDirection="column" + position="relative" paddingTop={1} backgroundColor={theme.background.default} > @@ -354,11 +458,27 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat backgroundColor={background()} onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())} onMouseOut={() => marquee.leave(tab.sessionID)} - onMouseDown={() => { + onMouseDown={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) { + setDragging(undefined) + if (!rail) return + setContextMenu({ + x: event.x - rail.screenX, + y: event.y - rail.screenY, + sessionID: tab.sessionID, + title: tab.title, + }) + event.preventDefault() + event.stopPropagation() + return + } marquee.enter(tab.sessionID, title(), restingTitleWidth()) setDragging(tab.sessionID) }} - onMouseUp={release} + onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return + release() + }} onMouseDrag={(event) => { if (!rail) return const target = Math.max( @@ -467,6 +587,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat fg={theme.text.subdued} selectable={false} onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return if (hovered() !== tab.sessionID) return event.stopPropagation() tabs.close(tab.sessionID) @@ -518,7 +639,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat } onMouseOver={() => setAddHovered(true)} onMouseOut={() => setAddHovered(false)} - onMouseUp={() => { + onMouseDown={(event: MouseEvent) => { + if (event.button !== RIGHT_MOUSE_BUTTON) return + if (!rail) return + setContextMenu({ x: event.x - rail.screenX, y: event.y - rail.screenY }) + event.preventDefault() + event.stopPropagation() + }} + onMouseUp={(event: MouseEvent) => { + if (event.button === RIGHT_MOUSE_BUTTON) return if (!newTab()) tabs.add?.() }} > @@ -547,6 +676,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat fg={theme.text.subdued} selectable={false} onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return if (!addHovered()) return event.stopPropagation() tabs.close() @@ -559,6 +689,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat + + {(state) => setContextMenu(undefined)} />} + ) } @@ -578,7 +711,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim // per slot crossing; the preview holds after release until the store reflects the move, // so the strip never flashes the pre-drag order while the write is in flight. const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() - let strip: { screenX: number } | undefined + const [contextMenu, setContextMenu] = createSignal() + let strip: { screenX: number; screenY: number } | undefined const hueStep = () => (mode() === "light" ? 800 : 200) const accent = () => theme.hue.accent[hueStep()] const activeNumber = () => theme.hue.interactive[hueStep()] @@ -825,11 +959,26 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim backgroundColor={background()} onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())} onMouseOut={() => marquee.leave(tab.sessionID)} - onMouseDown={() => { + onMouseDown={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) { + setDragging(undefined) + setContextMenu({ + x: event.x - (strip?.screenX ?? 0), + y: event.y - (strip?.screenY ?? 0), + sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID, + title: tab === NEW_SESSION_TAB ? undefined : tab.title, + }) + event.preventDefault() + event.stopPropagation() + return + } marquee.enter(tab.sessionID, title(), restingTitleWidth()) setDragging(tab.sessionID) }} - onMouseUp={release} + onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return + release() + }} onMouseDrag={(event) => { if (tab === NEW_SESSION_TAB) return const slot = slotAt(event.x) @@ -880,6 +1029,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim fg={closeColor()} selectable={false} onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return // The close mark only renders while hovered; without motion events a click can // land here first, and must select the tab instead of closing it invisibly. if (hovered() !== tab.sessionID) return @@ -907,11 +1057,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim selectable={false} onMouseOver={() => setAddHovered(true)} onMouseOut={() => setAddHovered(false)} - onMouseUp={() => tabs.add?.()} + onMouseDown={(event) => { + if (event.button !== RIGHT_MOUSE_BUTTON) return + setContextMenu({ x: event.x - (strip?.screenX ?? 0), y: event.y - (strip?.screenY ?? 0) }) + event.preventDefault() + event.stopPropagation() + }} + onMouseUp={(event) => { + if (event.button === RIGHT_MOUSE_BUTTON) return + tabs.add?.() + }} > {" + "} + + {(state) => setContextMenu(undefined)} />} + ) } From a20d9452451ba519e47c9b59a85941a3d7b628e4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Aug 2026 12:55:42 -0400 Subject: [PATCH 24/61] docs: base worktrees on v2 --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 749e8ef713..be191e23dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,9 @@ - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. - Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. -- The default branch in this repo is `dev`. -- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. +- The default branch in this repo is `v2`. +- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`. +- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs. ## Live V2 TUI Testing From fd30b9765d718cc52df7a1540a27d499e6dbd1c3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 13:16:12 -0400 Subject: [PATCH 25/61] refactor(core): spawn stdio MCP servers through the location environment (#42076) --- packages/core/src/mcp/client.ts | 74 ++------- packages/core/src/mcp/index.ts | 16 +- packages/core/src/mcp/stdio.ts | 181 ++++++++++++++++++++++ packages/core/test/fixture/environment.ts | 35 +++++ packages/core/test/mcp.test.ts | 156 +++++++++++++++++-- 5 files changed, 385 insertions(+), 77 deletions(-) create mode 100644 packages/core/src/mcp/stdio.ts diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 49a9e0fc21..b5cba91ff8 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -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), + 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((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( list: (cursor: string | undefined) => Promise, items: (result: R) => T[], diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index c1020cbff4..4685bc31eb 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -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() - 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], }) } diff --git a/packages/core/src/mcp/stdio.ts b/packages/core/src/mcp/stdio.ts new file mode 100644 index 0000000000..2da5c71a1d --- /dev/null +++ b/packages/core/src/mcp/stdio.ts @@ -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 + 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 +} + +/** + * 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(OUTGOING_CAPACITY) + const buffer = new ReadBuffer() + const state: { phase: "ready" | "starting" | "open" | "closed"; handle?: ChildProcessHandle } = { phase: "ready" } + let startup: Promise | undefined + let closing: Promise | 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 +}) diff --git a/packages/core/test/fixture/environment.ts b/packages/core/test/fixture/environment.ts index 67c4f858ac..d9a341a626 100644 --- a/packages/core/test/fixture/environment.ts +++ b/packages/core/test/fixture/environment.ts @@ -1,7 +1,42 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" 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) => + 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 diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 1f2cf2d2d2..9c2e49c628 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -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 | undefined @@ -167,6 +172,7 @@ function resourceMcpLayer( overrides?: { entries?: Config.Interface["entries"] subscribe?: Bus.Interface["subscribe"] + environment?: Layer.Layer }, ) { 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([ @@ -407,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", @@ -440,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 = [] + 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() + const release = Deferred.makeUnsafe() + const exited = Deferred.makeUnsafe() + const signals: Array = [] + 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", @@ -466,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", @@ -487,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", @@ -508,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", @@ -527,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", @@ -562,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, @@ -633,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, From 17cae23dce00d65facde929e1b5d3ef20011abc7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 13:17:13 -0400 Subject: [PATCH 26/61] fix(tui): stabilize repeated open menu (#42086) --- packages/tui/src/app.tsx | 15 ++++++--- packages/tui/src/component/dialog-open.tsx | 32 +++++++++---------- packages/tui/src/ui/dialog.tsx | 25 +++++++++------ .../tui/test/cli/tui/dialog-open.test.tsx | 20 ++++++++---- 4 files changed, 55 insertions(+), 37 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ebb2b6e5fb..ac3c68aac0 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -2,7 +2,7 @@ import { render, useRenderer, useTerminalDimensions } from "@opentui/solid" import { registerOpencodeSpinner } from "./component/register-spinner" import { Deferred, Effect } from "effect" import { Service, type Endpoint } from "@opencode-ai/client/effect/service" -import { OpenCode } from "@opencode-ai/client" +import { OpenCode, type SessionInfo } from "@opencode-ai/client" import { Global } from "@opencode-ai/util/global" import { ClipboardProvider, useClipboard } from "./context/clipboard" import { LogProvider, useLog, type LogSink } from "./context/log" @@ -69,7 +69,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" -import { DialogOpen } from "./component/dialog-open" +import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open" import { SessionTabs } from "./component/session-tabs" import { sessionTabsFitVertically } from "./ui/layout" import { ThemeErrorToast } from "./component/theme-error-toast" @@ -478,6 +478,7 @@ function App(props: { pair?: DialogPairCredentials }) { const promptRef = usePromptRef() const plugins = usePlugin() const clipboard = useClipboard() + let openingOpen: Promise | undefined // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // without having to open the status panel. Tracking the last alerted status avoids re-toasting // the same problem on every refresh while still re-alerting if the state changes. @@ -680,8 +681,14 @@ function App(props: { pair?: DialogPairCredentials }) { title: "Open session or project", category: "Session", slash: { name: "open", aliases: ["projects", "project"] }, - run: () => { - dialog.replace(() => ) + run: async () => { + if (dialog.key === DialogOpenKey || openingOpen) return + const previous = dialog.stack.at(-1) + openingOpen = loadDialogOpen(data, client) + const sessions = await openingOpen + openingOpen = undefined + if (dialog.stack.at(-1) !== previous) return + dialog.replace(() => , undefined, { key: DialogOpenKey, size: "large" }) }, }, ...Array.from({ length: 9 }, (_, i) => ({ diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index b19caf7f88..62cf0adbb3 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -1,4 +1,4 @@ -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal } from "solid-js" import type { SessionInfo } from "@opencode-ai/client" import { useTerminalDimensions } from "@opentui/solid" import { dialogWidth, useDialog } from "../ui/dialog" @@ -20,10 +20,22 @@ import { Spinner } from "./spinner" import { projectName } from "../util/project" const RECENT_LIMIT = 8 +export const DialogOpenKey = Symbol("DialogOpen") type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string } -export function DialogOpen() { +export async function loadDialogOpen(data: ReturnType, client: ReturnType) { + const [, sessions] = await Promise.all([ + data.project.sync().catch(() => {}), + client.api.session + .list({ limit: 50, order: "desc", parentID: null }) + .then((response) => response.data) + .catch(() => [] as SessionInfo[]), + ]) + return sessions +} + +export function DialogOpen(props: { sessions: SessionInfo[] }) { const dialog = useDialog() const route = useRoute() const data = useData() @@ -39,18 +51,6 @@ export function DialogOpen() { const [filter, setFilter] = createSignal("") const [selectionMoved, setSelectionMoved] = createSignal(false) - void data.project.sync().catch(() => {}) - - // One background fetch fills in recent sessions from other projects; the menu renders - // immediately from the local store and never blocks on the network. - const [fetched] = createResource( - () => - client.api.session - .list({ limit: 50, order: "desc", parentID: null }) - .then((response) => response.data) - .catch(() => [] as SessionInfo[]), - { initialValue: [] }, - ) const [matched] = createResource( () => { const value = filter().trim() @@ -72,7 +72,7 @@ export function DialogOpen() { const sessions = createMemo(() => { const seen = new Set() const match = matched() - return [...data.session.list(), ...fetched(), ...(match ? [match] : [])] + return [...data.session.list(), ...props.sessions, ...(match ? [match] : [])] .filter((session) => { if (session.parentID || seen.has(session.id)) return false seen.add(session.id) @@ -142,8 +142,6 @@ export function DialogOpen() { return [...sessionOptions, ...projectOptions] }) - onMount(() => dialog.setSize("large")) - return ( void + key?: unknown }[], size: "medium" as DialogSize, centered: false, @@ -155,7 +156,7 @@ function init() { }) refocus() }, - replace(input: any, onClose?: () => void) { + replace(input: any, onClose?: () => void, options?: { key?: unknown; size?: DialogSize }) { if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable focus?.blur() @@ -163,14 +164,17 @@ function init() { for (const item of store.stack) { if (item.onClose) item.onClose() } - setStore("size", "medium") - setStore("centered", false) - setStore("stack", [ - { - element: input, - onClose, - }, - ]) + batch(() => { + setStore("size", options?.size ?? "medium") + setStore("centered", false) + setStore("stack", [ + { + element: input, + onClose, + key: options?.key, + }, + ]) + }) }, get stack() { return store.stack @@ -181,6 +185,9 @@ function init() { get centered() { return store.centered }, + get key() { + return store.stack.at(-1)?.key + }, setSize(size: "medium" | "large" | "xlarge") { setStore("size", size) }, diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index 0937178b6f..f963e8ee27 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -2,9 +2,9 @@ import { expect, test } from "bun:test" import { testRender } from "@opentui/solid" import { onMount } from "solid-js" -import { DialogOpen } from "../../../src/component/dialog-open" +import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open" import { ConfigProvider } from "../../../src/config" -import { ClientProvider } from "../../../src/context/client" +import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider, useData } from "../../../src/context/data" import { Keymap } from "../../../src/context/keymap" import { LocationProvider, useLocation } from "../../../src/context/location" @@ -131,7 +131,7 @@ test("shows the current project and opens its root", async () => { } }) -test("preserves a moved project when sessions arrive", async () => { +test("waits for sessions before showing the populated picker", async () => { let resolveSessions!: (response: Response) => void const sessions = new Promise((resolve) => (resolveSessions = resolve)) const fixture = await renderOpen((url) => { @@ -157,8 +157,8 @@ test("preserves a moved project when sessions arrive", async () => { }) try { - await fixture.app.waitForFrame((frame) => frame.includes("Second project")) - fixture.app.mockInput.pressArrow("down") + await fixture.app.renderOnce() + expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects") resolveSessions( json({ @@ -176,7 +176,9 @@ test("preserves a moved project when sessions arrive", async () => { cursor: {}, }), ) - await fixture.app.waitForFrame((frame) => frame.includes("Recent session")) + await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project")) + fixture.app.mockInput.pressArrow("down") + fixture.app.mockInput.pressArrow("down") fixture.app.mockInput.pressEnter() await fixture.app.waitFor(() => fixture.route.data.type === "home") @@ -292,12 +294,16 @@ async function renderOpen( function Probe() { const dialog = useDialog() + const client = useClient() route = useRoute() location = useLocation() data = useData() storage = useStorage() onMount( - () => void Promise.resolve(beforeOpen?.({ data, location })).then(() => dialog.replace(() => )), + () => + void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) => + dialog.replace(() => , undefined, { key: DialogOpenKey, size: "large" }), + ), ) return null } From 723f5e0028a1e54eb14d45abf6c03bc640138c18 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 13:23:14 -0400 Subject: [PATCH 27/61] fix(tui): consolidate developer tools controls (#42088) --- packages/tui/src/component/devtools-bar.tsx | 11 ++++++++++- packages/tui/src/component/dialog-config.tsx | 11 +---------- packages/tui/test/config-v2.test.tsx | 5 +++++ 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index 2212c07aa0..545507fb28 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -429,6 +429,7 @@ export function DevToolsBar() { function BarItem(props: ParentProps<{ active: boolean; onClick: () => void }>) { const theme = useTheme() const renderer = useRenderer() + const [hovered, setHovered] = createSignal(false) return ( void }>) { flexDirection="row" paddingLeft={1} paddingRight={1} - backgroundColor={props.active ? theme.background.action.primary.focused : undefined} + backgroundColor={ + props.active + ? theme.background.action.primary.focused + : hovered() + ? theme.background.action.primary.hovered + : undefined + } + onMouseOver={() => setHovered(true)} + onMouseOut={() => setHovered(false)} onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return props.onClick() diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 9f84e810cd..ced25dd183 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -283,7 +283,7 @@ export const settings: Setting[] = [ keywords: ["selection", "clipboard"], }, { - title: "DevTools", + title: "Developer tools", category: "Debug", path: ["debug", "devtools"], default: false, @@ -291,15 +291,6 @@ export const settings: Setting[] = [ labels: ["off", "on"], keywords: ["debug bar", "developer tools"], }, - { - title: "Turn token usage", - category: "Debug", - path: ["debug", "turn_tokens"], - default: false, - values: [false, true, "verbose"], - labels: ["off", "on", "verbose"], - keywords: ["tokens", "usage", "debug"], - }, ] export function settingID(setting: Setting) { diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 57a1c2858d..23017e09f4 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -180,6 +180,11 @@ test("uses ctrl+z for input undo when terminal suspend is unavailable", () => { expect(overridden.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }]) }) +test("keeps turn token usage inside developer tools", () => { + expect(settings.find((setting) => setting.path.join(".") === "debug.devtools")?.title).toBe("Developer tools") + expect(settings.some((setting) => setting.path.join(".") === "debug.turn_tokens")).toBe(false) +}) + test("provides config and its host interface", async () => { const config = resolve({}, { terminalSuspend: true }) let current = {} From a841fc22bba9ad0581c3975828a5fdf11c8d28cc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 14:07:56 -0400 Subject: [PATCH 28/61] feat(server): workerd runtime profile and SDK workerd entrypoint (#41918) --- bun.lock | 497 ++++++++++++++---- package.json | 1 + packages/core/package.json | 5 + packages/core/src/database/database.ts | 9 + .../database/drizzle/effect-sqlite/session.ts | 15 + packages/core/src/database/sqlite.workerd.ts | 7 +- packages/core/src/filesystem/fff.workerd.ts | 12 + .../core/src/image/photon-wasm.workerd.ts | 4 + packages/core/src/mcp/index.ts | 7 + packages/core/src/mcp/oauth.ts | 3 +- packages/core/src/models-dev.ts | 28 +- packages/core/src/plugin/provider/openai.ts | 3 +- packages/core/src/pty/pty.workerd.ts | 9 + .../core/src/shell/parser-wasm.workerd.ts | 4 + .../core/src/util/process-lock-ffi.workerd.ts | 14 + .../test/fixture/durable-object-storage.ts | 47 ++ packages/core/test/sqlite-workerd.test.ts | 56 +- packages/protocol/src/groups/health.ts | 3 +- packages/sdk-next/package.json | 4 +- packages/sdk-next/src/opencode.ts | 25 +- packages/sdk-next/src/workerd.ts | 29 + packages/server/package.json | 3 +- packages/server/script/workerd-probe.ts | 59 +++ packages/server/src/fetch.ts | 16 +- packages/server/src/handlers/health.ts | 3 +- packages/server/src/options.ts | 6 + packages/server/src/routes.ts | 17 +- packages/server/src/workerd.ts | 150 ++++++ packages/server/test/workerd.test.ts | 34 ++ packages/util/package.json | 5 + packages/util/src/cross-spawn-spawner.ts | 5 +- packages/util/src/effect/app-node-platform.ts | 5 +- packages/util/src/fs-util.ts | 2 +- packages/util/src/global-roots.ts | 14 + packages/util/src/global-roots.workerd.ts | 16 + packages/util/src/global.ts | 11 +- packages/util/src/npm.ts | 2 +- packages/workerd-spike/package.json | 31 ++ packages/workerd-spike/src/worker.ts | 74 +++ packages/workerd-spike/test/env.d.ts | 5 + .../workerd-spike/test/shims/mime-types.mjs | 10 + packages/workerd-spike/test/spike.test.ts | 328 ++++++++++++ packages/workerd-spike/tsconfig.json | 14 + packages/workerd-spike/vitest.config.ts | 85 +++ packages/workerd-spike/wrangler.jsonc | 11 + ...udflare%2Fvitest-pool-workers@0.12.6.patch | 124 +++++ turbo.json | 4 + 47 files changed, 1624 insertions(+), 192 deletions(-) create mode 100644 packages/core/src/filesystem/fff.workerd.ts create mode 100644 packages/core/src/image/photon-wasm.workerd.ts create mode 100644 packages/core/src/pty/pty.workerd.ts create mode 100644 packages/core/src/shell/parser-wasm.workerd.ts create mode 100644 packages/core/src/util/process-lock-ffi.workerd.ts create mode 100644 packages/core/test/fixture/durable-object-storage.ts create mode 100644 packages/sdk-next/src/workerd.ts create mode 100644 packages/server/script/workerd-probe.ts create mode 100644 packages/server/src/workerd.ts create mode 100644 packages/server/test/workerd.test.ts create mode 100644 packages/util/src/global-roots.ts create mode 100644 packages/util/src/global-roots.workerd.ts create mode 100644 packages/workerd-spike/package.json create mode 100644 packages/workerd-spike/src/worker.ts create mode 100644 packages/workerd-spike/test/env.d.ts create mode 100644 packages/workerd-spike/test/shims/mime-types.mjs create mode 100644 packages/workerd-spike/test/spike.test.ts create mode 100644 packages/workerd-spike/tsconfig.json create mode 100644 packages/workerd-spike/vitest.config.ts create mode 100644 packages/workerd-spike/wrangler.jsonc create mode 100644 patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch diff --git a/bun.lock b/bun.lock index 2432c40f6f..ce22f86c5e 100644 --- a/bun.lock +++ b/bun.lock @@ -655,6 +655,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/server": "workspace:*", + "@opencode-ai/util": "workspace:*", "effect": "catalog:", }, "devDependencies": { @@ -1032,6 +1033,31 @@ "typescript": "catalog:", }, }, + "packages/workerd-spike": { + "name": "@opencode-ai/workerd-spike", + "version": "0.0.0", + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "0.12.6", + "@cloudflare/workers-types": "^4.20250808.0", + "@effect/platform-node": "catalog:", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "unenv": "2.0.0-rc.24", + "vitest": "3.2.7", + "wrangler": "4.28.0", + "xdg-basedir": "5.1.0", + }, + }, "packages/www": { "name": "@opencode-ai/www", "dependencies": { @@ -1061,6 +1087,7 @@ "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch", @@ -1534,6 +1561,8 @@ "@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.15.2", "", { "dependencies": { "@cloudflare/unenv-preset": "2.7.11", "@remix-run/node-fetch-server": "^0.8.0", "get-port": "^7.1.0", "miniflare": "4.20251118.1", "picocolors": "^1.1.1", "tinyglobby": "^0.2.12", "unenv": "2.0.0-rc.24", "wrangler": "4.50.0", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0" } }, "sha512-SPMxsesbABOjzcAa4IzW+yM+fTIjx3GG1doh229Pg16FjSEZJhknyRpcld4gnaZioK3JKwG9FWdKsUhbplKY8w=="], + "@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.12.6", "", { "dependencies": { "cjs-module-lexer": "^1.2.3", "esbuild": "0.27.0", "miniflare": "4.20260120.0", "wrangler": "4.60.0", "zod": "^3.25.76" }, "peerDependencies": { "@vitest/runner": "2.0.x - 3.2.x", "@vitest/snapshot": "2.0.x - 3.2.x", "vitest": "2.0.x - 3.2.x" } }, "sha512-smzhKzBdB4JYKo9bb8x3i70CtSj5GRhDfGvUhbLI7uPUv4QwMubddmZ+XYNekcy3C8/FG1fjLiwUqisITik8oQ=="], + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251118.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UmWmYEYS/LkK/4HFKN6xf3Hk8cw70PviR+ftr3hUvs9HYZS92IseZEp16pkL6ZBETrPRpZC7OrzoYF7ky6kHsg=="], "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251118.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RockU7Qzf4rxNfY1lx3j4rvwutNLjTIX7rr2hogbQ4mzLo8Ea40/oZTzXVxl+on75joLBrt0YpenGW8o/r44QA=="], @@ -2120,6 +2149,8 @@ "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], + "@opencode-ai/workerd-spike": ["@opencode-ai/workerd-spike@workspace:packages/workerd-spike"], + "@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"], "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.9.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ=="], @@ -3250,19 +3281,19 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + "@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], - "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + "@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], - "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + "@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + "@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], - "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], - "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + "@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], @@ -3594,6 +3625,8 @@ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], + "classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="], "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], @@ -4034,6 +4067,8 @@ "expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="], + "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], + "ext-list": ["ext-list@2.2.2", "", { "dependencies": { "mime-db": "^1.28.0" } }, "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA=="], "ext-name": ["ext-name@5.0.0", "", { "dependencies": { "ext-list": "^2.0.0", "sort-keys-length": "^1.0.0" } }, "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ=="], @@ -5504,7 +5539,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -5542,6 +5577,8 @@ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="], "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], @@ -5614,7 +5651,9 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], @@ -5802,6 +5841,8 @@ "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="], "vite-plugin-icons-spritesheet": ["vite-plugin-icons-spritesheet@3.0.1", "", { "dependencies": { "chalk": "^5.4.1", "glob": "^11.0.1", "node-html-parser": "^7.0.1", "tinyexec": "^0.3.2" }, "peerDependencies": { "vite": ">=5.2.0" } }, "sha512-Cr0+Z6wRMwSwKisWW9PHeTjqmQFv0jwRQQMc3YgAhAgZEe03j21el0P/CA31KN/L5eiL1LhR14VTXl96LetonA=="], @@ -5810,7 +5851,7 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], "volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="], @@ -6224,6 +6265,14 @@ "@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], + + "@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260120.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260120.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "^3.25.76" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q=="], + + "@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.60.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.11.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20260120.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260120.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260120.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g=="], + + "@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -6370,6 +6419,8 @@ "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], + "@opencode-ai/workerd-spike/wrangler": ["wrangler@4.28.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.6.0", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250803.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.19", "workerd": "1.20250803.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250803.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-y0yHIuScpok9oSErLqDbxkBChC2+/jZpvqMg2NxOto1JCyUtDUuKljOfcVMaI48d9GuhOCSoWSumYxLAHNxaLA=="], + "@opencode-ai/www/@astrojs/cloudflare": ["@astrojs/cloudflare@14.1.4", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/underscore-redirects": "1.0.3", "@cloudflare/vite-plugin": "^1.39.0", "piccolore": "^0.1.3", "vite": "^8.0.13" }, "peerDependencies": { "astro": "^7.0.0", "wrangler": "^4.83.0" } }, "sha512-Zyo1E/5/dmegmKODbwUzOd67euNd6oKcbllhAwe3uFFprA7mIFNkpF6yBBa78vIkpMNuU13MkBJHCLd+yJSuEA=="], "@opencode-ai/www/astro": ["astro@7.1.3", "", { "dependencies": { "@astrojs/compiler-rs": "^0.3.1", "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-satteri": "0.3.4", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "jsonc-parser": "^3.3.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unstorage": "^1.17.5", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { "@astrojs/markdown-remark": "7.2.1" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-4dhPyAAXthf3xLEYnG8SeL7yr/nTPPABfY7e9YF0yuO+vK9Xp+8Q5j4xzsmL3GueukQv4oNwGNTBepLOiDGeJA=="], @@ -6474,6 +6525,8 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "@solidjs/start/vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "@storybook/addon-docs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "@storybook/addon-docs/react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], @@ -6520,12 +6573,6 @@ "@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], - - "@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - - "@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], - "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], "@vscode/emmet-helper/vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], @@ -6840,6 +6887,10 @@ "sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="], + "storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + "storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], @@ -6850,6 +6901,8 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -6886,17 +6939,11 @@ "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], + "vite-node/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], - "vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], - - "vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], - - "vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], - - "vitest/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], - - "vitest/vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + "vitest/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], "vscode-json-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="], @@ -7194,6 +7241,72 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], + + "@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], + + "@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20260120.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260120.0", "@cloudflare/workerd-darwin-arm64": "1.20260120.0", "@cloudflare/workerd-linux-64": "1.20260120.0", "@cloudflare/workerd-linux-arm64": "1.20260120.0", "@cloudflare/workerd-windows-64": "1.20260120.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw=="], + + "@cloudflare/vitest-pool-workers/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], + + "@cloudflare/vitest-pool-workers/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.11.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260120.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260120.0", "@cloudflare/workerd-darwin-arm64": "1.20260120.0", "@cloudflare/workerd-linux-64": "1.20260120.0", "@cloudflare/workerd-linux-arm64": "1.20260120.0", "@cloudflare/workerd-windows-64": "1.20260120.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw=="], + "@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], "@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -7394,6 +7507,16 @@ "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@opencode-ai/workerd-spike/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.6.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.19", "workerd": "^1.20250802.0" }, "optionalPeers": ["workerd"] }, "sha512-h7Txw0WbDuUbrvZwky6+x7ft+U/Gppfn/rWx6IdR+e9gjygozRJnV26Y2TOr3yrIFa6OsZqqR2lN+jWTrakHXg=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], + + "@opencode-ai/workerd-spike/wrangler/miniflare": ["miniflare@4.20250803.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "^7.10.0", "workerd": "1.20250803.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-1tmCLfmMw0SqRBF9PPII9CVLQRzOrO7uIBmSng8BMSmtgs2kos7OeoM0sg6KbR9FrvP/zAniLyZuCAMAjuu4fQ=="], + + "@opencode-ai/workerd-spike/wrangler/unenv": ["unenv@2.0.0-rc.19", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.7", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.6.1" } }, "sha512-t/OMHBNAkknVCI7bVB9OWjUUAwhVv9vsPIAGnNUxnu3FxPQN11rjh0sksLMzc3g7IlTgvHmOTl4JM7JHpcv5wA=="], + + "@opencode-ai/workerd-spike/wrangler/workerd": ["workerd@1.20250803.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250803.0", "@cloudflare/workerd-darwin-arm64": "1.20250803.0", "@cloudflare/workerd-linux-64": "1.20250803.0", "@cloudflare/workerd-linux-arm64": "1.20250803.0", "@cloudflare/workerd-windows-64": "1.20250803.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oYH29mE/wNolPc32NHHQbySaNorj6+KASUtOvQHySxB5mO1NWdGuNv49woxNCF5971UYceGQndY+OLT+24C3wQ=="], + "@opencode-ai/www/@astrojs/cloudflare/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.1", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.1.1", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q=="], "@opencode-ai/www/@astrojs/cloudflare/@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@1.0.3", "", {}, "sha512-cxnGSw+sJigBLdX4TMSZKkzV6C3gMLJMucDk2W+n281Xhie68T2/9f1+1NMNDCZsc5i0FED7Qt5I10g2O9wtZg=="], @@ -7530,6 +7653,30 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@solidjs/start/vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@solidjs/start/vitest/@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@solidjs/start/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@solidjs/start/vitest/@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@solidjs/start/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@solidjs/start/vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@solidjs/start/vitest/@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + + "@solidjs/start/vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + + "@solidjs/start/vitest/std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "@solidjs/start/vitest/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + + "@solidjs/start/vitest/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "@solidjs/start/vitest/vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + "@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "@storybook/csf-plugin/unplugin/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], @@ -7542,8 +7689,6 @@ "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], - "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], @@ -7764,6 +7909,8 @@ "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], @@ -7838,12 +7985,6 @@ "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="], - "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], - - "vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - - "vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], @@ -8244,6 +8385,74 @@ "@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="], + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260120.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q=="], + + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260120.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA=="], + + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ=="], + + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA=="], + + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260120.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260120.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260120.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA=="], + + "@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260120.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA=="], + "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -8388,6 +8597,74 @@ "@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="], + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="], + + "@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], + + "@opencode-ai/workerd-spike/wrangler/miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "@opencode-ai/workerd-spike/wrangler/miniflare/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + + "@opencode-ai/workerd-spike/wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "@opencode-ai/workerd-spike/wrangler/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + + "@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250803.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6QciMnJp1p3F1qUiN0LaLfmw7SuZA/gfUBOe8Ft81pw16JYZ3CyiqIKPJvc1SV8jgDx8r+gz/PRi1NwOMt329A=="], + + "@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250803.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DoIgghDowtqoNhL6OoN/F92SKtrk7mRQKc4YSs/Dst8IwFZq+pCShOlWfB0MXqHKPSoiz5xLSrUKR9H6gQMPvw=="], + + "@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250803.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mYdz4vNWX3+PoqRjssepVQqgh42IBiSrl+wb7vbh7VVWUVzBnQKtW3G+UFiBF62hohCLexGIEi7L0cFfRlcKSQ=="], + + "@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250803.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RmrtUYLRUg6djKU7Z6yebS6YGJVnaDVY6bbXca+2s26vw4ibJDOTPLuBHFQF62Grw3fAfsNbjQh5i14vG2mqUg=="], + + "@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250803.0", "", { "os": "win32", "cpu": "x64" }, "sha512-uLV8gdudz36o9sUaAKbBxxTwZwLFz1KyW7QpBvOo4+r3Ib8yVKXGiySIMWGD7A0urSMrjf3e5LlLcJKgZUOjMA=="], + "@opencode-ai/www/@astrojs/cloudflare/@astrojs/internal-helpers/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "@opencode-ai/www/@astrojs/cloudflare/@cloudflare/vite-plugin/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], @@ -8598,6 +8875,12 @@ "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], + "@solidjs/start/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "@solidjs/start/vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "@solidjs/start/vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -8756,6 +9039,8 @@ "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + "temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -8764,78 +9049,6 @@ "unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - - "vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - - "vitest/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - - "vitest/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - - "vitest/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - - "vitest/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - - "vitest/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - - "vitest/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - - "vitest/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - - "vitest/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - - "vitest/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@astrojs/cloudflare/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -8940,6 +9153,8 @@ "@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="], + "@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], @@ -9168,6 +9383,78 @@ "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], + "@solidjs/start/vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@solidjs/start/vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "@solidjs/start/vitest/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + "archiver-utils/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], diff --git a/package.json b/package.json index 23c5416f1a..98c3f289d5 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch", "@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch" } } diff --git a/packages/core/package.json b/packages/core/package.json index 1d98d93c6b..719f449bd8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,26 +36,31 @@ "default": "./src/database/sqlite.node.ts" }, "#pty": { + "workerd": "./src/pty/pty.workerd.ts", "bun": "./src/pty/pty.bun.ts", "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" }, "#fff": { + "workerd": "./src/filesystem/fff.workerd.ts", "bun": "./src/filesystem/fff.bun.ts", "node": "./src/filesystem/fff.node.ts", "default": "./src/filesystem/fff.bun.ts" }, "#photon-wasm": { + "workerd": "./src/image/photon-wasm.workerd.ts", "bun": "./src/image/photon-wasm.bun.ts", "node": "./src/image/photon-wasm.node.ts", "default": "./src/image/photon-wasm.bun.ts" }, "#shell-parser-wasm": { + "workerd": "./src/shell/parser-wasm.workerd.ts", "bun": "./src/shell/parser-wasm.bun.ts", "node": "./src/shell/parser-wasm.node.ts", "default": "./src/shell/parser-wasm.bun.ts" }, "#process-lock-ffi": { + "workerd": "./src/util/process-lock-ffi.workerd.ts", "bun": "./src/util/process-lock-ffi.bun.ts", "node": "./src/util/process-lock-ffi.node.ts", "default": "./src/util/process-lock-ffi.bun.ts" diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 369106cde4..fb5eb27610 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -65,4 +65,13 @@ export function configured(options?: Options) { return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] }) } +/** `configured`, but over an injected SqlClient layer instead of a filesystem path. */ +export function configuredClient(client: Layer.Layer) { + return makeGlobalNode({ + service: Service, + layer: layerFromClient.pipe(Layer.provide(client)), + deps: [Global.node], + }) +} + export const node = configured({ path: ":memory:" }) diff --git a/packages/core/src/database/drizzle/effect-sqlite/session.ts b/packages/core/src/database/drizzle/effect-sqlite/session.ts index 7d8dc290cd..6cfb5dabf7 100644 --- a/packages/core/src/database/drizzle/effect-sqlite/session.ts +++ b/packages/core/src/database/drizzle/effect-sqlite/session.ts @@ -28,6 +28,20 @@ export interface EffectSQLiteQueryEffectHKT extends QueryEffectHKTBase { readonly context: never } +/** + * A SqlClient whose runtime rejects SQL transaction statements (BEGIN/COMMIT/SAVEPOINT) and + * manages transactions natively instead — Cloudflare Durable Object SQLite. Producers set + * `transactionStatements: false` (see `database/sqlite.workerd.ts`) and the session delegates + * to the client's `withTransaction` rather than issuing transaction statements. + */ +export interface NativeTransactionSqlClient extends SqlClient { + readonly transactionStatements: false +} + +export function managesTransactionsNatively(client: SqlClient): client is NativeTransactionSqlClient { + return (client as SqlClient & { readonly transactionStatements?: boolean }).transactionStatements === false +} + export type EffectSQLiteRunResult = readonly never[] export interface EffectSQLiteSessionOptions { @@ -120,6 +134,7 @@ export class EffectSQLiteSession extends SQLite } private withTransaction(effect: Effect.Effect, config: SQLiteTransactionConfig | undefined) { + if (managesTransactionsNatively(this.client)) return this.client.withTransaction(effect) return Effect.uninterruptibleMask((restore) => Effect.withFiber((fiber) => { const services = fiber.context diff --git a/packages/core/src/database/sqlite.workerd.ts b/packages/core/src/database/sqlite.workerd.ts index c0239bb07e..d0f4e5d2bf 100644 --- a/packages/core/src/database/sqlite.workerd.ts +++ b/packages/core/src/database/sqlite.workerd.ts @@ -4,6 +4,7 @@ import { Reactivity } from "effect/unstable/reactivity" import { SqlClient, Statement } from "effect/unstable/sql" import type { Connection } from "effect/unstable/sql/SqlConnection" import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError" +import type { NativeTransactionSqlClient } from "./drizzle/effect-sqlite/session.js" import { Sqlite } from "./sqlite.js" const ATTR_DB_SYSTEM_NAME = "db.system.name" @@ -215,10 +216,10 @@ const make = (options: Config) => config: options, withTransaction: makeWithTransaction(native, connection, semaphore), // Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such - // as the drizzle session must route through withTransaction instead. + // as the drizzle session detect this and route through withTransaction. transactionStatements: false, - }, - ) + } as const, + ) satisfies NativeTransactionSqlClient return client }) diff --git a/packages/core/src/filesystem/fff.workerd.ts b/packages/core/src/filesystem/fff.workerd.ts new file mode 100644 index 0000000000..12d2dc5278 --- /dev/null +++ b/packages/core/src/filesystem/fff.workerd.ts @@ -0,0 +1,12 @@ +import { bind } from "./fff.js" + +export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff.js" + +// No fff backend exists on workerd; every create reports unavailability and +// FileSystemSearch degrades the same way it does on a runtime without the native module. +const adapter = bind(undefined, "fff unavailable on workerd runtime") + +export const available = adapter.available +export const create = adapter.create + +export * as Fff from "./fff.workerd.js" diff --git a/packages/core/src/image/photon-wasm.workerd.ts b/packages/core/src/image/photon-wasm.workerd.ts new file mode 100644 index 0000000000..6207c54ce4 --- /dev/null +++ b/packages/core/src/image/photon-wasm.workerd.ts @@ -0,0 +1,4 @@ +// workerd has no filesystem path to a photon wasm artifact. Image.Photon only +// reads this lazily and surfaces a typed ResizerUnavailableError when loading +// fails, so an empty path degrades cleanly instead of breaking module load. +export default "" diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 4685bc31eb..9504e79e89 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -165,6 +165,8 @@ export const Options = Schema.Struct({ version: Schema.String, }), ), + /** Set false on runtimes that cannot spawn child processes; local (stdio) servers report failed instead of connecting. */ + stdio: Schema.optional(Schema.Boolean), }) export type Options = typeof Options.Type @@ -498,6 +500,11 @@ export const layer = (options?: Options) => const startServer = (name: ServerName, entry: ServerEntry) => Effect.gen(function* () { + if (options?.stdio === false && entry.config.type === "local") { + entry.status = { status: "failed", error: "stdio MCP servers are unavailable in this runtime" } + yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + return + } // Announce the handshake so connect() and credential reconnects don't show a stale // disabled/failed status for the duration of the connection attempt. entry.status = { status: "pending" } diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts index 1fda70dbd6..b6a87b9b93 100644 --- a/packages/core/src/mcp/oauth.ts +++ b/packages/core/src/mcp/oauth.ts @@ -2,7 +2,6 @@ export * as MCPOAuth from "./oauth.js" import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js" -import { createServer } from "node:http" import { Deferred, Effect } from "effect" import { Credential } from "@opencode-ai/schema/credential" import { ConfigMCP } from "@opencode-ai/schema/config/mcp" @@ -152,6 +151,8 @@ export const authorize = (input: { const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback" const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") + // Lazy so runtimes without a loopback listener (workerd) never evaluate node:http. + const { createServer } = yield* Effect.promise(() => import("node:http")) const server = createServer((request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1") if (url.pathname !== redirectPath) { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index e23d735a87..5fcdf7d486 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -544,6 +544,24 @@ const Cache = Schema.Struct({ }) const defaultSource = "https://models.opencode.ai" +// Bundled snapshot of https://models.opencode.ai/api.json, committed at +// packages/core/src/models-dev/snapshot.txt and refreshed via +// `bun run script/update-models-snapshot.ts`. Decoded and normalized once per +// isolate: the snapshot is a multi-MB module-level constant and one isolate can +// host many runtimes (Cloudflare colocates Durable Object instances), so +// per-runtime decoding would multiply the cost. +let bundledCache: readonly Snapshot[] | undefined +const bundledSnapshot = Effect.suspend(() => + bundledCache + ? Effect.succeed(bundledCache) + : decodeCatalog(snapshotText).pipe( + Effect.map((catalog) => { + bundledCache = normalize(catalog) + return bundledCache + }), + ), +) + function cacheKey(source: string) { if (source === defaultSource) return "models-dev:catalog" return `models-dev:catalog:${Hash.fast(source)}` @@ -607,11 +625,9 @@ export const layer = (options?: Options) => ) : Effect.succeed(undefined) - // Bundled snapshot of https://models.opencode.ai/api.json, committed at - // packages/core/src/models-dev/snapshot.txt and refreshed via - // `bun run script/update-models-snapshot.ts`. It is the boot-time floor - // for the catalog; the periodic fetch below still refreshes on top. - const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : decodeCatalog(snapshotText) + // The bundled snapshot is the boot-time floor for the catalog; the + // periodic fetch below still refreshes on top. + const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : bundledSnapshot const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { const text = yield* fetchApi() @@ -635,7 +651,7 @@ export const layer = (options?: Options) => const cached = options?.file ? undefined : yield* loadFromCache() if (cached) return normalize(cached.catalog) const bundled = yield* loadSnapshot - if (bundled) return normalize(bundled) + if (bundled) return bundled if (!fetch) return [] const catalog = yield* lock.withPermit( Effect.gen(function* () { diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 15437e7c89..2aeded888d 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,4 +1,3 @@ -import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration" import { define } from "@opencode-ai/plugin/effect/plugin" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" @@ -58,6 +57,8 @@ const browser = (app: App.Info) => const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) const code = yield* Deferred.make() const redirect = `http://localhost:${callbackPort}/auth/callback` + // Lazy so runtimes without a loopback listener (workerd) never evaluate node:http. + const { createServer } = yield* Effect.promise(() => import("node:http")) const server = createServer((request, response) => { const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) if (url.pathname !== "/auth/callback") { diff --git a/packages/core/src/pty/pty.workerd.ts b/packages/core/src/pty/pty.workerd.ts new file mode 100644 index 0000000000..f0d485e6f7 --- /dev/null +++ b/packages/core/src/pty/pty.workerd.ts @@ -0,0 +1,9 @@ +import type { Proc } from "./pty.js" + +export type { Disp, Exit, Opts, Proc } from "./pty.js" + +// workerd cannot spawn processes; the Pty service surfaces this as a defect if +// a terminal is ever requested on this runtime. +export function spawn(): Proc { + throw new Error("Pseudo-terminals are unavailable on the workerd runtime") +} diff --git a/packages/core/src/shell/parser-wasm.workerd.ts b/packages/core/src/shell/parser-wasm.workerd.ts new file mode 100644 index 0000000000..394d517a04 --- /dev/null +++ b/packages/core/src/shell/parser-wasm.workerd.ts @@ -0,0 +1,4 @@ +// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse +// loads these lazily and degrades when initialization fails, so empty paths +// keep module load side-effect free instead of resolving from disk. +export const shellParserWasm = { runtime: "", bash: "", powershell: "" } diff --git a/packages/core/src/util/process-lock-ffi.workerd.ts b/packages/core/src/util/process-lock-ffi.workerd.ts new file mode 100644 index 0000000000..1549ef42c7 --- /dev/null +++ b/packages/core/src/util/process-lock-ffi.workerd.ts @@ -0,0 +1,14 @@ +export type LockResult = + | { readonly acquired: true } + | { readonly acquired: false; readonly held: true } + | { readonly acquired: false; readonly held: false; readonly code: number } + +// workerd has no FFI and no cross-process file locking; a Durable Object is +// already single-threaded per instance, so nothing on this runtime should +// reach these. +const unavailable = (_fd: number): LockResult => { + throw new Error("Process locks are unavailable on the workerd runtime") +} + +export const lockDarwin = unavailable +export const lockLinux = unavailable diff --git a/packages/core/test/fixture/durable-object-storage.ts b/packages/core/test/fixture/durable-object-storage.ts new file mode 100644 index 0000000000..00bf8f4a63 --- /dev/null +++ b/packages/core/test/fixture/durable-object-storage.ts @@ -0,0 +1,47 @@ +import { Database } from "bun:sqlite" +import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd" + +// Emulates the Durable Object storage API over bun:sqlite so the workerd +// adapter and the workerd server profile can be verified without workerd or +// Cloudflare runtime dependencies. The real runtime is covered by the +// workerd-spike package, which boots inside an actual isolate. +export const makeDurableObjectStorage = (): DurableObjectStorage => { + const native = new Database(":memory:") + const toSqlStorageValue = (value: unknown) => { + if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null + const buffer = new ArrayBuffer(value.byteLength) + new Uint8Array(buffer).set(value) + return buffer + } + return { + sql: { + exec(query: string, ...bindings: Array) { + const statement = native.query(query) + const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue)) + const columnNames = statement.columnNames + return { + columnNames, + raw: () => rows[Symbol.iterator](), + toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))), + } + }, + }, + transaction(closure: (txn: { rollback(): void }) => Promise): Promise { + native.run("BEGIN") + let rolledBack = false + return closure({ rollback: () => (rolledBack = true) }).then( + (result) => { + native.run(rolledBack ? "ROLLBACK" : "COMMIT") + return result + }, + (error) => { + native.run("ROLLBACK") + throw error + }, + ) + }, + transactionSync(closure: () => T): T { + return native.transaction(closure)() + }, + } +} diff --git a/packages/core/test/sqlite-workerd.test.ts b/packages/core/test/sqlite-workerd.test.ts index 64194b0418..e539b5efe8 100644 --- a/packages/core/test/sqlite-workerd.test.ts +++ b/packages/core/test/sqlite-workerd.test.ts @@ -1,63 +1,19 @@ import { describe, expect, test } from "bun:test" -import { Database } from "bun:sqlite" import { Effect, Layer } from "effect" import { SqlClient } from "effect/unstable/sql" import { SqlError } from "effect/unstable/sql/SqlError" import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd" import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd" +import { makeDurableObjectStorage } from "./fixture/durable-object-storage" import { tempGlobalLayer } from "./fixture/global" -// Emulates the Durable Object storage API over bun:sqlite so the adapter can -// be verified without workerd or Cloudflare runtime dependencies. -const makeFakeStorage = () => { - const native = new Database(":memory:") - const toSqlStorageValue = (value: unknown) => { - if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null - const buffer = new ArrayBuffer(value.byteLength) - new Uint8Array(buffer).set(value) - return buffer - } - const storage: DurableObjectStorage = { - sql: { - exec(query: string, ...bindings: Array) { - const statement = native.query(query) - const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue)) - const columnNames = statement.columnNames - return { - columnNames, - raw: () => rows[Symbol.iterator](), - toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))), - } - }, - }, - transaction(closure: (txn: { rollback(): void }) => Promise): Promise { - native.run("BEGIN") - let rolledBack = false - return closure({ rollback: () => (rolledBack = true) }).then( - (result) => { - native.run(rolledBack ? "ROLLBACK" : "COMMIT") - return result - }, - (error) => { - native.run("ROLLBACK") - throw error - }, - ) - }, - transactionSync(closure: () => T): T { - return native.transaction(closure)() - }, - } - return storage -} - const run = (storage: DurableObjectStorage, effect: Effect.Effect) => Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped)) describe("sqlite.workerd", () => { test("executes statements with bindings and maps rows to records", async () => { const rows = await run( - makeFakeStorage(), + makeDurableObjectStorage(), Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)` @@ -73,7 +29,7 @@ describe("sqlite.workerd", () => { test("normalizes ArrayBuffer blob values to Uint8Array", async () => { const rows = await run( - makeFakeStorage(), + makeDurableObjectStorage(), Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql`CREATE TABLE blob (data BLOB NOT NULL)` @@ -86,7 +42,7 @@ describe("sqlite.workerd", () => { }) test("withTransaction commits on success and rolls back on failure", async () => { - const storage = makeFakeStorage() + const storage = makeDurableObjectStorage() const count = await run( storage, Effect.gen(function* () { @@ -109,7 +65,7 @@ describe("sqlite.workerd", () => { test("nested withTransaction fails with SqlError", async () => { const error = await run( - makeFakeStorage(), + makeDurableObjectStorage(), Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql`CREATE TABLE t (value TEXT NOT NULL)` @@ -122,7 +78,7 @@ describe("sqlite.workerd", () => { }) test("boots the full database layer with migrations over injected storage", async () => { - const storage = makeFakeStorage() + const storage = makeDurableObjectStorage() const core = await import("@opencode-ai/core/database/database") await Effect.runPromise( Effect.scoped( diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts index 932f809e59..ad58be86c0 100644 --- a/packages/protocol/src/groups/health.ts +++ b/packages/protocol/src/groups/health.ts @@ -5,7 +5,8 @@ export namespace ServiceStatus { export const Health = Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, - pid: Schema.Int.check(Schema.isGreaterThan(0)), + // 0 means the runtime has no OS process identity (e.g. workerd). + pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), }).annotate({ identifier: "ServiceHealth" }) export type Health = typeof Health.Type diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index 604eb22b14..85651bb064 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -5,7 +5,8 @@ "type": "module", "license": "MIT", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./workerd": "./src/workerd.ts" }, "scripts": { "test": "bun test --timeout 5000", @@ -17,6 +18,7 @@ "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/server": "workspace:*", + "@opencode-ai/util": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 5a21e09956..96a2faf754 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,5 +1,7 @@ import { OpenCode } from "@opencode-ai/client/effect" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" +import { SessionRestart } from "@opencode-ai/core/session/execution/restart" +import type { ServerFetch } from "@opencode-ai/server/fetch" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" import type { ServerOptions } from "@opencode-ai/server/options" import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect" @@ -13,21 +15,32 @@ export type CreateOptions = ServerOptions & { readonly log?: LogOptions } -export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) { +/** Host hooks for embedding opencode on a non-default runtime profile (e.g. workerd). */ +export type EmbedOptions = ServerFetch.BootOptions + +export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}, embed: EmbedOptions = {}) { const { log, ...server } = options const runtime = yield* Effect.acquireRelease( Effect.sync(() => ManagedRuntime.make( - createEmbeddedRoutes({ - ...server, - app: { ...server.app, name: server.app?.name ?? "sdk" }, - database: { path: ":memory:", ...server.database }, - }).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))), + createEmbeddedRoutes( + { + ...server, + app: { ...server.app, name: server.app?.name ?? "sdk" }, + database: { path: ":memory:", ...server.database }, + }, + embed.overrides ?? [], + ).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))), ), ), (runtime) => runtime.disposeEffect, ) const context = yield* runtime.contextEffect + // Unconditional, as on every runtime: the sweep is a no-op when nothing is + // suspended (always, for the default in-memory database). Forked so the + // returned client is never delayed; resumed drains are already logged and + // durably recorded by the execution layer. + yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions) const plugins = Context.get(context, SdkPlugins.Service) const router = Context.get(context, HttpRouter.HttpRouter) const handler = HttpEffect.toWebHandlerWith( diff --git a/packages/sdk-next/src/workerd.ts b/packages/sdk-next/src/workerd.ts new file mode 100644 index 0000000000..c32f1de8a8 --- /dev/null +++ b/packages/sdk-next/src/workerd.ts @@ -0,0 +1,29 @@ +export * as OpenCodeWorkerd from "./workerd" + +import { ServerWorkerd } from "@opencode-ai/server/workerd" +import { Layer } from "effect" +import * as OpenCode from "./opencode" +import type { LogOptions } from "./logging" + +export type CreateOptions = ServerWorkerd.Options & { + readonly log?: LogOptions +} + +/** + * Boots the embedded opencode SDK on the workerd runtime profile: the full + * application graph inside a Cloudflare Durable Object, with the database on + * the injected `DurableObjectStorage` SQLite and every intentionally-local + * service replaced or disabled (see `ServerWorkerd.replacements`). + * + * Suspended Sessions resume on boot (as on every runtime) because a Durable + * Object can be evicted mid-turn with no teardown; the write-ahead execution + * claim marks the turn and the boot-time sweep replays it. + * + * Returns the same typed `OpenCode.Interface` as `OpenCode.create` — typed + * session operations plus the live `events.subscribe()` stream — served over + * an in-process fetch transport, so no request leaves the isolate. + */ +export const create = ({ log, ...options }: CreateOptions) => + OpenCode.create({ ...ServerWorkerd.serverOptions(options), log }, { overrides: ServerWorkerd.replacements(options) }) + +export const layer = (options: CreateOptions) => Layer.effect(OpenCode.Service, create(options)) diff --git a/packages/server/package.json b/packages/server/package.json index 0a574ea56d..427b2619b7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,7 +10,8 @@ }, "scripts": { "test": "bun test --only-failures", - "typecheck": "tsgo -b" + "typecheck": "tsgo -b", + "probe:workerd": "bun run script/workerd-probe.ts" }, "dependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/server/script/workerd-probe.ts b/packages/server/script/workerd-probe.ts new file mode 100644 index 0000000000..749e1492db --- /dev/null +++ b/packages/server/script/workerd-probe.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +/** + * Bundle probe for the workerd profile: verifies the full module graph behind + * src/workerd.ts resolves under the `workerd` condition without any `bun:` + * builtins. `node:` builtins stay external (workerd provides them through + * nodejs_compat); the probe prints the surviving externals so the A4 boot + * spike knows exactly what the runtime must supply. + */ +import path from "node:path" +import { mkdtempSync } from "node:fs" +import os from "node:os" + +const outdir = mkdtempSync(path.join(os.tmpdir(), "opencode-workerd-probe-")) +const result = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "../src/workerd.ts")], + conditions: ["workerd"], + target: "node", + outdir, + sourcemap: "none", + throw: false, +}) + +if (!result.success) { + console.error(`workerd bundle probe FAILED (${result.logs.length} issues)`) + for (const log of result.logs) console.error(String(log)) + process.exit(1) +} + +// Everything in the graph is bundled, so any import specifier that survives in +// the output is an external the runtime must provide. A specifier reached only +// through `import()` is loaded lazily behind a runtime guard, so it costs +// nothing on a runtime that never takes that branch; a static import is +// evaluated on module load and must resolve. +const transpiler = new Bun.Transpiler({ loader: "js" }) +const externals = new Map() +const record = (specifier: string, isStatic: boolean) => { + const seen = externals.get(specifier) + externals.set(specifier, { static: (seen?.static ?? false) || isStatic }) +} +for (const artifact of result.outputs) { + const text = await artifact.text() + for (const imported of transpiler.scanImports(text)) record(imported.path, imported.kind !== "dynamic-import") + for (const match of text.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)) record(match[1], true) +} +const sorted = Array.from(externals.keys()).toSorted() +const isBun = (specifier: string) => specifier === "bun" || specifier.startsWith("bun:") +const leaked = sorted.filter((specifier) => isBun(specifier) && externals.get(specifier)!.static) +const bytes = result.outputs.reduce((total, artifact) => total + artifact.size, 0) + +console.log(`workerd bundle probe OK: ${result.outputs.length} artifacts, ${(bytes / 1024 / 1024).toFixed(1)} MiB`) +console.log(`external builtins (${sorted.length}):`) +for (const specifier of sorted) { + const lazy = externals.get(specifier)!.static ? "" : " (lazy)" + console.log(` ${specifier}${lazy}`) +} +if (leaked.length > 0) { + console.error(`FAILED: bun builtins statically imported in the workerd graph: ${leaked.join(", ")}`) + process.exit(1) +} diff --git a/packages/server/src/fetch.ts b/packages/server/src/fetch.ts index 0918d2d43f..6a671a9e78 100644 --- a/packages/server/src/fetch.ts +++ b/packages/server/src/fetch.ts @@ -3,10 +3,20 @@ export * as ServerFetch from "./fetch" import { Context, Effect, Layer } from "effect" import { HttpEffect, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" +import type { LayerNode } from "@opencode-ai/util/effect/layer-node" import { isAllowedCorsOrigin } from "./cors" import { createRoutes } from "./routes" import type { ServerOptions } from "./options" +export interface BootOptions { + /** + * Runtime-profile service replacements, applied after the standard set so later entries + * win — swaps services the standard graph assumes are local. See `ServerWorkerd.replacements`. + */ + readonly overrides?: LayerNode.Replacements +} + + /** * Builds a web-standard fetch handler — `(request: Request) => Promise` — serving the * same HttpApi routes as the Node server process without binding a port, owning a listener, or @@ -29,8 +39,10 @@ import type { ServerOptions } from "./options" * 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 = {}) { - const context = yield* Layer.build(createRoutes(options, () => []).pipe(Layer.provide(HttpServer.layerServices))) +export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}, boot: BootOptions = {}) { + const context = yield* Layer.build( + createRoutes(options, () => [], boot.overrides ?? []).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. yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions) diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index 785f277ed0..bc2c5caaf5 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -11,7 +11,8 @@ export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handler return { healthy: true as const, version: info.app.version ?? "unknown", - pid: process.pid, + // Runtimes without OS process identity (workerd) report 0. + pid: process.pid ?? 0, } }), ) diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index 96a70bd180..5ed50164a3 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -40,5 +40,11 @@ export const ServerOptions = Schema.Struct({ fff: Schema.optional(Schema.Boolean), }), ), + mcp: Schema.optional( + Schema.Struct({ + /** Set false on runtimes that cannot spawn child processes; local (stdio) MCP servers report failed instead of connecting. */ + stdio: Schema.optional(Schema.Boolean), + }), + ), }) export type ServerOptions = typeof ServerOptions.Type diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 965522466c..dd9272e310 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -66,27 +66,34 @@ const applicationServices = LayerNode.group([ SessionRestart.node, ]) -export function createRoutes(options: ServerOptions = {}, serviceURLs: () => ReadonlyArray = () => []) { +export function createRoutes( + options: ServerOptions = {}, + serviceURLs: () => ReadonlyArray = () => [], + overrides: LayerNode.Replacements = [], +) { return makeRoutes( options.password ? ServerAuth.Config.configLayer({ password: Option.some(options.password) }) : ServerAuth.Config.layer, options, serviceURLs, + overrides, ) } -export function createEmbeddedRoutes(options: ServerOptions = {}) { - return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => []) +export function createEmbeddedRoutes(options: ServerOptions = {}, overrides: LayerNode.Replacements = []) { + return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides) } function makeRoutes( auth: Layer.Layer, options: ServerOptions, serviceURLs: () => ReadonlyArray, + // Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win. + overrides: LayerNode.Replacements, ) { const pluginRuntimeCell = PluginRuntime.makeCell() - const replacements: LayerNode.Replacements = [ + const standard: LayerNode.Replacements = [ [Database.node, Database.configured(options.database)], [Bus.node, Bus.configured({ persist: options.events?.persist })], [App.node, App.configured(options.app)], @@ -113,6 +120,7 @@ function makeRoutes( name: options.app?.name ?? "opencode", version: options.app?.version ?? "unknown", }, + stdio: options.mcp?.stdio, }), ], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], @@ -122,6 +130,7 @@ function makeRoutes( WorkspaceDriver.registryNode({ [modalProvider]: modalWorkspaceDriver({ app: "opencode-workspaces" }) }), ], ] + const replacements: LayerNode.Replacements = [...standard, ...overrides] const serviceLayer = options.simulation ? Layer.unwrap( Effect.gen(function* () { diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts new file mode 100644 index 0000000000..6ed7fefde7 --- /dev/null +++ b/packages/server/src/workerd.ts @@ -0,0 +1,150 @@ +export * as ServerWorkerd from "./workerd" + +import { Effect, Layer } from "effect" +import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source" +import { Database } from "@opencode-ai/core/database/database" +import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd" +import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" +import { Pty } from "@opencode-ai/core/pty" +import { Shell } from "@opencode-ai/core/shell" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { Vcs } from "@opencode-ai/core/vcs" +import type { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { ServerFetch } from "./fetch" +import type { ServerOptions } from "./options" + +/** + * The workerd runtime profile: boots opencode core and server inside a + * Cloudflare Durable Object, with every intentionally-local service replaced + * or disabled. + * + * - Database runs on the injected `DurableObjectStorage` SQLite. + * - Watcher and fff are disabled through their existing option flags; pty, fff, + * shell-parser, photon, and process-lock native modules resolve to inert + * stubs under the `workerd` bundle condition. + * - Shell, FileSystem, FileSystemSearch, and Pty fail with a clear defect until + * a remote sandbox backs them; Snapshot and Vcs degrade to no-op results. + * - Config is injected as a string (no filesystem); plugin discovery is + * precompiled-only and MCP is restricted to remote transports. + * + * Bundle with the `workerd` condition, e.g. + * `bun build src/workerd.ts --conditions=workerd --target=node` + * (see `script/workerd-probe.ts`). + */ +export interface Options { + /** Durable Object storage whose SQLite database backs the opencode database. */ + readonly storage: DurableObjectStorage + readonly app?: ServerOptions["app"] + readonly password?: string + /** Inline opencode config content (JSON), same as `ServerOptions.config.content`. */ + readonly config?: { readonly content?: string } + /** models.dev catalog options; the bundled snapshot is the boot-time floor either way. */ + readonly models?: ServerOptions["models"] +} + +/** + * Builds the web-standard fetch handler for a Durable Object's `fetch()`. The + * application layer builds eagerly in the caller's scope, so hold it in the + * Durable Object instance rather than per request. + */ +export function create(options: Options) { + // Eviction can kill the isolate between a turn's Started and terminal events with no + // teardown. The write-ahead execution claim plus ServerFetch.make's boot-time resume + // recovers such orphaned turns by replaying the drain from durable history on the next wake. + return ServerFetch.make(serverOptions(options), { overrides: replacements(options) }) +} + +export function serverOptions(options: Options): ServerOptions { + return { + app: options.app, + password: options.password, + fs: { filewatcher: false, fff: false }, + // Durable event history is how a turn orphaned by eviction is recovered: + // the boot-time resume replays it. A runtime that dies without teardown + // cannot opt out of it, so this is not exposed as an option. + events: { persist: true }, + config: { content: options.config?.content }, + models: options.models, + // No child processes on workerd: local (stdio) MCP servers report failed + // instead of connecting; remote transports work unchanged. + mcp: { stdio: false }, + } +} + +/** The workerd replacement graph, applied after the standard server replacements. */ +export function replacements(options: Options): LayerNode.Replacements { + return [ + [Database.node, Database.configuredClient(sqliteLayer({ storage: options.storage }))], + [Snapshot.node, Snapshot.noopLayer], + [Vcs.node, vcsLayer], + [Shell.node, shellLayer], + [FileSystem.node, fileSystemLayer], + [FileSystemSearch.node, fileSystemSearchLayer], + [Pty.node, ptyLayer], + // Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm + // install, or import of plugin code from disk. + [ConfigPluginSource.node, ConfigPluginSource.empty], + ] +} + +const unavailable = (what: string) => Effect.die(new Error(`${what} is unavailable in the workerd profile`)) + +// Vcs degrades to empty results, matching its behavior for locations without a +// supported VCS, so read-only clients never need to special-case this runtime. +const vcsLayer = Layer.succeed( + Vcs.Service, + Vcs.Service.of({ + info: () => Effect.succeed({ branch: {} }), + status: () => Effect.succeed([]), + diff: () => Effect.succeed([]), + }), +) + +// Shell commands need a real process; queries for unknown IDs stay typed while +// creation is a defect until a remote sandbox backs them. +const shellLayer = Layer.succeed( + Shell.Service, + Shell.Service.of({ + name: () => Effect.succeed("unsupported"), + create: () => unavailable("Shell.create"), + list: () => Effect.succeed([]), + get: (id) => Effect.fail(new Shell.NotFoundError({ id })), + wait: (id) => Effect.fail(new Shell.NotFoundError({ id })), + timeout: (id) => Effect.fail(new Shell.NotFoundError({ id })), + output: (id) => Effect.fail(new Shell.NotFoundError({ id })), + remove: (id) => Effect.fail(new Shell.NotFoundError({ id })), + }), +) + +// The Location-scoped filesystem has no local worktree to serve until a remote +// sandbox backs it. +const fileSystemLayer = Layer.succeed( + FileSystem.Service, + FileSystem.Service.of({ + read: () => unavailable("FileSystem.read"), + list: () => unavailable("FileSystem.list"), + find: () => unavailable("FileSystem.find"), + }), +) + +const fileSystemSearchLayer = Layer.succeed( + FileSystemSearch.Service, + FileSystemSearch.Service.of({ + find: () => unavailable("FileSystemSearch.find"), + }), +) + +const ptyLayer = Layer.succeed( + Pty.Service, + Pty.Service.of({ + list: () => Effect.succeed([]), + get: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + create: () => unavailable("Pty.create"), + update: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + remove: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + write: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + attach: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + }), +) diff --git a/packages/server/test/workerd.test.ts b/packages/server/test/workerd.test.ts new file mode 100644 index 0000000000..0e3fc5b2ee --- /dev/null +++ b/packages/server/test/workerd.test.ts @@ -0,0 +1,34 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage" +import { it } from "../../core/test/lib/effect" +import { ServerWorkerd } from "../src/workerd" + +// Covers the profile's replacement graph composing and the database booting +// through the injected Durable Object storage. Verification inside a real +// isolate lives in the workerd-spike package. +it.live("boots the workerd profile over durable object storage", () => + Effect.gen(function* () { + const handler = yield* ServerWorkerd.create({ + storage: makeDurableObjectStorage(), + password: "secret", + app: { version: "workerd-test" }, + config: { content: "{}" }, + }) + + const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health"))) + expect(unauthorized.status).toBe(401) + + const health = yield* Effect.promise(() => + handler( + new Request("http://opencode.local/api/health", { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }), + ), + ) + expect(health.status).toBe(200) + + const body: unknown = yield* Effect.promise(() => health.json()) + expect(body).toMatchObject({ healthy: true, version: "workerd-test" }) + }).pipe(Effect.scoped), +) diff --git a/packages/util/package.json b/packages/util/package.json index 6612d6737d..340905a5e2 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -20,7 +20,12 @@ "./*": "./src/*.ts" }, "imports": { + "#global-roots": { + "workerd": "./src/global-roots.workerd.ts", + "default": "./src/global-roots.ts" + }, "#runtime-import": { + "workerd": "./src/runtime/import.bun.ts", "bun": "./src/runtime/import.bun.ts", "node": "./src/runtime/import.node.ts", "default": "./src/runtime/import.bun.ts" diff --git a/packages/util/src/cross-spawn-spawner.ts b/packages/util/src/cross-spawn-spawner.ts index 306c72bc5c..e863537a66 100644 --- a/packages/util/src/cross-spawn-spawner.ts +++ b/packages/util/src/cross-spawn-spawner.ts @@ -1,5 +1,8 @@ import type { NonEmptyReadonlyArray } from "effect/Array" -import { NodeFileSystem, NodePath, NodeSink, NodeStream } from "@effect/platform-node" +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" +import * as NodePath from "@effect/platform-node/NodePath" +import * as NodeSink from "@effect/platform-node/NodeSink" +import * as NodeStream from "@effect/platform-node/NodeStream" import { Deferred, Effect, Exit, FileSystem, Layer, Path, PlatformError, Predicate, Sink, Stream } from "effect" import type { Scope } from "effect" import { ChildProcess } from "effect/unstable/process" diff --git a/packages/util/src/effect/app-node-platform.ts b/packages/util/src/effect/app-node-platform.ts index 1f2ea9e828..a34f0447dd 100644 --- a/packages/util/src/effect/app-node-platform.ts +++ b/packages/util/src/effect/app-node-platform.ts @@ -1,4 +1,7 @@ -import { NodeFileSystem, NodePath } from "@effect/platform-node" +// Deep imports: the @effect/platform-node barrel eagerly pulls in undici, +// ioredis, and node:sqlite, which runtimes such as workerd cannot load. +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" +import * as NodePath from "@effect/platform-node/NodePath" import { FileSystem, Path } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { makeGlobalNode } from "./app-node.js" diff --git a/packages/util/src/fs-util.ts b/packages/util/src/fs-util.ts index 58d8048a72..1f3ed72998 100644 --- a/packages/util/src/fs-util.ts +++ b/packages/util/src/fs-util.ts @@ -1,4 +1,4 @@ -import { NodeFileSystem } from "@effect/platform-node" +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" import path, { dirname, isAbsolute, join, relative, sep } from "path" import { realpathSync } from "fs" import { readdir } from "fs/promises" diff --git a/packages/util/src/global-roots.ts b/packages/util/src/global-roots.ts new file mode 100644 index 0000000000..d1d2957a54 --- /dev/null +++ b/packages/util/src/global-roots.ts @@ -0,0 +1,14 @@ +import os from "os" +import path from "path" +import { xdgCache, xdgConfig, xdgData, xdgState } from "xdg-basedir" + +/** The XDG base directories that root opencode's global paths. */ +export function roots(app: string) { + return { + data: path.join(xdgData!, app), + cache: path.join(xdgCache!, app), + config: path.join(xdgConfig!, app), + state: path.join(xdgState!, app), + tmp: path.join(os.tmpdir(), app), + } +} diff --git a/packages/util/src/global-roots.workerd.ts b/packages/util/src/global-roots.workerd.ts new file mode 100644 index 0000000000..10713c74a7 --- /dev/null +++ b/packages/util/src/global-roots.workerd.ts @@ -0,0 +1,16 @@ +import os from "os" +import path from "path" + +// workerd has no home directory or XDG base dirs and only tmp is writable, so +// every global path roots under one directory there. Nothing durable lives in +// these: on workerd the database is on Durable Object storage. +export function roots(app: string) { + const root = path.join(os.tmpdir(), app) + return { + data: path.join(root, "data"), + cache: path.join(root, "cache"), + config: path.join(root, "config"), + state: path.join(root, "state"), + tmp: path.join(root, "tmp"), + } +} diff --git a/packages/util/src/global.ts b/packages/util/src/global.ts index 3e05ca9759..5fe51f8281 100644 --- a/packages/util/src/global.ts +++ b/packages/util/src/global.ts @@ -1,17 +1,16 @@ import path from "path" import fs from "fs" -import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir" import os from "os" import { Context, Effect, Layer } from "effect" +// XDG on runtimes with a home directory; one tmp-rooted directory on workerd. +// The variants resolve through the `workerd` bundle condition, like the +// native-module stubs, so no runtime sniffing happens here. +import { roots } from "#global-roots" import { Flock } from "./flock.js" import { makeGlobalNode } from "./effect/app-node.js" const app = "opencode" -const data = path.join(xdgData!, app) -const cache = path.join(xdgCache!, app) -const config = path.join(xdgConfig!, app) -const state = path.join(xdgState!, app) -const tmp = path.join(os.tmpdir(), app) +const { data, cache, config, state, tmp } = roots(app) const paths = { get home() { diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index 8a3fc77ec1..28b05a4bbf 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -3,7 +3,7 @@ export * as Npm from "./npm.js" import path from "path" import npa from "npm-package-arg" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" -import { NodeFileSystem } from "@effect/platform-node" +import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" import { FSUtil } from "./fs-util.js" import { Global } from "./global.js" import { EffectFlock } from "./effect-flock.js" diff --git a/packages/workerd-spike/package.json b/packages/workerd-spike/package.json new file mode 100644 index 0000000000..982e224dcd --- /dev/null +++ b/packages/workerd-spike/package.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/workerd-spike", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@opencode-ai/core": "workspace:*", + "@opencode-ai/server": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "0.12.6", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "@cloudflare/workers-types": "^4.20250808.0", + "vitest": "3.2.7", + "wrangler": "4.28.0", + "xdg-basedir": "5.1.0", + "unenv": "2.0.0-rc.24", + "@effect/platform-node": "catalog:" + } +} diff --git a/packages/workerd-spike/src/worker.ts b/packages/workerd-spike/src/worker.ts new file mode 100644 index 0000000000..918c5cf8c7 --- /dev/null +++ b/packages/workerd-spike/src/worker.ts @@ -0,0 +1,74 @@ +import { DurableObject } from "cloudflare:workers" +import { Effect, Scope } from "effect" +import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd" +import { ServerWorkerd } from "@opencode-ai/server/workerd" + +export interface Env { + OPENCODE: DurableObjectNamespace +} + +export const PASSWORD = "spike-secret" +export const FAKE_LLM_ORIGIN = "https://fake-llm.spike.test" +// A second provider on its own origin, so eviction tests can hold one turn +// open with a delayed interceptor without affecting the fast provider's mocks. +export const SLOW_LLM_ORIGIN = "https://slow-llm.spike.test" + +const model = (name: string) => ({ + name, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + limit: { context: 128_000, output: 8_192 }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, +}) + +// A minimal opencode config declaring fake providers on the native +// openai-compatible chat route. The apiKey marks the provider available +// without a stored credential; the baseURL points at the fetch-mocked +// endpoint so no real network is reached. +const CONFIG_CONTENT = JSON.stringify({ + model: "fake/spike-model", + providers: { + fake: { + name: "Fake", + package: "aisdk:@ai-sdk/openai-compatible", + settings: { apiKey: "spike-key", baseURL: `${FAKE_LLM_ORIGIN}/v1` }, + models: { "spike-model": model("Spike Model") }, + }, + slow: { + name: "Slow", + package: "aisdk:@ai-sdk/openai-compatible", + settings: { apiKey: "spike-key", baseURL: `${SLOW_LLM_ORIGIN}/v1` }, + models: { "slow-model": model("Slow Model") }, + }, + }, +}) + +export class OpencodeDurableObject extends DurableObject { + // The application layer builds eagerly into a scope that is never closed: a + // Durable Object is evicted without teardown, so the instance's lifetime is + // the scope's lifetime. Every request the instance serves shares one handler. + private readonly handler: Promise<(request: Request) => Promise> + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env) + const scope = Effect.runSync(Scope.make()) + this.handler = ServerWorkerd.create({ + storage: ctx.storage as unknown as DurableObjectStorage, + password: PASSWORD, + app: { version: "workerd-spike" }, + config: { content: CONFIG_CONTENT }, + // The bundled models.dev snapshot is the catalog floor; the providers + // under test come entirely from config content, so nothing needs the network. + models: { fetch: false }, + }).pipe(Scope.provide(scope), Effect.runPromise) + } + + override async fetch(request: Request) { + return (await this.handler)(request) + } +} + +export default { + fetch(request, env) { + return env.OPENCODE.getByName("spike").fetch(request) + }, +} satisfies ExportedHandler diff --git a/packages/workerd-spike/test/env.d.ts b/packages/workerd-spike/test/env.d.ts new file mode 100644 index 0000000000..58a35ae4a3 --- /dev/null +++ b/packages/workerd-spike/test/env.d.ts @@ -0,0 +1,5 @@ +import type { Env } from "../src/worker" + +declare module "cloudflare:test" { + interface ProvidedEnv extends Env {} +} diff --git a/packages/workerd-spike/test/shims/mime-types.mjs b/packages/workerd-spike/test/shims/mime-types.mjs new file mode 100644 index 0000000000..5d368d2588 --- /dev/null +++ b/packages/workerd-spike/test/shims/mime-types.mjs @@ -0,0 +1,10 @@ +// Spike shim: mime-types requires mime-db's JSON database, which the module +// fallback service cannot serve. The workerd profile stubs FileSystem, so +// mime lookups never influence behavior here. +export const lookup = () => false +export const contentType = () => false +export const extension = () => false +export const charset = () => false +export const types = {} +export const extensions = {} +export default { lookup, contentType, extension, charset, types, extensions } diff --git a/packages/workerd-spike/test/spike.test.ts b/packages/workerd-spike/test/spike.test.ts new file mode 100644 index 0000000000..b4ae1ecfba --- /dev/null +++ b/packages/workerd-spike/test/spike.test.ts @@ -0,0 +1,328 @@ +import { env, fetchMock, runInDurableObject } from "cloudflare:test" +import { afterEach, beforeAll, expect, it } from "vitest" +import { FAKE_LLM_ORIGIN, PASSWORD, SLOW_LLM_ORIGIN } from "../src/worker" + +// A4 boot spike: the full opencode core + server stack running inside a REAL +// Durable Object under workerd (@cloudflare/vitest-pool-workers), over real DO +// SQLite, with the LLM provider faked at the network edge via fetchMock. + +const AUTH = { authorization: `Basic ${btoa(`opencode:${PASSWORD}`)}` } +const BASE = "https://opencode.spike" + +const stub = () => env.OPENCODE.getByName("spike") + +const request = (path: string, init?: RequestInit) => + stub().fetch( + new Request(`${BASE}${path}`, { + ...init, + headers: { ...AUTH, ...(init?.body ? { "content-type": "application/json" } : {}), ...init?.headers }, + }), + ) + +const timed = async (label: string, work: () => Promise) => { + const started = Date.now() + const result = await work() + console.log(`[timing] ${label}: ${Date.now() - started}ms`) + return result +} + +// One SSE turn from the fake provider: a short assistant message, then stop. +const FAKE_SSE = [ + `data: ${JSON.stringify({ + id: "chatcmpl-spike", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello from a Durable Object!" } }], + })}`, + `data: ${JSON.stringify({ + id: "chatcmpl-spike", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 6, total_tokens: 16 }, + })}`, + "data: [DONE]", + "", +].join("\n\n") + +beforeAll(() => { + fetchMock.activate() + fetchMock.disableNetConnect() +}) + +afterEach(() => { + fetchMock.assertNoPendingInterceptors() +}) + +const completions = (origin: string) => + fetchMock + .get(origin) + .intercept({ method: "POST", path: (path) => path.endsWith("/chat/completions") }) + .reply(200, FAKE_SSE, { headers: { "content-type": "text/event-stream" } }) + +// Persisted once: tests run sequentially in one runtime (isolatedStorage:false) +// and the same interceptor serves every completed turn in the file. +const mocked = { llm: false } +const mockLLM = () => { + if (mocked.llm) return + mocked.llm = true + completions(FAKE_LLM_ORIGIN).persist() +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +type LogItem = { type?: string; seq?: number; durable?: { seq?: number }; [key: string]: unknown } + +// Durable events carry their aggregate sequence in the envelope; the trailing +// log.synced marker carries the replay watermark at top level. +const seqOf = (item: LogItem) => item.durable?.seq ?? item.seq ?? 0 + +const readLog = async (sessionID: string, after = 0) => { + const response = await request(`/api/experimental/session/${sessionID}/log?after=${after}&follow=false`) + expect(response.status).toBe(200) + const sse = await response.text() + return sse + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => JSON.parse(line.slice("data: ".length)) as LogItem) +} + +const createSession = async (model: { providerID: string; id: string } = { providerID: "fake", id: "spike-model" }) => { + const response = await timed("session create", () => + request("/api/session", { + method: "POST", + body: JSON.stringify({ + title: "spike session", + model, + location: { directory: "/tmp/project" }, + }), + }), + ) + expect(response.status).toBe(200) + const body = (await response.json()) as { data: { id: string } } + expect(body.data.id).toMatch(/^ses/) + return body.data.id +} + +it("boots the DO: migrations on real DO SQLite and an authed health 200", async () => { + const health = await timed("cold boot + health", () => request("/api/health")) + expect(health.status).toBe(200) + expect(await health.json()).toMatchObject({ healthy: true, version: "workerd-spike" }) + + const unauthorized = await request("/api/health", { headers: { authorization: "" } }) + expect(unauthorized.status).toBe(401) + + const warm = await timed("warm health", () => request("/api/health")) + expect(warm.status).toBe(200) + + const tables = await runInDurableObject(stub(), async (_instance, state) => + state.storage.sql + .exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .toArray() + .map((row) => row.name), + ) + expect(tables).toContain("session_v2") + expect(tables).toContain("migration") + + const migrations = await runInDurableObject(stub(), async (_instance, state) => + state.storage.sql.exec("SELECT count(*) AS count FROM migration").one(), + ) + console.log(`[info] migration journal rows: ${migrations.count}`) + expect(Number(migrations.count)).toBeGreaterThan(0) +}) + +it("creates a session through the API and persists the row", async () => { + await timed("boot before session create", () => request("/api/health")) + const sessionID = await createSession() + + const rows = await runInDurableObject(stub(), async (_instance, state) => + state.storage.sql.exec("SELECT id, title, directory FROM session_v2").toArray(), + ) + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ id: sessionID, title: "spike session", directory: "/tmp/project" }) +}) + +it("runs a full prompt turn against a fake provider and reads the durable log", async () => { + mockLLM() + await timed("boot before turn", () => request("/api/health")) + const sessionID = await createSession() + + await timed("full fake turn (prompt + wait)", async () => { + const prompt = await request(`/api/session/${sessionID}/prompt`, { + method: "POST", + body: JSON.stringify({ text: "Say hello" }), + }) + expect(prompt.status).toBe(200) + const pending = (await prompt.json()) as { data: { type: string } } + expect(pending.data.type).toBe("user") + + const wait = await request(`/api/session/${sessionID}/wait`, { method: "POST" }) + expect(wait.status).toBe(204) + }) + + const messages = await runInDurableObject(stub(), async (_instance, state) => + state.storage.sql + .exec("SELECT id, type FROM session_message WHERE session_id = ? ORDER BY seq", sessionID) + .toArray(), + ) + console.log(`[info] session_message rows: ${JSON.stringify(messages)}`) + const types = messages.map((row) => row.type) + expect(types).toContain("user") + expect(types).toContain("assistant") + + const events = await runInDurableObject(stub(), async (_instance, state) => + state.storage.sql.exec("SELECT count(*) AS count FROM event WHERE aggregate_id = ?", sessionID).one(), + ) + expect(Number(events.count)).toBeGreaterThan(0) + + // Durable log over the cursor route (non-follow): SSE body that ends once + // the reader catches up with the aggregate sequence. + const items = await timed("session log read", () => readLog(sessionID)) + console.log(`[info] durable log events: ${JSON.stringify(items.map((item) => item.type))}`) + const eventTypes = items.map((item) => item.type) + expect(eventTypes).toContain("session.input.admitted") + expect(eventTypes).toContain("session.text.ended") + expect(eventTypes).toContain("session.execution.succeeded") + expect(eventTypes).toContain("log.synced") +}) + +// A5 question 1 (ack-then-continue): the Slack flow returns the prompt request +// immediately and lets the turn continue inside the DO with no request held +// open. The turn runs on the coordinator's background fiber in the app layer +// runtime; under workerd an actor (Durable Object) owns one long-lived +// IoContext spanning requests, so pending work started during one request may +// keep running after its response is sent. This test proves the turn completes +// with NO request in flight: prompt, return, sleep (no polling), then read. +it("completes a turn in the background after the prompt request has returned", async () => { + mockLLM() + await request("/api/health") + const sessionID = await createSession() + + const promptReturned = Date.now() + const prompt = await request(`/api/session/${sessionID}/prompt`, { + method: "POST", + body: JSON.stringify({ text: "Say hello in the background" }), + }) + expect(prompt.status).toBe(200) + // No /wait, no polling: nothing touches the DO while the turn runs. + await sleep(1_000) + + const items = await readLog(sessionID) + const eventTypes = items.map((item) => item.type) + console.log(`[info] background turn events: ${JSON.stringify(eventTypes)}`) + console.log(`[timing] background turn observed complete ${Date.now() - promptReturned}ms after prompt returned`) + expect(eventTypes).toContain("session.execution.succeeded") + + const messages = await runInDurableObject(stub(), async (_instance, state) => + state.storage.sql + .exec("SELECT type FROM session_message WHERE session_id = ? ORDER BY seq", sessionID) + .toArray() + .map((row) => row.type), + ) + expect(messages).toEqual(["user", "assistant"]) +}) + +// Eviction mid-turn + durable log integrity: kill the DO instance between +// prompt-accepted and turn-complete via DurableObjectState.abort(), then let a +// fresh instance boot over the SAME persisted storage. Starting a turn writes +// a durable execution claim, and the workerd profile resumes claimed Sessions +// on boot, so the orphaned turn must replay from durable history and complete; +// a log consumer that checkpointed a cursor pre-eviction must be able to +// continue without gaps or duplicates. +it("recovers an evicted mid-turn session by replay on the next boot", async () => { + mockLLM() + // Hold the slow provider's first call open long enough to evict mid-call, + // then serve the replayed call instantly. + completions(SLOW_LLM_ORIGIN).delay(60_000) + completions(SLOW_LLM_ORIGIN).persist() + + await request("/api/health") + const sessionID = await createSession({ providerID: "slow", id: "slow-model" }) + + const prompt = await request(`/api/session/${sessionID}/prompt`, { + method: "POST", + body: JSON.stringify({ text: "Say hello before the eviction" }), + }) + expect(prompt.status).toBe(200) + + // Wait for the turn to pass admission and promotion: the pending row is + // consumed and the user message is projected, so the eviction lands inside + // the in-flight model call — the representative mid-turn point where no + // pending row is left to mark the unfinished work. + const snapshot = async () => + runInDurableObject(stub(), async (_instance, state) => ({ + messages: state.storage.sql + .exec("SELECT type FROM session_message WHERE session_id = ? ORDER BY seq", sessionID) + .toArray() + .map((row) => row.type), + pending: Number(state.storage.sql.exec("SELECT count(*) AS count FROM session_pending").one().count), + suspended: state.storage.sql.exec("SELECT time_suspended FROM session_v2 WHERE id = ?", sessionID).one() + .time_suspended, + })) + const promotedBy = Date.now() + 5_000 + while ((await snapshot()).messages.length === 0 && Date.now() < promotedBy) await sleep(50) + const before = await snapshot() + console.log(`[info] state before eviction: ${JSON.stringify(before)}`) + expect(before.messages).toEqual(["user"]) + expect(before.pending).toBe(0) + // The write-ahead claim: Started committed the marker, so this hard death + // leaves durable evidence of the in-flight turn. + expect(before.suspended).not.toBeNull() + + // A consumer checkpoints its durable log cursor before the eviction. + const preEviction = await readLog(sessionID) + const cursor = Math.max(...preEviction.map(seqOf)) + console.log(`[info] pre-eviction log: ${JSON.stringify(preEviction.map((item) => [seqOf(item), item.type]))}`) + + // Evict: forcibly reset the Durable Object instance. Storage survives; the + // in-flight model call and every fiber in the app layer die with the isolate. + await runInDurableObject(stub(), async (_instance, state) => { + state.abort("simulated eviction") + }).then( + () => { + throw new Error("abort() should break the instance") + }, + (error) => console.log(`[info] abort outcome: ${error}`), + ) + + // A fresh instance boots over the same persisted storage; boot resumes the + // claimed execution, which replays the drain from durable history. + const health = await timed("boot after eviction", () => request("/api/health")) + expect(health.status).toBe(200) + + // Resuming injects a synthetic continuation message ("the server restarted + // while you were working") ahead of the replayed drain, so a recovered turn + // settles as user -> synthetic -> assistant. + const recoveredBy = Date.now() + 10_000 + while ((await snapshot()).messages.length < 3 && Date.now() < recoveredBy) await sleep(50) + const after = await snapshot() + console.log(`[info] state after recovery: ${JSON.stringify(after)}`) + expect(after.messages).toEqual(["user", "synthetic", "assistant"]) + expect(after.pending).toBe(0) + // The claim is consumed: settled executions leave no marker behind. + expect(after.suspended).toBeNull() + + // Question 3: the full log replays a consistent, strictly gapless sequence... + const full = await readLog(sessionID) + const fullSeqs = full.filter((item) => item.type !== "log.synced").map(seqOf) + console.log(`[info] post-recovery log: ${JSON.stringify(full.map((item) => [seqOf(item), item.type]))}`) + expect(fullSeqs).toEqual(Array.from({ length: fullSeqs.length }, (_, index) => index + 1)) + const fullTypes = full.map((item) => item.type) + expect(fullTypes).toContain("session.execution.succeeded") + expect(fullTypes).toContain("session.text.ended") + + // ...and a consumer resuming from its pre-eviction checkpoint sees exactly + // the events after its cursor: no gaps, no duplicates. + const resumed = await readLog(sessionID, cursor) + const resumedSeqs = resumed.filter((item) => item.type !== "log.synced").map(seqOf) + expect(resumedSeqs[0]).toBe(cursor + 1) + expect(resumedSeqs).toEqual(fullSeqs.slice(fullSeqs.indexOf(cursor + 1))) + + // The recovered session stays usable: one more full turn on the same session. + const followUp = await request(`/api/session/${sessionID}/prompt`, { + method: "POST", + body: JSON.stringify({ text: "Say hello after the recovery" }), + }) + expect(followUp.status).toBe(200) + const wait = await request(`/api/session/${sessionID}/wait`, { method: "POST" }) + expect(wait.status).toBe(204) + expect((await snapshot()).messages).toEqual(["user", "synthetic", "assistant", "user", "assistant"]) +}) diff --git a/packages/workerd-spike/tsconfig.json b/packages/workerd-spike/tsconfig.json new file mode 100644 index 0000000000..de95f7c794 --- /dev/null +++ b/packages/workerd-spike/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "customConditions": ["workerd"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "types": ["@cloudflare/workers-types/experimental", "@cloudflare/vitest-pool-workers"] + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/packages/workerd-spike/vitest.config.ts b/packages/workerd-spike/vitest.config.ts new file mode 100644 index 0000000000..a924bf18a6 --- /dev/null +++ b/packages/workerd-spike/vitest.config.ts @@ -0,0 +1,85 @@ +// package.json pins vitest 3.2.7 AND its @vitest/* runtime packages exactly. +// The pool (0.12.6, the newest that doesn't segfault on macOS) requires vitest +// <=3.2 while the workspace also carries vitest 4.x, and hoisting differs by +// platform: on windows the pool loaded @vitest/utils 4.x against +// @vitest/pretty-format 3.2.7 and died on a missing export before any test +// ran. Declaring the full 3.2.7 set on this package makes resolution identical +// under either layout. Revisit the pins when bumping the pool. +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config" + +// Node builtins that workerd does not implement natively resolve to unenv +// polyfills (the same ones wrangler bundles). The rest of the node:* surface +// comes from workerd's nodejs_compat, allowlisted through the (patched) +// module fallback service: see patches/@cloudflare%2Fvitest-pool-workers. +import { createRequire } from "node:module" + +const resolve = createRequire(import.meta.url).resolve +const unenv = (name: string) => resolve(`unenv/node/${name}`) +const mockProxy = resolve("unenv/mock/proxy") + +export default defineWorkersConfig({ + // Core imports markdown files as text (bun `with { type: "text" }`); serve + // them as string modules. + plugins: [ + { + name: "markdown-as-text", + enforce: "pre", + transform(code, id) { + if (!id.endsWith(".md")) return null + return { code: `export default ${JSON.stringify(code)};`, map: null } + }, + }, + ], + resolve: { + alias: [ + { find: "node:os", replacement: unenv("os") }, + { find: "node:console", replacement: unenv("console") }, + { find: "node:child_process", replacement: unenv("child_process") }, + { find: "node:v8", replacement: unenv("v8") }, + { find: "node:http2", replacement: unenv("http2") }, + { find: "node:worker_threads", replacement: unenv("worker_threads") }, + // unenv's readline statically imports node:readline/promises, which the + // fallback service cannot map from a raw-served file; nothing in the + // workerd profile reads from a terminal, so mock the whole surface. + { find: /^node:readline(\/promises)?$/, replacement: mockProxy }, + // @effect/platform-node's sqlite module; the profile's database runs on + // Durable Object storage instead. + { find: "node:sqlite", replacement: mockProxy }, + // @effect/platform-node's barrel eagerly imports its undici HttpClient; + // the workerd profile only ever uses FetchHttpClient. + { find: /^undici$/, replacement: mockProxy }, + // mime-types requires mime-db's JSON database at require time; keep the + // lookup surface but back it with a static shim. + { find: /^mime-types$/, replacement: new URL("./test/shims/mime-types.mjs", import.meta.url).pathname }, + // util/npm.ts imports the npm toolchain at module scope; plugin installs + // never happen in the workerd profile (plugin discovery is precompiled-only), + // and @npmcli/config touches process.stdout.isTTY during module init. + { find: /^@npmcli\/config(\/.*)?$/, replacement: mockProxy }, + { find: /^@npmcli\/arborist(\/.*)?$/, replacement: mockProxy }, + { find: /^pacote(\/.*)?$/, replacement: mockProxy }, + ], + }, + test: { + testTimeout: 120_000, + + // The patched pool's module fallback service handles /@fs ids with posix + // assumptions; Windows drive-letter paths (/@fs/C:/...) fall through and + // modules served as raw text (snapshot.txt?mf_vitest_force=Text) fail to + // resolve. The guard is platform-independent — the bundle graph proven + // pure on Linux is the same graph everywhere — so skip the suite on + // Windows instead of teaching the pinned pool about win32 paths. + ...(process.platform === "win32" ? { include: [], passWithNoTests: true } : {}), + + poolOptions: { + workers: { + singleWorker: true, + // The booted opencode app layer keeps background fibers (and thus the + // DO's SQLite WAL) alive across tests; isolated storage cannot pop + // its stack frames around a live Durable Object. The spike's tests + // build on each other sequentially instead. + isolatedStorage: false, + wrangler: { configPath: "./wrangler.jsonc" }, + }, + }, + }, +}) diff --git a/packages/workerd-spike/wrangler.jsonc b/packages/workerd-spike/wrangler.jsonc new file mode 100644 index 0000000000..3a6c258b8a --- /dev/null +++ b/packages/workerd-spike/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "opencode-workerd-spike", + "main": "src/worker.ts", + "compatibility_date": "2025-09-06", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "OPENCODE", "class_name": "OpencodeDurableObject" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["OpencodeDurableObject"] }], +} diff --git a/patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch b/patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch new file mode 100644 index 0000000000..bea7d65377 --- /dev/null +++ b/patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch @@ -0,0 +1,124 @@ +diff --git a/node_modules/@cloudflare/vitest-pool-workers/.bun-tag-4f580cd931af41cf b/.bun-tag-4f580cd931af41cf +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@cloudflare/vitest-pool-workers/.bun-tag-9af33d5ef1b055b6 b/.bun-tag-9af33d5ef1b055b6 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@cloudflare/vitest-pool-workers/.bun-tag-bc13f50728613b9b b/.bun-tag-bc13f50728613b9b +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@cloudflare/vitest-pool-workers/.bun-tag-da6365f6377c435 b/.bun-tag-da6365f6377c435 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@cloudflare/vitest-pool-workers/.bun-tag-e8c4c34325da55fc b/.bun-tag-e8c4c34325da55fc +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@cloudflare/vitest-pool-workers/.bun-tag-fda91df55e69aaa5 b/.bun-tag-fda91df55e69aaa5 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/dist/pool/index.mjs b/dist/pool/index.mjs +index 22f57632afbb31d513363fd16373c61a5154a3be..3ff80ffe47dc003a2df813ad24bcb507b6e982a7 100644 +--- a/dist/pool/index.mjs ++++ b/dist/pool/index.mjs +@@ -31,7 +31,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge + var define_VITEST_POOL_WORKERS_DEFINE_BUILTIN_MODULES_default; + var init_define_VITEST_POOL_WORKERS_DEFINE_BUILTIN_MODULES = __esm({ + ""() { +- define_VITEST_POOL_WORKERS_DEFINE_BUILTIN_MODULES_default = ["node-internal:async_hooks", "node-internal:buffer", "node-internal:crypto", "node-internal:module", "node-internal:process", "node-internal:util", "node-internal:diagnostics_channel", "node-internal:zlib", "node-internal:url", "node-internal:timers", "node-internal:sqlite", "node-internal:dns", "node:_stream_duplex", "node:_stream_passthrough", "node:_stream_readable", "node:_stream_transform", "node:_stream_writable", "node:_tls_common", "node:_tls_wrap", "node:assert", "node:assert/strict", "node:async_hooks", "node:buffer", "node:constants", "node:crypto", "node:diagnostics_channel", "node:dns", "node:dns/promises", "node:events", "node:fs/promises", "node:module", "node:net", "node:path", "node:path/posix", "node:path/win32", "node:querystring", "node:stream", "node:stream/consumers", "node:stream/promises", "node:stream/web", "node:string_decoder", "node:test", "node:timers", "node:timers/promises", "node:tls", "node:url", "node:util", "node:util/types", "node:zlib", "node-internal:constants", "node-internal:crypto_cipher", "node-internal:crypto_dh", "node-internal:crypto_hash", "node-internal:crypto_hkdf", "node-internal:crypto_keys", "node-internal:crypto_pbkdf2", "node-internal:crypto_random", "node-internal:crypto_scrypt", "node-internal:crypto_sign", "node-internal:crypto_spkac", "node-internal:crypto_util", "node-internal:crypto_x509", "node-internal:debuglog", "node-internal:events", "node-internal:internal_assert", "node-internal:internal_assertionerror", "node-internal:internal_buffer", "node-internal:internal_comparisons", "node-internal:internal_diffs", "node-internal:internal_dns", "node-internal:internal_dns_client", "node-internal:internal_dns_constants", "node-internal:internal_dns_promises", "node-internal:internal_errors", "node-internal:internal_fs", "node-internal:internal_fs_callback", "node-internal:internal_fs_constants", "node-internal:internal_fs_promises", "node-internal:internal_fs_streams", "node-internal:internal_fs_sync", "node-internal:internal_fs_utils", "node-internal:internal_http", "node-internal:internal_http2_constants", "node-internal:internal_http_agent", "node-internal:internal_http_client", "node-internal:internal_http_constants", "node-internal:internal_http_incoming", "node-internal:internal_http_outgoing", "node-internal:internal_http_server", "node-internal:internal_http_util", "node-internal:internal_https_agent", "node-internal:internal_https_server", "node-internal:internal_inspect", "node-internal:internal_module", "node-internal:internal_net", "node-internal:internal_path", "node-internal:internal_process", "node-internal:internal_querystring", "node-internal:internal_readline", "node-internal:internal_readline_promises", "node-internal:internal_stringdecoder", "node-internal:internal_timers", "node-internal:internal_timers_global_override", "node-internal:internal_timers_promises", "node-internal:internal_tls", "node-internal:internal_tls_common", "node-internal:internal_tls_constants", "node-internal:internal_tls_jsstream", "node-internal:internal_tls_wrap", "node-internal:internal_types", "node-internal:internal_url", "node-internal:internal_utils", "node-internal:internal_zlib", "node-internal:internal_zlib_base", "node-internal:internal_zlib_constants", "node-internal:legacy_process", "node-internal:legacy_url", "node-internal:mock", "node-internal:public_process", "node-internal:streams_add_abort_signal", "node-internal:streams_compose", "node-internal:streams_destroy", "node-internal:streams_duplex", "node-internal:streams_end_of_stream", "node-internal:streams_legacy", "node-internal:streams_pipeline", "node-internal:streams_promises", "node-internal:streams_readable", "node-internal:streams_state", "node-internal:streams_transform", "node-internal:streams_util", "node-internal:streams_writable", "node-internal:validators", "internal:unsafe-eval", "cloudflare-internal:sockets", "cloudflare-internal:base64", "cloudflare:ai", "cloudflare:br", "cloudflare:email", "cloudflare:node", "cloudflare:pipelines", "cloudflare:sockets", "cloudflare:vectorize", "cloudflare:workers", "cloudflare:workflows", "cloudflare-internal:ai-api", "cloudflare-internal:aig-api", "cloudflare-internal:autorag-api", "cloudflare-internal:br-api", "cloudflare-internal:d1-api", "cloudflare-internal:http", "cloudflare-internal:images-api", "cloudflare-internal:pipeline-transform", "cloudflare-internal:streaming-base64", "cloudflare-internal:streaming-forms", "cloudflare-internal:test-tracing-wrapper", "cloudflare-internal:to-markdown-api", "cloudflare-internal:tracing-helpers", "cloudflare-internal:vectorize-api", "cloudflare-internal:workflows-api", "cloudflare-internal:workers", "cloudflare-internal:tracing", "cloudflare-internal:env", "cloudflare-internal:filesystem", "workerd:unsafe"]; ++ define_VITEST_POOL_WORKERS_DEFINE_BUILTIN_MODULES_default = ["node-internal:async_hooks", "node-internal:buffer", "node-internal:crypto", "node-internal:module", "node-internal:process", "node-internal:util", "node-internal:diagnostics_channel", "node-internal:zlib", "node-internal:url", "node-internal:timers", "node-internal:sqlite", "node-internal:dns", "node:fs", "node:http", "node:https", "node:perf_hooks", "node:process", "node:tty", "node:_stream_duplex", "node:_stream_passthrough", "node:_stream_readable", "node:_stream_transform", "node:_stream_writable", "node:_tls_common", "node:_tls_wrap", "node:assert", "node:assert/strict", "node:async_hooks", "node:buffer", "node:constants", "node:crypto", "node:diagnostics_channel", "node:dns", "node:dns/promises", "node:events", "node:fs/promises", "node:module", "node:net", "node:path", "node:path/posix", "node:path/win32", "node:querystring", "node:stream", "node:stream/consumers", "node:stream/promises", "node:stream/web", "node:string_decoder", "node:test", "node:timers", "node:timers/promises", "node:tls", "node:url", "node:util", "node:util/types", "node:zlib", "node-internal:constants", "node-internal:crypto_cipher", "node-internal:crypto_dh", "node-internal:crypto_hash", "node-internal:crypto_hkdf", "node-internal:crypto_keys", "node-internal:crypto_pbkdf2", "node-internal:crypto_random", "node-internal:crypto_scrypt", "node-internal:crypto_sign", "node-internal:crypto_spkac", "node-internal:crypto_util", "node-internal:crypto_x509", "node-internal:debuglog", "node-internal:events", "node-internal:internal_assert", "node-internal:internal_assertionerror", "node-internal:internal_buffer", "node-internal:internal_comparisons", "node-internal:internal_diffs", "node-internal:internal_dns", "node-internal:internal_dns_client", "node-internal:internal_dns_constants", "node-internal:internal_dns_promises", "node-internal:internal_errors", "node-internal:internal_fs", "node-internal:internal_fs_callback", "node-internal:internal_fs_constants", "node-internal:internal_fs_promises", "node-internal:internal_fs_streams", "node-internal:internal_fs_sync", "node-internal:internal_fs_utils", "node-internal:internal_http", "node-internal:internal_http2_constants", "node-internal:internal_http_agent", "node-internal:internal_http_client", "node-internal:internal_http_constants", "node-internal:internal_http_incoming", "node-internal:internal_http_outgoing", "node-internal:internal_http_server", "node-internal:internal_http_util", "node-internal:internal_https_agent", "node-internal:internal_https_server", "node-internal:internal_inspect", "node-internal:internal_module", "node-internal:internal_net", "node-internal:internal_path", "node-internal:internal_process", "node-internal:internal_querystring", "node-internal:internal_readline", "node-internal:internal_readline_promises", "node-internal:internal_stringdecoder", "node-internal:internal_timers", "node-internal:internal_timers_global_override", "node-internal:internal_timers_promises", "node-internal:internal_tls", "node-internal:internal_tls_common", "node-internal:internal_tls_constants", "node-internal:internal_tls_jsstream", "node-internal:internal_tls_wrap", "node-internal:internal_types", "node-internal:internal_url", "node-internal:internal_utils", "node-internal:internal_zlib", "node-internal:internal_zlib_base", "node-internal:internal_zlib_constants", "node-internal:legacy_process", "node-internal:legacy_url", "node-internal:mock", "node-internal:public_process", "node-internal:streams_add_abort_signal", "node-internal:streams_compose", "node-internal:streams_destroy", "node-internal:streams_duplex", "node-internal:streams_end_of_stream", "node-internal:streams_legacy", "node-internal:streams_pipeline", "node-internal:streams_promises", "node-internal:streams_readable", "node-internal:streams_state", "node-internal:streams_transform", "node-internal:streams_util", "node-internal:streams_writable", "node-internal:validators", "internal:unsafe-eval", "cloudflare-internal:sockets", "cloudflare-internal:base64", "cloudflare:ai", "cloudflare:br", "cloudflare:email", "cloudflare:node", "cloudflare:pipelines", "cloudflare:sockets", "cloudflare:vectorize", "cloudflare:workers", "cloudflare:workflows", "cloudflare-internal:ai-api", "cloudflare-internal:aig-api", "cloudflare-internal:autorag-api", "cloudflare-internal:br-api", "cloudflare-internal:d1-api", "cloudflare-internal:http", "cloudflare-internal:images-api", "cloudflare-internal:pipeline-transform", "cloudflare-internal:streaming-base64", "cloudflare-internal:streaming-forms", "cloudflare-internal:test-tracing-wrapper", "cloudflare-internal:to-markdown-api", "cloudflare-internal:tracing-helpers", "cloudflare-internal:vectorize-api", "cloudflare-internal:workflows-api", "cloudflare-internal:workers", "cloudflare-internal:tracing", "cloudflare-internal:env", "cloudflare-internal:filesystem", "workerd:unsafe"]; + } + }); + +@@ -2882,13 +2882,20 @@ function withImportMetaUrl(contents, url) { + return contents.replaceAll("import.meta.url", JSON.stringify(url.toString())); + } + var jsExtensions = [".js", ".mjs", ".cjs"]; ++function existsCaseSensitive(target) { ++ try { ++ return fs2.readdirSync(posixPath.dirname(target)).includes(posixPath.basename(target)); ++ } catch { ++ return false; ++ } ++} + function maybeGetTargetFilePath(target) { +- if (isFile(target)) { ++ if (isFile(target) && existsCaseSensitive(target)) { + return target; + } + for (const extension of jsExtensions) { + const targetWithExtension = target + extension; +- if (fs2.existsSync(targetWithExtension)) { ++ if (fs2.existsSync(targetWithExtension) && existsCaseSensitive(targetWithExtension)) { + return targetWithExtension; + } + } +@@ -2936,9 +2943,16 @@ async function viteResolve(vite, specifier, referrer, isRequire) { + } + return trimViteVersionHash(resolved.id); + } ++function stripFsPrefix(filePath) { ++ return filePath.startsWith("/@fs/") ? filePath.slice("/@fs".length) : filePath; ++} + async function resolve(vite, method, target, specifier, referrer) { + const referrerDir = posixPath.dirname(referrer); +- let filePath = maybeGetTargetFilePath(target); ++ const forceMatch = forceModuleTypeRegexp.exec(target); ++ if (forceMatch !== null && isFile(stripFsPrefix(target.slice(0, -forceMatch[0].length)))) { ++ return target; ++ } ++ let filePath = maybeGetTargetFilePath(stripFsPrefix(target)); + if (filePath !== void 0) { + return filePath; + } +@@ -2966,7 +2980,7 @@ function maybeGetForceTypeModuleContents(filePath) { + if (match === null) { + return; + } +- filePath = trimSuffix(match[0], filePath); ++ filePath = stripFsPrefix(trimSuffix(match[0], filePath)); + const type = match[1]; + const contents = fs2.readFileSync(filePath); + switch (type) { +@@ -3025,6 +3039,12 @@ async function load(vite, logBase, method, target, specifier, filePath) { + } + const isEsm = filePath.endsWith(".mjs") || filePath.endsWith(".js") && isWithinTypeModuleContext(filePath); + let contents = fs2.readFileSync(filePath, "utf8"); ++ // JSON files (e.g. require("./db.json")) are not JavaScript; serve them as ++ // a CommonJS module exporting the parsed value. ++ if (filePath.endsWith(".json")) { ++ debuglog(logBase, "json:", filePath); ++ return buildModuleResponse(target, { commonJsModule: `module.exports = ${contents};` }); ++ } + const targetUrl = pathToFileURL(target); + contents = withSourceUrl(contents, targetUrl); + if (isEsm) { +@@ -3055,6 +3075,11 @@ async function handleModuleFallbackRequest(vite, request) { + let referrer = url.searchParams.get("referrer"); + assert3(target !== null, "Expected specifier search param"); + assert3(referrer !== null, "Expected referrer search param"); ++ // Vite prefixes ids of files outside the project root with /@fs (monorepo ++ // workspaces); strip it from referrers so relative resolution works. ++ if (referrer.startsWith("/@fs/")) { ++ referrer = referrer.slice("/@fs".length); ++ } + const referrerDir = posixPath.dirname(referrer); + let specifier = getApproximateSpecifier(target, referrerDir); + if (specifier.startsWith("file:")) { +@@ -3626,7 +3651,16 @@ async function runTests(ctx, mf, workerName, project, config, files, invalidates + } + }); + const rules = project.options.miniflare?.modulesRules; +- const compiledRules = compileModuleRules(rules ?? []); ++ // miniflare's glob-to-regexp compilation is unanchored, so a `**/*.sql` ++ // rule would also capture `schema.sql.ts`; anchor each pattern to the end ++ // of the specifier. ++ const compiledRules = compileModuleRules(rules ?? []).map((rule) => ({ ++ ...rule, ++ include: { ++ include: rule.include.include.map((regexp) => new RegExp(`${regexp.source}$`, regexp.flags)), ++ exclude: rule.include.exclude ++ } ++ })); + const localRpcFunctions = createMethodsRPC(project.project, { + cacheFs: false + }); diff --git a/turbo.json b/turbo.json index d3438cc056..a6ad6a77a3 100644 --- a/turbo.json +++ b/turbo.json @@ -56,6 +56,10 @@ "@opencode-ai/session-ui#test": { "dependsOn": ["^build"], "outputs": [] + }, + "@opencode-ai/workerd-spike#test": { + "dependsOn": ["^build"], + "outputs": [] } } } From 912a1c4e8a63d3bf16bdd00a038d5d51d4b26378 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 14:08:25 -0400 Subject: [PATCH 29/61] fix(tui): move debug overlay into developer tools (#42098) --- packages/tui/src/app.tsx | 1 + packages/tui/src/component/devtools-bar.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index ac3c68aac0..0608195609 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -993,6 +993,7 @@ function App(props: { pair?: DialogPairCredentials }) { name: "app.debug", title: "Toggle debug panel", category: "System", + palette: undefined, run: () => { renderer.toggleDebugOverlay() dialog.clear() diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index 545507fb28..4f8fae4c67 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -1,4 +1,4 @@ -import { TextAttributes, type Renderable } from "@opentui/core" +import { CliRenderEvents, TextAttributes, type Renderable } from "@opentui/core" import { TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { open } from "node:fs/promises" import { tmpdir } from "node:os" @@ -45,6 +45,7 @@ export function DevToolsBar() { const [dumpPath, setDumpPath] = createSignal() const [dumpError, setDumpError] = createSignal() const [frontendSamples, setFrontendSamples] = createSignal([]) + const [debugOverlay, setDebugOverlay] = createSignal(renderer.debugOverlay.enabled) let focus: Renderable | null const connected = createMemo(() => client.connection.status() === "connected") const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt())) @@ -94,6 +95,10 @@ export function DevToolsBar() { ) onCleanup(offEscape) + const onDebugOverlayToggle = (enabled: boolean) => setDebugOverlay(enabled) + renderer.on(CliRenderEvents.DEBUG_OVERLAY_TOGGLE, onDebugOverlayToggle) + onCleanup(() => renderer.off(CliRenderEvents.DEBUG_OVERLAY_TOGGLE, onDebugOverlayToggle)) + onMount(() => { const eventLoop = monitorEventLoopDelay({ resolution: 20 }) let frontendCPU = process.cpuUsage() @@ -319,6 +324,9 @@ export function DevToolsBar() { unit=" MB" decimals={0} /> + renderer.toggleDebugOverlay()} hoverBackground> + {debugOverlay() ? "[x]" : "[ ]"} Debug overlay + From f8f50ee3750a169cdbf703a2ce16de95e40058db Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 12 Aug 2026 18:10:29 +0000 Subject: [PATCH 30/61] chore: generate --- packages/protocol/openapi.json | 2 +- packages/server/src/fetch.ts | 1 - packages/www/openapi.json | 2 +- packages/www/public/openapi.json | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 4c3f81f88d..ed8e6841c3 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -12146,7 +12146,7 @@ "type": "integer", "allOf": [ { - "exclusiveMinimum": 0 + "minimum": 0 } ] } diff --git a/packages/server/src/fetch.ts b/packages/server/src/fetch.ts index 6a671a9e78..4781be4fb0 100644 --- a/packages/server/src/fetch.ts +++ b/packages/server/src/fetch.ts @@ -16,7 +16,6 @@ export interface BootOptions { readonly overrides?: LayerNode.Replacements } - /** * Builds a web-standard fetch handler — `(request: Request) => Promise` — serving the * same HttpApi routes as the Node server process without binding a port, owning a listener, or diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 4c3f81f88d..ed8e6841c3 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -12146,7 +12146,7 @@ "type": "integer", "allOf": [ { - "exclusiveMinimum": 0 + "minimum": 0 } ] } diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 4c3f81f88d..ed8e6841c3 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -12146,7 +12146,7 @@ "type": "integer", "allOf": [ { - "exclusiveMinimum": 0 + "minimum": 0 } ] } From c86b3079ac2f1b26fcfcfb932521e25709f28e01 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 14:28:54 -0400 Subject: [PATCH 31/61] fix(tui): render Mermaid rich labels compactly (#42103) --- packages/merman/src/core/render-grid.ts | 12 +++- packages/merman/src/core/text-lines.ts | 33 ++++++++- packages/merman/src/core/text.test.ts | 15 +++- packages/merman/src/core/text.ts | 2 +- packages/merman/src/flowchart/drawing.ts | 38 +++++++--- .../merman/src/flowchart/flowchart.test.ts | 70 ++++++++++++++++++- packages/merman/src/flowchart/layout.ts | 33 ++++----- packages/merman/src/flowchart/parser.ts | 6 +- packages/merman/src/flowchart/style.ts | 7 +- packages/merman/src/test/markdown.test.ts | 39 +++++++++++ 10 files changed, 219 insertions(+), 36 deletions(-) diff --git a/packages/merman/src/core/render-grid.ts b/packages/merman/src/core/render-grid.ts index 72df0b358a..9dfd684078 100644 --- a/packages/merman/src/core/render-grid.ts +++ b/packages/merman/src/core/render-grid.ts @@ -5,13 +5,21 @@ export function renderDiagramGridStyledText