From 4d314f6e044f81e6da3bc2e913e7f306af4b99c1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 13 Jul 2026 22:37:59 +0800 Subject: [PATCH] feat(app): add versioned backend interface --- bun.lock | 4 + packages/app/package.json | 2 + packages/app/src/context/backend-v1.test.ts | 140 +++ packages/app/src/context/backend-v1.ts | 992 ++++++++++++++++ packages/app/src/context/backend.ts | 1190 +++++++++++++++++++ 5 files changed, 2328 insertions(+) create mode 100644 packages/app/src/context/backend-v1.test.ts create mode 100644 packages/app/src/context/backend-v1.ts create mode 100644 packages/app/src/context/backend.ts diff --git a/bun.lock b/bun.lock index edb60172f5..7270405a1f 100644 --- a/bun.lock +++ b/bun.lock @@ -40,9 +40,11 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/trees": "1.0.0-beta.4", @@ -2218,6 +2220,8 @@ "@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"], + "@opencode-ai/sdk-v1": ["@opencode-ai/sdk@https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ=="], + "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"], diff --git a/packages/app/package.json b/packages/app/package.json index 510e2015db..9a6fd69351 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -53,9 +53,11 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", + "@opencode-ai/sdk-v1": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.18.tgz", "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@pierre/trees": "1.0.0-beta.4", diff --git a/packages/app/src/context/backend-v1.test.ts b/packages/app/src/context/backend-v1.test.ts new file mode 100644 index 0000000000..daabb15a99 --- /dev/null +++ b/packages/app/src/context/backend-v1.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client" +import { createV1Backend } from "./backend-v1" + +function setup(respond: (request: Request) => Response | Promise) { + const requests: Request[] = [] + const fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request) + return respond(request) + }, + { preconnect: globalThis.fetch.preconnect }, + ) satisfies typeof globalThis.fetch + return { + requests, + backend: createV1Backend(createOpencodeClient({ baseUrl: "http://localhost", fetch })), + } +} + +function json(data: unknown, headers?: HeadersInit) { + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) }, + }) +} + +const session = { + id: "ses_1", + slug: "one", + projectID: "project", + directory: "/repo", + title: "Session", + version: "1", + time: { created: 1, updated: 2 }, +} + +describe("createV1Backend", () => { + test("normalizes session pagination and location", async () => { + const setupResult = setup(() => json([session], { "x-next-cursor": "456" })) + + const result = await setupResult.backend.common.sessions.list({ + location: { directory: "/repo", workspaceID: "workspace" }, + roots: true, + limit: 10, + cursor: "123", + }) + + expect(result).toEqual({ + items: [ + { + id: "ses_1", + parentID: undefined, + projectID: "project", + location: { directory: "/repo", workspaceID: undefined }, + title: "Session", + cost: 0, + tokens: undefined, + time: { created: 1, updated: 2 }, + share: undefined, + revert: undefined, + }, + ], + next: "456", + }) + const url = new URL(setupResult.requests[0].url) + expect(url.pathname).toBe("/experimental/session") + expect(url.searchParams.get("directory")).toBe("/repo") + expect(url.searchParams.get("workspace")).toBe("workspace") + expect(url.searchParams.get("roots")).toBe("true") + expect(url.searchParams.get("cursor")).toBe("123") + }) + + test("converts normalized prompts to legacy parts", async () => { + const setupResult = setup(() => new Response(null, { status: 204 })) + + await setupResult.backend.common.sessions.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "hello", + selection: { + agent: "build", + model: { id: "model", providerID: "provider", variant: "high" }, + }, + files: [{ uri: "data:text/plain;base64,aGk=", name: "hi.txt", mime: "text/plain" }], + agents: [{ name: "explore", text: "@explore", start: 6, end: 14 }], + }) + + const request = setupResult.requests[0] + expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async") + expect(await request.json()).toEqual({ + messageID: "msg_1", + model: { providerID: "provider", modelID: "model" }, + agent: "build", + variant: "high", + parts: [ + { type: "text", text: "hello" }, + { type: "file", mime: "text/plain", filename: "hi.txt", url: "data:text/plain;base64,aGk=" }, + { type: "agent", name: "explore", source: { value: "@explore", start: 6, end: 14 } }, + ], + }) + }) + + test("combines mixed file search and decodes binary content", async () => { + const setupResult = setup((request) => { + const url = new URL(request.url) + if (url.pathname === "/find/file") { + return json(url.searchParams.get("type") === "file" ? ["a.txt", "shared"] : ["dir", "shared"]) + } + return json({ type: "binary", content: "AAEC", encoding: "base64", mimeType: "application/octet-stream" }) + }) + + const found = await setupResult.backend.common.files.find({ query: "a" }) + const content = await setupResult.backend.common.files.read({ path: "a.bin" }) + + expect(found).toEqual([ + { path: "a.txt", type: "file" }, + { path: "shared", type: "directory" }, + { path: "dir", type: "directory" }, + ]) + expect([...content.bytes]).toEqual([0, 1, 2]) + expect(content.kind).toBe("binary") + expect(content.mimeType).toBe("application/octet-stream") + }) + + test("merges global config updates with untouched fields", async () => { + const bodies: unknown[] = [] + const setupResult = setup(async (request) => { + if (request.method === "GET") return json({ autoupdate: true, model: "old", disabled_providers: ["one"] }) + bodies.push(await request.json()) + return json({}) + }) + + await setupResult.backend.capabilities.configuration?.updateGlobal({ + model: "new", + disabledProviders: ["two"], + }) + + expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }]) + }) +}) diff --git a/packages/app/src/context/backend-v1.ts b/packages/app/src/context/backend-v1.ts new file mode 100644 index 0000000000..b5bc0e86d2 --- /dev/null +++ b/packages/app/src/context/backend-v1.ts @@ -0,0 +1,992 @@ +import type { + Config, + Event, + GlobalEvent, + Message, + Model, + OpencodeClient, + Part, + Project, + Provider, + Session, +} from "@opencode-ai/sdk-v1/v2/client" +import type { + AppAgent, + AppClient, + AppCommand, + AppConfig, + AppEvent, + AppEventEnvelope, + AppFileDiff, + AppModel, + AppPermissionRequest, + AppProject, + AppProvider, + AppQuestionRequest, + AppReference, + AppSession, + CommandInput, + DecoratedFileContent, + FileContent, + FileEntry, + LocationInput, + LocationRef, + Page, + PromptFile, + PromptInput, + ProviderCatalog, + RequestOptions, + SessionActivity, + TimelineContent, + TimelineItem, + ToolState, +} from "./backend" + +type CachedMessage = { + info: Message + parts: Part[] +} + +export function createV1Backend(client: OpencodeClient, defaultLocation?: LocationRef): AppClient { + const messages = new Map() + + const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const }) + const location = (input?: LocationInput) => legacyLocation(input?.location ?? defaultLocation) + const cache = (input: CachedMessage) => { + messages.set(input.info.id, input) + return toTimelineItem(input.info, input.parts) + } + + const loadMessage = async (input: { sessionID: string; messageID: string }, request?: RequestOptions) => { + const cached = messages.get(input.messageID) + if (cached) return cached + const result = await client.session.message({ ...input, ...legacyLocation(defaultLocation) }, options(request)) + messages.set(input.messageID, result.data) + return result.data + } + + return { + version: "v1", + common: { + health: { + get: async (request) => { + const result = await client.global.health(options(request)) + return result.data + }, + }, + projects: { + list: async (request) => { + const result = await client.project.list(undefined, options(request)) + return result.data.map(toProject) + }, + current: async (input, request) => { + const params = location(input) + const [project, path] = await Promise.all([ + client.project.current(params, options(request)), + client.path.get(params, options(request)), + ]) + return { id: project.data.id, directory: path.data.directory } + }, + }, + catalog: { + providers: async (input, request) => { + const result = await client.provider.list(location(input), options(request)) + return toProviderCatalog(result.data) + }, + agents: async (input, request) => { + const result = await client.app.agents(location(input), options(request)) + return normalizeAgents(result.data).map(toAgent) + }, + }, + commands: { + list: async (input, request) => { + const result = await client.command.list(location(input), options(request)) + return result.data.map(toCommand) + }, + }, + references: { + list: async (input, request) => { + const result = await client.v2.reference.list( + { location: apiLocation(input?.location ?? defaultLocation) }, + options(request), + ) + return result.data.data.map(toReference) + }, + }, + sessions: { + list: async (input, request) => { + const cursor = input?.cursor === undefined ? undefined : Number(input.cursor) + if (cursor !== undefined && !Number.isFinite(cursor)) + throw new Error(`Invalid session cursor: ${input?.cursor}`) + const result = await client.experimental.session.list( + { + ...location(input), + roots: input?.roots, + limit: input?.limit, + search: input?.search, + cursor, + }, + options(request), + ) + return { + items: result.data.map(toSession), + next: result.response.headers.get("x-next-cursor") ?? undefined, + } + }, + create: async (input, request) => { + const result = await client.session.create( + { + ...location(input), + agent: input?.agent, + model: input?.model, + }, + options(request), + ) + return toSession(result.data) + }, + get: async (input, request) => { + const result = await client.session.get({ sessionID: input.sessionID, ...location(input) }, options(request)) + return toSession(result.data) + }, + remove: async (input, request) => { + await client.session.delete({ sessionID: input.sessionID, ...location(input) }, options(request)) + }, + fork: async (input, request) => { + const result = await client.session.fork( + { sessionID: input.sessionID, messageID: input.messageID, ...location(input) }, + options(request), + ) + return toSession(result.data) + }, + rename: async (input, request) => { + await client.session.update( + { sessionID: input.sessionID, title: input.title, ...location(input) }, + options(request), + ) + }, + interrupt: async (input, request) => { + await client.session.abort({ sessionID: input.sessionID, ...location(input) }, options(request)) + }, + activity: async (input, request) => { + const result = await client.session.status(location(input), options(request)) + return Object.fromEntries( + Object.entries(result.data).flatMap(([sessionID, status]) => { + const activity = toActivity(status) + return activity ? [[sessionID, activity] as const] : [] + }), + ) + }, + history: async (input, request) => { + const result = await client.session.messages( + { + sessionID: input.sessionID, + limit: input.limit, + before: input.cursor, + ...legacyLocation(defaultLocation), + }, + options(request), + ) + return { + items: result.data.map(cache), + next: result.response.headers.get("x-next-cursor") ?? undefined, + } + }, + message: async (input, request) => cache(await loadMessage(input, request)), + prompt: async (input, request) => { + await client.session.promptAsync( + { + sessionID: input.sessionID, + messageID: input.id, + agent: input.selection?.agent, + model: input.selection?.model && { + providerID: input.selection.model.providerID, + modelID: input.selection.model.id, + }, + variant: input.selection?.model?.variant, + parts: toPromptParts(input), + ...legacyLocation(defaultLocation), + }, + options(request), + ) + }, + command: async (input, request) => { + await client.session.command( + { + sessionID: input.sessionID, + messageID: input.id, + command: input.command, + arguments: input.arguments ?? "", + agent: input.agent, + model: input.model && `${input.model.providerID}/${input.model.id}`, + variant: input.model?.variant, + parts: input.files?.map(toFilePart), + ...legacyLocation(defaultLocation), + }, + options(request), + ) + }, + }, + files: { + list: async (input, request) => { + const result = await client.file.list({ path: input.path ?? "", ...location(input) }, options(request)) + return result.data.map((item) => ({ path: item.path, type: item.type })) + }, + find: async (input, request) => { + const find = async (type: FileEntry["type"]) => { + const result = await client.find.files( + { query: input.query, type, limit: input.limit, ...location(input) }, + options(request), + ) + return result.data.map((path) => ({ path, type })) + } + if (input.type) return find(input.type) + const result = await Promise.all([find("file"), find("directory")]) + return [...new Map(result.flat().map((item) => [item.path, item])).values()] + }, + read: async (input, request) => { + const result = await client.file.read({ path: input.path, ...location(input) }, options(request)) + return toFileContent(result.data) + }, + }, + permissions: { + pending: async (input, request) => { + const result = await client.permission.list(location(input), options(request)) + return result.data.map(toPermission) + }, + reply: async (input, request) => { + await client.permission.reply( + { + requestID: input.requestID, + reply: input.reply, + message: input.message, + ...legacyLocation(defaultLocation), + }, + options(request), + ) + }, + }, + questions: { + pending: async (input, request) => { + const result = await client.question.list(location(input), options(request)) + return result.data.map(toQuestion) + }, + reply: async (input, request) => { + await client.question.reply( + { + requestID: input.requestID, + answers: input.answers.map((answer) => [...answer]), + ...legacyLocation(defaultLocation), + }, + options(request), + ) + }, + reject: async (input, request) => { + await client.question.reject( + { requestID: input.requestID, ...legacyLocation(defaultLocation) }, + options(request), + ) + }, + }, + vcs: { + status: async (input, request) => { + const result = await client.vcs.status(location(input), options(request)) + return result.data + }, + diff: async (input, request) => { + const result = await client.vcs.diff( + { + mode: input.mode === "working" ? "git" : "branch", + context: input.context, + ...location(input), + }, + options(request), + ) + return result.data + }, + }, + mcp: { + list: async (input, request) => { + const result = await client.mcp.status(location(input), options(request)) + return Object.entries(result.data).map(([name, status]) => ({ name, status })) + }, + resources: async (input, request) => { + const result = await client.experimental.resource.list(location(input), options(request)) + return { + resources: Object.values(result.data).map((item) => ({ + server: item.client, + name: item.name, + uri: item.uri, + description: item.description, + mimeType: item.mimeType, + })), + templates: [], + } + }, + }, + pty: { + list: async (input, request) => { + const result = await client.pty.list(location(input), options(request)) + return result.data.map(toPty) + }, + create: async (input, request) => { + const result = await client.pty.create( + { + title: input.title, + command: input.command, + args: input.args ? [...input.args] : undefined, + cwd: input.cwd, + env: input.env ? { ...input.env } : undefined, + ...location(input), + }, + options(request), + ) + return toPty(result.data) + }, + get: async (input, request) => { + const result = await client.pty.get({ ptyID: input.ptyID, ...location(input) }, options(request)) + return toPty(result.data) + }, + update: async (input, request) => { + const result = await client.pty.update( + { + ptyID: input.ptyID, + title: input.title, + size: input.size, + ...location(input), + }, + options(request), + ) + return toPty(result.data) + }, + remove: async (input, request) => { + await client.pty.remove({ ptyID: input.ptyID, ...location(input) }, options(request)) + }, + }, + events: { + subscribe: (request) => ({ + async *[Symbol.asyncIterator]() { + const result = await client.global.event(options(request)) + for await (const input of result.stream) { + const event = await toEvent(input, messages, loadMessage) + yield { + location: + input.directory === "global" + ? undefined + : { directory: input.directory, workspaceID: input.workspace }, + event, + } satisfies AppEventEnvelope + } + }, + }), + }, + disposeLocation: async (input, request) => { + await client.instance.dispose(location(input), options(request)) + }, + }, + capabilities: { + configuration: { + getGlobal: async (request) => { + const result = await client.global.config.get(options(request)) + return toConfig(result.data) + }, + updateGlobal: async (config, request) => { + const current = await client.global.config.get(options(request)) + await client.global.config.update({ config: { ...current.data, ...fromConfig(config) } }, options(request)) + }, + get: async (input, request) => { + const result = await client.config.get(location(input), options(request)) + return toConfig(result.data) + }, + }, + providerAuthV1: { + methods: async (input, request) => { + const result = await client.provider.auth(location(input), options(request)) + return result.data + }, + authorize: async (input, request) => { + const result = await client.provider.oauth.authorize( + { + providerID: input.providerID, + method: input.method, + inputs: input.values ? { ...input.values } : undefined, + ...location(input), + }, + options(request), + ) + return result.data + }, + callback: async (input, request) => { + await client.provider.oauth.callback( + { + providerID: input.providerID, + method: input.method, + code: input.code, + ...location(input), + }, + options(request), + ) + }, + setApiKey: async (input, request) => { + await client.auth.set( + { + providerID: input.providerID, + auth: { type: "api", key: input.key, metadata: input.metadata && { ...input.metadata } }, + }, + options(request), + ) + }, + remove: async (input, request) => { + await client.auth.remove(input, options(request)) + }, + }, + projectEditing: { + update: async (input, request) => { + const result = await client.project.update( + { + projectID: input.projectID, + name: input.name, + icon: input.icon, + commands: input.commands, + ...location(input), + }, + options(request), + ) + return toProject(result.data) + }, + initGit: async (input, request) => { + const result = await client.project.initGit(location(input), options(request)) + return toProject(result.data) + }, + }, + worktreesV1: { + list: async (input, request) => { + const result = await client.worktree.list(location(input), options(request)) + return result.data + }, + create: async (input, request) => { + const result = await client.worktree.create(location(input), options(request)) + return { directory: result.data.directory, branch: result.data.branch } + }, + remove: async (input, request) => { + await client.worktree.remove( + { ...location(input), worktreeRemoveInput: { directory: input.directory } }, + options(request), + ) + }, + reset: async (input, request) => { + await client.worktree.reset( + { ...location(input), worktreeResetInput: { directory: input.directory } }, + options(request), + ) + }, + }, + sessionExtrasV1: { + archive: async (sessionID, archivedAt, request) => { + await client.session.update( + { sessionID, time: { archived: archivedAt }, ...legacyLocation(defaultLocation) }, + options(request), + ) + }, + share: async (sessionID, request) => { + const result = await client.session.share({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) + if (!result.data.share) throw new Error(`Session ${sessionID} was shared without a URL`) + return result.data.share.url + }, + unshare: async (sessionID, request) => { + await client.session.unshare({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) + }, + diff: async (sessionID, request) => { + const result = await client.session.diff({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) + return result.data.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])) + }, + todos: async (sessionID, request) => { + const result = await client.session.todo({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) + return result.data.map((item) => ({ content: item.content, status: item.status })) + }, + summarize: async (sessionID, model, request) => { + await client.session.summarize( + { + sessionID, + providerID: model.providerID, + modelID: model.id, + ...legacyLocation(defaultLocation), + }, + options(request), + ) + }, + revert: async (sessionID, messageID, request) => { + await client.session.revert({ sessionID, messageID, ...legacyLocation(defaultLocation) }, options(request)) + }, + clearRevert: async (sessionID, request) => { + await client.session.unrevert({ sessionID, ...legacyLocation(defaultLocation) }, options(request)) + }, + shell: async (input, request) => { + await client.session.shell( + { + sessionID: input.sessionID, + messageID: input.id, + command: input.command, + agent: input.agent, + model: input.model && { providerID: input.model.providerID, modelID: input.model.id }, + ...location(input), + }, + options(request), + ) + }, + }, + lsp: { + status: async (input, request) => { + const result = await client.lsp.status(location(input), options(request)) + return result.data.map((item) => ({ id: item.id, name: item.name, status: item.status })) + }, + }, + mcpControl: { + connect: async (input, request) => { + await client.mcp.connect({ name: input.name, ...location(input) }, options(request)) + }, + disconnect: async (input, request) => { + await client.mcp.disconnect({ name: input.name, ...location(input) }, options(request)) + }, + authenticate: async (input, request) => { + await client.mcp.auth.authenticate({ name: input.name, ...location(input) }, options(request)) + }, + }, + pathInfo: { + get: async (input, request) => { + const result = await client.path.get(location(input), options(request)) + return result.data + }, + }, + vcsInfo: { + get: async (input, request) => { + const result = await client.vcs.get(location(input), options(request)) + return { branch: result.data.branch, defaultBranch: result.data.default_branch } + }, + }, + decoratedFiles: { + read: async (input, request) => { + const result = await client.file.read({ path: input.path, ...location(input) }, options(request)) + return toDecoratedFile(result.data) + }, + }, + ptyTransport: { + connectToken: async (input, request) => { + const result = await client.pty.connectToken({ ptyID: input.ptyID, ...location(input) }, options(request)) + return { ticket: result.data.ticket } + }, + }, + shellDiscovery: { + list: async (input, request) => { + const result = await client.pty.shells(location(input), options(request)) + return result.data + }, + }, + runtimeV1: { + disposeAll: async (request) => { + await client.global.dispose(options(request)) + }, + }, + }, + } +} + +function legacyLocation(input?: LocationRef) { + return { + directory: input?.directory, + workspace: input?.workspaceID, + } +} + +function apiLocation(input?: LocationRef) { + if (!input) return + return { + directory: input.directory, + workspace: input.workspaceID, + } +} + +function toProject(input: Project): AppProject { + return { + id: input.id, + worktree: input.worktree, + name: input.name, + icon: input.icon, + commands: input.commands, + sandboxes: input.sandboxes, + } +} + +function toSession(input: Session): AppSession { + return { + id: input.id, + parentID: input.parentID, + projectID: input.projectID, + location: { directory: input.directory, workspaceID: input.workspaceID }, + title: input.title, + cost: input.cost ?? 0, + tokens: input.tokens, + time: input.time, + share: input.share, + revert: input.revert && { messageID: input.revert.messageID }, + } +} + +function toModel(input: Model): AppModel { + return { + id: input.id, + providerID: input.providerID, + name: input.name, + family: input.family, + releaseDate: input.release_date, + cost: { + input: input.cost.input, + output: input.cost.output, + cacheRead: input.cost.cache.read, + cacheWrite: input.cost.cache.write, + }, + capabilities: { + reasoning: input.capabilities.reasoning, + input: input.capabilities.input, + }, + limit: input.limit, + variants: input.variants, + } +} + +function toProvider(input: Provider): AppProvider { + return { + id: input.id, + name: input.name, + models: Object.fromEntries( + Object.entries(input.models).flatMap(([id, model]) => + model.status === "deprecated" ? [] : [[id, toModel(model)]], + ), + ), + } +} + +function toProviderCatalog(input: { + all: Provider[] + connected: string[] + default: Record +}): ProviderCatalog { + return { + providers: new Map(input.all.map((provider) => [provider.id, toProvider(provider)])), + connected: input.connected, + defaults: input.default, + } +} + +function normalizeAgents(input: unknown) { + const valid = (item: unknown): item is import("@opencode-ai/sdk-v1/v2/client").Agent => { + if (!item || typeof item !== "object") return false + if (!("name" in item) || typeof item.name !== "string") return false + if (!("mode" in item)) return false + return item.mode === "subagent" || item.mode === "primary" || item.mode === "all" + } + if (Array.isArray(input)) return input.filter(valid) + if (valid(input)) return [input] + if (!input || typeof input !== "object") return [] + return Object.values(input).filter(valid) +} + +function toAgent(input: import("@opencode-ai/sdk-v1/v2/client").Agent): AppAgent { + return { + id: input.name, + name: input.name, + description: input.description, + mode: input.mode, + hidden: input.hidden ?? false, + color: input.color, + model: input.model && { + id: input.model.modelID, + providerID: input.model.providerID, + variant: input.variant, + }, + } +} + +function toCommand(input: import("@opencode-ai/sdk-v1/v2/client").Command): AppCommand { + return { name: input.name, description: input.description, source: input.source } +} + +function toReference(input: import("@opencode-ai/sdk-v1/v2/client").ReferenceInfo): AppReference { + return input +} + +function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity | undefined { + if (input.type === "idle") return + if (input.type === "busy") return { type: "running" } + return input +} + +function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem { + if (info.role === "user") { + return { + type: "user", + id: info.id, + sessionID: info.sessionID, + created: info.time.created, + content: parts.flatMap(toTimelineContent), + agent: info.agent, + model: { + id: info.model.modelID, + providerID: info.model.providerID, + variant: info.model.variant, + }, + raw: { info, parts }, + } + } + return { + type: "assistant", + id: info.id, + sessionID: info.sessionID, + parentID: info.parentID, + created: info.time.created, + completed: info.time.completed, + content: parts.flatMap(toTimelineContent), + agent: info.agent, + model: { id: info.modelID, providerID: info.providerID, variant: info.variant }, + tokens: info.tokens, + error: info.error, + raw: { info, parts }, + } +} + +function toTimelineContent(input: Part): TimelineContent[] { + if (input.type === "text") + return [ + { + type: input.type, + id: input.id, + text: input.text, + synthetic: input.synthetic, + ignored: input.ignored, + metadata: input.metadata, + }, + ] + if (input.type === "reasoning") return [{ type: input.type, id: input.id, text: input.text }] + if (input.type === "file") + return [ + { + type: input.type, + id: input.id, + uri: input.url, + name: input.filename, + mime: input.mime, + source: input.source && { + ...input.source, + text: { + text: input.source.text.value, + start: input.source.text.start, + end: input.source.text.end, + }, + }, + }, + ] + if (input.type === "agent") + return [ + { + type: input.type, + id: input.id, + name: input.name, + source: input.source && { text: input.source.value, start: input.source.start, end: input.source.end }, + }, + ] + if (input.type === "tool") + return [{ type: input.type, id: input.id, callID: input.callID, tool: input.tool, state: toToolState(input.state) }] + return [] +} + +function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState): ToolState { + if (input.status === "pending") return { status: input.status, input: input.input, raw: input.raw } + if (input.status === "running") + return { status: input.status, input: input.input, title: input.title, metadata: input.metadata } + if (input.status === "completed") + return { + status: input.status, + input: input.input, + output: input.output, + title: input.title, + metadata: input.metadata, + } + return { status: input.status, input: input.input, error: input.error, metadata: input.metadata } +} + +function toFilePart(input: PromptFile) { + return { + type: "file" as const, + mime: input.mime ?? "text/plain", + filename: input.name, + url: input.uri, + source: input.source?.path + ? { + type: "file" as const, + path: input.source.path, + text: { value: input.source.text, start: input.source.start, end: input.source.end }, + } + : undefined, + } +} + +function toPromptParts(input: PromptInput) { + return [ + { type: "text" as const, text: input.text }, + ...(input.files?.map(toFilePart) ?? []), + ...(input.agents?.map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: + agent.text !== undefined && agent.start !== undefined && agent.end !== undefined + ? { value: agent.text, start: agent.start, end: agent.end } + : undefined, + })) ?? []), + ] +} + +function toFileContent(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): FileContent { + if (input.encoding !== "base64") { + return { bytes: new TextEncoder().encode(input.content), kind: input.type, mimeType: input.mimeType } + } + return { + bytes: Uint8Array.from(atob(input.content), (character) => character.charCodeAt(0)), + kind: input.type, + mimeType: input.mimeType, + } +} + +function toDecoratedFile(input: import("@opencode-ai/sdk-v1/v2/client").FileContent): DecoratedFileContent { + return { + type: input.type, + content: input.content, + diff: input.diff, + encoding: input.encoding, + mimeType: input.mimeType, + patch: input.patch && { hunks: input.patch.hunks.map((hunk) => ({ lines: hunk.lines })) }, + } +} + +function toPermission(input: import("@opencode-ai/sdk-v1/v2/client").PermissionRequest): AppPermissionRequest { + return { + id: input.id, + sessionID: input.sessionID, + action: input.permission, + resources: input.patterns, + metadata: input.metadata, + } +} + +function toQuestion(input: import("@opencode-ai/sdk-v1/v2/client").QuestionRequest): AppQuestionRequest { + return { + id: input.id, + sessionID: input.sessionID, + questions: input.questions, + } +} + +function toPty(input: import("@opencode-ai/sdk-v1/v2/client").Pty) { + return { id: input.id, title: input.title } +} + +async function toEvent( + envelope: GlobalEvent, + messages: Map, + loadMessage: (input: { sessionID: string; messageID: string }) => Promise, +): Promise { + const input = envelope.payload as Event + if (input.type === "server.connected") return { type: input.type } + if (input.type === "global.disposed") return { type: "server.disposed" } + if (input.type === "server.instance.disposed") + return { type: "server.disposed", location: { directory: input.properties.directory } } + if (input.type === "project.updated") return { type: input.type, project: toProject(input.properties) } + if (input.type === "session.created" || input.type === "session.updated") + return { type: input.type, session: toSession(input.properties.info) } + if (input.type === "session.deleted") return { type: input.type, sessionID: input.properties.sessionID } + if (input.type === "session.status") { + const activity = toActivity(input.properties.status) + if (activity) return { type: "session.activity", sessionID: input.properties.sessionID, activity } + return { type: "unknown", raw: input } + } + if (input.type === "session.error") + return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error } + if (input.type === "message.updated") { + const cached = messages.get(input.properties.info.id) + const value = { info: input.properties.info, parts: cached?.parts ?? [] } + messages.set(input.properties.info.id, value) + return { type: "timeline.updated", item: toTimelineItem(value.info, value.parts) } + } + if (input.type === "message.part.updated") { + const messageID = input.properties.part.messageID + const value = await loadMessage({ sessionID: input.properties.sessionID, messageID }) + const parts = [...value.parts.filter((part) => part.id !== input.properties.part.id), input.properties.part] + const next = { info: value.info, parts } + messages.set(messageID, next) + return { type: "timeline.updated", item: toTimelineItem(next.info, next.parts) } + } + if (input.type === "message.removed") { + messages.delete(input.properties.messageID) + return { type: "timeline.removed", sessionID: input.properties.sessionID, itemID: input.properties.messageID } + } + if (input.type === "message.part.removed") { + const value = await loadMessage({ sessionID: input.properties.sessionID, messageID: input.properties.messageID }) + const next = { info: value.info, parts: value.parts.filter((part) => part.id !== input.properties.partID) } + messages.set(input.properties.messageID, next) + return { type: "timeline.updated", item: toTimelineItem(next.info, next.parts) } + } + if (input.type === "permission.asked") + return { type: "permission.requested", request: toPermission(input.properties) } + if (input.type === "permission.v2.asked") + return { + type: "permission.requested", + request: { + id: input.properties.id, + sessionID: input.properties.sessionID, + action: input.properties.action, + resources: input.properties.resources, + metadata: input.properties.metadata, + }, + } + if (input.type === "permission.replied" || input.type === "permission.v2.replied") + return { + type: "permission.replied", + sessionID: input.properties.sessionID, + requestID: input.properties.requestID, + } + if (input.type === "question.asked" || input.type === "question.v2.asked") + return { type: "question.requested", request: toQuestion(input.properties) } + if (input.type === "question.replied" || input.type === "question.v2.replied") + return { type: "question.replied", sessionID: input.properties.sessionID, requestID: input.properties.requestID } + if (input.type === "question.rejected" || input.type === "question.v2.rejected") + return { type: "question.rejected", sessionID: input.properties.sessionID, requestID: input.properties.requestID } + if (input.type === "file.watcher.updated") + return { type: "file.changed", path: input.properties.file, change: input.properties.event } + if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.properties.branch } + if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id } + return { type: "unknown", raw: input } +} + +function toConfig(input: Config): AppConfig { + return { + shell: input.shell, + model: input.model, + share: input.share, + plugin: input.plugin, + disabledProviders: input.disabled_providers, + provider: input.provider, + permission: input.permission, + } +} + +function fromConfig(input: AppConfig): Config { + return { + shell: input.shell, + model: input.model, + share: input.share, + plugin: input.plugin?.map((plugin): NonNullable[number] => + typeof plugin === "string" ? plugin : [plugin[0], { ...plugin[1] }], + ), + disabled_providers: input.disabledProviders ? [...input.disabledProviders] : undefined, + provider: input.provider as Config["provider"], + permission: input.permission as Config["permission"], + } +} diff --git a/packages/app/src/context/backend.ts b/packages/app/src/context/backend.ts new file mode 100644 index 0000000000..6f17b6d464 --- /dev/null +++ b/packages/app/src/context/backend.ts @@ -0,0 +1,1190 @@ +export type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue } + +export type RequestOptions = { + readonly signal?: AbortSignal +} + +export type LocationRef = { + readonly directory: string + readonly workspaceID?: string +} + +export type LocationInput = { + readonly location?: LocationRef +} + +export type ModelRef = { + readonly id: string + readonly providerID: string + readonly variant?: string +} + +export type Page = { + readonly items: readonly T[] + readonly previous?: string + readonly next?: string +} + +export type Health = { + readonly healthy: boolean + readonly version?: string + readonly pid?: number +} + +export type AppProject = { + readonly id: string + readonly worktree: string + readonly name?: string + readonly icon?: { + readonly url?: string + readonly override?: string + readonly color?: string + } + readonly commands?: { + readonly start?: string + } + readonly sandboxes: readonly string[] +} + +export type CurrentProject = { + readonly id: string + readonly directory: string +} + +export type AppModel = { + readonly id: string + readonly providerID: string + readonly name: string + readonly family?: string + readonly releaseDate?: string + readonly cost?: { + readonly input: number + readonly output?: number + readonly cacheRead?: number + readonly cacheWrite?: number + } + readonly capabilities: { + readonly reasoning: boolean + readonly input: { + readonly text: boolean + readonly image: boolean + readonly audio: boolean + readonly video: boolean + readonly pdf: boolean + } + } + readonly limit: { + readonly context: number + readonly output?: number + } + readonly variants?: Readonly> +} + +export type AppProvider = { + readonly id: string + readonly name: string + readonly models: Readonly> +} + +export type ProviderCatalog = { + readonly providers: ReadonlyMap + readonly connected: readonly string[] + readonly defaults: Readonly> +} + +export type AppAgent = { + readonly id: string + readonly name: string + readonly description?: string + readonly mode: "subagent" | "primary" | "all" + readonly hidden: boolean + readonly color?: string + readonly model?: ModelRef +} + +export type AppCommand = { + readonly name: string + readonly description?: string + readonly source?: "command" | "mcp" | "skill" +} + +export type AppReference = { + readonly name: string + readonly path: string + readonly description?: string + readonly hidden?: boolean + readonly source: + | { + readonly type: "local" + readonly path: string + } + | { + readonly type: "git" + readonly repository: string + readonly branch?: string + } +} + +export type TokenUsage = { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { + readonly read: number + readonly write: number + } +} + +export type AppSession = { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly location: LocationRef + readonly title: string + readonly cost: number + readonly tokens?: TokenUsage + readonly time: { + readonly created: number + readonly updated?: number + readonly archived?: number + } + readonly share?: { + readonly url: string + } + readonly revert?: { + readonly messageID: string + } +} + +export type SessionActivity = + | { readonly type: "running" } + | { + readonly type: "retry" + readonly attempt: number + readonly message: string + readonly next: number + readonly action?: { + readonly reason: string + readonly provider: string + readonly title: string + readonly message: string + readonly label: string + readonly link?: string + } + } + +export type SessionListInput = LocationInput & { + readonly roots?: boolean + readonly limit?: number + readonly search?: string + readonly cursor?: string +} + +export type SourceText = { + readonly text: string + readonly start: number + readonly end: number +} + +export type FileSource = + | { + readonly type: "file" | "symbol" + readonly path: string + readonly name?: string + readonly kind?: number + readonly text: SourceText + } + | { + readonly type: "resource" + readonly clientName: string + readonly uri: string + readonly text: SourceText + } + +export type ToolState = + | { + readonly status: "pending" + readonly input: Readonly> + readonly raw?: string + } + | { + readonly status: "running" + readonly input: Readonly> + readonly title?: string + readonly metadata?: Readonly> + } + | { + readonly status: "completed" + readonly input: Readonly> + readonly output: string + readonly title?: string + readonly metadata?: Readonly> + } + | { + readonly status: "error" + readonly input: Readonly> + readonly error: string + readonly metadata?: Readonly> + } + +export type TimelineContent = + | { + readonly type: "text" + readonly id: string + readonly text: string + readonly synthetic?: boolean + readonly ignored?: boolean + readonly metadata?: Readonly> + } + | { + readonly type: "reasoning" + readonly id: string + readonly text: string + } + | { + readonly type: "file" + readonly id: string + readonly uri: string + readonly name?: string + readonly mime?: string + readonly source?: FileSource + } + | { + readonly type: "agent" + readonly id: string + readonly name: string + readonly source?: SourceText + } + | { + readonly type: "tool" + readonly id: string + readonly callID?: string + readonly tool: string + readonly state: ToolState + } + +export type TimelineItem = + | { + readonly type: "user" + readonly id: string + readonly sessionID: string + readonly created: number + readonly content: readonly TimelineContent[] + readonly agent?: string + readonly model?: ModelRef + readonly raw?: unknown + } + | { + readonly type: "assistant" + readonly id: string + readonly sessionID: string + readonly parentID?: string + readonly created: number + readonly completed?: number + readonly content: readonly TimelineContent[] + readonly agent?: string + readonly model?: ModelRef + readonly tokens?: TokenUsage + readonly error?: unknown + readonly raw?: unknown + } + | { + readonly type: "agent-switch" | "model-switch" | "synthetic" | "system" | "skill" | "shell" | "compaction" + readonly id: string + readonly sessionID: string + readonly created: number + readonly raw?: unknown + } + +export type PromptFile = { + readonly uri: string + readonly name?: string + readonly mime?: string + readonly source?: SourceText & { + readonly path?: string + } +} + +export type PromptAgentMention = { + readonly name: string + readonly start?: number + readonly end?: number + readonly text?: string +} + +export type PromptInput = { + readonly sessionID: string + readonly id: string + readonly text: string + readonly files?: readonly PromptFile[] + readonly agents?: readonly PromptAgentMention[] + readonly selection?: { + readonly agent?: string + readonly model?: ModelRef + } + readonly delivery?: "steer" | "queue" +} + +export type CommandInput = { + readonly sessionID: string + readonly id?: string + readonly command: string + readonly arguments?: string + readonly agent?: string + readonly model?: ModelRef + readonly files?: readonly PromptFile[] + readonly delivery?: "steer" | "queue" +} + +export type FileEntry = { + readonly path: string + readonly type: "file" | "directory" +} + +export type FileContent = { + readonly bytes: Uint8Array + readonly kind?: "text" | "binary" + readonly mimeType?: string +} + +export type AppFileDiff = { + readonly file: string + readonly patch?: string + readonly additions: number + readonly deletions: number + readonly status?: "added" | "deleted" | "modified" +} + +export type AppPermissionRequest = { + readonly id: string + readonly sessionID: string + readonly action: string + readonly resources: readonly string[] + readonly metadata?: Readonly> +} + +export type AppQuestion = { + readonly question: string + readonly header?: string + readonly options: readonly { + readonly label: string + readonly description?: string + }[] + readonly multiple?: boolean + readonly custom?: boolean +} + +export type AppQuestionRequest = { + readonly id: string + readonly sessionID: string + readonly questions: readonly AppQuestion[] +} + +export type AppMcpStatus = + | { readonly status: "connected" } + | { readonly status: "pending" } + | { readonly status: "disabled" } + | { readonly status: "needs_auth" } + | { readonly status: "failed"; readonly error: string } + | { readonly status: "needs_client_registration"; readonly error: string } + +export type AppMcpServer = { + readonly name: string + readonly status: AppMcpStatus + readonly integrationID?: string +} + +export type AppMcpResource = { + readonly server: string + readonly name: string + readonly uri: string + readonly description?: string + readonly mimeType?: string +} + +export type AppMcpResourceTemplate = { + readonly server: string + readonly name: string + readonly uriTemplate: string + readonly description?: string + readonly mimeType?: string +} + +export type AppPty = { + readonly id: string + readonly title: string +} + +export type AppEventEnvelope = { + readonly location?: LocationRef + readonly event: AppEvent +} + +export type AppEvent = + | { readonly type: "server.connected" } + | { readonly type: "server.disposed"; readonly location?: LocationRef } + | { readonly type: "project.updated"; readonly project: AppProject } + | { readonly type: "session.created"; readonly session: AppSession } + | { readonly type: "session.updated"; readonly session: AppSession } + | { readonly type: "session.deleted"; readonly sessionID: string } + | { readonly type: "session.activity"; readonly sessionID: string; readonly activity: SessionActivity } + | { readonly type: "session.error"; readonly sessionID?: string; readonly error?: unknown } + | { readonly type: "timeline.updated"; readonly item: TimelineItem } + | { readonly type: "timeline.removed"; readonly sessionID: string; readonly itemID: string } + | { readonly type: "permission.requested"; readonly request: AppPermissionRequest } + | { readonly type: "permission.replied"; readonly sessionID: string; readonly requestID: string } + | { readonly type: "question.requested"; readonly request: AppQuestionRequest } + | { readonly type: "question.replied" | "question.rejected"; readonly sessionID: string; readonly requestID: string } + | { readonly type: "file.changed"; readonly path: string; readonly change: "add" | "change" | "unlink" } + | { readonly type: "vcs.branch.updated"; readonly branch?: string } + | { readonly type: "pty.exited"; readonly ptyID: string } + | { readonly type: "unknown"; readonly raw: unknown } + +export interface HealthApi { + get(options?: RequestOptions): Promise +} + +export interface ProjectApi { + list(options?: RequestOptions): Promise + current(input?: LocationInput, options?: RequestOptions): Promise +} + +export interface CatalogApi { + providers(input?: LocationInput, options?: RequestOptions): Promise + agents(input?: LocationInput, options?: RequestOptions): Promise +} + +export interface CommandApi { + list(input?: LocationInput, options?: RequestOptions): Promise +} + +export interface ReferenceApi { + list(input?: LocationInput, options?: RequestOptions): Promise +} + +export interface SessionApi { + list(input?: SessionListInput, options?: RequestOptions): Promise> + create( + input?: LocationInput & { readonly agent?: string; readonly model?: ModelRef }, + options?: RequestOptions, + ): Promise + get(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + fork( + input: LocationInput & { readonly sessionID: string; readonly messageID?: string }, + options?: RequestOptions, + ): Promise + rename( + input: LocationInput & { readonly sessionID: string; readonly title: string }, + options?: RequestOptions, + ): Promise + interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise + activity(input?: LocationInput, options?: RequestOptions): Promise>> + history( + input: { readonly sessionID: string; readonly limit?: number; readonly cursor?: string }, + options?: RequestOptions, + ): Promise> + message( + input: { readonly sessionID: string; readonly messageID: string }, + options?: RequestOptions, + ): Promise + prompt(input: PromptInput, options?: RequestOptions): Promise + command(input: CommandInput, options?: RequestOptions): Promise +} + +export interface FileApi { + list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise + find( + input: LocationInput & { + readonly query: string + readonly type?: "file" | "directory" + readonly limit?: number + }, + options?: RequestOptions, + ): Promise + read(input: LocationInput & { readonly path: string }, options?: RequestOptions): Promise +} + +export interface PermissionApi { + pending(input?: LocationInput, options?: RequestOptions): Promise + reply( + input: { + readonly sessionID: string + readonly requestID: string + readonly reply: "once" | "always" | "reject" + readonly message?: string + }, + options?: RequestOptions, + ): Promise +} + +export interface QuestionApi { + pending(input?: LocationInput, options?: RequestOptions): Promise + reply( + input: { + readonly sessionID: string + readonly requestID: string + readonly answers: readonly (readonly string[])[] + }, + options?: RequestOptions, + ): Promise + reject(input: { readonly sessionID: string; readonly requestID: string }, options?: RequestOptions): Promise +} + +export interface VcsApi { + status( + input?: LocationInput, + options?: RequestOptions, + ): Promise< + readonly { + readonly file: string + readonly additions: number + readonly deletions: number + readonly status: "added" | "deleted" | "modified" + }[] + > + diff( + input: LocationInput & { readonly mode: "working" | "branch"; readonly context?: number }, + options?: RequestOptions, + ): Promise +} + +export interface McpApi { + list(input?: LocationInput, options?: RequestOptions): Promise + resources( + input?: LocationInput, + options?: RequestOptions, + ): Promise<{ + readonly resources: readonly AppMcpResource[] + readonly templates: readonly AppMcpResourceTemplate[] + }> +} + +export interface PtyApi { + list(input?: LocationInput, options?: RequestOptions): Promise + create( + input: LocationInput & { + readonly title?: string + readonly command?: string + readonly args?: readonly string[] + readonly cwd?: string + readonly env?: Readonly> + }, + options?: RequestOptions, + ): Promise + get(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise + update( + input: LocationInput & { + readonly ptyID: string + readonly title?: string + readonly size?: { readonly rows: number; readonly cols: number } + }, + options?: RequestOptions, + ): Promise + remove(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise +} + +export interface EventApi { + subscribe(options?: RequestOptions): AsyncIterable +} + +export interface CommonClient { + readonly health: HealthApi + readonly projects: ProjectApi + readonly catalog: CatalogApi + readonly commands: CommandApi + readonly references: ReferenceApi + readonly sessions: SessionApi + readonly files: FileApi + readonly permissions: PermissionApi + readonly questions: QuestionApi + readonly vcs: VcsApi + readonly mcp: McpApi + readonly pty: PtyApi + readonly events: EventApi + disposeLocation(input: LocationInput, options?: RequestOptions): Promise +} + +export type AppProviderConfig = { + readonly npm?: string + readonly name?: string + readonly env?: readonly string[] + readonly options?: { + readonly baseURL?: string + readonly headers?: Readonly> + readonly [key: string]: unknown + } + readonly models?: Readonly> +} + +export type AppConfig = { + readonly shell?: string + readonly model?: string + readonly share?: "manual" | "auto" | "disabled" + readonly plugin?: readonly (string | readonly [string, Readonly>])[] + readonly disabledProviders?: readonly string[] + readonly provider?: Readonly> + readonly permission?: unknown +} + +export interface ConfigurationCapability { + getGlobal(options?: RequestOptions): Promise + updateGlobal(config: AppConfig, options?: RequestOptions): Promise + get(input?: LocationInput, options?: RequestOptions): Promise +} + +export type ProviderAuthPrompt = + | { + readonly type: "text" + readonly key: string + readonly message: string + readonly placeholder?: string + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + | { + readonly type: "select" + readonly key: string + readonly message: string + readonly options: readonly { readonly label: string; readonly value: string; readonly hint?: string }[] + readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string } + } + +export type ProviderAuthMethod = { + readonly type: "oauth" | "api" + readonly label: string + readonly prompts?: readonly ProviderAuthPrompt[] +} + +export type ProviderAuthorization = { + readonly url: string + readonly method: "auto" | "code" + readonly instructions: string +} + +export interface ProviderAuthV1Capability { + methods( + input?: LocationInput, + options?: RequestOptions, + ): Promise>> + authorize( + input: LocationInput & { + readonly providerID: string + readonly method: number + readonly values?: Readonly> + }, + options?: RequestOptions, + ): Promise + callback( + input: LocationInput & { readonly providerID: string; readonly method: number; readonly code?: string }, + options?: RequestOptions, + ): Promise + setApiKey( + input: { + readonly providerID: string + readonly key: string + readonly metadata?: Readonly> + }, + options?: RequestOptions, + ): Promise + remove(input: { readonly providerID: string }, options?: RequestOptions): Promise +} + +export interface ProjectEditingCapability { + update( + input: LocationInput & { + readonly projectID: string + readonly name?: string + readonly icon?: { readonly override?: string; readonly color?: string } + readonly commands?: { readonly start?: string } + }, + options?: RequestOptions, + ): Promise + initGit(input?: LocationInput, options?: RequestOptions): Promise +} + +export type AppWorktree = { + readonly directory: string + readonly branch?: string +} + +export interface WorktreesV1Capability { + list(input: LocationInput, options?: RequestOptions): Promise + create(input: LocationInput, options?: RequestOptions): Promise + remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise + reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise +} + +export type AppTodo = { + readonly id?: string + readonly content: string + readonly status: "pending" | "in_progress" | "completed" | "cancelled" | (string & {}) +} + +export type LegacySessionShellInput = LocationInput & { + readonly sessionID: string + readonly id?: string + readonly command: string + readonly agent: string + readonly model?: ModelRef +} + +export interface SessionExtrasV1Capability { + archive(sessionID: string, archivedAt: number, options?: RequestOptions): Promise + share(sessionID: string, options?: RequestOptions): Promise + unshare(sessionID: string, options?: RequestOptions): Promise + diff(sessionID: string, options?: RequestOptions): Promise + todos(sessionID: string, options?: RequestOptions): Promise + summarize(sessionID: string, model: ModelRef, options?: RequestOptions): Promise + revert(sessionID: string, messageID: string, options?: RequestOptions): Promise + clearRevert(sessionID: string, options?: RequestOptions): Promise + shell(input: LegacySessionShellInput, options?: RequestOptions): Promise +} + +export type AppLspStatus = { + readonly id: string + readonly name: string + readonly status: "connected" | "error" +} + +export interface LspCapability { + status(input?: LocationInput, options?: RequestOptions): Promise +} + +export interface McpControlCapability { + connect(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise + disconnect(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise + authenticate(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise +} + +export type AppPathInfo = { + readonly home: string + readonly directory: string + readonly state?: string + readonly config?: string + readonly worktree?: string +} + +export interface PathInfoCapability { + get(input?: LocationInput, options?: RequestOptions): Promise +} + +export type AppVcsInfo = { + readonly branch?: string + readonly defaultBranch?: string +} + +export interface VcsInfoCapability { + get(input?: LocationInput, options?: RequestOptions): Promise +} + +export type DecoratedFileContent = { + readonly type: "text" | "binary" + readonly content: string + readonly diff?: string + readonly encoding?: "base64" + readonly mimeType?: string + readonly patch?: { + readonly hunks: readonly { readonly lines: readonly string[] }[] + } +} + +export interface DecoratedFileCapability { + read(input: LocationInput & { readonly path: string }, options?: RequestOptions): Promise +} + +export type PtyTicket = { + readonly ticket: string +} + +export interface PtyTransportCapability { + connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise +} + +export type ShellOption = { + readonly name: string + readonly path: string + readonly acceptable: boolean +} + +export interface ShellDiscoveryCapability { + list(input?: LocationInput, options?: RequestOptions): Promise +} + +export interface RuntimeV1Capability { + disposeAll(options?: RequestOptions): Promise +} + +export type IntegrationMethod = + | { + readonly type: "oauth" + readonly id: string + readonly label: string + readonly prompts?: readonly ProviderAuthPrompt[] + } + | { + readonly type: "key" + readonly label: string + } + | { + readonly type: "environment" + readonly label: string + } + +export type IntegrationConnection = { + readonly id: string + readonly label: string +} + +export type IntegrationInfo = { + readonly id: string + readonly name: string + readonly methods: readonly IntegrationMethod[] + readonly connections: readonly IntegrationConnection[] +} + +export type IntegrationAttempt = { + readonly attemptID: string + readonly url: string + readonly instructions: string + readonly mode: "auto" | "code" + readonly time: { + readonly created: number + readonly expires: number + } +} + +export type IntegrationAttemptStatus = + | { readonly status: "pending" } + | { readonly status: "complete" } + | { readonly status: "failed"; readonly error?: string } + | { readonly status: "expired" } + +export interface IntegrationsV2Capability { + list(input?: LocationInput, options?: RequestOptions): Promise + get( + input: LocationInput & { readonly integrationID: string }, + options?: RequestOptions, + ): Promise + connectKey( + input: LocationInput & { readonly integrationID: string; readonly key: string; readonly label?: string }, + options?: RequestOptions, + ): Promise + connectOauth( + input: LocationInput & { + readonly integrationID: string + readonly methodID: string + readonly values: Readonly> + readonly label?: string + }, + options?: RequestOptions, + ): Promise + attemptStatus( + input: LocationInput & { readonly attemptID: string }, + options?: RequestOptions, + ): Promise + completeAttempt( + input: LocationInput & { readonly attemptID: string; readonly code?: string }, + options?: RequestOptions, + ): Promise + cancelAttempt(input: LocationInput & { readonly attemptID: string }, options?: RequestOptions): Promise + renameCredential( + input: LocationInput & { readonly credentialID: string; readonly label: string }, + options?: RequestOptions, + ): Promise + removeCredential(input: LocationInput & { readonly credentialID: string }, options?: RequestOptions): Promise +} + +export type PendingSessionInput = { + readonly id: string + readonly sessionID: string + readonly sequence: number + readonly created: number + readonly delivery?: "steer" | "queue" + readonly raw: unknown +} + +export type SessionLogItem = { + readonly sequence: number + readonly event: AppEvent | { readonly type: "unknown"; readonly raw: unknown } +} + +export type InstructionEntry = { + readonly key: string + readonly value: JsonValue +} + +export interface SessionExtrasV2Capability { + switchAgent(input: { readonly sessionID: string; readonly agent: string }, options?: RequestOptions): Promise + switchModel(input: { readonly sessionID: string; readonly model: ModelRef }, options?: RequestOptions): Promise + move( + input: { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean }, + options?: RequestOptions, + ): Promise + skill( + input: { readonly sessionID: string; readonly id?: string; readonly skill: string; readonly resume?: boolean }, + options?: RequestOptions, + ): Promise + synthetic( + input: { + readonly sessionID: string + readonly id?: string + readonly text: string + readonly description?: string + readonly metadata?: Readonly> + readonly delivery?: "steer" | "queue" + readonly resume?: boolean + }, + options?: RequestOptions, + ): Promise + shell( + input: { readonly sessionID: string; readonly id?: string; readonly command: string }, + options?: RequestOptions, + ): Promise + compact( + input: { readonly sessionID: string; readonly id?: string }, + options?: RequestOptions, + ): Promise + wait(input: { readonly sessionID: string }, options?: RequestOptions): Promise + context(input: { readonly sessionID: string }, options?: RequestOptions): Promise + pending(input: { readonly sessionID: string }, options?: RequestOptions): Promise + instructionEntries( + input: { readonly sessionID: string }, + options?: RequestOptions, + ): Promise + putInstructionEntry( + input: { readonly sessionID: string; readonly key: string; readonly value: JsonValue }, + options?: RequestOptions, + ): Promise + removeInstructionEntry( + input: { readonly sessionID: string; readonly key: string }, + options?: RequestOptions, + ): Promise + log( + input: { readonly sessionID: string; readonly after?: number; readonly follow?: boolean }, + options?: RequestOptions, + ): AsyncIterable + background(input: { readonly sessionID: string }, options?: RequestOptions): Promise + stageRevert( + input: { readonly sessionID: string; readonly messageID: string; readonly files?: readonly string[] }, + options?: RequestOptions, + ): Promise<{ readonly messageID: string }> + clearRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise + commitRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise +} + +export type ProjectDirectory = { + readonly directory: string + readonly strategy?: string +} + +export interface ProjectCopiesV2Capability { + directories( + input: LocationInput & { readonly projectID: string }, + options?: RequestOptions, + ): Promise + create( + input: LocationInput & { + readonly projectID: string + readonly strategy: string + readonly directory: string + readonly name?: string + }, + options?: RequestOptions, + ): Promise<{ readonly directory: string }> + remove( + input: LocationInput & { readonly projectID: string; readonly directory: string; readonly force: boolean }, + options?: RequestOptions, + ): Promise + refresh(input: LocationInput & { readonly projectID: string }, options?: RequestOptions): Promise +} + +export type FormField = + | { + readonly type: "string" + readonly key: string + readonly label: string + readonly required?: boolean + readonly description?: string + } + | { + readonly type: "number" | "integer" + readonly key: string + readonly label: string + readonly required?: boolean + readonly description?: string + } + | { + readonly type: "boolean" + readonly key: string + readonly label: string + readonly description?: string + } + | { + readonly type: "multiselect" + readonly key: string + readonly label: string + readonly options: readonly string[] + readonly required?: boolean + readonly description?: string + } + | { + readonly type: "external" + readonly key: string + readonly label: string + readonly url: string + readonly description?: string + } + +export type FormInfo = { + readonly id: string + readonly sessionID: string + readonly title: string + readonly metadata?: Readonly> + readonly fields: readonly FormField[] +} + +export type FormAnswer = Readonly> + +export type FormState = + | { readonly status: "pending" } + | { readonly status: "answered"; readonly answer: FormAnswer } + | { readonly status: "cancelled" } + +export interface FormsV2Capability { + pending(input?: LocationInput, options?: RequestOptions): Promise + list(input: { readonly sessionID: string }, options?: RequestOptions): Promise + create( + input: { + readonly sessionID: string + readonly id?: string + readonly title: string + readonly metadata?: Readonly> + readonly fields: readonly FormField[] + }, + options?: RequestOptions, + ): Promise + get(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise + state(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise + reply( + input: { readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }, + options?: RequestOptions, + ): Promise + cancel(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise +} + +export type SavedPermission = { + readonly id: string + readonly projectID: string + readonly action: string + readonly resource: string +} + +export interface SavedPermissionsV2Capability { + list(input?: { readonly projectID?: string }, options?: RequestOptions): Promise + remove(input: { readonly id: string }, options?: RequestOptions): Promise +} + +export type ShellProcess = { + readonly id: string + readonly command: string + readonly cwd: string + readonly status: "running" | "exited" + readonly created: number + readonly exitCode?: number + readonly metadata?: Readonly> +} + +export type ShellOutput = { + readonly output: string + readonly cursor: number + readonly size: number + readonly truncated: boolean +} + +export interface ShellsV2Capability { + list(input?: LocationInput, options?: RequestOptions): Promise + create( + input: LocationInput & { + readonly command: string + readonly cwd?: string + readonly timeout: number + readonly metadata?: Readonly> + }, + options?: RequestOptions, + ): Promise + get(input: LocationInput & { readonly id: string }, options?: RequestOptions): Promise + setTimeout( + input: LocationInput & { readonly id: string; readonly timeout: number }, + options?: RequestOptions, + ): Promise + output( + input: LocationInput & { readonly id: string; readonly cursor?: number; readonly limit?: number }, + options?: RequestOptions, + ): Promise + remove(input: LocationInput & { readonly id: string }, options?: RequestOptions): Promise +} + +export type ServerInfo = { + readonly urls: readonly string[] +} + +export type LocationInfo = LocationRef & { + readonly project: CurrentProject +} + +export type PluginInfo = { + readonly id: string +} + +export type SkillInfo = { + readonly id: string + readonly name: string + readonly description?: string + readonly slash?: boolean + readonly autoinvoke?: boolean + readonly location: LocationRef + readonly content: string +} + +export interface DiscoveryV2Capability { + server(options?: RequestOptions): Promise + location(input?: LocationInput, options?: RequestOptions): Promise + plugins(input?: LocationInput, options?: RequestOptions): Promise + skills(input?: LocationInput, options?: RequestOptions): Promise + models(input?: LocationInput, options?: RequestOptions): Promise + defaultModel(input?: LocationInput, options?: RequestOptions): Promise + generateText( + input: LocationInput & { readonly prompt: string; readonly model?: ModelRef }, + options?: RequestOptions, + ): Promise + loadedLocations(options?: RequestOptions): Promise +} + +export interface Capabilities { + readonly configuration?: ConfigurationCapability + readonly providerAuthV1?: ProviderAuthV1Capability + readonly integrationsV2?: IntegrationsV2Capability + readonly projectEditing?: ProjectEditingCapability + readonly worktreesV1?: WorktreesV1Capability + readonly projectCopiesV2?: ProjectCopiesV2Capability + readonly sessionExtrasV1?: SessionExtrasV1Capability + readonly sessionExtrasV2?: SessionExtrasV2Capability + readonly lsp?: LspCapability + readonly mcpControl?: McpControlCapability + readonly pathInfo?: PathInfoCapability + readonly vcsInfo?: VcsInfoCapability + readonly decoratedFiles?: DecoratedFileCapability + readonly ptyTransport?: PtyTransportCapability + readonly shellDiscovery?: ShellDiscoveryCapability + readonly shellsV2?: ShellsV2Capability + readonly formsV2?: FormsV2Capability + readonly savedPermissionsV2?: SavedPermissionsV2Capability + readonly discoveryV2?: DiscoveryV2Capability + readonly runtimeV1?: RuntimeV1Capability +} + +export interface AppClient { + readonly version: "v1" | "v2" + readonly common: CommonClient + readonly capabilities: Capabilities +}