瀏覽代碼

feat(app): add versioned backend interface

Brendan Allan 1 月之前
父節點
當前提交
4d314f6e04

+ 4 - 0
bun.lock

@@ -40,9 +40,11 @@
         "@dnd-kit/helpers": "0.5.0",
         "@dnd-kit/helpers": "0.5.0",
         "@dnd-kit/solid": "0.5.0",
         "@dnd-kit/solid": "0.5.0",
         "@kobalte/core": "catalog:",
         "@kobalte/core": "catalog:",
+        "@opencode-ai/client": "workspace:*",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/schema": "workspace:*",
         "@opencode-ai/sdk": "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/session-ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@pierre/trees": "1.0.0-beta.4",
         "@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-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/server": ["@opencode-ai/server@workspace:packages/server"],
 
 
     "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
     "@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],

+ 2 - 0
packages/app/package.json

@@ -53,9 +53,11 @@
     "@dnd-kit/helpers": "0.5.0",
     "@dnd-kit/helpers": "0.5.0",
     "@dnd-kit/solid": "0.5.0",
     "@dnd-kit/solid": "0.5.0",
     "@kobalte/core": "catalog:",
     "@kobalte/core": "catalog:",
+    "@opencode-ai/client": "workspace:*",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/schema": "workspace:*",
     "@opencode-ai/schema": "workspace:*",
     "@opencode-ai/sdk": "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/session-ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@pierre/trees": "1.0.0-beta.4",
     "@pierre/trees": "1.0.0-beta.4",

+ 140 - 0
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<Response>) {
+  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"] }])
+  })
+})

+ 992 - 0
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<string, CachedMessage>()
+
+  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<string, string>
+}): 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<string, CachedMessage>,
+  loadMessage: (input: { sessionID: string; messageID: string }) => Promise<CachedMessage>,
+): Promise<AppEvent> {
+  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<Config["plugin"]>[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"],
+  }
+}

+ 1190 - 0
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<T> = {
+  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<Record<string, unknown>>
+}
+
+export type AppProvider = {
+  readonly id: string
+  readonly name: string
+  readonly models: Readonly<Record<string, AppModel>>
+}
+
+export type ProviderCatalog = {
+  readonly providers: ReadonlyMap<string, AppProvider>
+  readonly connected: readonly string[]
+  readonly defaults: Readonly<Record<string, string>>
+}
+
+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<Record<string, unknown>>
+      readonly raw?: string
+    }
+  | {
+      readonly status: "running"
+      readonly input: Readonly<Record<string, unknown>>
+      readonly title?: string
+      readonly metadata?: Readonly<Record<string, unknown>>
+    }
+  | {
+      readonly status: "completed"
+      readonly input: Readonly<Record<string, unknown>>
+      readonly output: string
+      readonly title?: string
+      readonly metadata?: Readonly<Record<string, unknown>>
+    }
+  | {
+      readonly status: "error"
+      readonly input: Readonly<Record<string, unknown>>
+      readonly error: string
+      readonly metadata?: Readonly<Record<string, unknown>>
+    }
+
+export type TimelineContent =
+  | {
+      readonly type: "text"
+      readonly id: string
+      readonly text: string
+      readonly synthetic?: boolean
+      readonly ignored?: boolean
+      readonly metadata?: Readonly<Record<string, unknown>>
+    }
+  | {
+      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<Record<string, unknown>>
+}
+
+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<Health>
+}
+
+export interface ProjectApi {
+  list(options?: RequestOptions): Promise<readonly AppProject[]>
+  current(input?: LocationInput, options?: RequestOptions): Promise<CurrentProject>
+}
+
+export interface CatalogApi {
+  providers(input?: LocationInput, options?: RequestOptions): Promise<ProviderCatalog>
+  agents(input?: LocationInput, options?: RequestOptions): Promise<readonly AppAgent[]>
+}
+
+export interface CommandApi {
+  list(input?: LocationInput, options?: RequestOptions): Promise<readonly AppCommand[]>
+}
+
+export interface ReferenceApi {
+  list(input?: LocationInput, options?: RequestOptions): Promise<readonly AppReference[]>
+}
+
+export interface SessionApi {
+  list(input?: SessionListInput, options?: RequestOptions): Promise<Page<AppSession>>
+  create(
+    input?: LocationInput & { readonly agent?: string; readonly model?: ModelRef },
+    options?: RequestOptions,
+  ): Promise<AppSession>
+  get(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
+  remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+  fork(
+    input: LocationInput & { readonly sessionID: string; readonly messageID?: string },
+    options?: RequestOptions,
+  ): Promise<AppSession>
+  rename(
+    input: LocationInput & { readonly sessionID: string; readonly title: string },
+    options?: RequestOptions,
+  ): Promise<void>
+  interrupt(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+  activity(input?: LocationInput, options?: RequestOptions): Promise<Readonly<Record<string, SessionActivity>>>
+  history(
+    input: { readonly sessionID: string; readonly limit?: number; readonly cursor?: string },
+    options?: RequestOptions,
+  ): Promise<Page<TimelineItem>>
+  message(
+    input: { readonly sessionID: string; readonly messageID: string },
+    options?: RequestOptions,
+  ): Promise<TimelineItem>
+  prompt(input: PromptInput, options?: RequestOptions): Promise<void>
+  command(input: CommandInput, options?: RequestOptions): Promise<void>
+}
+
+export interface FileApi {
+  list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise<readonly FileEntry[]>
+  find(
+    input: LocationInput & {
+      readonly query: string
+      readonly type?: "file" | "directory"
+      readonly limit?: number
+    },
+    options?: RequestOptions,
+  ): Promise<readonly FileEntry[]>
+  read(input: LocationInput & { readonly path: string }, options?: RequestOptions): Promise<FileContent>
+}
+
+export interface PermissionApi {
+  pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPermissionRequest[]>
+  reply(
+    input: {
+      readonly sessionID: string
+      readonly requestID: string
+      readonly reply: "once" | "always" | "reject"
+      readonly message?: string
+    },
+    options?: RequestOptions,
+  ): Promise<void>
+}
+
+export interface QuestionApi {
+  pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppQuestionRequest[]>
+  reply(
+    input: {
+      readonly sessionID: string
+      readonly requestID: string
+      readonly answers: readonly (readonly string[])[]
+    },
+    options?: RequestOptions,
+  ): Promise<void>
+  reject(input: { readonly sessionID: string; readonly requestID: string }, options?: RequestOptions): Promise<void>
+}
+
+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<readonly AppFileDiff[]>
+}
+
+export interface McpApi {
+  list(input?: LocationInput, options?: RequestOptions): Promise<readonly AppMcpServer[]>
+  resources(
+    input?: LocationInput,
+    options?: RequestOptions,
+  ): Promise<{
+    readonly resources: readonly AppMcpResource[]
+    readonly templates: readonly AppMcpResourceTemplate[]
+  }>
+}
+
+export interface PtyApi {
+  list(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPty[]>
+  create(
+    input: LocationInput & {
+      readonly title?: string
+      readonly command?: string
+      readonly args?: readonly string[]
+      readonly cwd?: string
+      readonly env?: Readonly<Record<string, string>>
+    },
+    options?: RequestOptions,
+  ): Promise<AppPty>
+  get(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<AppPty>
+  update(
+    input: LocationInput & {
+      readonly ptyID: string
+      readonly title?: string
+      readonly size?: { readonly rows: number; readonly cols: number }
+    },
+    options?: RequestOptions,
+  ): Promise<AppPty>
+  remove(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<void>
+}
+
+export interface EventApi {
+  subscribe(options?: RequestOptions): AsyncIterable<AppEventEnvelope>
+}
+
+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<void>
+}
+
+export type AppProviderConfig = {
+  readonly npm?: string
+  readonly name?: string
+  readonly env?: readonly string[]
+  readonly options?: {
+    readonly baseURL?: string
+    readonly headers?: Readonly<Record<string, string>>
+    readonly [key: string]: unknown
+  }
+  readonly models?: Readonly<Record<string, { readonly name?: string }>>
+}
+
+export type AppConfig = {
+  readonly shell?: string
+  readonly model?: string
+  readonly share?: "manual" | "auto" | "disabled"
+  readonly plugin?: readonly (string | readonly [string, Readonly<Record<string, unknown>>])[]
+  readonly disabledProviders?: readonly string[]
+  readonly provider?: Readonly<Record<string, AppProviderConfig>>
+  readonly permission?: unknown
+}
+
+export interface ConfigurationCapability {
+  getGlobal(options?: RequestOptions): Promise<AppConfig>
+  updateGlobal(config: AppConfig, options?: RequestOptions): Promise<void>
+  get(input?: LocationInput, options?: RequestOptions): Promise<AppConfig>
+}
+
+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<Readonly<Record<string, readonly ProviderAuthMethod[]>>>
+  authorize(
+    input: LocationInput & {
+      readonly providerID: string
+      readonly method: number
+      readonly values?: Readonly<Record<string, string>>
+    },
+    options?: RequestOptions,
+  ): Promise<ProviderAuthorization>
+  callback(
+    input: LocationInput & { readonly providerID: string; readonly method: number; readonly code?: string },
+    options?: RequestOptions,
+  ): Promise<void>
+  setApiKey(
+    input: {
+      readonly providerID: string
+      readonly key: string
+      readonly metadata?: Readonly<Record<string, string>>
+    },
+    options?: RequestOptions,
+  ): Promise<void>
+  remove(input: { readonly providerID: string }, options?: RequestOptions): Promise<void>
+}
+
+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<AppProject>
+  initGit(input?: LocationInput, options?: RequestOptions): Promise<AppProject>
+}
+
+export type AppWorktree = {
+  readonly directory: string
+  readonly branch?: string
+}
+
+export interface WorktreesV1Capability {
+  list(input: LocationInput, options?: RequestOptions): Promise<readonly string[]>
+  create(input: LocationInput, options?: RequestOptions): Promise<AppWorktree>
+  remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<void>
+  reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<void>
+}
+
+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<void>
+  share(sessionID: string, options?: RequestOptions): Promise<string>
+  unshare(sessionID: string, options?: RequestOptions): Promise<void>
+  diff(sessionID: string, options?: RequestOptions): Promise<readonly AppFileDiff[]>
+  todos(sessionID: string, options?: RequestOptions): Promise<readonly AppTodo[]>
+  summarize(sessionID: string, model: ModelRef, options?: RequestOptions): Promise<void>
+  revert(sessionID: string, messageID: string, options?: RequestOptions): Promise<void>
+  clearRevert(sessionID: string, options?: RequestOptions): Promise<void>
+  shell(input: LegacySessionShellInput, options?: RequestOptions): Promise<void>
+}
+
+export type AppLspStatus = {
+  readonly id: string
+  readonly name: string
+  readonly status: "connected" | "error"
+}
+
+export interface LspCapability {
+  status(input?: LocationInput, options?: RequestOptions): Promise<readonly AppLspStatus[]>
+}
+
+export interface McpControlCapability {
+  connect(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise<void>
+  disconnect(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise<void>
+  authenticate(input: LocationInput & { readonly name: string }, options?: RequestOptions): Promise<void>
+}
+
+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<AppPathInfo>
+}
+
+export type AppVcsInfo = {
+  readonly branch?: string
+  readonly defaultBranch?: string
+}
+
+export interface VcsInfoCapability {
+  get(input?: LocationInput, options?: RequestOptions): Promise<AppVcsInfo>
+}
+
+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<DecoratedFileContent>
+}
+
+export type PtyTicket = {
+  readonly ticket: string
+}
+
+export interface PtyTransportCapability {
+  connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<PtyTicket>
+}
+
+export type ShellOption = {
+  readonly name: string
+  readonly path: string
+  readonly acceptable: boolean
+}
+
+export interface ShellDiscoveryCapability {
+  list(input?: LocationInput, options?: RequestOptions): Promise<readonly ShellOption[]>
+}
+
+export interface RuntimeV1Capability {
+  disposeAll(options?: RequestOptions): Promise<void>
+}
+
+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<readonly IntegrationInfo[]>
+  get(
+    input: LocationInput & { readonly integrationID: string },
+    options?: RequestOptions,
+  ): Promise<IntegrationInfo | null>
+  connectKey(
+    input: LocationInput & { readonly integrationID: string; readonly key: string; readonly label?: string },
+    options?: RequestOptions,
+  ): Promise<void>
+  connectOauth(
+    input: LocationInput & {
+      readonly integrationID: string
+      readonly methodID: string
+      readonly values: Readonly<Record<string, string>>
+      readonly label?: string
+    },
+    options?: RequestOptions,
+  ): Promise<IntegrationAttempt>
+  attemptStatus(
+    input: LocationInput & { readonly attemptID: string },
+    options?: RequestOptions,
+  ): Promise<IntegrationAttemptStatus>
+  completeAttempt(
+    input: LocationInput & { readonly attemptID: string; readonly code?: string },
+    options?: RequestOptions,
+  ): Promise<void>
+  cancelAttempt(input: LocationInput & { readonly attemptID: string }, options?: RequestOptions): Promise<void>
+  renameCredential(
+    input: LocationInput & { readonly credentialID: string; readonly label: string },
+    options?: RequestOptions,
+  ): Promise<void>
+  removeCredential(input: LocationInput & { readonly credentialID: string }, options?: RequestOptions): Promise<void>
+}
+
+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<void>
+  switchModel(input: { readonly sessionID: string; readonly model: ModelRef }, options?: RequestOptions): Promise<void>
+  move(
+    input: { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean },
+    options?: RequestOptions,
+  ): Promise<void>
+  skill(
+    input: { readonly sessionID: string; readonly id?: string; readonly skill: string; readonly resume?: boolean },
+    options?: RequestOptions,
+  ): Promise<void>
+  synthetic(
+    input: {
+      readonly sessionID: string
+      readonly id?: string
+      readonly text: string
+      readonly description?: string
+      readonly metadata?: Readonly<Record<string, JsonValue>>
+      readonly delivery?: "steer" | "queue"
+      readonly resume?: boolean
+    },
+    options?: RequestOptions,
+  ): Promise<PendingSessionInput>
+  shell(
+    input: { readonly sessionID: string; readonly id?: string; readonly command: string },
+    options?: RequestOptions,
+  ): Promise<void>
+  compact(
+    input: { readonly sessionID: string; readonly id?: string },
+    options?: RequestOptions,
+  ): Promise<PendingSessionInput>
+  wait(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+  context(input: { readonly sessionID: string }, options?: RequestOptions): Promise<readonly TimelineItem[]>
+  pending(input: { readonly sessionID: string }, options?: RequestOptions): Promise<readonly PendingSessionInput[]>
+  instructionEntries(
+    input: { readonly sessionID: string },
+    options?: RequestOptions,
+  ): Promise<readonly InstructionEntry[]>
+  putInstructionEntry(
+    input: { readonly sessionID: string; readonly key: string; readonly value: JsonValue },
+    options?: RequestOptions,
+  ): Promise<void>
+  removeInstructionEntry(
+    input: { readonly sessionID: string; readonly key: string },
+    options?: RequestOptions,
+  ): Promise<void>
+  log(
+    input: { readonly sessionID: string; readonly after?: number; readonly follow?: boolean },
+    options?: RequestOptions,
+  ): AsyncIterable<SessionLogItem>
+  background(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+  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<void>
+  commitRevert(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+}
+
+export type ProjectDirectory = {
+  readonly directory: string
+  readonly strategy?: string
+}
+
+export interface ProjectCopiesV2Capability {
+  directories(
+    input: LocationInput & { readonly projectID: string },
+    options?: RequestOptions,
+  ): Promise<readonly ProjectDirectory[]>
+  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<void>
+  refresh(input: LocationInput & { readonly projectID: string }, options?: RequestOptions): Promise<void>
+}
+
+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<Record<string, JsonValue>>
+  readonly fields: readonly FormField[]
+}
+
+export type FormAnswer = Readonly<Record<string, string | number | boolean | readonly string[]>>
+
+export type FormState =
+  | { readonly status: "pending" }
+  | { readonly status: "answered"; readonly answer: FormAnswer }
+  | { readonly status: "cancelled" }
+
+export interface FormsV2Capability {
+  pending(input?: LocationInput, options?: RequestOptions): Promise<readonly FormInfo[]>
+  list(input: { readonly sessionID: string }, options?: RequestOptions): Promise<readonly FormInfo[]>
+  create(
+    input: {
+      readonly sessionID: string
+      readonly id?: string
+      readonly title: string
+      readonly metadata?: Readonly<Record<string, JsonValue>>
+      readonly fields: readonly FormField[]
+    },
+    options?: RequestOptions,
+  ): Promise<FormInfo>
+  get(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise<FormInfo>
+  state(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise<FormState>
+  reply(
+    input: { readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer },
+    options?: RequestOptions,
+  ): Promise<void>
+  cancel(input: { readonly sessionID: string; readonly formID: string }, options?: RequestOptions): Promise<void>
+}
+
+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<readonly SavedPermission[]>
+  remove(input: { readonly id: string }, options?: RequestOptions): Promise<void>
+}
+
+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<Record<string, JsonValue>>
+}
+
+export type ShellOutput = {
+  readonly output: string
+  readonly cursor: number
+  readonly size: number
+  readonly truncated: boolean
+}
+
+export interface ShellsV2Capability {
+  list(input?: LocationInput, options?: RequestOptions): Promise<readonly ShellProcess[]>
+  create(
+    input: LocationInput & {
+      readonly command: string
+      readonly cwd?: string
+      readonly timeout: number
+      readonly metadata?: Readonly<Record<string, JsonValue>>
+    },
+    options?: RequestOptions,
+  ): Promise<ShellProcess>
+  get(input: LocationInput & { readonly id: string }, options?: RequestOptions): Promise<ShellProcess>
+  setTimeout(
+    input: LocationInput & { readonly id: string; readonly timeout: number },
+    options?: RequestOptions,
+  ): Promise<ShellProcess>
+  output(
+    input: LocationInput & { readonly id: string; readonly cursor?: number; readonly limit?: number },
+    options?: RequestOptions,
+  ): Promise<ShellOutput>
+  remove(input: LocationInput & { readonly id: string }, options?: RequestOptions): Promise<void>
+}
+
+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<ServerInfo>
+  location(input?: LocationInput, options?: RequestOptions): Promise<LocationInfo>
+  plugins(input?: LocationInput, options?: RequestOptions): Promise<readonly PluginInfo[]>
+  skills(input?: LocationInput, options?: RequestOptions): Promise<readonly SkillInfo[]>
+  models(input?: LocationInput, options?: RequestOptions): Promise<readonly AppModel[]>
+  defaultModel(input?: LocationInput, options?: RequestOptions): Promise<AppModel | null>
+  generateText(
+    input: LocationInput & { readonly prompt: string; readonly model?: ModelRef },
+    options?: RequestOptions,
+  ): Promise<string>
+  loadedLocations(options?: RequestOptions): Promise<readonly LocationRef[]>
+}
+
+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
+}