ソースを参照

refactor(app): route backend through adapters

Brendan Allan 1 ヶ月 前
コミット
08ea08e830
100 ファイル変更4825 行追加1765 行削除
  1. 0 1
      bun.lock
  2. 3 3
      packages/app/src/components/command-palette.ts
  3. 154 41
      packages/app/src/components/dialog-connect-provider.tsx
  4. 14 11
      packages/app/src/components/dialog-custom-provider.tsx
  5. 4 2
      packages/app/src/components/dialog-edit-project.tsx
  6. 11 9
      packages/app/src/components/dialog-fork.tsx
  7. 16 11
      packages/app/src/components/dialog-select-directory-v2.tsx
  8. 3 4
      packages/app/src/components/dialog-select-directory.tsx
  9. 12 9
      packages/app/src/components/directory-picker-domain.test.ts
  10. 22 10
      packages/app/src/components/directory-picker-domain.ts
  11. 1 1
      packages/app/src/components/file-tree-v2-model.ts
  12. 1 1
      packages/app/src/components/file-tree-v2.tsx
  13. 1 1
      packages/app/src/components/file-tree.tsx
  14. 2 2
      packages/app/src/components/prompt-input.tsx
  15. 2 0
      packages/app/src/components/prompt-input/build-request-parts.test.ts
  16. 2 2
      packages/app/src/components/prompt-input/build-request-parts.ts
  17. 37 9
      packages/app/src/components/prompt-input/submit.test.ts
  18. 89 57
      packages/app/src/components/prompt-input/submit.ts
  19. 1 1
      packages/app/src/components/session/session-context-breakdown.test.ts
  20. 5 5
      packages/app/src/components/session/session-context-breakdown.ts
  21. 1 1
      packages/app/src/components/session/session-context-metrics.test.ts
  22. 6 6
      packages/app/src/components/session/session-context-metrics.ts
  23. 1 1
      packages/app/src/components/session/session-context-tab.tsx
  24. 5 5
      packages/app/src/components/settings-general.tsx
  25. 35 16
      packages/app/src/components/settings-providers.tsx
  26. 5 5
      packages/app/src/components/settings-v2/general.tsx
  27. 35 16
      packages/app/src/components/settings-v2/providers.tsx
  28. 2 2
      packages/app/src/components/settings-v2/servers.tsx
  29. 29 42
      packages/app/src/components/terminal.tsx
  30. 6 4
      packages/app/src/components/titlebar-tab-nav.tsx
  31. 2 3
      packages/app/src/components/titlebar.tsx
  32. 70 0
      packages/app/src/context/backend-client.test.ts
  33. 64 0
      packages/app/src/context/backend-client.ts
  34. 270 3
      packages/app/src/context/backend-v1.test.ts
  35. 363 144
      packages/app/src/context/backend-v1.ts
  36. 566 77
      packages/app/src/context/backend-v2.test.ts
  37. 751 331
      packages/app/src/context/backend-v2.ts
  38. 56 0
      packages/app/src/context/backend.test-fixture.ts
  39. 556 81
      packages/app/src/context/backend.ts
  40. 21 13
      packages/app/src/context/directory-sync.ts
  41. 33 9
      packages/app/src/context/file.tsx
  42. 1 1
      packages/app/src/context/file/content-cache.ts
  43. 1 1
      packages/app/src/context/file/tree-store.ts
  44. 1 1
      packages/app/src/context/file/types.ts
  45. 7 5
      packages/app/src/context/file/watcher.ts
  46. 52 44
      packages/app/src/context/global-sync/bootstrap.test.ts
  47. 167 94
      packages/app/src/context/global-sync/bootstrap.ts
  48. 2 3
      packages/app/src/context/global-sync/child-store.test.ts
  49. 5 4
      packages/app/src/context/global-sync/child-store.ts
  50. 14 5
      packages/app/src/context/global-sync/event-reducer.test.ts
  51. 113 30
      packages/app/src/context/global-sync/event-reducer.ts
  52. 3 2
      packages/app/src/context/global-sync/mcp.ts
  53. 28 9
      packages/app/src/context/global-sync/session-cache.test.ts
  54. 8 15
      packages/app/src/context/global-sync/session-cache.ts
  55. 2 2
      packages/app/src/context/global-sync/session-trim.test.ts
  56. 8 8
      packages/app/src/context/global-sync/session-trim.ts
  57. 53 39
      packages/app/src/context/global-sync/types.ts
  58. 1 34
      packages/app/src/context/global-sync/utils.test.ts
  59. 1 36
      packages/app/src/context/global-sync/utils.ts
  60. 41 6
      packages/app/src/context/global.tsx
  61. 8 3
      packages/app/src/context/layout.tsx
  62. 9 1
      packages/app/src/context/local.tsx
  63. 2 2
      packages/app/src/context/models.tsx
  64. 9 10
      packages/app/src/context/notification.tsx
  65. 1 1
      packages/app/src/context/permission-auto-respond.test.ts
  66. 20 10
      packages/app/src/context/permission.tsx
  67. 1 1
      packages/app/src/context/prompt-state.ts
  68. 58 61
      packages/app/src/context/server-sdk.test.ts
  69. 29 68
      packages/app/src/context/server-sdk.tsx
  70. 177 9
      packages/app/src/context/server-session.test.ts
  71. 240 71
      packages/app/src/context/server-session.ts
  72. 95 70
      packages/app/src/context/server-sync.tsx
  73. 1 1
      packages/app/src/context/sync-optimistic.test.ts
  74. 12 12
      packages/app/src/context/sync.tsx
  75. 3 3
      packages/app/src/context/tabs.tsx
  76. 35 31
      packages/app/src/context/terminal.tsx
  77. 3 3
      packages/app/src/hooks/provider-catalog.test.ts
  78. 4 4
      packages/app/src/hooks/provider-catalog.ts
  79. 10 2
      packages/app/src/pages/home.tsx
  80. 105 44
      packages/app/src/pages/layout.tsx
  81. 11 1
      packages/app/src/pages/layout/helpers.test.ts
  82. 11 2
      packages/app/src/pages/layout/helpers.ts
  83. 3 2
      packages/app/src/pages/layout/sidebar-items.tsx
  84. 3 1
      packages/app/src/pages/layout/sidebar-workspace.tsx
  85. 67 33
      packages/app/src/pages/session.tsx
  86. 11 1
      packages/app/src/pages/session/composer/session-composer-state.test.ts
  87. 12 2
      packages/app/src/pages/session/composer/session-composer-state.ts
  88. 1 1
      packages/app/src/pages/session/composer/session-permission-dock.tsx
  89. 18 3
      packages/app/src/pages/session/composer/session-question-dock.tsx
  90. 5 1
      packages/app/src/pages/session/composer/session-request-tree.ts
  91. 11 3
      packages/app/src/pages/session/review-tab.tsx
  92. 1 1
      packages/app/src/pages/session/session-model-helpers.test.ts
  93. 1 1
      packages/app/src/pages/session/session-model-helpers.ts
  94. 1 1
      packages/app/src/pages/session/session-side-panel.tsx
  95. 58 20
      packages/app/src/pages/session/timeline/message-timeline.tsx
  96. 5 1
      packages/app/src/pages/session/timeline/model.test.ts
  97. 1 1
      packages/app/src/pages/session/timeline/model.ts
  98. 7 1
      packages/app/src/pages/session/timeline/projection.ts
  99. 10 3
      packages/app/src/pages/session/timeline/rows.ts
  100. 1 1
      packages/app/src/pages/session/timeline/timeline-row.ts

+ 0 - 1
bun.lock

@@ -840,7 +840,6 @@
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/core": "workspace:*",
-        "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
         "@pierre/diffs": "catalog:",
         "@shikijs/stream": "catalog:",

+ 3 - 3
packages/app/src/components/command-palette.ts

@@ -169,7 +169,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
       const name = store.vcs?.branch ?? getFilename(directory)
       return `${kind} : ${name || path}`
     },
-    load: (directory) => serverSDK.client.session.list({ directory, roots: true }),
+    load: async (directory) => (await serverSDK.backend).common.sessions.list({ location: { directory }, roots: true }),
     untitled: () => language.t("command.session.new"),
     category: () => language.t("command.category.session"),
   })
@@ -233,7 +233,7 @@ function createCommandEntry(option: CommandOption, category: string): CommandPal
 function createSessionEntries(props: {
   workspaces: () => string[]
   label: (directory: string) => string
-  load: (directory: string) => ReturnType<ServerSDK["client"]["session"]["list"]>
+  load: (directory: string) => ReturnType<Awaited<ServerSDK["backend"]>["common"]["sessions"]["list"]>
   untitled: () => string
   category: () => string
 }) {
@@ -263,7 +263,7 @@ function createSessionEntries(props: {
         return props
           .load(directory)
           .then((result) =>
-            (result.data ?? [])
+            result.items
               .filter((session) => !!session?.id)
               .map((session) => ({
                 id: session.id,

+ 154 - 41
packages/app/src/components/dialog-connect-provider.tsx

@@ -1,4 +1,4 @@
-import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
+import type { ProviderAuthorization, ProviderAuthMethod } from "@/context/backend"
 import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -31,6 +31,7 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { CustomProviderForm } from "./dialog-custom-provider"
 
 const CUSTOM_ID = "_custom"
+type AuthMethod = ProviderAuthMethod & { readonly id?: string }
 
 export function useProviderConnectController(options: { onBack?: () => void } = {}) {
   const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -176,6 +177,7 @@ function ProviderConnection(props: {
   const providers = useProviders(props.directory)
 
   const alive = { value: true }
+  const connected = { value: false }
   const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
 
   onCleanup(() => {
@@ -188,7 +190,16 @@ function ProviderConnection(props: {
   const provider = createMemo(
     () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
   )
-  const fallback = createMemo<ProviderAuthMethod[]>(() => [
+  const integrationID = () => {
+    const value = provider()
+    if (!("integrationID" in value) || typeof value.integrationID !== "string") return props.provider
+    return value.integrationID
+  }
+  const location = () => {
+    const directory = props.directory?.()
+    return directory ? { location: { directory } } : {}
+  }
+  const fallback = createMemo<AuthMethod[]>(() => [
     {
       type: "api" as const,
       label: language.t("provider.connect.method.apiKey"),
@@ -197,19 +208,52 @@ function ProviderConnection(props: {
   const [auth] = createResource(
     () => props.provider,
     async () => {
+      const backend = await serverSDK().backend
+      if (backend.version === "v2") {
+        const capability = backend.capabilities.integrationsV2
+        if (!capability) throw new Error("Server does not support provider integrations")
+        const integration = await capability.get({ ...location(), integrationID: integrationID() })
+        if (!alive.value) return fallback()
+        return (
+          integration?.methods.flatMap((method): AuthMethod[] => {
+            if (method.type === "environment") return []
+            if (method.type === "key") return [{ type: "api", label: method.label }]
+            return [{ type: "oauth", id: method.id, label: method.label, prompts: method.prompts }]
+          }) ?? fallback()
+        )
+      }
+
       const cached = serverSync().data.provider_auth[props.provider]
       if (cached) return cached
-      const res = await serverSDK().client.provider.auth()
+      const capability = backend.capabilities.providerAuthV1
+      if (!capability) throw new Error("Server does not support provider authentication")
+      const result = await capability.methods(location())
       if (!alive.value) return fallback()
-      serverSync().set("provider_auth", res.data ?? {})
-      return res.data?.[props.provider] ?? fallback()
+      const normalized = Object.fromEntries(
+        Object.entries(result).map(([id, methods]) => [
+          id,
+          methods.map((method) => ({
+            ...method,
+            prompts: method.prompts?.map((prompt) =>
+              prompt.type === "select"
+                ? { ...prompt, options: prompt.options.map((option) => ({ ...option })) }
+                : { ...prompt },
+            ),
+          })),
+        ]),
+      )
+      serverSync().set("provider_auth", normalized)
+      return normalized[props.provider] ?? fallback()
     },
   )
   const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
-  const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
+  const methods = createMemo<AuthMethod[]>(() => [
+    ...(auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()),
+  ])
   const [store, setStore] = createStore({
     methodIndex: undefined as undefined | number,
-    authorization: undefined as undefined | ProviderAuthAuthorization,
+    authorization: undefined as undefined | ProviderAuthorization,
+    attemptID: undefined as string | undefined,
     promptInputs: undefined as undefined | Record<string, string>,
     state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
     error: undefined as string | undefined,
@@ -221,7 +265,7 @@ function ProviderConnection(props: {
     | { type: "auth.prompt" }
     | { type: "auth.inputs"; inputs: Record<string, string> }
     | { type: "auth.pending" }
-    | { type: "auth.complete"; authorization: ProviderAuthAuthorization }
+    | { type: "auth.complete"; authorization: ProviderAuthorization; attemptID?: string }
     | { type: "auth.error"; error: string }
 
   function dispatch(action: Action) {
@@ -230,6 +274,7 @@ function ProviderConnection(props: {
         if (action.type === "method.select") {
           draft.methodIndex = action.index
           draft.authorization = undefined
+          draft.attemptID = undefined
           draft.promptInputs = undefined
           draft.state = undefined
           draft.error = undefined
@@ -238,6 +283,7 @@ function ProviderConnection(props: {
         if (action.type === "method.reset") {
           draft.methodIndex = undefined
           draft.authorization = undefined
+          draft.attemptID = undefined
           draft.promptInputs = undefined
           draft.state = undefined
           draft.error = undefined
@@ -262,6 +308,7 @@ function ProviderConnection(props: {
         if (action.type === "auth.complete") {
           draft.state = "complete"
           draft.authorization = action.authorization
+          draft.attemptID = action.attemptID
           draft.error = undefined
           return
         }
@@ -273,6 +320,16 @@ function ProviderConnection(props: {
 
   const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
 
+  onCleanup(() => {
+    if (!store.attemptID || connected.value) return
+    void (async () => {
+      const backend = await serverSDK().backend
+      await backend.capabilities.integrationsV2
+        ?.cancelAttempt({ ...location(), attemptID: store.attemptID! })
+        .catch(() => undefined)
+    })()
+  })
+
   const methodLabel = (value?: { type?: string; label?: string }) => {
     if (!value) return ""
     if (value.type === "api") return language.t("provider.connect.method.apiKey")
@@ -322,15 +379,33 @@ function ProviderConnection(props: {
       }
       dispatch({ type: "auth.pending" })
       const start = Date.now()
-      await serverSDK()
-        .client.provider.oauth.authorize(
-          {
-            providerID: props.provider,
-            method: index,
-            inputs,
-          },
-          { throwOnError: true },
-        )
+      const backend = await serverSDK().backend
+      const request =
+        backend.version === "v1"
+          ? (() => {
+              const capability = backend.capabilities.providerAuthV1
+              if (!capability) throw new Error("Server does not support provider authentication")
+              return capability.authorize({ ...location(), providerID: props.provider, method: index, values: inputs })
+            })()
+          : (() => {
+              const capability = backend.capabilities.integrationsV2
+              if (!capability) throw new Error("Server does not support provider integrations")
+              if (!method.id) throw new Error("Provider OAuth method is missing an ID")
+              return capability
+                .connectOauth({
+                  ...location(),
+                  integrationID: integrationID(),
+                  methodID: method.id,
+                  values: inputs ?? {},
+                })
+                .then((attempt) => ({
+                  url: attempt.url,
+                  method: attempt.mode,
+                  instructions: attempt.instructions,
+                  attemptID: attempt.attemptID,
+                }))
+            })()
+      await request
         .then((x) => {
           if (!alive.value) return
           const elapsed = Date.now() - start
@@ -341,11 +416,19 @@ function ProviderConnection(props: {
             timer.current = setTimeout(() => {
               timer.current = undefined
               if (!alive.value) return
-              dispatch({ type: "auth.complete", authorization: x.data! })
+              dispatch({
+                type: "auth.complete",
+                authorization: x,
+                attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
+              })
             }, delay)
             return
           }
-          dispatch({ type: "auth.complete", authorization: x.data! })
+          dispatch({
+            type: "auth.complete",
+            authorization: x,
+            attemptID: "attemptID" in x && typeof x.attemptID === "string" ? x.attemptID : undefined,
+          })
         })
         .catch((e) => {
           if (!alive.value) return
@@ -445,7 +528,7 @@ function ProviderConnection(props: {
               <div>
                 <List
                   class="px-3"
-                  items={select()?.options ?? []}
+                  items={[...(select()?.options ?? [])]}
                   key={(x) => x.value}
                   current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
                   onSelect={(value) => {
@@ -498,7 +581,10 @@ function ProviderConnection(props: {
   })
 
   async function complete() {
-    await serverSDK().client.global.dispose()
+    const backend = await serverSDK().backend
+    if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
+    if (backend.version === "v2") await serverSync().refreshProviders()
+    connected.value = true
     dialog.close()
     showToast({
       variant: "success",
@@ -570,14 +656,20 @@ function ProviderConnection(props: {
       }
 
       setFormStore("error", undefined)
-      await serverSDK().client.auth.set({
-        providerID: props.provider,
-        auth: {
-          type: "api",
+      const backend = await serverSDK().backend
+      if (backend.version === "v1") {
+        const capability = backend.capabilities.providerAuthV1
+        if (!capability) throw new Error("Server does not support provider authentication")
+        await capability.setApiKey({ providerID: props.provider, key: apiKey, metadata: store.promptInputs })
+      } else {
+        const capability = backend.capabilities.integrationsV2
+        if (!capability) throw new Error("Server does not support provider integrations")
+        await capability.connectKey({
+          ...location(),
+          integrationID: integrationID(),
           key: apiKey,
-          ...(store.promptInputs ? { metadata: store.promptInputs } : {}),
-        },
-      })
+        })
+      }
       await complete()
     }
 
@@ -642,13 +734,20 @@ function ProviderConnection(props: {
       }
 
       setFormStore("error", undefined)
-      const result = await serverSDK()
-        .client.provider.oauth.callback({
-          providerID: props.provider,
-          method: store.methodIndex,
-          code,
-        })
-        .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
+      const result = await (async () => {
+        const backend = await serverSDK().backend
+        if (backend.version === "v1") {
+          const capability = backend.capabilities.providerAuthV1
+          if (!capability) throw new Error("Server does not support provider authentication")
+          await capability.callback({ providerID: props.provider, method: store.methodIndex!, code })
+          return
+        }
+        const capability = backend.capabilities.integrationsV2
+        if (!capability) throw new Error("Server does not support provider integrations")
+        if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
+        await capability.completeAttempt({ ...location(), attemptID: store.attemptID, code })
+      })()
+        .then(() => ({ ok: true as const }))
         .catch((error) => ({ ok: false as const, error }))
       if (result.ok) {
         await complete()
@@ -695,12 +794,26 @@ function ProviderConnection(props: {
 
     onMount(() => {
       void (async () => {
-        const result = await serverSDK()
-          .client.provider.oauth.callback({
-            providerID: props.provider,
-            method: store.methodIndex,
-          })
-          .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
+        const result = await (async () => {
+          const backend = await serverSDK().backend
+          if (backend.version === "v1") {
+            const capability = backend.capabilities.providerAuthV1
+            if (!capability) throw new Error("Server does not support provider authentication")
+            await capability.callback({ providerID: props.provider, method: store.methodIndex! })
+            return
+          }
+          const capability = backend.capabilities.integrationsV2
+          if (!capability) throw new Error("Server does not support provider integrations")
+          if (!store.attemptID) throw new Error("Provider OAuth attempt is missing")
+          while (alive.value) {
+            const status = await capability.attemptStatus({ ...location(), attemptID: store.attemptID })
+            if (status.status === "complete") return
+            if (status.status === "failed") throw new Error(status.error ?? "Authorization failed")
+            if (status.status === "expired") throw new Error("Authorization expired")
+            await new Promise((resolve) => setTimeout(resolve, 500))
+          }
+        })()
+          .then(() => ({ ok: true as const }))
           .catch((error) => ({ ok: false as const, error }))
 
         if (!alive.value) return

+ 14 - 11
packages/app/src/components/dialog-custom-provider.tsx

@@ -118,7 +118,7 @@ export function CustomProviderForm() {
     const output = validateCustomProvider({
       form,
       t: language.t,
-      disabledProviders: serverSync().data.config.disabled_providers ?? [],
+      disabledProviders: [...(serverSync().data.config.disabledProviders ?? [])],
       existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
     })
     batch(() => {
@@ -131,23 +131,26 @@ export function CustomProviderForm() {
 
   const saveMutation = useMutation(() => ({
     mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
-      const disabledProviders = serverSync().data.config.disabled_providers ?? []
+      const disabledProviders = serverSync().data.config.disabledProviders ?? []
       const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
+      const backend = await serverSDK().backend
 
-      if (result.key) {
-        await serverSDK().client.auth.set({
-          providerID: result.providerID,
-          auth: {
-            type: "api",
-            key: result.key,
-          },
-        })
+      if (result.key && backend.version === "v1") {
+        const capability = backend.capabilities.providerAuthV1
+        if (!capability) throw new Error("Server does not support provider authentication")
+        await capability.setApiKey({ providerID: result.providerID, key: result.key })
       }
 
       await serverSync().updateConfig({
         provider: { [result.providerID]: result.config },
-        disabled_providers: nextDisabled,
+        disabledProviders: nextDisabled,
       })
+      if (result.key && backend.version === "v2") {
+        const capability = backend.capabilities.integrationsV2
+        if (!capability) throw new Error("Server does not support provider integrations")
+        await capability.connectKey({ integrationID: result.providerID, key: result.key })
+        await serverSync().refreshProviders()
+      }
       return result
     },
     onSuccess: (result) => {

+ 4 - 2
packages/app/src/components/dialog-edit-project.tsx

@@ -80,9 +80,11 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
       const start = store.startup.trim()
 
       if (props.project.id && props.project.id !== "global") {
-        await serverSDK().client.project.update({
+        const editing = (await serverSDK().backend).capabilities.projectEditing
+        if (!editing) throw new Error("Project editing is not supported by this server")
+        await editing.update({
           projectID: props.project.id,
-          directory: props.project.worktree,
+          location: { directory: props.project.worktree },
           name,
           icon: { color: store.color || "", override: store.iconOverride || "" },
           commands: { start },

+ 11 - 9
packages/app/src/components/dialog-fork.tsx

@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { showToast } from "@/utils/toast"
 import { extractPromptFromParts } from "@/utils/prompt"
-import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
+import type { AppPart } from "@/context/backend"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { useLanguage } from "@/context/language"
 
@@ -42,7 +42,9 @@ export const DialogFork: Component = () => {
       if (message.role !== "user") continue
 
       const parts = sync().data.part[message.id] ?? []
-      const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
+      const textPart = parts.find(
+        (x): x is Extract<AppPart, { type: "text" }> => x.type === "text" && !x.synthetic && !x.ignored,
+      )
       if (!textPart) continue
 
       result.push({
@@ -69,15 +71,15 @@ export const DialogFork: Component = () => {
     const dir = base64Encode(sdk().directory)
 
     sdk()
-      .client.session.fork({ sessionID, messageID: item.id })
+      .backend.then((client) => {
+        const capability = client.capabilities.sessionActionsV1
+        if (!capability) throw new Error("Session forking is not supported by this server")
+        return capability.fork({ location: { directory: sdk().directory }, sessionID, messageID: item.id })
+      })
       .then((forked) => {
-        if (!forked.data) {
-          showToast({ title: language.t("common.requestFailed") })
-          return
-        }
         dialog.close()
-        prompt.set(restored, undefined, { dir, id: forked.data.id })
-        navigate(`/${dir}/session/${forked.data.id}`)
+        prompt.set(restored, undefined, { dir, id: forked.id })
+        navigate(`/${dir}/session/${forked.id}`)
       })
       .catch((err: unknown) => {
         const message = err instanceof Error ? err.message : String(err)

+ 16 - 11
packages/app/src/components/dialog-select-directory-v2.tsx

@@ -68,9 +68,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
   const [fallbackPath] = createResource(
     () => (missingBase() ? true : undefined),
     () =>
-      sdk.client.path
-        .get()
-        .then((result) => result.data)
+      sdk.backend
+        .then((client) => client.capabilities.pathInfo?.get())
         .catch(() => undefined),
     { initialValue: undefined },
   )
@@ -83,20 +82,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
       fallbackPath()?.home ||
       fallbackPath()?.directory,
   )
-  const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
+  const search = createDirectorySearch({ backend: sdk.backend, home, base: () => root() || start() })
   const [suggestions] = createResource(input, async (value) => {
     const typed = cleanPickerInput(value).replace(/\/+$/, "")
     const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
     if (!typed || typed === current) return { query: value, items: [] }
     const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
     if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
-    const files = await sdk.client.find
-      .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
-      .then((result) => result.data ?? [])
+    const files = await sdk.backend
+      .then((client) =>
+        client.common.files.find({
+          location: { directory: root() },
+          query: pickerFileSearchQuery(root(), value, home()),
+          type: "file",
+          limit: 20,
+        }),
+      )
       .catch(() => [])
     const results = [
       ...directories,
-      ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
+      ...files.map((file) => ({ absolute: file.absolute ?? absoluteTreePath(root(), file.path), type: "file" as const })),
     ]
     return {
       query: value,
@@ -115,9 +120,9 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
       existing ??
       loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
         if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
-        return sdk.client.file
-          .list({ directory: absolute, path: "" })
-          .then((result) => result.data ?? [])
+        return sdk.backend
+          .then((client) => client.common.files.list({ location: { directory: absolute }, path: "" }))
+          .then((nodes) => nodes.map((node) => ({ name: node.name ?? node.path, type: node.type })))
           .catch(() => undefined)
       })
     listings.set(key, request)

+ 3 - 4
packages/app/src/components/dialog-select-directory.tsx

@@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
   const [fallbackPath] = createResource(
     () => (missingBase() ? true : undefined),
     async () => {
-      return sdk.client.path
-        .get()
-        .then((x) => x.data)
+      return sdk.backend
+        .then((client) => client.capabilities.pathInfo?.get())
         .catch(() => undefined)
     },
     { initialValue: undefined },
@@ -74,7 +73,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
   )
 
   const directories = createDirectorySearch({
-    sdk,
+    backend: sdk.backend,
     home,
     base: start,
   })

+ 12 - 9
packages/app/src/components/directory-picker-domain.test.ts

@@ -21,6 +21,7 @@ import {
   pickerRoot,
   pickerAbsoluteInput,
 } from "./directory-picker-domain"
+import { createAppClient } from "@/context/backend.test-fixture"
 
 test("maps server directory entries into Pierre paths", () => {
   expect(
@@ -132,18 +133,20 @@ test("scopes file autocomplete to the current browser root", () => {
 
 test("resolves directory autocomplete from the current browser root", async () => {
   const directories: string[] = []
-  const sdk = {
-    client: {
-      find: {
-        files: (input: { directory: string }) => {
-          directories.push(input.directory)
-          return Promise.resolve({ data: [] })
+  const backend = Promise.resolve(
+    createAppClient({
+      common: {
+        files: {
+          find: (input) => {
+            directories.push(input.location?.directory ?? "")
+            return Promise.resolve([])
+          },
         },
       },
-    },
-  } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
+    }),
+  )
   let base = "/repo"
-  const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
+  const search = createDirectorySearch({ backend, home: () => "/home/luke", base: () => base })
 
   await search("components")
   base = "/repo/src"

+ 22 - 10
packages/app/src/components/directory-picker-domain.ts

@@ -247,7 +247,7 @@ export function nativePickerPath(path: string) {
 }
 import { getFilename } from "@opencode-ai/core/util/path"
 import fuzzysort from "fuzzysort"
-import { ServerSDK } from "@/context/server-sdk"
+import type { AppClient } from "@/context/backend"
 
 export function cleanPickerInput(value: string) {
   const first = (value ?? "").split(/\r?\n/)[0] ?? ""
@@ -321,7 +321,11 @@ export function displayPickerPath(path: string, input: string, home: string) {
   return pickerTilde(value, home) || value
 }
 
-export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
+export function createDirectorySearch(args: {
+  backend: Promise<AppClient>
+  base: () => string | undefined
+  home: () => string
+}) {
   const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
   let current = 0
 
@@ -342,14 +346,16 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
     const key = trimPickerPath(directory)
     const existing = cache.get(key)
     if (existing) return existing
-    const request = args.sdk.client.file
-      .list({ directory: key, path: "" })
-      .then((result) => result.data ?? [])
+    const request = args.backend
+      .then((client) => client.common.files.list({ location: { directory: key }, path: "" }))
       .catch(() => [])
       .then((nodes) =>
         nodes
           .filter((node) => node.type === "directory")
-          .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
+          .map((node) => ({
+            name: node.name ?? getFilename(node.path),
+            absolute: trimPickerPath(normalizePickerDrive(node.absolute ?? joinPickerPath(key, node.path))),
+          })),
       )
     cache.set(key, request)
     return request
@@ -371,12 +377,18 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
     const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
     const query = normalizePickerDrive(input.path)
     if (!pathInput) {
-      const results = await args.sdk.client.find
-        .files({ directory: input.directory, query, type: "directory", limit: 50 })
-        .then((result) => result.data ?? [])
+      const results = await args.backend
+        .then((client) =>
+          client.common.files.find({
+            location: { directory: input.directory },
+            query,
+            type: "directory",
+            limit: 50,
+          }),
+        )
         .catch(() => [])
       if (!active()) return []
-      return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
+      return results.map((item) => item.absolute ?? joinPickerPath(input.directory, item.path)).slice(0, 50)
     }
     const segments = query.replace(/^\/+/, "").split("/")
     const head = segments.slice(0, -1).filter((part) => part && part !== ".")

+ 1 - 1
packages/app/src/components/file-tree-v2-model.ts

@@ -1,4 +1,4 @@
-import type { FileNode } from "@opencode-ai/sdk/v2"
+import type { AppFileNode as FileNode } from "@/context/backend"
 
 export type FileTreeV2Model = {
   children: ReadonlyMap<string, readonly FileTreeV2Node[]>

+ 1 - 1
packages/app/src/components/file-tree-v2.tsx

@@ -12,7 +12,7 @@ import {
   type ParentProps,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
-import type { FileNode } from "@opencode-ai/sdk/v2"
+import type { AppFileNode as FileNode } from "@/context/backend"
 import { Icon } from "@opencode-ai/ui/v2/icon"
 import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
 import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"

+ 1 - 1
packages/app/src/components/file-tree.tsx

@@ -17,7 +17,7 @@ import {
   type ParentProps,
 } from "solid-js"
 import { Dynamic } from "solid-js/web"
-import type { FileNode } from "@opencode-ai/sdk/v2"
+import type { AppFileNode as FileNode } from "@/context/backend"
 
 const MAX_DEPTH = 128
 

+ 2 - 2
packages/app/src/components/prompt-input.tsx

@@ -74,7 +74,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder"
 import { createPromptInputTransientState } from "./prompt-input/transient-state"
 import { showToast } from "@/utils/toast"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
-import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
+import type { AppReference as ReferenceInfo } from "@/context/backend"
 
 export type PromptInputState = ReturnType<typeof usePrompt>
 
@@ -678,7 +678,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         type: "resource",
         name: resource.name,
         uri: resource.uri,
-        client: resource.client,
+        client: resource.server,
         display: resource.name,
         description: resource.description,
         mime: resource.mimeType,

+ 2 - 0
packages/app/src/components/prompt-input/build-request-parts.test.ts

@@ -46,6 +46,8 @@ describe("buildRequestParts", () => {
     ).toBe(true)
 
     expect(result.optimisticParts).toHaveLength(result.requestParts.length)
+    expect(result.optimisticParts.map((part) => part.id)).toEqual(result.requestParts.map((part) => part.id))
+    expect(result.optimisticParts.map((part) => part.type)).toEqual(result.requestParts.map((part) => part.type))
     expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
   })
 

+ 2 - 2
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -1,12 +1,12 @@
 import { getFilename } from "@opencode-ai/core/util/path"
-import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
+import type { AppPart as Part, PromptPart } from "@/context/backend"
 import type { FileSelection } from "@/context/file"
 import { encodeFilePath } from "@/context/file/path"
 import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
 import { Identifier } from "@/utils/id"
 import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
 
-type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
+type PromptRequestPart = PromptPart
 
 type ContextFile = {
   key: string

+ 37 - 9
packages/app/src/components/prompt-input/submit.test.ts

@@ -81,6 +81,34 @@ const clientFor = (directory: string) => {
 
 beforeAll(async () => {
   const rootClient = clientFor("/repo/main")
+  const backend = Promise.resolve({
+    common: {
+      sessions: {
+        create: async (input: { location?: { directory?: string } }) => {
+          const directory = input.location?.directory ?? "/repo/main"
+          createdSessions.push(directory)
+          return {
+            id: `session-${createdSessions.length}`,
+            projectID: "project",
+            location: { directory },
+            title: `New session ${createdSessions.length}`,
+            cost: 0,
+            time: { created: Date.now() },
+          }
+        },
+        prompt: async () => undefined,
+        command: async () => undefined,
+        interrupt: async () => undefined,
+      },
+    },
+    capabilities: {
+      sessionExtrasV1: {
+        shell: async (input: { location?: { directory?: string } }) => {
+          sentShell.push(input.location?.directory ?? "/repo/main")
+        },
+      },
+    },
+  })
 
   mock.module("@solidjs/router", () => ({
     useNavigate: () => () => undefined,
@@ -89,13 +117,6 @@ beforeAll(async () => {
     useSearchParams: () => [search, () => undefined],
   }))
 
-  mock.module("@opencode-ai/sdk/v2/client", () => ({
-    createOpencodeClient: (input: { directory: string }) => {
-      createdClients.push(input.directory)
-      return clientFor(input.directory)
-    },
-  }))
-
   mock.module("@opencode-ai/ui/toast", () => ({
     Toast: { Region: () => null },
     showToast: () => 0,
@@ -161,6 +182,7 @@ beforeAll(async () => {
         scope: "local",
         directory: "/repo/main",
         client: rootClient,
+        backend,
         url: "http://localhost:4096",
         createClient(opts: any) {
           return clientFor(opts.directory)
@@ -282,7 +304,7 @@ describe("prompt submit worktree selection", () => {
     selected = "/repo/worktree-b"
     await submit.handleSubmit(event)
 
-    expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
+    expect(createdClients).toEqual([])
     expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
     expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
     expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
@@ -441,7 +463,13 @@ describe("prompt submit worktree selection", () => {
 
     await submit.handleSubmit(event)
 
-    expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
+    expect(storedSessions["/repo/worktree-a"]).toEqual([
+      expect.objectContaining({
+        id: "session-1",
+        title: "New session 1",
+        location: { directory: "/repo/worktree-a" },
+      }),
+    ])
     expect(optimisticSeeded).toEqual([true])
   })
 })

+ 89 - 57
packages/app/src/components/prompt-input/submit.ts

@@ -1,4 +1,3 @@
-import type { Message, Session } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@/utils/toast"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { Binary } from "@opencode-ai/core/util/binary"
@@ -11,7 +10,7 @@ import { useLayout } from "@/context/layout"
 import { useLocal, type ModelSelection } from "@/context/local"
 import { usePermission } from "@/context/permission"
 import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
-import { useSDK, type DirectorySDK } from "@/context/sdk"
+import { useSDK } from "@/context/sdk"
 import { useSync, type DirectorySync } from "@/context/sync"
 import { Identifier } from "@/utils/id"
 import { Worktree as WorktreeState } from "@/utils/worktree"
@@ -20,6 +19,7 @@ import { setCursorPosition } from "./editor-dom"
 import { formatServerError } from "@/utils/server-errors"
 import { ScopedKey } from "@/utils/server-scope"
 import { createPromptSubmissionState } from "./submission-state"
+import type { AppClient, AppMessage, AppSession } from "@/context/backend"
 
 type PendingPrompt = {
   abort: AbortController
@@ -39,13 +39,14 @@ export type FollowupDraft = {
 }
 
 type FollowupSendInput = {
-  client: DirectorySDK["client"]
+  backend: Promise<AppClient>
   serverSync: ServerSync
   sync: DirectorySync
   draft: FollowupDraft
   messageID?: string
   optimisticBusy?: boolean
   before?: () => Promise<boolean> | boolean
+  commitRevert?: boolean
 }
 
 const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
@@ -53,6 +54,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
 const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
 
 export async function sendFollowupDraft(input: FollowupSendInput) {
+  const backend = await input.backend
+  const location = { directory: input.draft.sessionDirectory }
   const text = draftText(input.draft.prompt)
   const images = draftImages(input.draft.prompt)
   const setBusy = () => {
@@ -81,19 +84,26 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
         return false
       }
 
-      await input.client.session.command({
+      if (input.commitRevert)
+        await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
+      const capability = backend.capabilities.sessionActionsV1
+      if (!capability) throw new Error("Commands are not supported by this server")
+      await capability.command({
+        location,
         sessionID: input.draft.sessionID,
+        id: input.messageID,
         command: cmd,
         arguments: tail.join(" "),
         agent: input.draft.agent,
-        model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
-        variant: input.draft.variant,
-        parts: images.map((attachment) => ({
-          id: Identifier.ascending("part"),
-          type: "file" as const,
+        model: {
+          providerID: input.draft.model.providerID,
+          id: input.draft.model.modelID,
+          variant: input.draft.variant,
+        },
+        files: images.map((attachment) => ({
+          uri: attachment.dataUrl,
           mime: attachment.mime,
-          url: attachment.dataUrl,
-          filename: attachment.filename,
+          name: attachment.filename,
         })),
       })
       return true
@@ -114,7 +124,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
     sessionDirectory: input.draft.sessionDirectory,
   })
 
-  const message: Message = {
+  const message: AppMessage = {
     id: messageID,
     sessionID: input.draft.sessionID,
     role: "user",
@@ -152,13 +162,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
       return false
     }
 
-    await input.client.session.promptAsync({
+    if (input.commitRevert)
+      await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: input.draft.sessionID })
+    await backend.common.sessions.prompt({
+      location,
       sessionID: input.draft.sessionID,
-      agent: input.draft.agent,
-      model: input.draft.model,
-      messageID,
+      id: messageID,
+      text,
       parts: requestParts,
-      variant: input.draft.variant,
+      selection: {
+        agent: input.draft.agent,
+        model: {
+          providerID: input.draft.model.providerID,
+          id: input.draft.model.modelID,
+          variant: input.draft.variant,
+        },
+      },
     })
     return true
   } catch (err) {
@@ -172,7 +191,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
 
 type PromptSubmitInput = {
   prompt: ReturnType<typeof usePrompt>
-  info: Accessor<{ id: string } | undefined>
+  info: Accessor<{ id: string; revert?: { messageID: string } } | undefined>
   imageAttachments: Accessor<ImageAttachmentPart[]>
   commentCount: Accessor<number>
   autoAccept: Accessor<boolean>
@@ -233,9 +252,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       return Promise.resolve()
     }
     return sdk()
-      .client.session.abort({
-        sessionID,
-      })
+      .backend.then((client) =>
+        client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
+      )
       .catch(() => {})
   }
 
@@ -262,10 +281,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
   }
 
-  const seed = (dir: string, info: Session) => {
+  const seed = (dir: string, info: AppSession) => {
     serverSync().session.remember(info)
     const [, setStore] = serverSync().child(dir)
-    setStore("session", (list: Session[]) => {
+    setStore("session", (list: AppSession[]) => {
       const result = Binary.search(list, info.id, (item) => item.id)
       const next = [...list]
       if (result.found) {
@@ -313,19 +332,18 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     input.resetHistoryNavigation()
 
     const projectDirectory = sdk().directory
+    const backend = await sdk().backend
     const isNewSession = !params.id
     const shouldAutoAccept = isNewSession && input.autoAccept()
     const worktreeSelection = input.newSessionWorktree?.() || "main"
 
     let sessionDirectory = projectDirectory
-    let client = sdk().client
 
     if (isNewSession) {
       if (worktreeSelection === "create") {
-        const createdWorktree = await client.worktree
-          .create({ directory: projectDirectory })
-          .then((x) => x.data)
-          .catch((err) => {
+        const createdWorktree = await backend.capabilities.worktreesV1
+          ?.create({ location: { directory: projectDirectory } })
+          .catch((err: unknown) => {
             showToast({
               title: language.t("prompt.toast.worktreeCreateFailed.title"),
               description: errorMessage(err),
@@ -349,10 +367,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       }
 
       if (sessionDirectory !== projectDirectory) {
-        client = sdk().createClient({
-          directory: sessionDirectory,
-          throwOnError: true,
-        })
         serverSync().child(sessionDirectory)
       }
 
@@ -361,9 +375,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     let session = input.info()
     if (!session && isNewSession) {
-      const created = await client.session
-        .create()
-        .then((x) => x.data ?? undefined)
+      const created = await sdk()
+        .backend.then((backend) =>
+          backend.common.sessions.create({ location: { directory: sessionDirectory } }),
+        )
         .catch((err) => {
           showToast({
             title: language.t("prompt.toast.sessionCreateFailed.title"),
@@ -447,12 +462,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
 
     if (mode === "shell") {
       clearInput()
-      client.session
-        .shell({
-          sessionID: session.id,
-          agent,
-          model,
-          command: text,
+      sdk()
+        .backend.then(async (backend) => {
+          const location = { directory: sessionDirectory }
+          if (session.revert)
+            await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
+          if (backend.capabilities.sessionExtrasV1)
+            return backend.capabilities.sessionExtrasV1.shell({
+              location,
+              sessionID: session.id,
+              agent,
+              model: { id: model.modelID, providerID: model.providerID },
+              command: text,
+            })
+          if (backend.capabilities.sessionExtrasV2?.shell)
+            return backend.capabilities.sessionExtrasV2.shell({ location, sessionID: session.id, command: text })
+          throw new Error("Shell prompts are not supported by this server")
         })
         .catch((err) => {
           showToast({
@@ -470,21 +495,27 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       const customCommand = sync().data.command.find((c) => c.name === commandName)
       if (customCommand) {
         clearInput()
-        client.session
-          .command({
-            sessionID: session.id,
-            command: commandName,
-            arguments: args.join(" "),
-            agent,
-            model: `${model.providerID}/${model.modelID}`,
-            variant,
-            parts: images.map((attachment) => ({
-              id: Identifier.ascending("part"),
-              type: "file" as const,
-              mime: attachment.mime,
-              url: attachment.dataUrl,
-              filename: attachment.filename,
-            })),
+        sdk()
+          .backend.then(async (backend) => {
+            const location = { directory: sessionDirectory }
+            if (session.revert)
+              await backend.capabilities.sessionExtrasV2?.commitRevert({ location, sessionID: session.id })
+            const capability = backend.capabilities.sessionActionsV1
+            if (!capability) throw new Error("Commands are not supported by this server")
+            return capability.command({
+              location,
+              sessionID: session.id,
+              id: Identifier.ascending("message"),
+              command: commandName,
+              arguments: args.join(" "),
+              agent,
+              model: { id: model.modelID, providerID: model.providerID, variant },
+              files: images.map((attachment) => ({
+                uri: attachment.dataUrl,
+                mime: attachment.mime,
+                name: attachment.filename,
+              })),
+            })
           })
           .catch((err) => {
             showToast({
@@ -570,12 +601,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
     }
 
     void sendFollowupDraft({
-      client,
+      backend: sdk().backend,
       sync: sync(),
       serverSync: serverSync(),
       draft,
       messageID,
       optimisticBusy: sessionDirectory === projectDirectory,
+      commitRevert: !!session.revert,
       before: waitForWorktree,
     }).catch((err) => {
       pending.delete(pendingKey(session.id))

+ 1 - 1
packages/app/src/components/session/session-context-breakdown.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import type { AppMessage as Message, AppPart as Part } from "@/context/backend"
 import { estimateSessionContextBreakdown } from "./session-context-breakdown"
 
 const user = (id: string) => {

+ 5 - 5
packages/app/src/components/session/session-context-breakdown.ts

@@ -1,4 +1,4 @@
-import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import type { AppMessage, AppPart } from "@/context/backend"
 
 export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
 
@@ -13,14 +13,14 @@ const estimateTokens = (chars: number) => Math.ceil(chars / 4)
 const toPercent = (tokens: number, input: number) => (tokens / input) * 100
 const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
 
-const charsFromUserPart = (part: Part) => {
+const charsFromUserPart = (part: AppPart) => {
   if (part.type === "text") return part.text.length
   if (part.type === "file") return part.source?.text.value.length ?? 0
   if (part.type === "agent") return part.source?.value.length ?? 0
   return 0
 }
 
-const charsFromAssistantPart = (part: Part) => {
+const charsFromAssistantPart = (part: AppPart) => {
   if (part.type === "text") return { assistant: part.text.length, tool: 0 }
   if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
   if (part.type !== "tool") return { assistant: 0, tool: 0 }
@@ -68,8 +68,8 @@ const build = (
 }
 
 export function estimateSessionContextBreakdown(args: {
-  messages: Message[]
-  parts: Record<string, Part[] | undefined>
+  messages: AppMessage[]
+  parts: Record<string, AppPart[] | undefined>
   input: number
   systemPrompt?: string
 }) {

+ 1 - 1
packages/app/src/components/session/session-context-metrics.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { Message } from "@opencode-ai/sdk/v2/client"
+import type { AppMessage as Message } from "@/context/backend"
 import { getSessionContext } from "./session-context-metrics"
 
 const assistant = (

+ 6 - 6
packages/app/src/components/session/session-context-metrics.ts

@@ -1,4 +1,4 @@
-import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
+import type { AppAssistantMessage, AppMessage } from "@/context/backend"
 
 type Provider = {
   id: string
@@ -14,7 +14,7 @@ type Model = {
 }
 
 type Context = {
-  message: AssistantMessage
+  message: AppAssistantMessage
   provider?: Provider
   model?: Model
   providerLabel: string
@@ -25,11 +25,11 @@ type Context = {
   usage: number | null
 }
 
-const tokenTotal = (msg: AssistantMessage) => {
+const tokenTotal = (msg: AppAssistantMessage) => {
   return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
 }
 
-const lastAssistantWithTokens = (messages: Message[]) => {
+const lastAssistantWithTokens = (messages: AppMessage[]) => {
   for (let i = messages.length - 1; i >= 0; i--) {
     const msg = messages[i]
     if (msg.role !== "assistant") continue
@@ -38,7 +38,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
   }
 }
 
-const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
+const build = (messages: AppMessage[] = [], providers: Provider[] = []): Context | undefined => {
   const message = lastAssistantWithTokens(messages)
   if (!message) return undefined
 
@@ -60,6 +60,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
   }
 }
 
-export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
+export function getSessionContext(messages: AppMessage[] = [], providers: Provider[] = []) {
   return build(messages, providers)
 }

+ 1 - 1
packages/app/src/components/session/session-context-tab.tsx

@@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
 import { File } from "@opencode-ai/session-ui/file"
 import { Markdown } from "@opencode-ai/session-ui/markdown"
 import { ScrollView } from "@opencode-ai/ui/scroll-view"
-import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
+import type { AppMessage as Message, AppPart as Part, AppUserMessage as UserMessage } from "@/context/backend"
 import { useLanguage } from "@/context/language"
 import { useProviders } from "@/hooks/use-providers"
 import { useSDK } from "@/context/sdk"

+ 5 - 5
packages/app/src/components/settings-general.tsx

@@ -126,11 +126,11 @@ export const SettingsGeneral: Component = () => {
   const serverSdk = useServerSDK()
 
   const [shells] = createResource(
-    () =>
-      serverSdk()
-        .client.pty.shells()
-        .then((res) => res.data ?? [])
-        .catch(() => [] as ShellOption[]),
+    async () => {
+      const capability = (await serverSdk().backend).capabilities.shellDiscovery
+      if (!capability) return []
+      return capability.list().catch(() => [] as ShellOption[])
+    },
     { initialValue: [] as ShellOption[] },
   )
 

+ 35 - 16
packages/app/src/components/settings-providers.tsx

@@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "./dialog-co
 import { DialogCustomProvider } from "./dialog-custom-provider"
 import { SettingsList } from "./settings-list"
 import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
+import { credentialConnectionIDs } from "@/context/backend"
 
 type ProviderSource = "env" | "api" | "config" | "custom"
 type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -96,12 +97,12 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
   }
 
   const disableProvider = async (providerID: string, name: string) => {
-    const before = serverSync().data.config.disabled_providers ?? []
+    const before = serverSync().data.config.disabledProviders ?? []
     const next = before.includes(providerID) ? before : [...before, providerID]
-    serverSync().set("config", "disabled_providers", next)
+    serverSync().set("config", "disabledProviders", next)
 
     await serverSync()
-      .updateConfig({ disabled_providers: next })
+      .updateConfig({ disabledProviders: next })
       .then(() => {
         showToast({
           variant: "success",
@@ -111,29 +112,47 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
         })
       })
       .catch((err: unknown) => {
-        serverSync().set("config", "disabled_providers", before)
+        serverSync().set("config", "disabledProviders", before)
         const message = err instanceof Error ? err.message : String(err)
         showToast({ title: language.t("common.requestFailed"), description: message })
       })
   }
 
-  const disconnect = async (providerID: string, name: string) => {
-    if (isConfigCustom(providerID)) {
-      await serverSDK()
-        .client.auth.remove({ providerID })
-        .catch(() => undefined)
-      await disableProvider(providerID, name)
+  const disconnect = async (item: ProviderItem) => {
+    const backend = await serverSDK().backend
+    const remove = async () => {
+      if (backend.version === "v1") {
+        const capability = backend.capabilities.providerAuthV1
+        if (!capability) throw new Error("Server does not support provider authentication")
+        await capability.remove({ providerID: item.id })
+        return
+      }
+      const capability = backend.capabilities.integrationsV2
+      if (!capability) throw new Error("Server does not support provider integrations")
+      const integrationID =
+        "integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
+      const integration = await capability.get({ integrationID })
+      await Promise.all(
+        credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
+          capability.removeCredential({ credentialID }),
+        ),
+      )
+    }
+
+    if (isConfigCustom(item.id)) {
+      await remove().catch(() => undefined)
+      await disableProvider(item.id, item.name)
       return
     }
-    await serverSDK()
-      .client.auth.remove({ providerID })
+    await remove()
       .then(async () => {
-        await serverSDK().client.global.dispose()
+        if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
+        if (backend.version === "v2") await serverSync().refreshProviders()
         showToast({
           variant: "success",
           icon: "circle-check",
-          title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
-          description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
+          title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
+          description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
         })
       })
       .catch((err: unknown) => {
@@ -179,7 +198,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
                         </span>
                       }
                     >
-                      <Button size="large" variant="ghost" onClick={() => void disconnect(item.id, item.name)}>
+                      <Button size="large" variant="ghost" onClick={() => void disconnect(item)}>
                         {language.t("common.disconnect")}
                       </Button>
                     </Show>

+ 5 - 5
packages/app/src/components/settings-v2/general.tsx

@@ -121,11 +121,11 @@ export const SettingsGeneralV2: Component<{
   const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
   const [shells] = createResource(
-    () =>
-      serverSdk()
-        .client.pty.shells()
-        .then((res) => res.data ?? [])
-        .catch(() => [] as ShellOption[]),
+    async () => {
+      const capability = (await serverSdk().backend).capabilities.shellDiscovery
+      if (!capability) return []
+      return capability.list().catch(() => [] as ShellOption[])
+    },
     { initialValue: [] as ShellOption[] },
   )
 

+ 35 - 16
packages/app/src/components/settings-v2/providers.tsx

@@ -12,6 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "../dialog-c
 import { DialogCustomProvider } from "../dialog-custom-provider"
 import { SettingsListV2 } from "./parts/list"
 import "./settings-v2.css"
+import { credentialConnectionIDs } from "@/context/backend"
 
 type ProviderSource = "env" | "api" | "config" | "custom"
 type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -90,12 +91,12 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
   }
 
   const disableProvider = async (providerID: string, name: string) => {
-    const before = serverSync().data.config.disabled_providers ?? []
+    const before = serverSync().data.config.disabledProviders ?? []
     const next = before.includes(providerID) ? before : [...before, providerID]
-    serverSync().set("config", "disabled_providers", next)
+    serverSync().set("config", "disabledProviders", next)
 
     await serverSync()
-      .updateConfig({ disabled_providers: next })
+      .updateConfig({ disabledProviders: next })
       .then(() => {
         showToast({
           variant: "success",
@@ -105,29 +106,47 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
         })
       })
       .catch((err: unknown) => {
-        serverSync().set("config", "disabled_providers", before)
+        serverSync().set("config", "disabledProviders", before)
         const message = err instanceof Error ? err.message : String(err)
         showToast({ title: language.t("common.requestFailed"), description: message })
       })
   }
 
-  const disconnect = async (providerID: string, name: string) => {
-    if (isConfigCustom(providerID)) {
-      await serverSdk()
-        .client.auth.remove({ providerID })
-        .catch(() => undefined)
-      await disableProvider(providerID, name)
+  const disconnect = async (item: ProviderItem) => {
+    const backend = await serverSdk().backend
+    const remove = async () => {
+      if (backend.version === "v1") {
+        const capability = backend.capabilities.providerAuthV1
+        if (!capability) throw new Error("Server does not support provider authentication")
+        await capability.remove({ providerID: item.id })
+        return
+      }
+      const capability = backend.capabilities.integrationsV2
+      if (!capability) throw new Error("Server does not support provider integrations")
+      const integrationID =
+        "integrationID" in item && typeof item.integrationID === "string" ? item.integrationID : item.id
+      const integration = await capability.get({ integrationID })
+      await Promise.all(
+        credentialConnectionIDs(integration?.connections ?? []).map((credentialID) =>
+          capability.removeCredential({ credentialID }),
+        ),
+      )
+    }
+
+    if (isConfigCustom(item.id)) {
+      await remove().catch(() => undefined)
+      await disableProvider(item.id, item.name)
       return
     }
-    await serverSdk()
-      .client.auth.remove({ providerID })
+    await remove()
       .then(async () => {
-        await serverSdk().client.global.dispose()
+        if (backend.version === "v1") await backend.capabilities.runtimeV1?.disposeAll()
+        if (backend.version === "v2") await serverSync().refreshProviders()
         showToast({
           variant: "success",
           icon: "circle-check",
-          title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
-          description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
+          title: language.t("provider.disconnect.toast.disconnected.title", { provider: item.name }),
+          description: language.t("provider.disconnect.toast.disconnected.description", { provider: item.name }),
         })
       })
       .catch((err: unknown) => {
@@ -175,7 +194,7 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) =
                         </span>
                       }
                     >
-                      <ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
+                      <ButtonV2 size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
                         {language.t("common.disconnect")}
                       </ButtonV2>
                     </Show>

+ 2 - 2
packages/app/src/components/settings-v2/servers.tsx

@@ -110,8 +110,8 @@ export const SettingsServersV2: Component = () => {
                       <div class="settings-v2-servers-copy">
                         <span class="settings-v2-servers-name">{serverName(item)}</span>
                         <span class="settings-v2-servers-meta">
-                          <Show when={health()?.version}>v{health()?.version}</Show>
-                          <Show when={health()?.version && item.type === "http"}> • </Show>
+                          <Show when={health()?.installationVersion}>v{health()?.installationVersion}</Show>
+                          <Show when={health()?.installationVersion && item.type === "http"}> • </Show>
                           <Show
                             when={item.type === "http" && item.http.username}
                             fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}

+ 29 - 42
packages/app/src/components/terminal.tsx

@@ -11,12 +11,10 @@ import { matchKeybind, parseKeybind } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSDK } from "@/context/sdk"
-import { useServerSDK } from "@/context/server-sdk"
 import { terminalFontFamily, useSettings } from "@/context/settings"
 import type { LocalPTY } from "@/context/terminal"
 import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
 import { terminalWriter } from "@/utils/terminal-writer"
-import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
 
 const TOGGLE_TERMINAL_ID = "terminal.toggle"
 const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -174,16 +172,8 @@ export const Terminal = (props: TerminalProps) => {
   const settings = useSettings()
   const theme = useTheme()
   const language = useLanguage()
-  // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session.
-  const connection = useServerSDK()().server
   const directory = sdk().directory
-  const client = sdk().client
-  const url = sdk().url
-  const auth = connection.http
-  const username = auth?.username ?? "opencode"
-  const password = auth?.password ?? ""
-  const authToken = connection.type === "http" ? connection.authToken : false
-  const sameOrigin = new URL(url, location.href).origin === location.origin
+  const backend = sdk().backend
   let container!: HTMLDivElement
   const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
   const id = local.pty.id
@@ -233,11 +223,14 @@ export const Terminal = (props: TerminalProps) => {
   }
 
   const pushSize = (cols: number, rows: number) => {
-    return client.pty
-      .update({
-        ptyID: id,
-        size: { cols, rows },
-      })
+    return backend
+      .then((client) =>
+        client.common.pty.update({
+          ptyID: id,
+          size: { cols, rows },
+          location: { directory },
+        }),
+      )
       .catch((err) => {
         debugTerminal("failed to sync terminal size", err)
       })
@@ -490,33 +483,32 @@ export const Terminal = (props: TerminalProps) => {
       }
 
       const gone = () =>
-        client.pty
-          .get({ ptyID: id }, { throwOnError: false })
-          .then((result) => result.response.status === 404)
+        backend
+          .then((client) => {
+            const transport = client.capabilities.ptyTransport
+            if (transport) return transport.exists({ ptyID: id, location: { directory } }).then((exists) => !exists)
+            return client.common.pty.get({ ptyID: id, location: { directory } }).then(() => false)
+          })
           .catch((err) => {
             debugTerminal("failed to inspect terminal session", err)
             return false
           })
 
       const connectToken = async () => {
-        const result = await client.pty
-          .connectToken(
-            { ptyID: id, directory },
-            {
-              throwOnError: false,
-              headers: { "x-opencode-ticket": "1" },
-            },
-          )
+        const transport = (await backend).capabilities.ptyTransport
+        if (!transport) return
+        const result = await transport
+          .connectToken({ ptyID: id, location: { directory } })
           .catch((err: unknown) => {
             if (err instanceof Error && err.message.includes("Request is not supported")) return
             throw err
           })
         if (!result) return
-        if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
-        if (result.response.status === 404 || result.response.status === 405) return
-        if (result.response.status === 403)
+        if (result.status === 200 && result.ticket) return result.ticket
+        if (result.status === 404 || result.status === 405) return
+        if (result.status === 403)
           throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
-        throw new Error(`PTY connect ticket failed with ${result.response.status}`)
+        throw new Error(`PTY connect ticket failed with ${result.status}`)
       }
 
       const retry = (err: unknown) => {
@@ -549,18 +541,13 @@ export const Terminal = (props: TerminalProps) => {
         if (once.value) return
         if (disposed) return
 
+        const transport = (await backend).capabilities.ptyTransport
+        if (!transport) {
+          fail(new Error("PTY transport is not supported by this server"))
+          return
+        }
         const socket = new WebSocket(
-          terminalWebSocketURL({
-            url,
-            id,
-            directory,
-            cursor: seek,
-            ticket,
-            sameOrigin,
-            username,
-            password,
-            authToken,
-          }),
+          transport.connectURL({ ptyID: id, location: { directory }, cursor: seek, ticket }),
         )
         socket.binaryType = "arraybuffer"
         ws = socket

+ 6 - 4
packages/app/src/components/titlebar-tab-nav.tsx

@@ -9,7 +9,7 @@ import { ServerConnection, serverName } from "@/context/server"
 import { displayName, projectForSession } from "@/pages/layout/helpers"
 import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
 import { showToast } from "@/utils/toast"
-import type { Session } from "@opencode-ai/sdk/v2"
+import type { AppSession } from "@/context/backend"
 import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
 import { TabPreviewPopover } from "./titlebar-tab-popover"
 import "./titlebar-tab-nav.css"
@@ -21,7 +21,7 @@ export function TabNavItem(props: {
   ref?: Ref<HTMLDivElement>
   href: string
   server: ServerConnection.Key
-  session: () => Session | undefined
+  session: () => AppSession | undefined
   fallbackTitle?: string
   onTitleChange?: (title: string) => void
   onTitleChangeFailed?: (title: string) => void
@@ -120,8 +120,10 @@ export function TabNavItem(props: {
     const ctx = serverCtx()
     const session = props.session()
     if (!ctx || !session) return
-    const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true })
-    await client.session.update({ sessionID: session.id, title })
+    const client = await ctx.sdk.backend
+    const capability = client.capabilities.sessionActionsV1
+    if (!capability) throw new Error("Session renaming is not supported by this server")
+    await capability.rename({ location: { directory: session.directory }, sessionID: session.id, title })
   }
 
   const closeRename = async (save: boolean) => {

+ 2 - 3
packages/app/src/components/titlebar.tsx

@@ -267,9 +267,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
                 return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
               },
               ({ route, sdk }) =>
-                sdk.client.session
-                  .get({ sessionID: route.sessionId })
-                  .then((x) => x.data)
+                sdk.backend
+                  .then((client) => client.common.sessions.get({ sessionID: route.sessionId }))
                   .catch(() => {}),
             )
 

+ 70 - 0
packages/app/src/context/backend-client.test.ts

@@ -0,0 +1,70 @@
+import { describe, expect, test } from "bun:test"
+import type { ServerHealth } from "@/utils/server-health"
+import { backendIdentity, createBackendForServer } from "./backend-client"
+
+const server = {
+  type: "http" as const,
+  http: {
+    url: "http://localhost:4096",
+    username: "user",
+    password: "secret",
+  },
+  authToken: false,
+}
+
+function setup(health: ServerHealth) {
+  const requests: Request[] = []
+  const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+    const request = input instanceof Request ? input : new Request(input, init)
+    requests.push(request)
+    if (new URL(request.url).pathname === "/project")
+      return new Response("[]", { headers: { "content-type": "application/json" } })
+    return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
+      headers: { "content-type": "application/json" },
+    })
+  }) as typeof globalThis.fetch
+  return {
+    requests,
+    fetch,
+    backend: createBackendForServer({ server, browserUrl: "https://app.example.test", fetch, health: Promise.resolve(health) }),
+  }
+}
+
+describe("createBackendForServer", () => {
+  test("changes backend identity when credentials for the same URL change", () => {
+    expect(backendIdentity(server)).not.toBe(
+      backendIdentity({ ...server, http: { ...server.http, password: "replacement" } }),
+    )
+  })
+
+  test("selects v2 and configures fetch and authentication", async () => {
+    const result = setup({ healthy: true, version: "v2" })
+    const backend = await result.backend
+
+    expect(backend.version).toBe("v2")
+    expect(result.requests).toHaveLength(0)
+    await backend.common.health.get()
+    expect(new URL(result.requests[0].url).pathname).toBe("/api/health")
+    expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
+
+    expect(backend.capabilities.projectList).toBeUndefined()
+    expect(backend.capabilities.vcs).toBeUndefined()
+    expect(backend.capabilities.mcp).toBeUndefined()
+    expect(result.requests).toHaveLength(1)
+    expect(backend.version).toBe("v2")
+  })
+
+  test("selects v1 after fallback detection", async () => {
+    const result = setup({ healthy: true, version: "v1", installationVersion: "1.2.3" })
+    const backend = await result.backend
+
+    expect(backend.version).toBe("v1")
+    expect(result.requests).toHaveLength(0)
+    await backend.common.health.get()
+    expect(new URL(result.requests[0].url).pathname).toBe("/global/health")
+    expect(result.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
+    expect(backend.capabilities.projectList).toBeDefined()
+    expect(backend.capabilities.vcs).toBeDefined()
+    expect(backend.capabilities.mcp).toBeDefined()
+  })
+})

+ 64 - 0
packages/app/src/context/backend-client.ts

@@ -0,0 +1,64 @@
+import { OpenCode } from "@opencode-ai/client"
+import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
+import type { ServerHealth } from "@/utils/server-health"
+import { authTokenFromCredentials } from "@/utils/server"
+import type { ServerConnection } from "./server"
+import type { LocationRef } from "./backend"
+import { createV1Backend } from "./backend-v1"
+import { createV2Backend } from "./backend-v2"
+
+function options(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
+  return {
+    baseUrl: server.url,
+    fetch,
+    headers: server.password
+      ? {
+          Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
+        }
+      : undefined,
+  }
+}
+
+export function createV1RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
+  return createOpencodeClient(options(server, fetch))
+}
+
+export function createV2RawClient(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch) {
+  return OpenCode.make(options(server, fetch))
+}
+
+export function backendIdentity(server: ServerConnection.Any) {
+  return `${server.type}\n${server.http.url}\n${server.http.username ?? ""}\n${server.http.password ?? ""}\n${
+    server.type === "http" && server.authToken === true ? "token" : ""
+  }`
+}
+
+export async function createBackendForServer(input: {
+  server: ServerConnection.Any
+  browserUrl: string
+  fetch: typeof globalThis.fetch
+  eventFetch?: typeof globalThis.fetch
+  health: Promise<ServerHealth>
+  defaultLocation?: LocationRef
+}) {
+  const health = await input.health
+  const eventFetch = input.eventFetch ?? input.fetch
+  const transport = {
+    baseUrl: input.server.http.url,
+    fetch: input.fetch,
+    username: input.server.http.username,
+    password: input.server.http.password,
+    sameOrigin: new URL(input.server.http.url, input.browserUrl).origin === new URL(input.browserUrl).origin,
+    authToken: input.server.type === "http" && input.server.authToken === true,
+  }
+  if (health.version === "v2")
+    return createV2Backend(
+      createV2RawClient(input.server.http, input.fetch),
+      transport,
+      input.defaultLocation,
+      createV2RawClient(input.server.http, eventFetch),
+    )
+  const legacy = createV1RawClient(input.server.http, input.fetch)
+  const eventLegacy = eventFetch === input.fetch ? legacy : createV1RawClient(input.server.http, eventFetch)
+  return createV1Backend(legacy, input.defaultLocation, eventLegacy, transport)
+}

+ 270 - 3
packages/app/src/context/backend-v1.test.ts

@@ -1,8 +1,13 @@
 import { describe, expect, test } from "bun:test"
 import { createOpencodeClient } from "@opencode-ai/sdk-v1/v2/client"
+import type { PtyTransportConfig } from "./backend"
 import { createV1Backend } from "./backend-v1"
 
-function setup(respond: (request: Request) => Response | Promise<Response>) {
+function setup(
+  respond: (request: Request) => Response | Promise<Response>,
+  withDefault = false,
+  transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken">>,
+) {
   const requests: Request[] = []
   const fetch = Object.assign(
     async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -12,9 +17,22 @@ function setup(respond: (request: Request) => Response | Promise<Response>) {
     },
     { preconnect: globalThis.fetch.preconnect },
   ) satisfies typeof globalThis.fetch
+  const client = createOpencodeClient({ baseUrl: "http://localhost", fetch })
   return {
     requests,
-    backend: createV1Backend(createOpencodeClient({ baseUrl: "http://localhost", fetch })),
+    backend: createV1Backend(
+      client,
+      withDefault ? { directory: "/default", workspaceID: "default-workspace" } : undefined,
+      client,
+      {
+        baseUrl: "http://localhost",
+        fetch,
+        username: "user",
+        password: "secret",
+        sameOrigin: transport?.sameOrigin ?? false,
+        authToken: transport?.authToken ?? false,
+      },
+    ),
   }
 }
 
@@ -35,6 +53,21 @@ const session = {
 }
 
 describe("createV1Backend", () => {
+  test("preserves location in OAuth callbacks", async () => {
+    const setupResult = setup(() => json({}))
+
+    await setupResult.backend.capabilities.providerAuthV1?.callback({
+      providerID: "provider",
+      method: 1,
+      code: "code",
+      location: { directory: "/repo", workspaceID: "workspace" },
+    })
+
+    const url = new URL(setupResult.requests[0].url)
+    expect(url.searchParams.get("directory")).toBe("/repo")
+    expect(url.searchParams.get("workspace")).toBe("workspace")
+  })
+
   test("normalizes session pagination and location", async () => {
     const setupResult = setup(() => json([session], { "x-next-cursor": "456" }))
 
@@ -49,9 +82,13 @@ describe("createV1Backend", () => {
       items: [
         {
           id: "ses_1",
+          slug: "one",
+          version: "1",
           parentID: undefined,
           projectID: "project",
           location: { directory: "/repo", workspaceID: undefined },
+          directory: "/repo",
+          workspaceID: undefined,
           title: "Session",
           cost: 0,
           tokens: undefined,
@@ -60,7 +97,7 @@ describe("createV1Backend", () => {
           revert: undefined,
         },
       ],
-      next: "456",
+      older: "456",
     })
     const url = new URL(setupResult.requests[0].url)
     expect(url.pathname).toBe("/experimental/session")
@@ -75,6 +112,7 @@ describe("createV1Backend", () => {
 
     await setupResult.backend.common.sessions.prompt({
       sessionID: "ses_1",
+      location: { directory: "/explicit", workspaceID: "explicit-workspace" },
       id: "msg_1",
       text: "hello",
       selection: {
@@ -87,6 +125,8 @@ describe("createV1Backend", () => {
 
     const request = setupResult.requests[0]
     expect(new URL(request.url).pathname).toBe("/session/ses_1/prompt_async")
+    expect(new URL(request.url).searchParams.get("directory")).toBe("/explicit")
+    expect(new URL(request.url).searchParams.get("workspace")).toBe("explicit-workspace")
     expect(await request.json()).toEqual({
       messageID: "msg_1",
       model: { providerID: "provider", modelID: "model" },
@@ -100,6 +140,29 @@ describe("createV1Backend", () => {
     })
   })
 
+  test("preserves ordered prompt part IDs and metadata", async () => {
+    const setupResult = setup(() => new Response(null, { status: 204 }))
+
+    await setupResult.backend.common.sessions.prompt({
+      sessionID: "ses_1",
+      id: "msg_1",
+      text: "visible",
+      parts: [
+        { id: "part_text", type: "text", text: "visible" },
+        { id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
+        { id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
+        { id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
+      ],
+    })
+
+    expect((await setupResult.requests[0].json()).parts).toEqual([
+      { id: "part_text", type: "text", text: "visible" },
+      { id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
+      { id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
+      { id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
+    ])
+  })
+
   test("combines mixed file search and decodes binary content", async () => {
     const setupResult = setup((request) => {
       const url = new URL(request.url)
@@ -122,6 +185,139 @@ describe("createV1Backend", () => {
     expect(content.mimeType).toBe("application/octet-stream")
   })
 
+  test("preserves project, provider, and file metadata", async () => {
+    const setupResult = setup((request) => {
+      const path = new URL(request.url).pathname
+      if (path === "/project")
+        return json([
+          { id: "project", worktree: "/repo", vcs: "git", time: { created: 1, initialized: 2 }, sandboxes: [] },
+        ])
+      if (path === "/provider")
+        return json({
+          all: [{ id: "provider", name: "Provider", source: "config", env: [], options: {}, models: {} }],
+          connected: [],
+          default: {},
+        })
+      return json([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
+    })
+
+    const projectList = setupResult.backend.capabilities.projectList
+    if (!projectList) throw new Error("Missing project list capability")
+    const projects = await projectList.list()
+    const providers = await setupResult.backend.common.catalog.providers()
+    const files = await setupResult.backend.common.files.list({})
+
+    expect(projects[0]).toMatchObject({ vcs: "git", time: { created: 1, initialized: 2 } })
+    expect(providers.providers.get("provider")?.source).toBe("config")
+    expect(files).toEqual([{ name: "a.txt", path: "a.txt", absolute: "/repo/a.txt", type: "file", ignored: false }])
+  })
+
+  test("uses explicit session locations and preserves mutation confirmations", async () => {
+    const setupResult = setup((request) => {
+      const path = new URL(request.url).pathname
+      if (request.method === "DELETE" || path.startsWith("/experimental/worktree")) return json(true)
+      if (request.method === "GET" && path === "/session/ses_1/message") return json([], { "x-next-cursor": "older" })
+      return json(session)
+    }, true)
+
+    const history = await setupResult.backend.common.sessions.history({
+      sessionID: "ses_1",
+      location: { directory: "/explicit", workspaceID: "explicit-workspace" },
+    })
+    const removed = await setupResult.backend.capabilities.sessionActionsV1?.remove({ sessionID: "ses_1" })
+    const reverted = await setupResult.backend.capabilities.sessionExtrasV1?.revert({
+      sessionID: "ses_1",
+      messageID: "msg_1",
+    })
+    const cleared = await setupResult.backend.capabilities.sessionExtrasV1?.clearRevert({ sessionID: "ses_1" })
+    const worktreeRemoved = await setupResult.backend.capabilities.worktreesV1?.remove({ directory: "/copy" })
+    const worktreeReset = await setupResult.backend.capabilities.worktreesV1?.reset({ directory: "/copy" })
+
+    expect(history.older).toBe("older")
+    expect(removed).toBe(true)
+    expect(reverted?.id).toBe("ses_1")
+    expect(cleared?.id).toBe("ses_1")
+    expect(worktreeRemoved).toBe(true)
+    expect(worktreeReset).toBe(true)
+    const urls = setupResult.requests.map((request) => new URL(request.url))
+    expect(urls[0].searchParams.get("directory")).toBe("/explicit")
+    expect(urls[1].searchParams.get("directory")).toBe("/default")
+  })
+
+  test("normalizes idle and compatibility events", async () => {
+    const events = [
+      { type: "session.status", properties: { sessionID: "ses_1", status: { type: "idle" } } },
+      {
+        type: "todo.updated",
+        properties: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
+      },
+      {
+        type: "message.part.delta",
+        properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "hi" },
+      },
+      { type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
+      { type: "worktree.ready", properties: { name: "copy", branch: "copy" } },
+      { type: "lsp.updated", properties: {} },
+      { type: "reference.updated", properties: {} },
+      { type: "mcp.tools.changed", properties: { server: "docs" } },
+      { type: "server.instance.disposed", properties: { directory: "/repo" } },
+    ]
+    const setupResult = setup(
+      () =>
+        new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), {
+          headers: { "content-type": "text/event-stream" },
+        }),
+    )
+    const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
+    const result = await Promise.all(events.map(() => iterator.next()))
+
+    expect(result.map((item) => item.value?.event.type)).toEqual([
+      "session.activity",
+      "todo.updated",
+      "timeline.delta",
+      "timeline.part.removed",
+      "worktree.ready",
+      "lsp.updated",
+      "reference.updated",
+      "mcp.updated",
+      "instance.disposed",
+    ])
+    expect(result[0].value?.event).toEqual({ type: "session.activity", sessionID: "ses_1", activity: { type: "idle" } })
+    expect(result[1].value?.event).toMatchObject({ todos: [{ priority: "high" }] })
+  })
+
+  test("updates the V1 projection cache for deltas and removals", async () => {
+    const info = {
+      id: "msg_1",
+      sessionID: "ses_1",
+      role: "user",
+      time: { created: 1 },
+      agent: "build",
+      model: { providerID: "p", modelID: "m" },
+    }
+    const part = { id: "part_1", sessionID: "ses_1", messageID: "msg_1", type: "text", text: "a" }
+    const events = [
+      { type: "message.updated", properties: { info } },
+      { type: "message.part.updated", properties: { sessionID: "ses_1", part } },
+      { type: "message.part.delta", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1", field: "text", delta: "b" } },
+      { type: "message.updated", properties: { info } },
+      { type: "message.part.removed", properties: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" } },
+      { type: "message.updated", properties: { info } },
+    ]
+    const setupResult = setup(() => new Response(events.map((payload) => `data: ${JSON.stringify({ directory: "/repo", payload })}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
+    const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
+    await iterator.next()
+    await iterator.next()
+    await iterator.next()
+    const afterDelta = await iterator.next()
+    await iterator.next()
+    const afterRemoval = await iterator.next()
+
+    expect(afterDelta.value?.event).toMatchObject({ item: { content: [{ id: "part_1", text: "ab" }] } })
+    expect(afterRemoval.value?.event).toMatchObject({ item: { content: [] } })
+    expect(setupResult.requests).toHaveLength(1)
+  })
+
   test("merges global config updates with untouched fields", async () => {
     const bodies: unknown[] = []
     const setupResult = setup(async (request) => {
@@ -137,4 +333,75 @@ describe("createV1Backend", () => {
 
     expect(bodies).toEqual([{ autoupdate: true, model: "new", disabled_providers: ["two"] }])
   })
+
+  test("uses legacy PTY endpoints, location queries, status, tickets, and auth fallback", async () => {
+    const setupResult = setup((request) => {
+      const path = new URL(request.url).pathname
+      if (path.endsWith("/connect-token")) return new Response(null, { status: 405 })
+      if (request.method === "GET") return new Response(null, { status: 404 })
+      return new Response(null, { status: 204 })
+    })
+
+    await setupResult.backend.common.permissions.reply({
+      sessionID: "ses_1",
+      requestID: "per_1",
+      reply: "once",
+      location: { directory: "/explicit", workspaceID: "workspace" },
+    })
+    await setupResult.backend.common.questions.reject({
+      sessionID: "ses_1",
+      requestID: "que_1",
+      location: { directory: "/explicit", workspaceID: "workspace" },
+    })
+    const transport = setupResult.backend.capabilities.ptyTransport
+    const ticket = await transport?.connectToken({
+      ptyID: "pty_1",
+      location: { directory: "/explicit", workspaceID: "workspace" },
+    })
+    const exists = await transport?.exists({
+      ptyID: "pty_1",
+      location: { directory: "/explicit", workspaceID: "workspace" },
+    })
+
+    expect(ticket).toEqual({ status: 405, ticket: undefined })
+    expect(exists).toBe(false)
+    expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("directory"))).toEqual([
+      "/explicit",
+      "/explicit",
+      "/explicit",
+      "/explicit",
+    ])
+    expect(setupResult.requests[2].headers.get("x-opencode-ticket")).toBe("1")
+
+    const fallback = transport?.connectURL({
+      ptyID: "pty/1",
+      location: { directory: "/explicit", workspaceID: "workspace" },
+      cursor: 12,
+    })
+    expect(fallback?.pathname).toBe("/pty/pty%2F1/connect")
+    expect(fallback?.protocol).toBe("ws:")
+    expect(fallback?.searchParams.get("directory")).toBe("/explicit")
+    expect(fallback?.searchParams.get("workspace")).toBe("workspace")
+    expect(fallback?.searchParams.get("cursor")).toBe("12")
+    expect(fallback?.searchParams.get("auth_token")).toBe(btoa("user:secret"))
+
+    const ticketURL = transport?.connectURL({
+      ptyID: "pty_1",
+      location: { directory: "/explicit" },
+      cursor: -1,
+      ticket: "ticket value",
+    })
+    expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
+    expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
+  })
+
+  test("preserves same-origin auth-token policy for PTY URLs", () => {
+    const saved = setup(() => new Response(), false, { sameOrigin: true }).backend.capabilities.ptyTransport
+    const token = setup(() => new Response(), false, { sameOrigin: true, authToken: true }).backend.capabilities
+      .ptyTransport
+    const input = { ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 }
+
+    expect(saved?.connectURL(input).searchParams.has("auth_token")).toBe(false)
+    expect(token?.connectURL(input).searchParams.get("auth_token")).toBe(btoa("user:secret"))
+  })
 })

+ 363 - 144
packages/app/src/context/backend-v1.ts

@@ -34,6 +34,7 @@ import type {
   Page,
   PromptFile,
   PromptInput,
+  PtyTransportConfig,
   ProviderCatalog,
   RequestOptions,
   SessionActivity,
@@ -47,7 +48,12 @@ type CachedMessage = {
   parts: Part[]
 }
 
-export function createV1Backend(client: OpencodeClient, defaultLocation?: LocationRef): AppClient {
+export function createV1Backend(
+  client: OpencodeClient,
+  defaultLocation?: LocationRef,
+  eventClient: OpencodeClient = client,
+  transportConfig?: PtyTransportConfig,
+): AppClient {
   const messages = new Map<string, CachedMessage>()
 
   const options = (input?: RequestOptions) => ({ signal: input?.signal, throwOnError: true as const })
@@ -57,13 +63,58 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
     return toTimelineItem(input.info, input.parts)
   }
 
-  const loadMessage = async (input: { sessionID: string; messageID: string }, request?: RequestOptions) => {
+  const loadMessage = async (
+    input: LocationInput & { sessionID: string; messageID: string },
+    request?: RequestOptions,
+    force?: boolean,
+  ) => {
     const cached = messages.get(input.messageID)
-    if (cached) return cached
-    const result = await client.session.message({ ...input, ...legacyLocation(defaultLocation) }, options(request))
+    if (cached && !force) return cached
+    const result = await client.session.message(
+      { sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
+      options(request),
+    )
     messages.set(input.messageID, result.data)
     return result.data
   }
+  const sessionActions = {
+    remove: async (input: LocationInput & { sessionID: string }, request?: RequestOptions) => {
+      const result = await client.session.delete(
+        { sessionID: input.sessionID, ...location(input) },
+        options(request),
+      )
+      return result.data
+    },
+    fork: async (input: LocationInput & { sessionID: string; messageID?: string }, request?: RequestOptions) => {
+      const result = await client.session.fork(
+        { sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
+        options(request),
+      )
+      return toSession(result.data)
+    },
+    rename: async (input: LocationInput & { sessionID: string; title: string }, request?: RequestOptions) => {
+      await client.session.update(
+        { sessionID: input.sessionID, title: input.title, ...location(input) },
+        options(request),
+      )
+    },
+    command: async (input: CommandInput, request?: RequestOptions) => {
+      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),
+          ...location(input),
+        },
+        options(request),
+      )
+    },
+  }
 
   return {
     version: "v1",
@@ -75,10 +126,6 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
         },
       },
       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([
@@ -130,7 +177,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
           )
           return {
             items: result.data.map(toSession),
-            next: result.response.headers.get("x-next-cursor") ?? undefined,
+            older: result.response.headers.get("x-next-cursor") ?? undefined,
           }
         },
         create: async (input, request) => {
@@ -148,22 +195,6 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
           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))
         },
@@ -182,16 +213,16 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
               sessionID: input.sessionID,
               limit: input.limit,
               before: input.cursor,
-              ...legacyLocation(defaultLocation),
+              ...location(input),
             },
             options(request),
           )
           return {
             items: result.data.map(cache),
-            next: result.response.headers.get("x-next-cursor") ?? undefined,
+            older: result.response.headers.get("x-next-cursor") ?? undefined,
           }
         },
-        message: async (input, request) => cache(await loadMessage(input, request)),
+        message: async (input, request) => cache(await loadMessage(input, request, true)),
         prompt: async (input, request) => {
           await client.session.promptAsync(
             {
@@ -204,23 +235,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
               },
               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),
+              ...location(input),
             },
             options(request),
           )
@@ -229,7 +244,13 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
       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 }))
+          return result.data.map((item) => ({
+            path: item.path,
+            name: item.name,
+            absolute: item.absolute,
+            type: item.type,
+            ignored: false,
+          }))
         },
         find: async (input, request) => {
           const find = async (type: FileEntry["type"]) => {
@@ -259,7 +280,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
               requestID: input.requestID,
               reply: input.reply,
               message: input.message,
-              ...legacyLocation(defaultLocation),
+              ...location(input),
             },
             options(request),
           )
@@ -275,52 +296,13 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
             {
               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: [],
-          }
+        reject: async (input, request) => {
+          await client.question.reject({ requestID: input.requestID, ...location(input) }, options(request))
         },
       },
       pty: {
@@ -365,7 +347,7 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
       events: {
         subscribe: (request) => ({
           async *[Symbol.asyncIterator]() {
-            const result = await client.global.event(options(request))
+            const result = await eventClient.global.event(options(request))
             for await (const input of result.stream) {
               const event = await toEvent(input, messages, loadMessage)
               yield {
@@ -379,11 +361,51 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
           },
         }),
       },
-      disposeLocation: async (input, request) => {
-        await client.instance.dispose(location(input), options(request))
-      },
     },
     capabilities: {
+      projectList: {
+        list: async (request) => {
+          const result = await client.project.list(undefined, options(request))
+          return result.data.map(toProject)
+        },
+      },
+      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: [],
+          }
+        },
+      },
+      sessionActionsV1: sessionActions,
       configuration: {
         getGlobal: async (request) => {
           const result = await client.global.config.get(options(request))
@@ -468,57 +490,74 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
           return { directory: result.data.directory, branch: result.data.branch }
         },
         remove: async (input, request) => {
-          await client.worktree.remove(
+          const result = await client.worktree.remove(
             { ...location(input), worktreeRemoveInput: { directory: input.directory } },
             options(request),
           )
+          return result.data
         },
         reset: async (input, request) => {
-          await client.worktree.reset(
+          const result = await client.worktree.reset(
             { ...location(input), worktreeResetInput: { directory: input.directory } },
             options(request),
           )
+          return result.data
         },
       },
       sessionExtrasV1: {
-        archive: async (sessionID, archivedAt, request) => {
+        archive: async (input, request) => {
           await client.session.update(
-            { sessionID, time: { archived: archivedAt }, ...legacyLocation(defaultLocation) },
+            { sessionID: input.sessionID, time: { archived: input.archivedAt }, ...location(input) },
             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`)
+        share: async (input, request) => {
+          const result = await client.session.share(
+            { sessionID: input.sessionID, ...location(input) },
+            options(request),
+          )
+          if (!result.data.share) throw new Error(`Session ${input.sessionID} was shared without a URL`)
           return result.data.share.url
         },
-        unshare: async (sessionID, request) => {
-          await client.session.unshare({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
+        unshare: async (input, request) => {
+          await client.session.unshare({ sessionID: input.sessionID, ...location(input) }, options(request))
         },
-        diff: async (sessionID, request) => {
-          const result = await client.session.diff({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
+        diff: async (input, request) => {
+          const result = await client.session.diff({ sessionID: input.sessionID, ...location(input) }, 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 }))
+        todos: async (input, request) => {
+          const result = await client.session.todo({ sessionID: input.sessionID, ...location(input) }, options(request))
+          return result.data.map((item) => ({
+            content: item.content,
+            status: item.status,
+            priority: item.priority,
+          }))
         },
-        summarize: async (sessionID, model, request) => {
+        summarize: async (input, request) => {
           await client.session.summarize(
             {
-              sessionID,
-              providerID: model.providerID,
-              modelID: model.id,
-              ...legacyLocation(defaultLocation),
+              sessionID: input.sessionID,
+              providerID: input.model.providerID,
+              modelID: input.model.id,
+              ...location(input),
             },
             options(request),
           )
         },
-        revert: async (sessionID, messageID, request) => {
-          await client.session.revert({ sessionID, messageID, ...legacyLocation(defaultLocation) }, options(request))
+        revert: async (input, request) => {
+          const result = await client.session.revert(
+            { sessionID: input.sessionID, messageID: input.messageID, ...location(input) },
+            options(request),
+          )
+          return toSession(result.data)
         },
-        clearRevert: async (sessionID, request) => {
-          await client.session.unrevert({ sessionID, ...legacyLocation(defaultLocation) }, options(request))
+        clearRevert: async (input, request) => {
+          const result = await client.session.unrevert(
+            { sessionID: input.sessionID, ...location(input) },
+            options(request),
+          )
+          return toSession(result.data)
         },
         shell: async (input, request) => {
           await client.session.shell(
@@ -569,11 +608,26 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
           return toDecoratedFile(result.data)
         },
       },
-      ptyTransport: {
+      ptyTransport: transportConfig && {
         connectToken: async (input, request) => {
-          const result = await client.pty.connectToken({ ptyID: input.ptyID, ...location(input) }, options(request))
-          return { ticket: result.data.ticket }
+          const result = await client.pty.connectToken(
+            { ptyID: input.ptyID, ...location(input) },
+            {
+              signal: request?.signal,
+              throwOnError: false,
+              headers: { "x-opencode-ticket": "1" },
+            },
+          )
+          return { status: result.response.status, ticket: result.data?.ticket }
         },
+        exists: async (input, request) => {
+          const result = await client.pty.get(
+            { ptyID: input.ptyID, ...location(input) },
+            { signal: request?.signal, throwOnError: false },
+          )
+          return result.response.status !== 404
+        },
+        connectURL: (input) => ptyConnectURL(transportConfig, "/pty", input, legacyLocation(input.location)),
       },
       shellDiscovery: {
         list: async (input, request) => {
@@ -582,6 +636,9 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
         },
       },
       runtimeV1: {
+        disposeLocation: async (input, request) => {
+          await client.instance.dispose(location(input), options(request))
+        },
         disposeAll: async (request) => {
           await client.global.dispose(options(request))
         },
@@ -590,10 +647,31 @@ export function createV1Backend(client: OpencodeClient, defaultLocation?: Locati
   }
 }
 
+function ptyConnectURL(
+  config: PtyTransportConfig,
+  root: string,
+  input: { ptyID: string; cursor: number; ticket?: string },
+  location: { directory?: string; workspace?: string },
+) {
+  const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}${root}/${encodeURIComponent(input.ptyID)}/connect`)
+  if (location.directory) url.searchParams.set("directory", location.directory)
+  if (location.workspace) url.searchParams.set("workspace", location.workspace)
+  url.searchParams.set("cursor", String(input.cursor))
+  url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
+  if (input.ticket) {
+    url.searchParams.set("ticket", input.ticket)
+    return url
+  }
+  if (config.password && (!config.sameOrigin || config.authToken))
+    url.searchParams.set("auth_token", btoa(`${config.username ?? "opencode"}:${config.password}`))
+  return url
+}
+
 function legacyLocation(input?: LocationRef) {
+  if (!input) return {}
   return {
-    directory: input?.directory,
-    workspace: input?.workspaceID,
+    directory: input.directory,
+    workspace: input.workspaceID,
   }
 }
 
@@ -609,6 +687,8 @@ function toProject(input: Project): AppProject {
   return {
     id: input.id,
     worktree: input.worktree,
+    vcs: input.vcs,
+    time: { ...input.time, updated: input.time.updated ?? input.time.created },
     name: input.name,
     icon: input.icon,
     commands: input.commands,
@@ -619,9 +699,13 @@ function toProject(input: Project): AppProject {
 function toSession(input: Session): AppSession {
   return {
     id: input.id,
+    slug: input.slug,
+    version: input.version,
     parentID: input.parentID,
     projectID: input.projectID,
     location: { directory: input.directory, workspaceID: input.workspaceID },
+    directory: input.directory,
+    workspaceID: input.workspaceID,
     title: input.title,
     cost: input.cost ?? 0,
     tokens: input.tokens,
@@ -657,6 +741,7 @@ function toProvider(input: Provider): AppProvider {
   return {
     id: input.id,
     name: input.name,
+    source: input.source,
     models: Object.fromEntries(
       Object.entries(input.models).flatMap(([id, model]) =>
         model.status === "deprecated" ? [] : [[id, toModel(model)]],
@@ -714,8 +799,8 @@ function toReference(input: import("@opencode-ai/sdk-v1/v2/client").ReferenceInf
   return input
 }
 
-function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity | undefined {
-  if (input.type === "idle") return
+function toActivity(input: import("@opencode-ai/sdk-v1/v2/client").SessionStatus): SessionActivity {
+  if (input.type === "idle") return { type: "idle" }
   if (input.type === "busy") return { type: "running" }
   return input
 }
@@ -734,7 +819,10 @@ function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem {
         providerID: info.model.providerID,
         variant: info.model.variant,
       },
-      raw: { info, parts },
+      format: info.format,
+      summary: info.summary,
+      system: info.system,
+      tools: info.tools,
     }
   }
   return {
@@ -749,7 +837,12 @@ function toTimelineItem(info: Message, parts: readonly Part[]): TimelineItem {
     model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
     tokens: info.tokens,
     error: info.error,
-    raw: { info, parts },
+    mode: info.mode,
+    path: info.path,
+    cost: info.cost,
+    structured: info.structured,
+    finish: info.finish,
+    summary: info.summary,
   }
 }
 
@@ -763,9 +856,11 @@ function toTimelineContent(input: Part): TimelineContent[] {
         synthetic: input.synthetic,
         ignored: input.ignored,
         metadata: input.metadata,
+        time: input.time,
       },
     ]
-  if (input.type === "reasoning") return [{ type: input.type, id: input.id, text: input.text }]
+  if (input.type === "reasoning")
+    return [{ type: input.type, id: input.id, text: input.text, metadata: input.metadata, time: input.time }]
   if (input.type === "file")
     return [
       {
@@ -794,14 +889,52 @@ function toTimelineContent(input: Part): TimelineContent[] {
       },
     ]
   if (input.type === "tool")
-    return [{ type: input.type, id: input.id, callID: input.callID, tool: input.tool, state: toToolState(input.state) }]
+    return [
+      {
+        type: input.type,
+        id: input.id,
+        callID: input.callID,
+        tool: input.tool,
+        state: toToolState(input.state),
+        metadata: input.metadata,
+      },
+    ]
+  if (input.type === "subtask")
+    return [
+      {
+        type: input.type,
+        id: input.id,
+        prompt: input.prompt,
+        description: input.description,
+        agent: input.agent,
+        model: input.model && { id: input.model.modelID, providerID: input.model.providerID },
+        command: input.command,
+      },
+    ]
+  if (input.type === "step-start") return [{ type: input.type, id: input.id, snapshot: input.snapshot }]
+  if (input.type === "step-finish")
+    return [
+      {
+        type: input.type,
+        id: input.id,
+        reason: input.reason,
+        snapshot: input.snapshot,
+        cost: input.cost,
+        tokens: input.tokens,
+      },
+    ]
+  if (input.type === "snapshot") return [{ type: input.type, id: input.id, snapshot: input.snapshot }]
+  if (input.type === "patch") return [{ type: input.type, id: input.id, hash: input.hash, files: input.files }]
+  if (input.type === "retry")
+    return [{ type: input.type, id: input.id, attempt: input.attempt, error: input.error, time: input.time }]
+  if (input.type === "compaction") return [{ type: input.type, id: input.id, auto: input.auto }]
   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 }
+    return { status: input.status, input: input.input, title: input.title, metadata: input.metadata, time: input.time }
   if (input.status === "completed")
     return {
       status: input.status,
@@ -809,8 +942,10 @@ function toToolState(input: import("@opencode-ai/sdk-v1/v2/client").ToolState):
       output: input.output,
       title: input.title,
       metadata: input.metadata,
+      time: input.time,
+      attachments: input.attachments,
     }
-  return { status: input.status, input: input.input, error: input.error, metadata: input.metadata }
+  return { status: input.status, input: input.input, error: input.error, metadata: input.metadata, time: input.time }
 }
 
 function toFilePart(input: PromptFile) {
@@ -830,6 +965,7 @@ function toFilePart(input: PromptFile) {
 }
 
 function toPromptParts(input: PromptInput) {
+  if (input.parts) return input.parts.map(toPromptPart)
   return [
     { type: "text" as const, text: input.text },
     ...(input.files?.map(toFilePart) ?? []),
@@ -844,6 +980,11 @@ function toPromptParts(input: PromptInput) {
   ]
 }
 
+function toPromptPart(input: NonNullable<PromptInput["parts"]>[number]) {
+  if (input.type === "file" && input.source?.type === "resource") return { ...input, source: undefined }
+  return { ...input }
+}
+
 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 }
@@ -862,7 +1003,7 @@ function toDecoratedFile(input: import("@opencode-ai/sdk-v1/v2/client").FileCont
     diff: input.diff,
     encoding: input.encoding,
     mimeType: input.mimeType,
-    patch: input.patch && { hunks: input.patch.hunks.map((hunk) => ({ lines: hunk.lines })) },
+    patch: input.patch,
   }
 }
 
@@ -872,7 +1013,10 @@ function toPermission(input: import("@opencode-ai/sdk-v1/v2/client").PermissionR
     sessionID: input.sessionID,
     action: input.permission,
     resources: input.patterns,
-    metadata: input.metadata,
+    permission: input.permission,
+    patterns: input.patterns,
+    always: input.always,
+    metadata: input.metadata ?? {},
   }
 }
 
@@ -880,7 +1024,11 @@ function toQuestion(input: import("@opencode-ai/sdk-v1/v2/client").QuestionReque
   return {
     id: input.id,
     sessionID: input.sessionID,
-    questions: input.questions,
+    questions: input.questions.map((question) => ({
+      ...question,
+      header: question.header ?? "",
+      options: question.options.map((option) => ({ ...option, description: option.description ?? "" })),
+    })),
   }
 }
 
@@ -891,22 +1039,54 @@ function toPty(input: import("@opencode-ai/sdk-v1/v2/client").Pty) {
 async function toEvent(
   envelope: GlobalEvent,
   messages: Map<string, CachedMessage>,
-  loadMessage: (input: { sessionID: string; messageID: string }) => Promise<CachedMessage>,
+  loadMessage: (input: LocationInput & { sessionID: string; messageID: string }) => Promise<CachedMessage>,
 ): Promise<AppEvent> {
   const input = envelope.payload as Event
-  if (input.type === "server.connected") return { type: input.type }
+  const eventLocation =
+    envelope.directory === "global"
+      ? undefined
+      : { location: { directory: envelope.directory, workspaceID: envelope.workspace } }
+  if (input.type === "server.connected") {
+    messages.clear()
+    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 } }
+    return { type: "instance.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.deleted") {
+    for (const [messageID, message] of messages) {
+      if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID)
+    }
+    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 (activity.type === "idle") {
+      for (const [messageID, message] of messages) {
+        if (message.info.sessionID === input.properties.sessionID) messages.delete(messageID)
+      }
+    }
+    return { type: "session.activity", sessionID: input.properties.sessionID, activity }
   }
+  if (input.type === "session.diff")
+    return {
+      type: input.type,
+      sessionID: input.properties.sessionID,
+      diff: input.properties.diff.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])),
+    }
+  if (input.type === "todo.updated")
+    return {
+      type: input.type,
+      sessionID: input.properties.sessionID,
+      todos: input.properties.todos.map((todo) => ({
+        content: todo.content,
+        status: todo.status,
+        priority: todo.priority,
+      })),
+    }
   if (input.type === "session.error")
     return { type: input.type, sessionID: input.properties.sessionID, error: input.properties.error }
   if (input.type === "message.updated") {
@@ -917,7 +1097,7 @@ async function toEvent(
   }
   if (input.type === "message.part.updated") {
     const messageID = input.properties.part.messageID
-    const value = await loadMessage({ sessionID: input.properties.sessionID, messageID })
+    const value = await loadMessage({ sessionID: input.properties.sessionID, messageID, ...eventLocation })
     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)
@@ -928,10 +1108,40 @@ async function toEvent(
     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) }
+    const cached = messages.get(input.properties.messageID)
+    if (cached)
+      messages.set(input.properties.messageID, {
+        ...cached,
+        parts: cached.parts.filter((part) => part.id !== input.properties.partID),
+      })
+    return {
+      type: "timeline.part.removed",
+      sessionID: input.properties.sessionID,
+      itemID: input.properties.messageID,
+      contentID: input.properties.partID,
+    }
+  }
+  if (input.type === "message.part.delta") {
+    const cached = messages.get(input.properties.messageID)
+    const part = cached?.parts.find((part) => part.id === input.properties.partID)
+    if (cached && part) {
+      const current = part[input.properties.field as keyof Part]
+      if (typeof current === "string")
+        messages.set(input.properties.messageID, {
+          ...cached,
+          parts: cached.parts.map((item) =>
+            item.id === part.id ? { ...item, [input.properties.field]: current + input.properties.delta } : item,
+          ),
+        })
+    }
+    return {
+      type: "timeline.delta",
+      sessionID: input.properties.sessionID,
+      itemID: input.properties.messageID,
+      contentID: input.properties.partID,
+      field: input.properties.field,
+      delta: input.properties.delta,
+    }
   }
   if (input.type === "permission.asked")
     return { type: "permission.requested", request: toPermission(input.properties) }
@@ -942,8 +1152,11 @@ async function toEvent(
         id: input.properties.id,
         sessionID: input.properties.sessionID,
         action: input.properties.action,
-        resources: input.properties.resources,
-        metadata: input.properties.metadata,
+        resources: [...input.properties.resources],
+        permission: input.properties.action,
+        patterns: [...input.properties.resources],
+        always: [],
+        metadata: input.properties.metadata ?? {},
       },
     }
   if (input.type === "permission.replied" || input.type === "permission.v2.replied")
@@ -961,6 +1174,12 @@ async function toEvent(
   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 === "worktree.ready")
+    return { type: input.type, name: input.properties.name, branch: input.properties.branch }
+  if (input.type === "worktree.failed") return { type: input.type, message: input.properties.message }
+  if (input.type === "lsp.updated") return { type: input.type }
+  if (input.type === "reference.updated") return { type: input.type }
+  if (input.type === "mcp.tools.changed") return { type: "mcp.updated", server: input.properties.server }
   if (input.type === "pty.exited") return { type: input.type, ptyID: input.properties.id }
   return { type: "unknown", raw: input }
 }

+ 566 - 77
packages/app/src/context/backend-v2.test.ts

@@ -1,8 +1,12 @@
 import { describe, expect, test } from "bun:test"
 import { OpenCode } from "@opencode-ai/client"
+import { credentialConnectionIDs, type PtyTransportConfig } from "./backend"
 import { createV2Backend } from "./backend-v2"
 
-function setup(respond: (request: Request) => Response | Promise<Response>) {
+function setup(
+  respond: (request: Request) => Response | Promise<Response>,
+  transport?: Partial<Pick<PtyTransportConfig, "sameOrigin" | "authToken" | "password">>,
+) {
   const requests: Request[] = []
   const fetch = Object.assign(
     async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -12,12 +16,22 @@ function setup(respond: (request: Request) => Response | Promise<Response>) {
     },
     { preconnect: globalThis.fetch.preconnect },
   ) satisfies typeof globalThis.fetch
+  const client = OpenCode.make({ baseUrl: "http://localhost", fetch })
   return {
     requests,
-    backend: createV2Backend(OpenCode.make({ baseUrl: "http://localhost", fetch }), {
-      directory: "/default",
-      workspaceID: "default-workspace",
-    }),
+    backend: createV2Backend(
+      client,
+      {
+        baseUrl: "http://localhost",
+        fetch,
+        username: "user",
+        password: transport && "password" in transport ? transport.password : "secret",
+        sameOrigin: transport?.sameOrigin ?? false,
+        authToken: transport?.authToken ?? false,
+      },
+      { directory: "/default", workspaceID: "default-workspace" },
+      client,
+    ),
   }
 }
 
@@ -36,12 +50,45 @@ const session = {
 }
 
 describe("createV2Backend", () => {
+  test("uses no legacy endpoints for bootstrap and common reads", async () => {
+    const setupResult = setup((request) => {
+      const path = new URL(request.url).pathname
+      if (path === "/api/health") return json({ healthy: true, version: "v2" })
+      if (path === "/api/location")
+        return json({
+          directory: "/default",
+          workspaceID: "default-workspace",
+          project: { id: "project", directory: "/default" },
+        })
+      return json({ location: { directory: "/default" }, data: [] })
+    })
+
+    await Promise.all([
+      setupResult.backend.common.health.get(),
+      setupResult.backend.common.projects.current(),
+      setupResult.backend.common.catalog.providers(),
+      setupResult.backend.common.catalog.agents(),
+      setupResult.backend.common.commands.list(),
+      setupResult.backend.common.references.list(),
+      setupResult.backend.common.files.list({}),
+      setupResult.backend.common.files.find({ query: "src" }),
+      setupResult.backend.common.permissions.pending(),
+      setupResult.backend.common.questions.pending(),
+      setupResult.backend.common.pty.list(),
+    ])
+
+    const paths = setupResult.requests.map((request) => new URL(request.url).pathname)
+    expect(paths.every((path) => path.startsWith("/api/"))).toBe(true)
+    expect(paths.some((path) => path === "/project" || path.startsWith("/vcs") || path.startsWith("/mcp"))).toBe(
+      false,
+    )
+  })
+
   test("normalizes session pages and preserves location precedence", async () => {
     const setupResult = setup(() => json({ data: [session], cursor: { previous: "before", next: "after" } }))
 
     const result = await setupResult.backend.common.sessions.list({
       location: { directory: "/explicit", workspaceID: "explicit-workspace" },
-      roots: true,
       limit: 10,
       cursor: "cursor",
     })
@@ -50,9 +97,13 @@ describe("createV2Backend", () => {
       items: [
         {
           id: "ses_1",
+          slug: "ses_1",
+          version: "",
           parentID: undefined,
           projectID: "project",
           location: { directory: "/repo", workspaceID: "workspace" },
+          directory: "/repo",
+          workspaceID: "workspace",
           title: "Session",
           cost: 1.5,
           tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
@@ -60,27 +111,187 @@ describe("createV2Backend", () => {
           revert: undefined,
         },
       ],
-      previous: "before",
-      next: "after",
+      newer: "before",
+      older: "after",
     })
     const url = new URL(setupResult.requests[0].url)
     expect(url.pathname).toBe("/api/session")
     expect(url.searchParams.get("directory")).toBe("/explicit")
     expect(url.searchParams.get("workspace")).toBe("explicit-workspace")
-    expect(url.searchParams.get("parentID")).toBe("null")
+    expect(url.searchParams.has("parentID")).toBe(false)
     expect(url.searchParams.get("cursor")).toBe("cursor")
   })
 
-  test("uses the default location and maps binary file responses", async () => {
-    const setupResult = setup(() => new Response(Uint8Array.from([0, 1, 2])))
+  test("paginates native sessions until roots:true contains only the requested roots", async () => {
+    const child = { ...session, id: "child", parentID: "root_1" }
+    const root1 = { ...session, id: "root_1" }
+    const root2 = { ...session, id: "root_2" }
+    const setupResult = setup((request) => {
+      const cursor = new URL(request.url).searchParams.get("cursor")
+      if (!cursor) return json({ data: [child], cursor: { next: "one" } })
+      if (cursor === "one") return json({ data: [root1], cursor: { next: "two" } })
+      return json({ data: [root2], cursor: { next: "three" } })
+    })
 
-    const result = await setupResult.backend.common.files.read({ path: "dir/a.bin" })
+    const result = await setupResult.backend.common.sessions.list({ roots: true, limit: 2 })
+
+    expect(result.items.map((item) => item.id)).toEqual(["root_1", "root_2"])
+    expect(result.older).toBe("three")
+    expect(setupResult.requests.map((request) => new URL(request.url).searchParams.get("limit"))).toEqual(["1", "1", "1"])
+  })
+
+  test("uses only native endpoints for bootstrap operations and binary file reads", async () => {
+    const setupResult = setup((request) => {
+      if (new URL(request.url).pathname === "/api/fs/read/dir%2Fa.txt")
+        return new Response(Uint8Array.from([0, 1, 255]), { headers: { "content-type": "application/octet-stream" } })
+      return json({ location: { directory: "/default" }, data: [] })
+    })
+
+    await setupResult.backend.common.files.list({ path: "dir" })
+    const content = await setupResult.backend.common.files.read({ path: "dir/a.txt" })
 
-    expect([...result.bytes]).toEqual([0, 1, 2])
     const url = new URL(setupResult.requests[0].url)
-    expect(url.pathname).toBe("/api/fs/read/dir/a.bin")
+    expect(url.pathname).toBe("/api/fs/list")
     expect(url.searchParams.get("location[directory]")).toBe("/default")
     expect(url.searchParams.get("location[workspace]")).toBe("default-workspace")
+    expect(content).toEqual({
+      bytes: Uint8Array.from([0, 1, 255]),
+      kind: "binary",
+      mimeType: "application/octet-stream",
+    })
+    const readURL = new URL(setupResult.requests[1].url)
+    expect(readURL.pathname).toBe("/api/fs/read/dir%2Fa.txt")
+    expect(readURL.searchParams.get("location[directory]")).toBe("/default")
+    expect(readURL.searchParams.get("location[workspace]")).toBe("default-workspace")
+    expect(setupResult.requests[1].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
+    expect(setupResult.requests.every((request) => new URL(request.url).pathname.startsWith("/api/"))).toBe(true)
+    expect(setupResult.backend.version).toBe("v2")
+    expect(Object.keys(setupResult.backend.capabilities).sort()).toEqual([
+      "integrationsV2",
+      "projectCopiesV2",
+      "ptyTransport",
+      "savedPermissionsV2",
+      "sessionExtrasV2",
+    ])
+    expect(setupResult.backend.capabilities.providerAuthV1).toBeUndefined()
+    expect(setupResult.backend.capabilities.worktreesV1).toBeUndefined()
+    expect(setupResult.backend.capabilities.sessionExtrasV1).toBeUndefined()
+    expect(setupResult.backend.capabilities.runtimeV1).toBeUndefined()
+    expect(setupResult.backend.capabilities.projectList).toBeUndefined()
+    expect(setupResult.backend.capabilities.vcs).toBeUndefined()
+    expect(setupResult.backend.capabilities.mcp).toBeUndefined()
+    expect(setupResult.backend.capabilities.sessionExtrasV2?.move).toBeUndefined()
+    expect(setupResult.backend.capabilities.projectCopiesV2?.directories).toBeUndefined()
+  })
+
+  test("uses native PTY endpoints and preserves status through the v2 adapter", async () => {
+    const setupResult = setup((request) =>
+      request.method === "GET" ? new Response(null, { status: 404 }) : new Response(null, { status: 403 }),
+    )
+    const transport = setupResult.backend.capabilities.ptyTransport
+
+    const ticket = await transport?.connectToken({
+      ptyID: "pty_1",
+      location: { directory: "/explicit", workspaceID: "workspace" },
+    })
+    const exists = await transport?.exists({
+      ptyID: "pty_1",
+      location: { directory: "/explicit", workspaceID: "workspace" },
+    })
+
+    expect(ticket).toEqual({ status: 403, ticket: undefined })
+    expect(exists).toBe(false)
+    expect(setupResult.requests[0].headers.get("x-opencode-ticket")).toBe("1")
+    const tokenURL = new URL(setupResult.requests[0].url)
+    const existsURL = new URL(setupResult.requests[1].url)
+    expect(tokenURL.pathname).toBe("/api/pty/pty_1/connect-token")
+    expect(existsURL.pathname).toBe("/api/pty/pty_1")
+    expect(tokenURL.searchParams.get("location[directory]")).toBe("/explicit")
+    expect(tokenURL.searchParams.get("location[workspace]")).toBe("workspace")
+    expect(setupResult.requests[0].headers.get("authorization")).toBe(`Basic ${btoa("user:secret")}`)
+
+    expect(() =>
+      transport?.connectURL({
+        ptyID: "pty/1",
+        location: { directory: "/explicit", workspaceID: "workspace" },
+        cursor: 8,
+      }),
+    ).toThrow("require a ticket")
+
+    const ticketURL = transport?.connectURL({
+      ptyID: "pty_1",
+      location: { directory: "/explicit" },
+      cursor: 0,
+      ticket: "ticket value",
+    })
+    expect(ticketURL?.searchParams.get("ticket")).toBe("ticket value")
+    expect(ticketURL?.searchParams.has("auth_token")).toBe(false)
+  })
+
+  test("allows ticketless same-origin native PTY URLs without credential queries", () => {
+    const transport = setup(() => new Response(), { sameOrigin: true, password: undefined }).backend.capabilities
+      .ptyTransport
+    const url = transport?.connectURL({ ptyID: "pty_1", location: { directory: "/repo" }, cursor: 0 })
+
+    expect(url?.searchParams.has("auth_token")).toBe(false)
+    expect(url?.pathname).toBe("/api/pty/pty_1/connect")
+  })
+
+  test("preserves native provider integration IDs", async () => {
+    const setupResult = setup((request) => {
+      const path = new URL(request.url).pathname
+      if (path === "/api/provider")
+        return json({
+          location: { directory: "/default" },
+          data: [
+            {
+              id: "provider",
+              integrationID: "integration",
+              name: "Provider",
+              api: { type: "native", settings: {} },
+              request: { headers: {}, body: {} },
+            },
+          ],
+        })
+      return json({ location: { directory: "/default" }, data: [] })
+    })
+
+    const result = await setupResult.backend.common.catalog.providers()
+
+    expect(result.providers.get("provider")?.integrationID).toBe("integration")
+  })
+
+  test("preserves integration connection kinds", async () => {
+    const setupResult = setup(() =>
+      json({
+        location: { directory: "/default" },
+        data: [
+          {
+            id: "integration",
+            name: "Integration",
+            methods: [],
+            connections: [
+              { type: "credential", id: "credential", label: "Saved" },
+              { type: "environment", name: "TOKEN" },
+            ],
+          },
+        ],
+      }),
+    )
+
+    const integrations = await setupResult.backend.capabilities.integrationsV2?.list()
+    expect(integrations).toEqual([
+      {
+        id: "integration",
+        name: "Integration",
+        methods: [],
+        connections: [
+          { id: "credential", label: "Saved", kind: "credential" },
+          { id: "TOKEN", label: "TOKEN", kind: "environment" },
+        ],
+      },
+    ])
+    expect(credentialConnectionIDs(integrations?.[0]?.connections ?? [])).toEqual(["credential"])
   })
 
   test("switches prompt selection before admission", async () => {
@@ -114,16 +325,81 @@ describe("createV2Backend", () => {
       "/api/session/ses_1/prompt",
     ])
     expect(await setupResult.requests[2].json()).toEqual({
+      id: "msg_1",
+      prompt: {
+        text: "hello",
+        files: [
+          {
+            uri: "data:text/plain;base64,aGk=",
+            mime: "application/octet-stream",
+            name: "hi.txt",
+            source: { start: 0, end: 2, text: "hi" },
+          },
+        ],
+      },
+    })
+  })
+
+  test("serializes selection and prompt admission per session", async () => {
+    const firstPrompt = Promise.withResolvers<void>()
+    const firstPromptStarted = Promise.withResolvers<void>()
+    const setupResult = setup(async (request) => {
+      const path = new URL(request.url).pathname
+      if (path.endsWith("/prompt") && setupResult.requests.filter((item) => item.url.endsWith("/prompt")).length === 1) {
+        firstPromptStarted.resolve()
+        await firstPrompt.promise
+      }
+      return path.endsWith("/prompt")
+        ? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
+        : new Response(null, { status: 204 })
+    })
+
+    const first = setupResult.backend.common.sessions.prompt({
+      sessionID: "ses_1",
+      id: "msg_1",
+      text: "first",
+      selection: { agent: "build" },
+    })
+    await firstPromptStarted.promise
+    const second = setupResult.backend.common.sessions.prompt({
+      sessionID: "ses_1",
+      id: "msg_2",
+      text: "second",
+      selection: { agent: "plan" },
+    })
+
+    expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
+      "/api/session/ses_1/agent",
+      "/api/session/ses_1/prompt",
+    ])
+    firstPrompt.resolve()
+    await Promise.all([first, second])
+    expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
+      "/api/session/ses_1/agent",
+      "/api/session/ses_1/prompt",
+      "/api/session/ses_1/agent",
+      "/api/session/ses_1/prompt",
+    ])
+  })
+
+  test("does not switch a selection already present in session state", async () => {
+    const setupResult = setup((request) =>
+      request.url.endsWith("/prompt")
+        ? json({ data: { id: "msg", sessionID: "ses_1", timeCreated: 1, type: "user", data: { text: "" } } })
+        : json({ data: { ...session, agent: "build", model: { id: "model", providerID: "provider" } } }),
+    )
+    await setupResult.backend.common.sessions.get({ sessionID: "ses_1" })
+    await setupResult.backend.common.sessions.prompt({
+      sessionID: "ses_1",
       id: "msg_1",
       text: "hello",
-      files: [
-        {
-          uri: "data:text/plain;base64,aGk=",
-          name: "hi.txt",
-          mention: { start: 0, end: 2, text: "hi" },
-        },
-      ],
+      selection: { agent: "build", model: { id: "model", providerID: "provider" } },
     })
+
+    expect(setupResult.requests.map((item) => new URL(item.url).pathname)).toEqual([
+      "/api/session/ses_1",
+      "/api/session/ses_1/prompt",
+    ])
   })
 
   test("requests file staging when selected revert files are present", async () => {
@@ -138,75 +414,266 @@ describe("createV2Backend", () => {
     expect(await setupResult.requests[0].json()).toEqual({ messageID: "msg_1", files: true })
   })
 
-  test("normalizes retry events before message projection refresh", async () => {
+  test("maps ordered app prompt parts to the native prompt shape", async () => {
     const setupResult = setup(() =>
-      new Response(
-        `data: ${JSON.stringify({
-          id: "evt_retry",
-          created: 1,
-          type: "session.retry.scheduled",
-          durable: { aggregateID: "ses_1", seq: 3, version: 1 },
-          location: { directory: "/repo" },
-          data: {
-            sessionID: "ses_1",
-            assistantMessageID: "msg_1",
-            attempt: 2,
-            at: 1234,
-            error: { type: "rate_limit", message: "try again" },
-          },
-        })}\n\n`,
-        { headers: { "content-type": "text/event-stream" } },
-      ),
+      json({ data: { admittedSeq: 1, id: "msg_1", sessionID: "ses_1", timeCreated: 1, type: "user", data: {} } }),
+    )
+
+    await setupResult.backend.common.sessions.prompt({
+      sessionID: "ses_1",
+      id: "msg_1",
+      text: "visible",
+      parts: [
+        { id: "part_text", type: "text", text: "visible" },
+        { id: "part_note", type: "text", text: "note", synthetic: true, metadata: { source: "review" } },
+        { id: "part_file", type: "file", mime: "text/plain", url: "file:///repo/a.ts", filename: "a.ts" },
+        { id: "part_agent", type: "agent", name: "build", source: { value: "@build", start: 7, end: 13 } },
+      ],
+    })
+
+    expect(await setupResult.requests[0].json()).toEqual({
+      id: "msg_1",
+      prompt: {
+        text: "visiblenote",
+        files: [{ uri: "file:///repo/a.ts", mime: "text/plain", name: "a.ts" }],
+        agents: [{ name: "build", source: { text: "@build", start: 7, end: 13 } }],
+      },
+    })
+  })
+
+  test("commits a staged revert through the V2 capability", async () => {
+    const setupResult = setup(() => new Response(null, { status: 204 }))
+
+    await setupResult.backend.capabilities.sessionExtrasV2?.commitRevert({ sessionID: "ses_1" })
+
+    expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/revert/commit")
+  })
+
+  test("normalizes current session activity events without projection refresh", async () => {
+    const setupResult = setup(
+      () =>
+        new Response(
+          `data: ${JSON.stringify({
+            id: "evt_started",
+            type: "session.next.step.started",
+            durable: { aggregateID: "ses_1", seq: 3, version: 1 },
+            location: { directory: "/repo" },
+            data: {
+              timestamp: 1,
+              sessionID: "ses_1",
+              assistantMessageID: "msg_1",
+              agent: "build",
+              model: { id: "model", providerID: "provider" },
+            },
+          })}\n\n`,
+          { headers: { "content-type": "text/event-stream" } },
+        ),
     )
 
     const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
 
-    expect(result.value).toEqual({
+    expect(result.value).toMatchObject({
       location: { directory: "/repo" },
       event: {
         type: "session.activity",
         sessionID: "ses_1",
-        activity: { type: "retry", attempt: 2, message: "try again", next: 1234 },
+        activity: { type: "running" },
       },
     })
     expect(setupResult.requests).toHaveLength(1)
   })
 
+  test("maps completed native steps to idle activity", async () => {
+    const setupResult = setup(
+      () =>
+        new Response(
+          `data: ${JSON.stringify({
+            id: "evt_ended",
+            type: "session.next.step.ended",
+            data: {
+              timestamp: 2,
+              sessionID: "ses_1",
+              assistantMessageID: "msg_1",
+              finish: "stop",
+              cost: 1,
+              tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
+            },
+          })}\n\n`,
+          { headers: { "content-type": "text/event-stream" } },
+        ),
+    )
+
+    const result = await setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]().next()
+
+    expect(result.value?.event).toEqual({
+      type: "session.activity",
+      sessionID: "ses_1",
+      activity: { type: "idle" },
+    })
+  })
+
+  test("projects failed steps as completed assistant errors and clears running", async () => {
+    const events = [
+      {
+        id: "start",
+        type: "session.next.step.started",
+        data: { timestamp: 1, sessionID: "ses_1", assistantMessageID: "msg_1", agent: "build", model: { id: "m", providerID: "p" } },
+      },
+      { id: "text", type: "session.next.text.started", data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" } },
+      { id: "delta", type: "session.next.text.delta", data: { timestamp: 3, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1", delta: "partial" } },
+      { id: "failed", type: "session.next.step.failed", data: { timestamp: 4, sessionID: "ses_1", assistantMessageID: "msg_1", error: { type: "unknown", message: "boom" } } },
+    ]
+    const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
+    const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
+    await iterator.next()
+    await iterator.next()
+    await iterator.next()
+
+    expect((await iterator.next()).value?.event).toMatchObject({
+      type: "session.activity",
+      activity: { type: "idle" },
+      item: { completed: 4, error: { data: { message: "boom" } }, content: [{ id: "text_1", text: "partial" }] },
+    })
+  })
+
+  test("does not infer assistant parents across history pages or direct fetches", async () => {
+    const assistant = { id: "assistant", type: "assistant", time: { created: 2 }, agent: "build", model: { id: "m", providerID: "p" }, content: [] }
+    const user = { id: "user", type: "user", time: { created: 1 }, text: "hello" }
+    const setupResult = setup((request) => {
+      const url = new URL(request.url)
+      if (url.pathname.endsWith("/message/assistant")) return json({ data: assistant })
+      if (url.searchParams.get("cursor")) return json({ data: [user], cursor: {} })
+      return json({ data: [assistant], cursor: { next: "older" } })
+    })
+
+    const first = await setupResult.backend.common.sessions.history({ sessionID: "ses_1" })
+    await setupResult.backend.common.sessions.history({ sessionID: "ses_1", cursor: first.older })
+    const direct = await setupResult.backend.common.sessions.message({ sessionID: "ses_1", messageID: "assistant" })
+
+    expect(first.items[0]).toMatchObject({ type: "assistant", parentID: undefined })
+    expect(direct).toMatchObject({ type: "assistant", parentID: undefined })
+  })
+
+  test("normalizes lifecycle and provider refresh events without HTTP fallbacks", async () => {
+    const events = [
+      { id: "moved", type: "session.next.moved", data: { timestamp: 1, sessionID: "ses_1", location: { directory: "/next" } } },
+      { id: "revert", type: "session.next.revert.staged", data: { timestamp: 2, sessionID: "ses_1", revert: { messageID: "msg_1" } } },
+      { id: "integration", type: "integration.updated", data: {} },
+      { id: "unknown", type: "session.next.context.updated", data: { timestamp: 3, sessionID: "ses_1", messageID: "msg_1", text: "x" } },
+    ]
+    const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
+    const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
+
+    expect((await iterator.next()).value?.event).toEqual({ type: "session.moved", sessionID: "ses_1", location: { directory: "/next" } })
+    expect((await iterator.next()).value?.event).toEqual({ type: "session.revert", sessionID: "ses_1", revert: { messageID: "msg_1" } })
+    expect((await iterator.next()).value?.event).toEqual({ type: "provider.updated" })
+    expect((await iterator.next()).value?.event.type).toBe("unknown")
+    expect(setupResult.requests).toHaveLength(1)
+  })
+
+  test("normalizes native session create, update, and delete lifecycle events", async () => {
+    const info = {
+      id: "ses_1",
+      slug: "one",
+      version: "2",
+      projectID: "project",
+      directory: "/repo",
+      title: "Session",
+      time: { created: 1, updated: 2 },
+    }
+    const events = [
+      { id: "created", type: "session.created", data: { sessionID: "ses_1", info } },
+      { id: "updated", type: "session.updated", data: { sessionID: "ses_1", info: { ...info, title: "Renamed" } } },
+      { id: "deleted", type: "session.deleted", data: { sessionID: "ses_1", info } },
+    ]
+    const setupResult = setup(() => new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }))
+    const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
+
+    expect((await iterator.next()).value?.event).toMatchObject({ type: "session.created", session: { id: "ses_1", title: "Session" } })
+    expect((await iterator.next()).value?.event).toMatchObject({ type: "session.updated", session: { id: "ses_1", title: "Renamed" } })
+    expect((await iterator.next()).value?.event).toEqual({ type: "session.deleted", sessionID: "ses_1" })
+  })
+
   test("normalizes durable session log events through the event mapper", async () => {
-    const setupResult = setup(() =>
-      new Response(
-        `data: ${JSON.stringify({
-          id: "evt_started",
-          created: 1,
-          type: "session.execution.started",
-          durable: { aggregateID: "ses_1", seq: 7, version: 1 },
-          data: { sessionID: "ses_1" },
-        })}\n\n`,
-        { headers: { "content-type": "text/event-stream" } },
-      ),
+    const setupResult = setup(
+      () =>
+        new Response(
+          `data: ${JSON.stringify({
+            id: "evt_started",
+            type: "session.next.step.started",
+            durable: { aggregateID: "ses_1", seq: 7, version: 1 },
+            data: {
+              timestamp: 1,
+              sessionID: "ses_1",
+              assistantMessageID: "msg_1",
+              agent: "build",
+              model: { id: "model", providerID: "provider" },
+            },
+          })}\n\n`,
+          { headers: { "content-type": "text/event-stream" } },
+        ),
     )
     const capability = setupResult.backend.capabilities.sessionExtrasV2
     if (!capability) throw new Error("Missing V2 session capability")
 
     const result = await capability.log({ sessionID: "ses_1" })[Symbol.asyncIterator]().next()
 
-    expect(result.value).toEqual({
+    expect(result.value).toMatchObject({
       sequence: 7,
       event: { type: "session.activity", sessionID: "ses_1", activity: { type: "running" } },
     })
-    expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/experimental/session/ses_1/log")
+    expect(new URL(setupResult.requests[0].url).pathname).toBe("/api/session/ses_1/event")
   })
 
-  test("refreshes message projections for streamed V2 fragments", async () => {
+  test("normalizes native todo and part removal events", async () => {
+    const events = [
+      {
+        id: "evt_todo",
+        type: "todo.updated",
+        data: { sessionID: "ses_1", todos: [{ content: "Ship", status: "pending", priority: "high" }] },
+      },
+      {
+        id: "evt_removed",
+        type: "message.part.removed",
+        data: { sessionID: "ses_1", messageID: "msg_1", partID: "part_1" },
+      },
+    ]
+    const setupResult = setup(
+      () =>
+        new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
+          headers: { "content-type": "text/event-stream" },
+        }),
+    )
+    const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
+
+    expect((await iterator.next()).value?.event).toEqual({
+      type: "todo.updated",
+      sessionID: "ses_1",
+      todos: [{ content: "Ship", status: "pending", priority: "high" }],
+    })
+    expect((await iterator.next()).value?.event).toEqual({
+      type: "timeline.part.removed",
+      sessionID: "ses_1",
+      itemID: "msg_1",
+      contentID: "part_1",
+    })
+  })
+
+  test("preserves streamed V2 timeline deltas without projection refresh", async () => {
     const setupResult = setup((request) => {
       if (new URL(request.url).pathname === "/api/event") {
         return new Response(
           `data: ${JSON.stringify({
             id: "evt_1",
-            created: 1,
-            type: "session.text.delta",
+            type: "session.next.text.delta",
             location: { directory: "/repo" },
-            data: { sessionID: "ses_1", assistantMessageID: "msg_1", ordinal: 0, delta: "hi" },
+            data: {
+              timestamp: 1,
+              sessionID: "ses_1",
+              assistantMessageID: "msg_1",
+              textID: "text_1",
+              delta: "hi",
+            },
           })}\n\n`,
           { headers: { "content-type": "text/event-stream" } },
         )
@@ -218,7 +685,7 @@ describe("createV2Backend", () => {
           time: { created: 1 },
           agent: "build",
           model: { id: "model", providerID: "provider" },
-          content: [{ type: "text", text: "hello" }],
+          content: [{ type: "text", id: "text_1", text: "hello" }],
         },
       })
     })
@@ -228,29 +695,51 @@ describe("createV2Backend", () => {
     expect(result.value).toEqual({
       location: { directory: "/repo" },
       event: {
-        type: "timeline.updated",
-        item: {
-          type: "assistant",
-          id: "msg_1",
+        type: "timeline.delta",
+        sessionID: "ses_1",
+        itemID: "msg_1",
+        contentID: "text_1",
+        field: "text",
+        delta: "hi",
+      },
+    })
+    expect(setupResult.requests).toHaveLength(1)
+  })
+
+  test("projects native fragment starts without blocking the event stream on HTTP", async () => {
+    const events = [
+      {
+        id: "evt_step",
+        type: "session.next.step.started",
+        data: {
+          timestamp: 1,
           sessionID: "ses_1",
-          created: 1,
-          completed: undefined,
-          content: [{ type: "text", id: "msg_1:text:0", text: "hello" }],
+          assistantMessageID: "msg_1",
           agent: "build",
           model: { id: "model", providerID: "provider" },
-          tokens: undefined,
-          error: undefined,
-          raw: {
-            id: "msg_1",
-            type: "assistant",
-            time: { created: 1 },
-            agent: "build",
-            model: { id: "model", providerID: "provider" },
-            content: [{ type: "text", text: "hello" }],
-          },
         },
       },
+      {
+        id: "evt_text",
+        type: "session.next.text.started",
+        data: { timestamp: 2, sessionID: "ses_1", assistantMessageID: "msg_1", textID: "text_1" },
+      },
+    ]
+    const setupResult = setup(
+      () =>
+        new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
+          headers: { "content-type": "text/event-stream" },
+        }),
+    )
+    const iterator = setupResult.backend.common.events.subscribe()[Symbol.asyncIterator]()
+
+    await iterator.next()
+    expect((await iterator.next()).value?.event).toEqual({
+      type: "timeline.content.updated",
+      sessionID: "ses_1",
+      itemID: "msg_1",
+      content: { type: "text", id: "text_1", text: "" },
     })
-    expect(new URL(setupResult.requests[1].url).pathname).toBe("/api/session/ses_1/message/msg_1")
+    expect(setupResult.requests).toHaveLength(1)
   })
 })

+ 751 - 331
packages/app/src/context/backend-v2.ts

@@ -36,6 +36,8 @@ import type {
   LocationRef,
   PendingSessionInput,
   PromptFile,
+  PromptInput,
+  PtyTransportConfig,
   RequestOptions,
   SessionActivity,
   SessionLogItem,
@@ -44,30 +46,43 @@ import type {
   TimelineItem,
 } from "./backend"
 
-export function createV2Backend(client: OpenCodeClient, defaultLocation?: LocationRef): AppClient {
+export function createV2Backend(
+  client: OpenCodeClient,
+  transportConfig: PtyTransportConfig,
+  defaultLocation?: LocationRef,
+  eventClient: OpenCodeClient = client,
+): AppClient {
   const request = (input?: RequestOptions) => ({ signal: input?.signal })
   const location = (input?: LocationInput) => toLocation(input?.location ?? defaultLocation)
-
-  const loadSession = async (sessionID: string, options?: RequestOptions) =>
-    toSession(await client.session.get({ sessionID }, request(options)))
-  const loadMessage = async (sessionID: string, messageID: string, options?: RequestOptions) =>
-    toTimelineItem(await client.session.message({ sessionID, messageID }, request(options)), sessionID)
+  const projectedContent = new Map<string, TimelineContent>()
+  const projectedItems = new Map<string, TimelineItem>()
+  const projectedSessions = new Map<string, AppSession>()
+  const projectedPrompts = new Map<string, { sessionID: string; content: TimelineContent[] }>()
+  const selections = new Map<string, { agent?: string; model?: { id: string; providerID: string; variant?: string } }>()
+  const sessionAdmissions = new Map<string, Promise<void>>()
+  const projectSession = (input: SessionInfo) => {
+    const result = toSession(input)
+    projectedSessions.set(result.id, result)
+    selections.set(result.id, { agent: input.agent, model: input.model })
+    return result
+  }
+  const loadSession = async (input: LocationInput & { sessionID: string }, options?: RequestOptions) =>
+    projectSession(await client.session.get({ sessionID: input.sessionID }, request(options)))
 
   return {
     version: "v2",
     common: {
       health: { get: (options) => client.health.get(request(options)) },
       projects: {
-        list: async (options) => (await client.project.list(request(options))).map(toProject),
-        current: (input, options) => client.project.current({ location: location(input) }, request(options)),
+        current: async (input, options) =>
+          (await client.location.get({ location: location(input) }, request(options))).project,
       },
       catalog: {
         providers: async (input, options) => {
           const params = { location: location(input) }
-          const [providers, models, selected] = await Promise.all([
+          const [providers, models] = await Promise.all([
             client.provider.list(params, request(options)),
             client.model.list(params, request(options)),
-            client.model.default(params, request(options)),
           ])
           return {
             providers: new Map(
@@ -76,6 +91,7 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
                 {
                   id: provider.id,
                   name: provider.name,
+                  integrationID: provider.integrationID,
                   models: Object.fromEntries(
                     models.data
                       .filter((model) => model.providerID === provider.id && model.status !== "deprecated")
@@ -85,7 +101,7 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
               ]),
             ),
             connected: providers.data.filter((provider) => !provider.disabled).map((provider) => provider.id),
-            defaults: selected.data ? { [selected.data.providerID]: selected.data.id } : {},
+            defaults: {},
           }
         },
         agents: async (input, options) =>
@@ -110,106 +126,157 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
       },
       sessions: {
         list: async (input, options) => {
-          const result = await client.session.list(
-            {
-              workspace: input?.location?.workspaceID ?? defaultLocation?.workspaceID,
-              directory: input?.location?.directory ?? defaultLocation?.directory,
-              parentID: input?.roots ? null : undefined,
-              limit: input?.limit,
-              search: input?.search,
-              cursor: input?.cursor,
-            },
-            request(options),
-          )
+          const params = {
+            workspace: input?.location?.workspaceID ?? defaultLocation?.workspaceID,
+            directory: input?.location?.directory ?? defaultLocation?.directory,
+            search: input?.search,
+          }
+          const results = new Map<string, SessionInfo>()
+          const target = input?.limit ?? Number.POSITIVE_INFINITY
+          let cursor = input?.cursor
+          let newer: string | undefined
+          const cursors = new Set<string>()
+          do {
+            if (cursor) {
+              if (cursors.has(cursor)) break
+              cursors.add(cursor)
+            }
+            const result = await client.session.list(
+              { ...params, limit: input?.roots ? 1 : input?.limit, cursor },
+              request(options),
+            )
+            if (newer === undefined) newer = result.cursor.previous ?? undefined
+            result.data.filter((item) => !input?.roots || !item.parentID).forEach((item) => results.set(item.id, item))
+            cursor = result.cursor.next ?? undefined
+            if (!input?.roots || results.size >= target) break
+          } while (cursor)
           return {
-            items: result.data.map(toSession),
-            previous: result.cursor.previous ?? undefined,
-            next: result.cursor.next ?? undefined,
+            items: [...results.values()].slice(0, target).map(projectSession),
+            newer,
+            older: cursor,
           }
         },
         create: async (input, options) =>
-          toSession(
+          projectSession(
             await client.session.create(
               { location: input?.location ?? defaultLocation, agent: input?.agent, model: input?.model },
               request(options),
             ),
           ),
-        get: (input, options) => loadSession(input.sessionID, options),
-        remove: async (input, options) => {
-          await client.session.remove({ sessionID: input.sessionID }, request(options))
-        },
-        fork: async (input, options) =>
-          toSession(await client.session.fork({ sessionID: input.sessionID, messageID: input.messageID }, request(options))),
-        rename: async (input, options) => {
-          await client.session.rename({ sessionID: input.sessionID, title: input.title }, request(options))
-        },
+        get: loadSession,
         interrupt: async (input, options) => {
           await client.session.interrupt({ sessionID: input.sessionID }, request(options))
         },
-        activity: async (_input, options) => {
-          const result = await client.session.active(request(options))
-          return Object.fromEntries(Object.keys(result).map((sessionID) => [sessionID, { type: "running" } as const]))
-        },
+        activity: (_input, options) => client.session.active(request(options)),
         history: async (input, options) => {
           const result = await client.message.list(
             { sessionID: input.sessionID, limit: input.limit, cursor: input.cursor },
             request(options),
           )
           return {
-            items: result.data.map((item) => toTimelineItem(item, input.sessionID)),
-            previous: result.cursor.previous ?? undefined,
-            next: result.cursor.next ?? undefined,
+            items: result.data.map((item) =>
+              toTimelineItem(item, input.sessionID, undefined, projectedPrompts.get(item.id)?.content),
+            ),
+            newer: result.cursor.previous ?? undefined,
+            older: result.cursor.next ?? undefined,
           }
         },
-        message: (input, options) => loadMessage(input.sessionID, input.messageID, options),
-        prompt: async (input, options) => {
-          if (input.selection?.agent)
-            await client.session.switchAgent(
-              { sessionID: input.sessionID, agent: input.selection.agent },
-              request(options),
-            )
-          if (input.selection?.model)
-            await client.session.switchModel(
-              { sessionID: input.sessionID, model: input.selection.model },
-              request(options),
+        message: async (input, options) =>
+          toTimelineItem(
+            await client.session.message({ sessionID: input.sessionID, messageID: input.messageID }, request(options)),
+            input.sessionID,
+            undefined,
+            projectedPrompts.get(input.messageID)?.content,
+          ),
+        prompt: (input, options) => {
+          const previous = sessionAdmissions.get(input.sessionID) ?? Promise.resolve()
+          const admission = previous.catch(() => undefined).then(async () => {
+            const selected = selections.get(input.sessionID)
+            if (input.selection?.agent && input.selection.agent !== selected?.agent) {
+              await client.session.switchAgent(
+                { sessionID: input.sessionID, agent: input.selection.agent },
+                request(options),
+              )
+              selections.set(input.sessionID, { ...selected, agent: input.selection.agent })
+            }
+            const current = selections.get(input.sessionID)
+            if (input.selection?.model && !sameModel(input.selection.model, current?.model)) {
+              await client.session.switchModel(
+                { sessionID: input.sessionID, model: input.selection.model },
+                request(options),
+              )
+              selections.set(input.sessionID, { ...current, model: input.selection.model })
+            }
+            const parts = input.parts
+            if (parts) projectedPrompts.set(input.id, { sessionID: input.sessionID, content: toPromptContent(parts) })
+            const response = await transportConfig.fetch(
+              nativeSessionURL(transportConfig, input.sessionID, "/prompt"),
+              {
+                method: "POST",
+                signal: options?.signal,
+                headers: transportHeaders(transportConfig, false, true),
+                body: JSON.stringify({
+                  id: input.id,
+                  prompt: {
+                    text:
+                      parts
+                        ?.filter((part) => part.type === "text")
+                        .map((part) => part.text)
+                        .join("") ?? input.text,
+                    files:
+                      parts
+                        ?.filter((part) => part.type === "file")
+                        .map((file) => ({
+                          uri: file.url,
+                          mime: file.mime,
+                          name: file.filename,
+                          source: file.source && {
+                            start: file.source.text.start,
+                            end: file.source.text.end,
+                            text: file.source.text.value,
+                          },
+                        })) ?? input.files?.map(toPromptFile),
+                    agents:
+                      parts
+                        ?.filter((part) => part.type === "agent")
+                        .map((agent) => ({
+                          name: agent.name,
+                          source: agent.source && {
+                            start: agent.source.start,
+                            end: agent.source.end,
+                            text: agent.source.value,
+                          },
+                        })) ??
+                      input.agents?.map((agent) => ({
+                        name: agent.name,
+                        source:
+                          agent.start === undefined || agent.end === undefined || agent.text === undefined
+                            ? undefined
+                            : { start: agent.start, end: agent.end, text: agent.text },
+                      })),
+                  },
+                  delivery: input.delivery,
+                }),
+              },
             )
-          await client.session.prompt(
-            {
-              sessionID: input.sessionID,
-              id: input.id,
-              text: input.text,
-              files: input.files?.map(toPromptFile),
-              agents: input.agents?.map((agent) => ({
-                name: agent.name,
-                mention:
-                  agent.start === undefined || agent.end === undefined || agent.text === undefined
-                    ? undefined
-                    : { start: agent.start, end: agent.end, text: agent.text },
-              })),
-              delivery: input.delivery,
-            },
-            request(options),
-          )
-        },
-        command: async (input, options) => {
-          await client.session.command(
-            {
-              sessionID: input.sessionID,
-              id: input.id,
-              command: input.command,
-              arguments: input.arguments,
-              agent: input.agent,
-              model: input.model,
-              files: input.files?.map(toPromptFile),
-              delivery: input.delivery,
-            },
-            request(options),
-          )
+            if (response.ok) return
+            projectedPrompts.delete(input.id)
+            throw new Error(`Failed to submit prompt: ${response.status} ${response.statusText}`)
+          })
+          sessionAdmissions.set(input.sessionID, admission)
+          return admission.finally(() => {
+            if (sessionAdmissions.get(input.sessionID) === admission) sessionAdmissions.delete(input.sessionID)
+          })
         },
       },
       files: {
         list: async (input, options) =>
-          (await client.file.list({ location: location(input), path: input.path }, request(options))).data,
+          (await client.file.list({ location: location(input), path: input.path }, request(options))).data.map((item) => ({
+            ...item,
+            name: item.path.split(/[\\/]/).at(-1) ?? item.path,
+            absolute: item.path,
+            ignored: false,
+          })),
         find: async (input, options) =>
           (
             await client.file.find(
@@ -217,9 +284,19 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
               request(options),
             )
           ).data,
-        read: async (input, options) => ({
-          bytes: await client.file.read({ location: location(input), path: input.path }, request(options)),
-        }),
+        read: async (input, options) => {
+          const response = await transportConfig.fetch(
+            nativeFileURL(transportConfig, input.path, input.location ?? defaultLocation),
+            { signal: options?.signal, headers: transportHeaders(transportConfig) },
+          )
+          if (!response.ok) throw new Error(`Failed to read file: ${response.status} ${response.statusText}`)
+          const mimeType = response.headers.get("content-type") ?? undefined
+          return {
+            bytes: new Uint8Array(await response.arrayBuffer()),
+            kind: mimeType?.startsWith("text/") ? "text" : "binary",
+            mimeType,
+          }
+        },
       },
       permissions: {
         pending: async (input, options) =>
@@ -238,18 +315,6 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
           await client.question.reject(input, request(options))
         },
       },
-      vcs: {
-        status: async (input, options) =>
-          (await client.vcs.status({ location: location(input) }, request(options))).data,
-        diff: async (input, options) =>
-          (await client.vcs.diff({ ...input, location: location(input) }, request(options))).data.map(toFileDiff),
-      },
-      mcp: {
-        list: async (input, options) =>
-          (await client.mcp.list({ location: location(input) }, request(options))).data.map(toMcpServer),
-        resources: async (input, options) =>
-          (await client.mcp.resource.catalog({ location: location(input) }, request(options))).data,
-      },
       pty: {
         list: async (input, options) =>
           (await client.pty.list({ location: location(input) }, request(options))).data.map(toPty),
@@ -287,20 +352,50 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
       events: {
         subscribe: (options) => ({
           async *[Symbol.asyncIterator]() {
-            for await (const input of client.event.subscribe(request(options))) {
+            for await (const input of eventClient.event.subscribe(request(options))) {
               yield {
                 location: input.location,
-                event: await toEvent(input, loadSession, loadMessage, options),
+                event: toEvent(input, projectedContent, projectedItems, projectedSessions, projectedPrompts),
               }
             }
           },
         }),
       },
-      disposeLocation: async (input, options) => {
-        await client.debug.location.evict({ location: location(input) }, request(options))
-      },
     },
     capabilities: {
+      ptyTransport: {
+        connectToken: async (input, options) => {
+          const response = await transportConfig.fetch(
+            nativePtyURL(transportConfig, input.ptyID, "/connect-token", input.location ?? defaultLocation),
+            {
+              method: "POST",
+              signal: options?.signal,
+              headers: transportHeaders(transportConfig, true),
+            },
+          )
+          const body = response.ok ? ((await response.json()) as { data?: { ticket?: string } }) : undefined
+          return { status: response.status, ticket: body?.data?.ticket }
+        },
+        exists: async (input, options) => {
+          const response = await transportConfig.fetch(
+            nativePtyURL(transportConfig, input.ptyID, "", input.location ?? defaultLocation),
+            { signal: options?.signal, headers: transportHeaders(transportConfig) },
+          )
+          return response.status !== 404
+        },
+        connectURL: (input) => {
+          const url = nativePtyURL(transportConfig, input.ptyID, "/connect", input.location)
+          url.searchParams.set("cursor", String(input.cursor))
+          url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
+          if (input.ticket) {
+            url.searchParams.set("ticket", input.ticket)
+            return url
+          }
+          if (!transportConfig.sameOrigin || transportConfig.password || transportConfig.authToken)
+            throw new Error("Native PTY connections require a ticket for cross-origin or authenticated access")
+          return url
+        },
+      },
       integrationsV2: {
         list: async (input, options) =>
           (await client.integration.list({ location: location(input) }, request(options))).data.map(toIntegration),
@@ -354,57 +449,27 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
       sessionExtrasV2: {
         switchAgent: async (input, options) => {
           await client.session.switchAgent(input, request(options))
+          selections.set(input.sessionID, { ...selections.get(input.sessionID), agent: input.agent })
         },
         switchModel: async (input, options) => {
           await client.session.switchModel(input, request(options))
+          selections.set(input.sessionID, { ...selections.get(input.sessionID), model: input.model })
         },
-        move: async (input, options) => {
-          await client.session.move(
-            { sessionID: input.sessionID, destination: { directory: input.directory }, moveChanges: input.moveChanges },
-            request(options),
-          )
-        },
-        skill: async (input, options) => {
-          await client.session.skill(input, request(options))
-        },
-        synthetic: async (input, options) =>
-          toPending(
-            await client.session.synthetic(
-              { ...input, metadata: input.metadata && toClientRecord(input.metadata) },
-              request(options),
-            ),
-          ),
-        shell: async (input, options) => {
-          await client.session.shell(input, request(options))
-        },
-        compact: async (input, options) => toPending(await client.session.compact(input, request(options))),
         wait: async (input, options) => {
           await client.session.wait(input, request(options))
         },
         context: async (input, options) =>
           (await client.session.context(input, request(options))).map((item) => toTimelineItem(item, input.sessionID)),
-        pending: async (input, options) =>
-          (await client.session.pending.list(input, request(options))).map(toPending),
-        instructionEntries: (input, options) => client.session.instructions.entry.list(input, request(options)),
-        putInstructionEntry: async (input, options) => {
-          await client.session.instructions.entry.put({ ...input, value: toClientJson(input.value) }, request(options))
-        },
-        removeInstructionEntry: async (input, options) => {
-          await client.session.instructions.entry.remove(input, request(options))
-        },
         log: (input, options) => ({
           async *[Symbol.asyncIterator]() {
-            for await (const item of client.session.log(input, request(options))) {
+            for await (const item of nativeSessionEvents(transportConfig, input.sessionID, input.after, options)) {
               yield {
-                sequence: "durable" in item ? item.durable.seq : (item.seq ?? 0),
-                event: await toLogEvent(item, loadSession, loadMessage, options),
+                sequence: eventSequence(item),
+                event: toEvent(item, projectedContent, projectedItems, projectedSessions, projectedPrompts),
               } satisfies SessionLogItem
             }
           },
         }),
-        background: async (input, options) => {
-          await client.session.background(input, request(options))
-        },
         stageRevert: async (input, options) => {
           const result = await client.session.revert.stage(
             { sessionID: input.sessionID, messageID: input.messageID, files: input.files?.length ? true : undefined },
@@ -420,8 +485,6 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
         },
       },
       projectCopiesV2: {
-        directories: (input, options) =>
-          client.project.directories({ projectID: input.projectID, location: location(input) }, request(options)),
         create: (input, options) => client.projectCopy.create({ ...input, location: location(input) }, request(options)),
         remove: async (input, options) => {
           await client.projectCopy.remove({ ...input, location: location(input) }, request(options))
@@ -430,98 +493,12 @@ export function createV2Backend(client: OpenCodeClient, defaultLocation?: Locati
           await client.projectCopy.refresh({ ...input, location: location(input) }, request(options))
         },
       },
-      formsV2: {
-        pending: async (input, options) =>
-          (await client.form.request.list({ location: location(input) }, request(options))).data.map(toForm),
-        list: async (input, options) => (await client.form.list(input, request(options))).map(toForm),
-        create: async (input, options) => {
-          if (input.fields.length === 0) throw new Error("A form requires at least one field")
-          const fields: FormCreateInput["fields"] = [
-            fromFormField(input.fields[0]),
-            ...input.fields.slice(1).map(fromFormField),
-          ]
-          return toForm(
-            await client.form.create(
-              { ...input, metadata: input.metadata && toClientRecord(input.metadata), fields },
-              request(options),
-            ),
-          )
-        },
-        get: async (input, options) => toForm(await client.form.get(input, request(options))),
-        state: (input, options) => client.form.state(input, request(options)),
-        reply: async (input, options) => {
-          await client.form.reply({ ...input, answer: { ...input.answer } }, request(options))
-        },
-        cancel: async (input, options) => {
-          await client.form.cancel(input, request(options))
-        },
-      },
       savedPermissionsV2: {
         list: (input, options) => client.permission.saved.list(input, request(options)),
         remove: async (input, options) => {
           await client.permission.saved.remove(input, request(options))
         },
       },
-      shellsV2: {
-        list: async (input, options) =>
-          (await client.shell.list({ location: location(input) }, request(options))).data.map(toShell),
-        create: async (input, options) =>
-          toShell(
-            (
-              await client.shell.create(
-                {
-                  ...input,
-                  metadata: input.metadata && toClientRecord(input.metadata),
-                  location: location(input),
-                },
-                request(options),
-              )
-            ).data,
-          ),
-        get: async (input, options) =>
-          toShell((await client.shell.get({ id: input.id, location: location(input) }, request(options))).data),
-        setTimeout: async (input, options) =>
-          toShell(
-            (
-              await client.shell.timeout(
-                { id: input.id, timeout: input.timeout, location: location(input) },
-                request(options),
-              )
-            ).data,
-          ),
-        output: async (input, options) =>
-          (
-            await client.shell.output(
-              { id: input.id, cursor: input.cursor, limit: input.limit, location: location(input) },
-              request(options),
-            )
-          ).data,
-        remove: async (input, options) => {
-          await client.shell.remove({ id: input.id, location: location(input) }, request(options))
-        },
-      },
-      discoveryV2: {
-        server: (options) => client.server.get(request(options)),
-        location: (input, options) => client.location.get({ location: location(input) }, request(options)),
-        plugins: async (input, options) =>
-          (await client.plugin.list({ location: location(input) }, request(options))).data,
-        skills: async (input, options) => {
-          const result = await client.skill.list({ location: location(input) }, request(options))
-          return result.data.map((item) => ({
-            ...item,
-            location: { directory: item.location },
-          }))
-        },
-        models: async (input, options) =>
-          (await client.model.list({ location: location(input) }, request(options))).data.map(toModel),
-        defaultModel: async (input, options) => {
-          const result = await client.model.default({ location: location(input) }, request(options))
-          return result.data ? toModel(result.data) : null
-        },
-        generateText: async (input, options) =>
-          (await client.generate.text({ ...input, location: location(input) }, request(options))).text,
-        loadedLocations: (options) => client.debug.location.list(request(options)),
-      },
     },
   }
 }
@@ -531,10 +508,46 @@ function toLocation(input?: LocationRef) {
   return { directory: input.directory, workspace: input.workspaceID }
 }
 
+function sameModel(
+  left: { id: string; providerID: string; variant?: string },
+  right?: { id: string; providerID: string; variant?: string },
+) {
+  return left.id === right?.id && left.providerID === right.providerID && left.variant === right.variant
+}
+
+function nativeSessionURL(config: PtyTransportConfig, sessionID: string, suffix: string) {
+  return new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/session/${encodeURIComponent(sessionID)}${suffix}`)
+}
+
+function nativePtyURL(config: PtyTransportConfig, ptyID: string, suffix: string, location?: LocationRef) {
+  const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/pty/${encodeURIComponent(ptyID)}${suffix}`)
+  if (location?.directory) url.searchParams.set("location[directory]", location.directory)
+  if (location?.workspaceID) url.searchParams.set("location[workspace]", location.workspaceID)
+  return url
+}
+
+function nativeFileURL(config: PtyTransportConfig, path: string, location?: LocationRef) {
+  const url = new URL(`${config.baseUrl.replace(/\/+$/, "")}/api/fs/read/${encodeURIComponent(path)}`)
+  if (location?.directory) url.searchParams.set("location[directory]", location.directory)
+  if (location?.workspaceID) url.searchParams.set("location[workspace]", location.workspaceID)
+  return url
+}
+
+function transportHeaders(config: PtyTransportConfig, ticket = false, json = false) {
+  const headers = new Headers()
+  if (config.password)
+    headers.set("authorization", `Basic ${btoa(`${config.username ?? "opencode"}:${config.password}`)}`)
+  if (ticket) headers.set("x-opencode-ticket", "1")
+  if (json) headers.set("content-type", "application/json")
+  return headers
+}
+
 function toProject(input: Project): AppProject {
   return {
     id: input.id,
     worktree: input.worktree,
+    vcs: input.vcs === "git" ? "git" : undefined,
+    time: input.time,
     name: input.name,
     icon: input.icon,
     commands: input.commands,
@@ -545,9 +558,13 @@ function toProject(input: Project): AppProject {
 function toSession(input: SessionInfo): AppSession {
   return {
     id: input.id,
+    slug: input.id,
+    version: "",
     parentID: input.parentID,
     projectID: input.projectID,
     location: input.location,
+    directory: input.location.directory,
+    workspaceID: input.location.workspaceID,
     title: input.title,
     cost: input.cost,
     tokens: input.tokens,
@@ -592,20 +609,26 @@ function toAgent(input: AgentInfo): AppAgent {
   }
 }
 
-function toTimelineItem(input: SessionMessageInfo, sessionID: string): TimelineItem {
+function toTimelineItem(
+  input: SessionMessageInfo,
+  sessionID: string,
+  parentID?: string,
+  projectedPrompt?: TimelineContent[],
+): TimelineItem {
   if (input.type === "user") {
     return {
       type: "user",
       id: input.id,
       sessionID,
       created: input.time.created,
-      content: [
+      content: projectedPrompt ?? [
         { type: "text", id: `${input.id}:text`, text: input.text },
         ...(input.files?.map((file, index) => ({
           type: "file" as const,
           id: `${input.id}:file:${index}`,
           uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
           name: file.name,
+          mime: file.mime,
         })) ?? []),
         ...(input.agents?.map((agent, index) => ({
           type: "agent" as const,
@@ -614,24 +637,24 @@ function toTimelineItem(input: SessionMessageInfo, sessionID: string): TimelineI
           source: agent.mention,
         })) ?? []),
       ],
-      raw: input,
     }
   }
-  if (input.type === "assistant") return toAssistant(input, sessionID)
+  if (input.type === "assistant") return toAssistant(input, sessionID, parentID)
   const type =
     input.type === "agent-switched"
       ? "agent-switch"
       : input.type === "model-switched"
         ? "model-switch"
         : input.type
-  return { type, id: input.id, sessionID, created: input.time.created, raw: input }
+  return { type, id: input.id, sessionID, created: input.time.created }
 }
 
-function toAssistant(input: SessionMessageAssistant, sessionID: string): TimelineItem {
+function toAssistant(input: SessionMessageAssistant, sessionID: string, parentID?: string): TimelineItem {
   return {
     type: "assistant",
     id: input.id,
     sessionID,
+    parentID,
     created: input.time.created,
     completed: input.time.completed,
     content: input.content.flatMap((item, index): TimelineContent[] => {
@@ -653,7 +676,12 @@ function toAssistant(input: SessionMessageAssistant, sessionID: string): Timelin
             type: "tool",
             id: item.id,
             tool: item.name,
-            state: { status: "running", input: item.state.input, metadata: item.state.structured },
+            state: {
+              status: "running",
+              input: item.state.input,
+              metadata: item.state.structured,
+              time: { start: input.time.created },
+            },
           },
         ]
       if (item.state.status === "error")
@@ -667,6 +695,7 @@ function toAssistant(input: SessionMessageAssistant, sessionID: string): Timelin
               input: item.state.input,
               error: item.state.error.message,
               metadata: item.state.structured,
+              time: { start: input.time.created, end: input.time.completed ?? input.time.created },
             },
           },
         ]
@@ -681,7 +710,9 @@ function toAssistant(input: SessionMessageAssistant, sessionID: string): Timelin
             output: item.state.content
               .map((content) => (content.type === "text" ? content.text : content.uri))
               .join("\n"),
+            title: "",
             metadata: item.state.structured,
+            time: { start: input.time.created, end: input.time.completed ?? input.time.created },
           },
         },
       ]
@@ -689,31 +720,77 @@ function toAssistant(input: SessionMessageAssistant, sessionID: string): Timelin
     agent: input.agent,
     model: input.model,
     tokens: input.tokens,
-    error: input.error,
-    raw: input,
+    error: input.error && { name: "UnknownError", data: { message: input.error.message } },
   }
 }
 
 function toPromptFile(input: PromptFile) {
   return {
     uri: input.uri,
+    mime: input.mime ?? "application/octet-stream",
     name: input.name,
-    mention: input.source && { start: input.source.start, end: input.source.end, text: input.source.text },
+    source: input.source && { start: input.source.start, end: input.source.end, text: input.source.text },
   }
 }
 
+function toPromptContent(parts: NonNullable<PromptInput["parts"]>): TimelineContent[] {
+  return parts.map((part) => {
+    if (part.type === "text") return { ...part }
+    if (part.type === "agent")
+      return {
+        type: part.type,
+        id: part.id,
+        name: part.name,
+        source: part.source && { text: part.source.value, start: part.source.start, end: part.source.end },
+      }
+    return {
+      type: part.type,
+      id: part.id,
+      uri: part.url,
+      name: part.filename,
+      mime: part.mime,
+      source:
+        part.source?.type === "resource"
+          ? {
+              type: part.source.type,
+              clientName: part.source.clientName,
+              uri: part.source.uri,
+              text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
+            }
+          : part.source && {
+              type: part.source.type,
+              path: part.source.path,
+              name: part.source.type === "symbol" ? part.source.name : undefined,
+              kind: part.source.type === "symbol" ? part.source.kind : undefined,
+              text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
+            },
+    }
+  })
+}
+
 function toPermission(input: PermissionV2Request): AppPermissionRequest {
   return {
     id: input.id,
     sessionID: input.sessionID,
     action: input.action,
     resources: input.resources,
-    metadata: input.metadata,
+    permission: input.action,
+    patterns: [...input.resources],
+    always: [],
+    metadata: input.metadata ? { ...input.metadata } : {},
   }
 }
 
 function toQuestion(input: QuestionV2Request): AppQuestionRequest {
-  return { id: input.id, sessionID: input.sessionID, questions: input.questions }
+  return {
+    id: input.id,
+    sessionID: input.sessionID,
+    questions: input.questions.map((question) => ({
+      ...question,
+      header: question.header ?? "",
+      options: question.options.map((option) => ({ ...option, description: option.description ?? "" })),
+    })),
+  }
 }
 
 function toFileDiff(input: { file: string; patch: string; additions: number; deletions: number; status: "added" | "deleted" | "modified" }): AppFileDiff {
@@ -739,8 +816,8 @@ function toIntegration(input: IntegrationInfo) {
     }),
     connections: input.connections.map((connection): IntegrationConnection =>
       connection.type === "credential"
-        ? { id: connection.id, label: connection.label }
-        : { id: connection.name, label: connection.name },
+        ? { id: connection.id, label: connection.label, kind: "credential" }
+        : { id: connection.name, label: connection.name, kind: "environment" },
     ),
   }
 }
@@ -856,115 +933,458 @@ function toShell(input: ShellInfo1): ShellProcess {
   }
 }
 
-async function toEvent(
+type NativeEventData = {
+  sessionID: string
+  assistantMessageID: string
+  messageID: string
+  partID: string
+  requestID: string
+  textID: string
+  reasoningID: string
+  callID: string
+  timestamp: number
+  agent: string
+  model: { id: string; providerID: string; variant?: string }
+  finish: string
+  cost: number
+  tokens: import("./backend").TokenUsage
+  error: { message: string }
+  location: LocationRef
+  revert: { messageID: string }
+  prompt: { text: string; files?: { uri: string; name?: string; mime?: string }[]; agents?: { name: string; source?: import("./backend").SourceText }[] }
+  text: string
+  delta: string
+  field: string
+  name: string
+  tool: string
+  input: Record<string, unknown>
+  structured: Record<string, unknown>
+  content: import("./backend").ToolOutputContent[]
+  outputPaths: string[]
+  result: unknown
+  provider: import("./backend").ToolProviderInfo
+  providerMetadata: Record<string, unknown>
+  info: unknown
+  todos: { content: string; status: string; priority: string }[]
+  diff: { file?: string; patch?: string; additions: number; deletions: number; status?: "added" | "deleted" | "modified" }[]
+}
+
+function toEvent(
   input: OpenCodeEvent,
-  loadSession: (sessionID: string, options?: RequestOptions) => Promise<AppSession>,
-  loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise<TimelineItem>,
-  options?: RequestOptions,
-): Promise<AppEvent> {
-  if (input.type === "server.connected") return { type: input.type }
-  if (input.type === "session.deleted") return { type: input.type, sessionID: input.data.sessionID }
-  if (input.type === "session.error") return { type: input.type, sessionID: input.data.sessionID, error: input.data.error }
-  if (input.type === "session.status") {
-    const activity = toActivity(input.data.status)
-    return activity
-      ? { type: "session.activity", sessionID: input.data.sessionID, activity }
-      : { type: "unknown", raw: input }
-  }
-  if (input.type === "session.execution.started")
-    return { type: "session.activity", sessionID: input.data.sessionID, activity: { type: "running" } }
-  if (input.type === "session.retry.scheduled")
+  projectedContent: Map<string, TimelineContent>,
+  projectedItems: Map<string, TimelineItem>,
+  projectedSessions: Map<string, AppSession>,
+  projectedPrompts: Map<string, { sessionID: string; content: TimelineContent[] }>,
+): AppEvent {
+  const type = input.type as string
+  const data = input.data as unknown as NativeEventData
+  if (type === "server.connected") {
+    projectedContent.clear()
+    projectedItems.clear()
+    projectedSessions.clear()
+    projectedPrompts.clear()
+    return { type: "server.connected" }
+  }
+  if (type === "session.created" || type === "session.updated") {
+    const session = eventSession(data.info)
+    if (!session) return { type: "unknown", raw: input }
+    projectedSessions.set(session.id, session)
+    return { type: type === "session.created" ? "session.created" : "session.updated", session }
+  }
+  if (type === "session.deleted") {
+    projectedSessions.delete(data.sessionID)
+    clearSessionProjection(data.sessionID, projectedContent, projectedItems)
+    clearSessionPrompts(data.sessionID, projectedPrompts)
+    return { type: "session.deleted", sessionID: data.sessionID }
+  }
+  if (type === "session.next.moved") {
+    const current = projectedSessions.get(data.sessionID)
+    if (current)
+      projectedSessions.set(current.id, {
+        ...current,
+        location: data.location,
+        directory: data.location.directory,
+        workspaceID: data.location.workspaceID,
+      })
+    return { type: "session.moved", sessionID: data.sessionID, location: data.location }
+  }
+  if (type === "session.next.revert.staged") {
+    const revert = { messageID: data.revert.messageID }
+    const current = projectedSessions.get(data.sessionID)
+    if (current) projectedSessions.set(current.id, { ...current, revert })
+    return { type: "session.revert", sessionID: data.sessionID, revert }
+  }
+  if (type === "session.next.revert.cleared" || type === "session.next.revert.committed") {
+    const current = projectedSessions.get(data.sessionID)
+    if (current) projectedSessions.set(current.id, { ...current, revert: undefined })
+    return { type: "session.revert", sessionID: data.sessionID }
+  }
+  if (
+    type === "integration.updated" ||
+    type === "integration.connection.updated" ||
+    type === "catalog.updated" ||
+    type === "models-dev.refreshed"
+  )
+    return { type: "provider.updated" }
+  if (type === "permission.v2.asked")
+    return { type: "permission.requested", request: toPermission(input.data as PermissionV2Request) }
+  if (type === "permission.v2.replied")
+    return { type: "permission.replied", sessionID: data.sessionID, requestID: data.requestID }
+  if (type === "question.v2.asked")
+    return { type: "question.requested", request: toQuestion(input.data as QuestionV2Request) }
+  if (type === "question.v2.replied" || type === "question.v2.rejected")
+    return {
+      type: type === "question.v2.replied" ? "question.replied" : "question.rejected",
+      sessionID: data.sessionID,
+      requestID: data.requestID,
+    }
+  if (type === "file.watcher.updated" || type === "filesystem.changed") {
+    const changed = input.data as unknown as { file: string; event: "add" | "change" | "unlink" }
+    return { type: "file.changed", path: changed.file, change: changed.event }
+  }
+  if (type === "pty.exited") return { type: "pty.exited", ptyID: (input.data as unknown as { id: string }).id }
+  if (type === "message.removed")
+    return { type: "timeline.removed", sessionID: data.sessionID, itemID: data.messageID }
+  if (type === "session.next.prompted" || type === "session.next.prompt.admitted")
     return {
-      type: "session.activity",
-      sessionID: input.data.sessionID,
-      activity: {
-        type: "retry",
-        attempt: input.data.attempt,
-        message: input.data.error.message,
-        next: input.data.at,
+      type: "timeline.updated",
+      item: {
+        type: "user",
+        id: data.messageID,
+        sessionID: data.sessionID,
+        created: data.timestamp,
+        content: projectedPrompts.get(data.messageID)?.content ?? [
+          { type: "text", id: `${data.messageID}:text`, text: data.prompt.text },
+          ...(data.prompt.files?.map((file, index) => ({
+            type: "file" as const,
+            id: `${data.messageID}:file:${index}`,
+            uri: file.uri,
+            name: file.name,
+            mime: file.mime,
+          })) ?? []),
+          ...(data.prompt.agents?.map((agent, index) => ({
+            type: "agent" as const,
+            id: `${data.messageID}:agent:${index}`,
+            name: agent.name,
+            source: agent.source,
+          })) ?? []),
+        ],
       },
     }
-  if (input.type === "session.execution.failed")
-    return { type: "session.error", sessionID: input.data.sessionID, error: input.data.error }
-  if (input.type === "session.idle") return { type: "unknown", raw: input }
-  if (input.type === "permission.v2.asked") return { type: "permission.requested", request: toPermission(input.data) }
-  if (input.type === "permission.v2.replied")
-    return { type: "permission.replied", sessionID: input.data.sessionID, requestID: input.data.requestID }
-  if (input.type === "question.v2.asked") return { type: "question.requested", request: toQuestion(input.data) }
-  if (input.type === "question.v2.replied" || input.type === "question.v2.rejected")
+  if (type === "session.next.step.started") {
+    const item = {
+      type: "assistant" as const,
+      id: data.assistantMessageID,
+      sessionID: data.sessionID,
+      created: data.timestamp,
+      content: [] as TimelineContent[],
+      agent: data.agent,
+      model: data.model,
+    }
+    projectedItems.set(item.id, item)
+    return { type: "session.activity", sessionID: data.sessionID, activity: { type: "running" }, item }
+  }
+  if (type === "session.next.step.ended") {
+    const current = projectedItems.get(data.assistantMessageID)
+    const item =
+      current?.type === "assistant"
+        ? {
+            ...current,
+            completed: data.timestamp,
+            finish: data.finish,
+            cost: data.cost,
+            tokens: data.tokens,
+            content: projectedMessageContent(data.assistantMessageID, projectedContent),
+          }
+        : undefined
+    projectedItems.delete(data.assistantMessageID)
+    clearMessageContent(data.assistantMessageID, projectedContent)
+    clearSessionPrompts(data.sessionID, projectedPrompts)
+    return { type: "session.activity", sessionID: data.sessionID, activity: { type: "idle" }, item }
+  }
+  if (type === "session.next.step.failed") {
+    const current = projectedItems.get(data.assistantMessageID)
+    const item =
+      current?.type === "assistant"
+        ? {
+            ...current,
+            completed: data.timestamp,
+            error: { name: "UnknownError" as const, data: { message: data.error.message } },
+            content: projectedMessageContent(data.assistantMessageID, projectedContent),
+          }
+        : undefined
+    projectedItems.delete(data.assistantMessageID)
+    clearMessageContent(data.assistantMessageID, projectedContent)
+    clearSessionPrompts(data.sessionID, projectedPrompts)
+    return { type: "session.activity", sessionID: data.sessionID, activity: { type: "idle" }, item }
+  }
+  if (type === "session.next.text.started" || type === "session.next.text.ended") {
+    const content = {
+      type: "text" as const,
+      id: data.textID,
+      text: type === "session.next.text.ended" ? data.text : "",
+    }
+    projectedContent.set(`${data.assistantMessageID}:${content.id}`, content)
     return {
-      type: input.type === "question.v2.replied" ? "question.replied" : "question.rejected",
-      sessionID: input.data.sessionID,
-      requestID: input.data.requestID,
+      type: "timeline.content.updated",
+      sessionID: data.sessionID,
+      itemID: data.assistantMessageID,
+      content,
+    }
+  }
+  if (type === "session.next.reasoning.started" || type === "session.next.reasoning.ended") {
+    const content = {
+      type: "reasoning" as const,
+      id: data.reasoningID,
+      text: type === "session.next.reasoning.ended" ? data.text : "",
+      metadata: data.providerMetadata,
+      time: { start: data.timestamp, end: type === "session.next.reasoning.ended" ? data.timestamp : undefined },
     }
-  if (input.type === "filesystem.changed")
-    return { type: "file.changed", path: input.data.file, change: input.data.event }
-  if (input.type === "vcs.branch.updated") return { type: input.type, branch: input.data.branch }
-  if (input.type === "pty.exited") return { type: input.type, ptyID: input.data.id }
-  if (input.type === "message.removed")
-    return { type: "timeline.removed", sessionID: input.data.sessionID, itemID: input.data.messageID }
-  if (input.type === "message.updated")
+    projectedContent.set(`${data.assistantMessageID}:${content.id}`, content)
     return {
-      type: "timeline.updated",
-      item: await loadMessage(input.data.sessionID, input.data.info.id, options),
+      type: "timeline.content.updated",
+      sessionID: data.sessionID,
+      itemID: data.assistantMessageID,
+      content,
     }
-  if (input.type === "message.part.updated")
+  }
+  if (
+    type === "session.next.tool.input.started" ||
+    type === "session.next.tool.input.ended" ||
+    type === "session.next.tool.called" ||
+    type === "session.next.tool.progress" ||
+    type === "session.next.tool.success" ||
+    type === "session.next.tool.failed"
+  ) {
+    const key = `${data.assistantMessageID}:${data.callID}`
+    const previous = projectedContent.get(key)
+    const tool = previous?.type === "tool" ? previous.tool : data.name ?? data.tool
+    const priorInput = previous?.type === "tool" ? previous.state.input : {}
+    const state = (() => {
+      if (type === "session.next.tool.input.started")
+        return { status: "pending" as const, input: {}, raw: "" }
+      if (type === "session.next.tool.input.ended")
+        return { status: "pending" as const, input: {}, raw: data.text }
+      if (type === "session.next.tool.called")
+        return {
+          status: "running" as const,
+          input: data.input,
+          time: { start: data.timestamp },
+          provider: data.provider,
+        }
+      if (type === "session.next.tool.progress")
+        return {
+          status: "running" as const,
+          input: priorInput,
+          metadata: data.structured,
+          time: { start: data.timestamp },
+          content: data.content,
+        }
+      if (type === "session.next.tool.failed")
+        return {
+          status: "error" as const,
+          input: priorInput,
+          error: data.error.message,
+          time: { start: data.timestamp, end: data.timestamp },
+          result: data.result,
+          provider: data.provider,
+        }
+      return {
+        status: "completed" as const,
+        input: priorInput,
+        output: data.content.map((content) => (content.type === "text" ? content.text : content.uri)).join("\n"),
+        title: "",
+        metadata: data.structured,
+        time: { start: data.timestamp, end: data.timestamp },
+        content: data.content,
+        outputPaths: data.outputPaths,
+        result: data.result,
+        provider: data.provider,
+        attachments: data.content.flatMap((content, index) =>
+          content.type === "file"
+            ? [
+                {
+                  id: `${data.callID}:attachment:${index}`,
+                  sessionID: data.sessionID,
+                  messageID: data.assistantMessageID,
+                  type: "file" as const,
+                  mime: content.mime,
+                  filename: content.name,
+                  url: content.uri,
+                },
+              ]
+            : [],
+        ),
+      }
+    })()
+    const content = { type: "tool" as const, id: data.callID, callID: data.callID, tool, state }
+    projectedContent.set(key, content)
     return {
-      type: "timeline.updated",
-      item: await loadMessage(input.data.sessionID, input.data.part.messageID, options),
+      type: "timeline.content.updated",
+      sessionID: data.sessionID,
+      itemID: data.assistantMessageID,
+      content,
     }
-
-  const sessionID = eventSessionID(input)
-  const messageID = eventMessageID(input)
-  if (sessionID && messageID)
-    return { type: "timeline.updated", item: await loadMessage(sessionID, messageID, options) }
-  if (sessionID && isSessionProjectionEvent(input.type)) {
-    const session = await loadSession(sessionID, options)
-    return { type: input.type === "session.created" ? "session.created" : "session.updated", session }
   }
+  if (type === "todo.updated")
+    return {
+      type: "todo.updated",
+      sessionID: data.sessionID,
+      todos: data.todos.map((todo) => ({
+        content: todo.content,
+        status: todo.status,
+        priority: todo.priority,
+      })),
+    }
+  if (type === "reference.updated") return { type: "reference.updated" }
+  if (type === "session.diff")
+    return {
+      type: "session.diff",
+      sessionID: data.sessionID,
+      diff: data.diff.flatMap((item) => (item.file ? [{ ...item, file: item.file }] : [])),
+    }
+  if (type === "message.part.removed") {
+    projectedContent.delete(`${data.messageID}:${data.partID}`)
+    return {
+      type: "timeline.part.removed",
+      sessionID: data.sessionID,
+      itemID: data.messageID,
+      contentID: data.partID,
+    }
+  }
+  if (type === "message.part.delta")
+    return projectDelta(
+      {
+        type: "timeline.delta",
+        sessionID: data.sessionID,
+        itemID: data.messageID,
+        contentID: data.partID,
+        field: data.field,
+        delta: data.delta,
+      },
+      projectedContent,
+    )
+  if (type === "session.next.text.delta" || type === "session.next.reasoning.delta")
+    return projectDelta(
+      {
+        type: "timeline.delta",
+        sessionID: data.sessionID,
+        itemID: data.assistantMessageID,
+        contentID: type === "session.next.text.delta" ? data.textID : data.reasoningID,
+        field: "text",
+        delta: data.delta,
+      },
+      projectedContent,
+    )
   return { type: "unknown", raw: input }
 }
 
-function toActivity(input: import("@opencode-ai/client").SessionStatus): SessionActivity | undefined {
-  if (input.type === "idle") return
-  if (input.type === "busy") return { type: "running" }
-  return input
+function clearSessionPrompts(
+  sessionID: string,
+  projected: Map<string, { sessionID: string; content: TimelineContent[] }>,
+) {
+  for (const [messageID, prompt] of projected) if (prompt.sessionID === sessionID) projected.delete(messageID)
+}
+
+function projectDelta(event: Extract<AppEvent, { type: "timeline.delta" }>, projected: Map<string, TimelineContent>) {
+  const key = `${event.itemID}:${event.contentID}`
+  const content = projected.get(key)
+  if (content && event.field === "text" && "text" in content)
+    projected.set(key, { ...content, text: content.text + event.delta })
+  return event
 }
 
-function eventSessionID(input: OpenCodeEvent) {
-  if (!("data" in input) || typeof input.data !== "object" || input.data === null || !("sessionID" in input.data)) return
-  return typeof input.data.sessionID === "string" ? input.data.sessionID : undefined
+function projectedMessageContent(messageID: string, projected: Map<string, TimelineContent>) {
+  return [...projected.entries()].filter(([key]) => key.startsWith(`${messageID}:`)).map(([, content]) => content)
 }
 
-function eventMessageID(input: OpenCodeEvent) {
-  if (!("data" in input) || typeof input.data !== "object" || input.data === null) return
-  if ("assistantMessageID" in input.data && typeof input.data.assistantMessageID === "string")
-    return input.data.assistantMessageID
-  if ("messageID" in input.data && typeof input.data.messageID === "string") return input.data.messageID
-  if (input.type === "session.input.promoted") return input.data.inputID
+function clearMessageContent(messageID: string, projected: Map<string, TimelineContent>) {
+  for (const key of projected.keys()) if (key.startsWith(`${messageID}:`)) projected.delete(key)
 }
 
-function isSessionProjectionEvent(type: string) {
-  return [
-    "session.created",
-    "session.updated",
-    "session.agent.selected",
-    "session.model.selected",
-    "session.moved",
-    "session.renamed",
-    "session.usage.updated",
-    "session.revert.staged",
-    "session.revert.cleared",
-    "session.revert.committed",
-  ].includes(type)
+function clearSessionProjection(
+  sessionID: string,
+  projectedContent: Map<string, TimelineContent>,
+  projectedItems: Map<string, TimelineItem>,
+) {
+  const messages = [...projectedItems.values()].filter((item) => item.sessionID === sessionID).map((item) => item.id)
+  messages.forEach((messageID) => clearMessageContent(messageID, projectedContent))
+  for (const [id, item] of projectedItems) if (item.sessionID === sessionID) projectedItems.delete(id)
 }
 
-async function toLogEvent(
-  input: import("@opencode-ai/client").SessionLogOutput,
-  loadSession: (sessionID: string, options?: RequestOptions) => Promise<AppSession>,
-  loadMessage: (sessionID: string, messageID: string, options?: RequestOptions) => Promise<TimelineItem>,
+function eventSession(input: unknown): AppSession | undefined {
+  const info = record(input)
+  if (!info || typeof info.id !== "string" || typeof info.projectID !== "string" || typeof info.title !== "string") return
+  const location = record(info.location)
+  const directory =
+    typeof location?.directory === "string"
+      ? location.directory
+      : typeof info.directory === "string"
+        ? info.directory
+        : undefined
+  if (!directory) return
+  const time = record(info.time)
+  if (typeof time?.created !== "number" || typeof time.updated !== "number") return
+  const workspaceID =
+    typeof location?.workspaceID === "string"
+      ? location.workspaceID
+      : typeof info.workspaceID === "string"
+        ? info.workspaceID
+        : undefined
+  return {
+    id: info.id,
+    slug: typeof info.slug === "string" ? info.slug : info.id,
+    version: typeof info.version === "string" ? info.version : "",
+    parentID: typeof info.parentID === "string" ? info.parentID : undefined,
+    projectID: info.projectID,
+    location: { directory, workspaceID },
+    directory,
+    workspaceID,
+    title: info.title,
+    time: {
+      created: time.created,
+      updated: time.updated,
+      archived: typeof time.archived === "number" ? time.archived : undefined,
+    },
+  }
+}
+
+function record(input: unknown): Record<string, unknown> | undefined {
+  if (!input || typeof input !== "object" || Array.isArray(input)) return
+  return input as Record<string, unknown>
+}
+
+function eventSequence(input: OpenCodeEvent) {
+  const durable = record(record(input)?.durable)
+  return typeof durable?.seq === "number" ? durable.seq : 0
+}
+
+async function* nativeSessionEvents(
+  config: PtyTransportConfig,
+  sessionID: string,
+  after: number | undefined,
   options?: RequestOptions,
-): Promise<AppEvent> {
-  if (input.type === "log.synced") return { type: "unknown", raw: input }
-  return toEvent(input, loadSession, loadMessage, options)
+): AsyncIterable<OpenCodeEvent> {
+  const url = nativeSessionURL(config, sessionID, "/event")
+  if (after !== undefined) url.searchParams.set("after", String(after))
+  const response = await config.fetch(url, { signal: options?.signal, headers: transportHeaders(config) })
+  if (!response.ok) throw new Error(`Failed to read session events: ${response.status} ${response.statusText}`)
+  if (!response.body) return
+  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
+  let buffer = ""
+  while (true) {
+    const result = await reader.read()
+    buffer += result.value ?? ""
+    const records = buffer.split("\n\n")
+    buffer = records.pop() ?? ""
+    for (const entry of records) {
+      const payload = entry
+        .split("\n")
+        .filter((line) => line.startsWith("data:"))
+        .map((line) => line.slice(5).trimStart())
+        .join("\n")
+      if (payload) yield JSON.parse(payload) as OpenCodeEvent
+    }
+    if (result.done) break
+  }
 }

+ 56 - 0
packages/app/src/context/backend.test-fixture.ts

@@ -0,0 +1,56 @@
+import type { AppClient, Capabilities, CommonClient } from "./backend"
+
+type PartialApi<T> = {
+  [K in keyof T]?: T[K] extends (...args: never[]) => unknown ? T[K] : PartialApi<T[K]>
+}
+
+export function createAppClient(input: {
+  version?: AppClient["version"]
+  common?: PartialApi<CommonClient>
+  capabilities?: Capabilities
+} = {}): AppClient {
+  const unsupported = async () => {
+    throw new Error("Backend fixture method is not configured")
+  }
+  const defaults: CommonClient = {
+    health: { get: unsupported },
+    projects: { current: unsupported },
+    catalog: { providers: unsupported, agents: unsupported },
+    commands: { list: unsupported },
+    references: { list: unsupported },
+    sessions: {
+      list: unsupported,
+      create: unsupported,
+      get: unsupported,
+      interrupt: unsupported,
+      activity: unsupported,
+      history: unsupported,
+      message: unsupported,
+      prompt: unsupported,
+    },
+    files: { list: unsupported, find: unsupported, read: unsupported },
+    permissions: { pending: unsupported, reply: unsupported },
+    questions: { pending: unsupported, reply: unsupported, reject: unsupported },
+    pty: { list: unsupported, create: unsupported, get: unsupported, update: unsupported, remove: unsupported },
+    events: {
+      async *subscribe() {},
+    },
+  }
+  return {
+    version: input.version ?? "v1",
+    capabilities: input.capabilities ?? {},
+    common: {
+      health: { ...defaults.health, ...input.common?.health },
+      projects: { ...defaults.projects, ...input.common?.projects },
+      catalog: { ...defaults.catalog, ...input.common?.catalog },
+      commands: { ...defaults.commands, ...input.common?.commands },
+      references: { ...defaults.references, ...input.common?.references },
+      sessions: { ...defaults.sessions, ...input.common?.sessions },
+      files: { ...defaults.files, ...input.common?.files },
+      permissions: { ...defaults.permissions, ...input.common?.permissions },
+      questions: { ...defaults.questions, ...input.common?.questions },
+      pty: { ...defaults.pty, ...input.common?.pty },
+      events: { ...defaults.events, ...input.common?.events },
+    },
+  }
+}

+ 556 - 81
packages/app/src/context/backend.ts

@@ -21,8 +21,10 @@ export type ModelRef = {
 
 export type Page<T> = {
   readonly items: readonly T[]
-  readonly previous?: string
-  readonly next?: string
+  /** Cursor for items older than this page. */
+  readonly older?: string
+  /** Cursor for items newer than this page. */
+  readonly newer?: string
 }
 
 export type Health = {
@@ -34,6 +36,12 @@ export type Health = {
 export type AppProject = {
   readonly id: string
   readonly worktree: string
+  readonly vcs?: "git"
+  readonly time: {
+    readonly created: number
+    readonly updated: number
+    readonly initialized?: number
+  }
   readonly name?: string
   readonly icon?: {
     readonly url?: string
@@ -43,7 +51,7 @@ export type AppProject = {
   readonly commands?: {
     readonly start?: string
   }
-  readonly sandboxes: readonly string[]
+  sandboxes: string[]
 }
 
 export type CurrentProject = {
@@ -83,6 +91,8 @@ export type AppModel = {
 export type AppProvider = {
   readonly id: string
   readonly name: string
+  readonly source?: "env" | "config" | "custom" | "api"
+  readonly integrationID?: string
   readonly models: Readonly<Record<string, AppModel>>
 }
 
@@ -137,15 +147,19 @@ export type TokenUsage = {
 
 export type AppSession = {
   readonly id: string
+  readonly slug: string
+  readonly version: string
   readonly parentID?: string
   readonly projectID: string
-  readonly location: LocationRef
-  readonly title: string
-  readonly cost: number
+  readonly location?: LocationRef
+  readonly directory: string
+  readonly workspaceID?: string
+  title: string
+  readonly cost?: number
   readonly tokens?: TokenUsage
   readonly time: {
     readonly created: number
-    readonly updated?: number
+    readonly updated: number
     readonly archived?: number
   }
   readonly share?: {
@@ -157,6 +171,8 @@ export type AppSession = {
 }
 
 export type SessionActivity =
+  | { readonly type: "idle" }
+  | { readonly type: "busy" }
   | { readonly type: "running" }
   | {
       readonly type: "retry"
@@ -205,28 +221,139 @@ export type ToolState =
   | {
       readonly status: "pending"
       readonly input: Readonly<Record<string, unknown>>
-      readonly raw?: string
+      readonly raw: string
     }
   | {
       readonly status: "running"
       readonly input: Readonly<Record<string, unknown>>
       readonly title?: string
       readonly metadata?: Readonly<Record<string, unknown>>
+      readonly time: { readonly start: number }
+      readonly content?: readonly ToolOutputContent[]
+      readonly provider?: ToolProviderInfo
     }
   | {
       readonly status: "completed"
       readonly input: Readonly<Record<string, unknown>>
       readonly output: string
-      readonly title?: string
-      readonly metadata?: Readonly<Record<string, unknown>>
+      readonly title: string
+      readonly metadata: Readonly<Record<string, unknown>>
+      readonly time: { readonly start: number; readonly end: number; readonly compacted?: number }
+      readonly attachments?: AppFilePart[]
+      readonly content?: readonly ToolOutputContent[]
+      readonly outputPaths?: readonly string[]
+      readonly result?: unknown
+      readonly provider?: ToolProviderInfo
     }
   | {
       readonly status: "error"
       readonly input: Readonly<Record<string, unknown>>
       readonly error: string
       readonly metadata?: Readonly<Record<string, unknown>>
+      readonly time: { readonly start: number; readonly end: number }
+      readonly content?: readonly ToolOutputContent[]
+      readonly result?: unknown
+      readonly provider?: ToolProviderInfo
     }
 
+export type ToolOutputContent =
+  | { readonly type: "text"; readonly text: string }
+  | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
+
+export type ToolProviderInfo = {
+  readonly executed: boolean
+  readonly metadata?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
+  readonly resultMetadata?: Readonly<Record<string, Readonly<Record<string, unknown>>>>
+}
+
+export type AppMessage = AppUserMessage | AppAssistantMessage
+
+export type AppUserMessage = {
+  readonly id: string
+  readonly sessionID: string
+  readonly role: "user"
+  readonly time: { readonly created: number }
+  readonly format?:
+    | { readonly type: "text" }
+    | { readonly type: "json_schema"; readonly schema: Readonly<Record<string, unknown>>; readonly retryCount?: number }
+  readonly summary?: {
+    readonly title?: string
+    readonly body?: string
+    readonly diffs: (Omit<AppFileDiff, "file"> & { readonly file?: string })[]
+  }
+  readonly agent: string
+  readonly model: { readonly providerID: string; readonly modelID: string; readonly variant?: string }
+  readonly system?: string
+  readonly tools?: Readonly<Record<string, boolean>>
+}
+
+export type AppAssistantMessage = {
+  readonly id: string
+  readonly sessionID: string
+  readonly role: "assistant"
+  readonly time: { readonly created: number; readonly completed?: number }
+  readonly parentID: string
+  readonly modelID: string
+  readonly providerID: string
+  readonly mode: string
+  readonly agent: string
+  readonly path: { readonly cwd: string; readonly root: string }
+  readonly summary?: boolean
+  readonly cost: number
+  readonly tokens: TokenUsage & { readonly total?: number }
+  readonly structured?: unknown
+  readonly variant?: string
+  readonly finish?: string
+  readonly error?: AppMessageError
+}
+
+export type AppMessageError =
+  | { readonly name: "ProviderAuthError"; readonly data: { readonly providerID: string; readonly message: string } }
+  | { readonly name: "UnknownError"; readonly data: { readonly message: string; readonly ref?: string } }
+  | { readonly name: "MessageOutputLengthError"; readonly data: Readonly<Record<string, unknown>> }
+  | { readonly name: "MessageAbortedError"; readonly data: { readonly message: string } }
+  | { readonly name: "StructuredOutputError"; readonly data: { readonly message: string; readonly retries: number } }
+  | { readonly name: "ContextOverflowError"; readonly data: { readonly message: string; readonly responseBody?: string } }
+  | { readonly name: "ContentFilterError"; readonly data: { readonly message: string } }
+  | { readonly name: "APIError"; readonly data: { readonly message: string; readonly statusCode?: number; readonly isRetryable: boolean; readonly responseHeaders?: Readonly<Record<string, string>>; readonly responseBody?: string; readonly metadata?: Readonly<Record<string, string>> } }
+
+export type AppRetryError = Extract<AppMessageError, { readonly name: "APIError" }>
+
+export type AppSessionError = AppMessageError | { readonly type: "unknown"; readonly message: string }
+
+type AppPartBase = {
+  readonly id: string
+  readonly sessionID: string
+  readonly messageID: string
+}
+
+export type AppFilePart = AppPartBase & {
+  readonly type: "file"
+  readonly mime: string
+  readonly filename?: string
+  readonly url: string
+  readonly source?: AppFilePartSource
+}
+
+export type AppFilePartSource =
+  | { readonly type: "file"; readonly path: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
+  | { readonly type: "symbol"; readonly path: string; readonly name: string; readonly kind: number; readonly range: { readonly start: { readonly line: number; readonly character: number }; readonly end: { readonly line: number; readonly character: number } }; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
+  | { readonly type: "resource"; readonly clientName: string; readonly uri: string; readonly text: { readonly value: string; readonly start: number; readonly end: number } }
+
+export type AppPart =
+  | (AppPartBase & { readonly type: "text"; readonly text: string; readonly synthetic?: boolean; readonly ignored?: boolean; readonly time?: { readonly start: number; readonly end?: number }; readonly metadata?: Readonly<Record<string, unknown>> })
+  | (AppPartBase & { readonly type: "reasoning"; readonly text: string; readonly metadata?: Readonly<Record<string, unknown>>; readonly time: { readonly start: number; readonly end?: number } })
+  | AppFilePart
+  | (AppPartBase & { readonly type: "agent"; readonly name: string; readonly source?: { readonly value: string; readonly start: number; readonly end: number } })
+  | (AppPartBase & { readonly type: "tool"; readonly callID: string; readonly tool: string; readonly state: ToolState; readonly metadata?: Readonly<Record<string, unknown>> })
+  | (AppPartBase & { readonly type: "subtask"; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: { readonly providerID: string; readonly modelID: string }; readonly command?: string })
+  | (AppPartBase & { readonly type: "step-start"; readonly snapshot?: string })
+  | (AppPartBase & { readonly type: "step-finish"; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } })
+  | (AppPartBase & { readonly type: "snapshot"; readonly snapshot: string })
+  | (AppPartBase & { readonly type: "patch"; readonly hash: string; readonly files: string[] })
+  | (AppPartBase & { readonly type: "retry"; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } })
+  | (AppPartBase & { readonly type: "compaction"; readonly auto: boolean })
+
 export type TimelineContent =
   | {
       readonly type: "text"
@@ -235,11 +362,14 @@ export type TimelineContent =
       readonly synthetic?: boolean
       readonly ignored?: boolean
       readonly metadata?: Readonly<Record<string, unknown>>
+      readonly time?: { readonly start: number; readonly end?: number }
     }
   | {
       readonly type: "reasoning"
       readonly id: string
       readonly text: string
+      readonly metadata?: Readonly<Record<string, unknown>>
+      readonly time?: { readonly start: number; readonly end?: number }
     }
   | {
       readonly type: "file"
@@ -261,7 +391,15 @@ export type TimelineContent =
       readonly callID?: string
       readonly tool: string
       readonly state: ToolState
+      readonly metadata?: Readonly<Record<string, unknown>>
     }
+  | { readonly type: "subtask"; readonly id: string; readonly prompt: string; readonly description: string; readonly agent: string; readonly model?: ModelRef; readonly command?: string }
+  | { readonly type: "step-start"; readonly id: string; readonly snapshot?: string }
+  | { readonly type: "step-finish"; readonly id: string; readonly reason: string; readonly snapshot?: string; readonly cost: number; readonly tokens: TokenUsage & { readonly total?: number } }
+  | { readonly type: "snapshot"; readonly id: string; readonly snapshot: string }
+  | { readonly type: "patch"; readonly id: string; readonly hash: string; readonly files: string[] }
+  | { readonly type: "retry"; readonly id: string; readonly attempt: number; readonly error: AppRetryError; readonly time: { readonly created: number } }
+  | { readonly type: "compaction"; readonly id: string; readonly auto: boolean }
 
 export type TimelineItem =
   | {
@@ -272,7 +410,10 @@ export type TimelineItem =
       readonly content: readonly TimelineContent[]
       readonly agent?: string
       readonly model?: ModelRef
-      readonly raw?: unknown
+      readonly format?: AppUserMessage["format"]
+      readonly summary?: AppUserMessage["summary"]
+      readonly system?: string
+      readonly tools?: Readonly<Record<string, boolean>>
     }
   | {
       readonly type: "assistant"
@@ -285,17 +426,158 @@ export type TimelineItem =
       readonly agent?: string
       readonly model?: ModelRef
       readonly tokens?: TokenUsage
-      readonly error?: unknown
-      readonly raw?: unknown
+      readonly error?: AppMessageError
+      readonly mode?: string
+      readonly path?: AppAssistantMessage["path"]
+      readonly cost?: number
+      readonly structured?: unknown
+      readonly finish?: string
+      readonly summary?: boolean
+      readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: readonly string[] }
     }
   | {
       readonly type: "agent-switch" | "model-switch" | "synthetic" | "system" | "skill" | "shell" | "compaction"
       readonly id: string
       readonly sessionID: string
       readonly created: number
-      readonly raw?: unknown
+      readonly metadata?: Readonly<Record<string, unknown>>
+      readonly text?: string
+      readonly reason?: "auto" | "manual"
+      readonly summary?: string
+      readonly recent?: string
+      readonly callID?: string
+      readonly command?: string
+      readonly output?: string
     }
 
+export function timelineMessage(item: TimelineItem): AppMessage | undefined {
+  if (item.type === "user")
+    return {
+      id: item.id,
+      sessionID: item.sessionID,
+      role: "user",
+      time: { created: item.created },
+      format: item.format,
+      summary: item.summary,
+      agent: item.agent ?? "",
+      model: {
+        providerID: item.model?.providerID ?? "",
+        modelID: item.model?.id ?? "",
+        variant: item.model?.variant,
+      },
+      system: item.system,
+      tools: item.tools,
+    }
+  if (item.type !== "assistant") return
+  return {
+    id: item.id,
+    sessionID: item.sessionID,
+    role: "assistant",
+    time: { created: item.created, completed: item.completed },
+    parentID: item.parentID ?? "",
+    modelID: item.model?.id ?? "",
+    providerID: item.model?.providerID ?? "",
+    variant: item.model?.variant,
+    mode: item.mode ?? item.agent ?? "",
+    agent: item.agent ?? "",
+    path: item.path ?? { cwd: "", root: "" },
+    summary: item.summary,
+    cost: item.cost ?? 0,
+    tokens: item.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+    structured: item.structured,
+    finish: item.finish,
+    error: item.error,
+  }
+}
+
+export function timelineParts(item: TimelineItem): AppPart[] {
+  if (item.type !== "user" && item.type !== "assistant") return []
+  return item.content.map((content): AppPart => {
+    const base = { id: content.id, sessionID: item.sessionID, messageID: item.id }
+    if (content.type === "file")
+      return {
+        ...base,
+        type: content.type,
+        mime: content.mime ?? "application/octet-stream",
+        filename: content.name,
+        url: content.uri,
+        source:
+          content.source?.type === "resource"
+            ? {
+                type: content.source.type,
+                clientName: content.source.clientName,
+                uri: content.source.uri,
+                text: {
+                  value: content.source.text.text,
+                  start: content.source.text.start,
+                  end: content.source.text.end,
+                },
+              }
+            : content.source?.type === "symbol"
+              ? {
+                  type: content.source.type,
+                  path: content.source.path,
+                  name: content.source.name ?? "",
+                  kind: content.source.kind ?? 0,
+                  range: {
+                    start: { line: 0, character: content.source.text.start },
+                    end: { line: 0, character: content.source.text.end },
+                  },
+                  text: {
+                    value: content.source.text.text,
+                    start: content.source.text.start,
+                    end: content.source.text.end,
+                  },
+                }
+              : content.source && {
+                  type: "file",
+                  path: content.source.path,
+                  text: {
+                    value: content.source.text.text,
+                    start: content.source.text.start,
+                    end: content.source.text.end,
+                  },
+                },
+      }
+    if (content.type === "agent")
+      return {
+        ...base,
+        type: content.type,
+        name: content.name,
+        source: content.source && {
+          value: content.source.text,
+          start: content.source.start,
+          end: content.source.end,
+        },
+      }
+    if (content.type === "tool")
+      return {
+        ...base,
+        type: content.type,
+        callID: content.callID ?? content.id,
+        tool: content.tool,
+        state: content.state,
+        metadata: content.metadata,
+      }
+    if (content.type === "subtask")
+      return {
+        ...base,
+        ...content,
+        model: content.model && { providerID: content.model.providerID, modelID: content.model.id },
+      }
+    if (content.type === "reasoning")
+      return {
+        ...base,
+        ...content,
+        time: content.time ?? {
+          start: item.created,
+          end: item.type === "assistant" ? item.completed : undefined,
+        },
+      }
+    return { ...base, ...content }
+  })
+}
+
 export type PromptFile = {
   readonly uri: string
   readonly name?: string
@@ -312,12 +594,60 @@ export type PromptAgentMention = {
   readonly text?: string
 }
 
-export type PromptInput = {
+export type PromptPart =
+  | {
+      readonly id: string
+      readonly type: "text"
+      readonly text: string
+      readonly synthetic?: boolean
+      readonly ignored?: boolean
+      readonly time?: { readonly start: number; readonly end?: number }
+      readonly metadata?: Readonly<Record<string, unknown>>
+    }
+  | {
+      readonly id: string
+      readonly type: "file"
+      readonly mime: string
+      readonly url: string
+      readonly filename?: string
+      readonly source?:
+        | {
+            readonly type: "file"
+            readonly path: string
+            readonly text: { readonly value: string; readonly start: number; readonly end: number }
+          }
+        | {
+            readonly type: "symbol"
+            readonly path: string
+            readonly name: string
+            readonly kind: number
+            readonly range: {
+              readonly start: { readonly line: number; readonly character: number }
+              readonly end: { readonly line: number; readonly character: number }
+            }
+            readonly text: { readonly value: string; readonly start: number; readonly end: number }
+          }
+        | {
+            readonly type: "resource"
+            readonly clientName: string
+            readonly uri: string
+            readonly text: { readonly value: string; readonly start: number; readonly end: number }
+          }
+    }
+  | {
+      readonly id: string
+      readonly type: "agent"
+      readonly name: string
+      readonly source?: { readonly value: string; readonly start: number; readonly end: number }
+    }
+
+export type PromptInput = LocationInput & {
   readonly sessionID: string
   readonly id: string
   readonly text: string
   readonly files?: readonly PromptFile[]
   readonly agents?: readonly PromptAgentMention[]
+  readonly parts?: readonly PromptPart[]
   readonly selection?: {
     readonly agent?: string
     readonly model?: ModelRef
@@ -325,7 +655,7 @@ export type PromptInput = {
   readonly delivery?: "steer" | "queue"
 }
 
-export type CommandInput = {
+export type CommandInput = LocationInput & {
   readonly sessionID: string
   readonly id?: string
   readonly command: string
@@ -338,9 +668,19 @@ export type CommandInput = {
 
 export type FileEntry = {
   readonly path: string
+  readonly name?: string
+  readonly absolute?: string
   readonly type: "file" | "directory"
 }
 
+export type AppFileNode = {
+  readonly name: string
+  readonly path: string
+  readonly absolute: string
+  readonly type: "file" | "directory"
+  readonly ignored: boolean
+}
+
 export type FileContent = {
   readonly bytes: Uint8Array
   readonly kind?: "text" | "binary"
@@ -355,20 +695,26 @@ export type AppFileDiff = {
   readonly status?: "added" | "deleted" | "modified"
 }
 
+export type AppSnapshotFileDiff = Omit<AppFileDiff, "file"> & { readonly file?: string }
+export type AppVcsFileDiff = AppFileDiff
+
 export type AppPermissionRequest = {
   readonly id: string
   readonly sessionID: string
   readonly action: string
   readonly resources: readonly string[]
-  readonly metadata?: Readonly<Record<string, unknown>>
+  readonly permission: string
+  readonly patterns: string[]
+  readonly always: string[]
+  readonly metadata: Record<string, unknown>
 }
 
 export type AppQuestion = {
   readonly question: string
-  readonly header?: string
-  readonly options: readonly {
+  readonly header: string
+  readonly options: {
     readonly label: string
-    readonly description?: string
+    readonly description: string
   }[]
   readonly multiple?: boolean
   readonly custom?: boolean
@@ -377,7 +723,15 @@ export type AppQuestion = {
 export type AppQuestionRequest = {
   readonly id: string
   readonly sessionID: string
-  readonly questions: readonly AppQuestion[]
+  readonly questions: AppQuestion[]
+}
+
+export type AppQuestionAnswer = string[]
+
+export type AppSessionNotFoundError = {
+  readonly _tag: "SessionNotFoundError"
+  readonly sessionID: string
+  readonly message: string
 }
 
 export type AppMcpStatus =
@@ -423,20 +777,56 @@ export type AppEventEnvelope = {
 export type AppEvent =
   | { readonly type: "server.connected" }
   | { readonly type: "server.disposed"; readonly location?: LocationRef }
+  | { readonly type: "instance.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: "session.moved"; readonly sessionID: string; readonly location: LocationRef }
+  | { readonly type: "session.revert"; readonly sessionID: string; readonly revert?: { readonly messageID: string } }
+  | {
+      readonly type: "session.activity"
+      readonly sessionID: string
+      readonly activity: SessionActivity
+      readonly item?: TimelineItem
+    }
+  | { readonly type: "session.diff"; readonly sessionID: string; readonly diff: readonly AppFileDiff[] }
+  | { readonly type: "todo.updated"; readonly sessionID: string; readonly todos: readonly AppTodo[] }
+  | { readonly type: "session.error"; readonly sessionID?: string; readonly error?: AppSessionError }
   | { readonly type: "timeline.updated"; readonly item: TimelineItem }
+  | {
+      readonly type: "timeline.content.updated"
+      readonly sessionID: string
+      readonly itemID: string
+      readonly content: TimelineContent
+    }
   | { readonly type: "timeline.removed"; readonly sessionID: string; readonly itemID: string }
+  | {
+      readonly type: "timeline.delta"
+      readonly sessionID: string
+      readonly itemID: string
+      readonly contentID: string
+      readonly field: string
+      readonly delta: string
+    }
+  | {
+      readonly type: "timeline.part.removed"
+      readonly sessionID: string
+      readonly itemID: string
+      readonly contentID: 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: "worktree.ready"; readonly name: string; readonly branch?: string }
+  | { readonly type: "worktree.failed"; readonly message: string }
+  | { readonly type: "lsp.updated" }
+  | { readonly type: "reference.updated" }
+  | { readonly type: "mcp.updated"; readonly server?: string }
+  | { readonly type: "provider.updated" }
   | { readonly type: "pty.exited"; readonly ptyID: string }
   | { readonly type: "unknown"; readonly raw: unknown }
 
@@ -445,10 +835,13 @@ export interface HealthApi {
 }
 
 export interface ProjectApi {
-  list(options?: RequestOptions): Promise<readonly AppProject[]>
   current(input?: LocationInput, options?: RequestOptions): Promise<CurrentProject>
 }
 
+export interface ProjectListCapability {
+  list(options?: RequestOptions): Promise<readonly AppProject[]>
+}
+
 export interface CatalogApi {
   providers(input?: LocationInput, options?: RequestOptions): Promise<ProviderCatalog>
   agents(input?: LocationInput, options?: RequestOptions): Promise<readonly AppAgent[]>
@@ -469,31 +862,34 @@ export interface SessionApi {
     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 },
+    input: LocationInput & { readonly sessionID: string; readonly limit?: number; readonly cursor?: string },
     options?: RequestOptions,
   ): Promise<Page<TimelineItem>>
   message(
-    input: { readonly sessionID: string; readonly messageID: string },
+    input: LocationInput & { readonly sessionID: string; readonly messageID: string },
     options?: RequestOptions,
   ): Promise<TimelineItem>
   prompt(input: PromptInput, options?: RequestOptions): Promise<void>
+}
+
+export interface SessionActionsV1Capability {
+  remove(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<boolean>
+  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>
   command(input: CommandInput, options?: RequestOptions): Promise<void>
 }
 
 export interface FileApi {
-  list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise<readonly FileEntry[]>
+  list(input: LocationInput & { readonly path?: string }, options?: RequestOptions): Promise<readonly AppFileNode[]>
   find(
     input: LocationInput & {
       readonly query: string
@@ -508,7 +904,7 @@ export interface FileApi {
 export interface PermissionApi {
   pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppPermissionRequest[]>
   reply(
-    input: {
+    input: LocationInput & {
       readonly sessionID: string
       readonly requestID: string
       readonly reply: "once" | "always" | "reject"
@@ -521,14 +917,17 @@ export interface PermissionApi {
 export interface QuestionApi {
   pending(input?: LocationInput, options?: RequestOptions): Promise<readonly AppQuestionRequest[]>
   reply(
-    input: {
+    input: LocationInput & {
       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>
+  reject(
+    input: LocationInput & { readonly sessionID: string; readonly requestID: string },
+    options?: RequestOptions,
+  ): Promise<void>
 }
 
 export interface VcsApi {
@@ -598,11 +997,8 @@ export interface CommonClient {
   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 = {
@@ -655,6 +1051,8 @@ export type ProviderAuthMethod = {
   readonly prompts?: readonly ProviderAuthPrompt[]
 }
 
+export type AppProviderAuthResponse = Readonly<Record<string, readonly ProviderAuthMethod[]>>
+
 export type ProviderAuthorization = {
   readonly url: string
   readonly method: "auto" | "code"
@@ -710,14 +1108,15 @@ export type AppWorktree = {
 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>
+  remove(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<boolean>
+  reset(input: LocationInput & { readonly directory: string }, options?: RequestOptions): Promise<boolean>
 }
 
 export type AppTodo = {
   readonly id?: string
   readonly content: string
   readonly status: "pending" | "in_progress" | "completed" | "cancelled" | (string & {})
+  readonly priority: "high" | "medium" | "low" | (string & {})
 }
 
 export type LegacySessionShellInput = LocationInput & {
@@ -729,14 +1128,23 @@ export type LegacySessionShellInput = LocationInput & {
 }
 
 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>
+  archive(
+    input: LocationInput & { readonly sessionID: string; readonly archivedAt: number },
+    options?: RequestOptions,
+  ): Promise<void>
+  share(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<string>
+  unshare(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+  diff(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<readonly AppFileDiff[]>
+  todos(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<readonly AppTodo[]>
+  summarize(
+    input: LocationInput & { readonly sessionID: string; readonly model: ModelRef },
+    options?: RequestOptions,
+  ): Promise<void>
+  revert(
+    input: LocationInput & { readonly sessionID: string; readonly messageID: string },
+    options?: RequestOptions,
+  ): Promise<AppSession>
+  clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<AppSession>
   shell(input: LegacySessionShellInput, options?: RequestOptions): Promise<void>
 }
 
@@ -784,7 +1192,18 @@ export type DecoratedFileContent = {
   readonly encoding?: "base64"
   readonly mimeType?: string
   readonly patch?: {
-    readonly hunks: readonly { readonly lines: readonly string[] }[]
+    readonly oldFileName: string
+    readonly newFileName: string
+    readonly oldHeader?: string
+    readonly newHeader?: string
+    readonly hunks: readonly {
+      readonly oldStart: number
+      readonly oldLines: number
+      readonly newStart: number
+      readonly newLines: number
+      readonly lines: readonly string[]
+    }[]
+    readonly index?: string
   }
 }
 
@@ -793,11 +1212,28 @@ export interface DecoratedFileCapability {
 }
 
 export type PtyTicket = {
-  readonly ticket: string
+  readonly status: number
+  readonly ticket?: string
+}
+
+export type PtyTransportConfig = {
+  readonly baseUrl: string
+  readonly fetch: typeof globalThis.fetch
+  readonly username?: string
+  readonly password?: string
+  readonly sameOrigin: boolean
+  readonly authToken: boolean
 }
 
 export interface PtyTransportCapability {
   connectToken(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<PtyTicket>
+  exists(input: LocationInput & { readonly ptyID: string }, options?: RequestOptions): Promise<boolean>
+  connectURL(input: {
+    readonly ptyID: string
+    readonly location: LocationRef
+    readonly cursor: number
+    readonly ticket?: string
+  }): URL
 }
 
 export type ShellOption = {
@@ -811,6 +1247,7 @@ export interface ShellDiscoveryCapability {
 }
 
 export interface RuntimeV1Capability {
+  disposeLocation(input: LocationInput, options?: RequestOptions): Promise<void>
   disposeAll(options?: RequestOptions): Promise<void>
 }
 
@@ -833,6 +1270,11 @@ export type IntegrationMethod =
 export type IntegrationConnection = {
   readonly id: string
   readonly label: string
+  readonly kind: "credential" | "environment"
+}
+
+export function credentialConnectionIDs(connections: readonly IntegrationConnection[]) {
+  return connections.filter((connection) => connection.kind === "credential").map((connection) => connection.id)
 }
 
 export type IntegrationInfo = {
@@ -914,18 +1356,37 @@ export type InstructionEntry = {
 }
 
 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 },
+  diff?(
+    input: LocationInput & { readonly sessionID: string },
+    options?: RequestOptions,
+  ): Promise<readonly AppFileDiff[]>
+  todos?(
+    input: LocationInput & { readonly sessionID: string },
+    options?: RequestOptions,
+  ): Promise<readonly AppTodo[]>
+  switchAgent(
+    input: LocationInput & { readonly sessionID: string; readonly agent: string },
     options?: RequestOptions,
   ): Promise<void>
-  skill(
-    input: { readonly sessionID: string; readonly id?: string; readonly skill: string; readonly resume?: boolean },
+  switchModel(
+    input: LocationInput & { readonly sessionID: string; readonly model: ModelRef },
     options?: RequestOptions,
   ): Promise<void>
-  synthetic(
-    input: {
+  move?(
+    input: LocationInput & { readonly sessionID: string; readonly directory: string; readonly moveChanges?: boolean },
+    options?: RequestOptions,
+  ): Promise<void>
+  skill?(
+    input: LocationInput & {
+      readonly sessionID: string
+      readonly id?: string
+      readonly skill: string
+      readonly resume?: boolean
+    },
+    options?: RequestOptions,
+  ): Promise<void>
+  synthetic?(
+    input: LocationInput & {
       readonly sessionID: string
       readonly id?: string
       readonly text: string
@@ -936,40 +1397,50 @@ export interface SessionExtrasV2Capability {
     },
     options?: RequestOptions,
   ): Promise<PendingSessionInput>
-  shell(
-    input: { readonly sessionID: string; readonly id?: string; readonly command: string },
+  shell?(
+    input: LocationInput & { readonly sessionID: string; readonly id?: string; readonly command: string },
     options?: RequestOptions,
   ): Promise<void>
-  compact(
-    input: { readonly sessionID: string; readonly id?: string },
+  compact?(
+    input: LocationInput & { 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 },
+  wait(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+  context(
+    input: LocationInput & { readonly sessionID: string },
+    options?: RequestOptions,
+  ): Promise<readonly TimelineItem[]>
+  pending?(
+    input: LocationInput & { readonly sessionID: string },
+    options?: RequestOptions,
+  ): Promise<readonly PendingSessionInput[]>
+  instructionEntries?(
+    input: LocationInput & { readonly sessionID: string },
     options?: RequestOptions,
   ): Promise<readonly InstructionEntry[]>
-  putInstructionEntry(
-    input: { readonly sessionID: string; readonly key: string; readonly value: JsonValue },
+  putInstructionEntry?(
+    input: LocationInput & { readonly sessionID: string; readonly key: string; readonly value: JsonValue },
     options?: RequestOptions,
   ): Promise<void>
-  removeInstructionEntry(
-    input: { readonly sessionID: string; readonly key: string },
+  removeInstructionEntry?(
+    input: LocationInput & { readonly sessionID: string; readonly key: string },
     options?: RequestOptions,
   ): Promise<void>
   log(
-    input: { readonly sessionID: string; readonly after?: number; readonly follow?: boolean },
+    input: LocationInput & { readonly sessionID: string; readonly after?: number; readonly follow?: boolean },
     options?: RequestOptions,
   ): AsyncIterable<SessionLogItem>
-  background(input: { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+  background?(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
   stageRevert(
-    input: { readonly sessionID: string; readonly messageID: string; readonly files?: readonly string[] },
+    input: LocationInput & {
+      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>
+  clearRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
+  commitRevert(input: LocationInput & { readonly sessionID: string }, options?: RequestOptions): Promise<void>
 }
 
 export type ProjectDirectory = {
@@ -978,7 +1449,7 @@ export type ProjectDirectory = {
 }
 
 export interface ProjectCopiesV2Capability {
-  directories(
+  directories?(
     input: LocationInput & { readonly projectID: string },
     options?: RequestOptions,
   ): Promise<readonly ProjectDirectory[]>
@@ -1161,6 +1632,9 @@ export interface DiscoveryV2Capability {
 }
 
 export interface Capabilities {
+  readonly projectList?: ProjectListCapability
+  readonly vcs?: VcsApi
+  readonly mcp?: McpApi
   readonly configuration?: ConfigurationCapability
   readonly providerAuthV1?: ProviderAuthV1Capability
   readonly integrationsV2?: IntegrationsV2Capability
@@ -1168,6 +1642,7 @@ export interface Capabilities {
   readonly worktreesV1?: WorktreesV1Capability
   readonly projectCopiesV2?: ProjectCopiesV2Capability
   readonly sessionExtrasV1?: SessionExtrasV1Capability
+  readonly sessionActionsV1?: SessionActionsV1Capability
   readonly sessionExtrasV2?: SessionExtrasV2Capability
   readonly lsp?: LspCapability
   readonly mcpControl?: McpControlCapability

+ 21 - 13
packages/app/src/context/directory-sync.ts

@@ -1,7 +1,7 @@
 import { Binary } from "@opencode-ai/core/util/binary"
-import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
+import type { AppMessage, AppPart, AppSession } from "./backend"
 import { createMemo } from "solid-js"
-import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
+import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store"
 import type { createServerSdkContext } from "./server-sdk"
 import type { createServerSyncContextInner } from "./server-sync"
 import type { State } from "./global-sync/types"
@@ -23,8 +23,8 @@ export const createDirSyncContext = (
   serverSync: ReturnType<typeof createServerSyncContextInner>,
   serverSDK: ReturnType<typeof createServerSdkContext>,
 ) => {
-  const client = serverSDK.createClient({ directory, throwOnError: true })
   const current = createMemo(() => serverSync.child(directory, { mcp: true }))
+  const [sessionPage, setSessionPage] = createStore({ cursor: undefined as string | undefined, complete: false })
   const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
   const data = new Proxy({} as State, {
     get(_, property: keyof State) {
@@ -72,7 +72,7 @@ export const createDirSyncContext = (
       if (match.found) return serverSync.data.project[match.index]
     },
     session: {
-      remember(session: Session) {
+      remember(session: AppSession) {
         serverSync.session.remember(session)
         index(session.id)
       },
@@ -81,7 +81,7 @@ export const createDirSyncContext = (
         if (session?.directory === directory) return session
       },
       optimistic: {
-        add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
+        add(input: { directory?: string; sessionID: string; message: AppMessage; parts: AppPart[] }) {
           serverSync.session.optimistic.add(input)
         },
         remove(input: { directory?: string; sessionID: string; messageID: string }) {
@@ -91,7 +91,7 @@ export const createDirSyncContext = (
       addOptimisticMessage(input: {
         sessionID: string
         messageID: string
-        parts: Part[]
+        parts: AppPart[]
         agent: string
         model: { providerID: string; modelID: string }
         variant?: string
@@ -110,7 +110,7 @@ export const createDirSyncContext = (
         })
       },
       async sync(sessionID: string, options?: { force?: boolean }) {
-        await serverSync.session.sync(sessionID, options)
+        await serverSync.session.sync(sessionID, { ...options, location: { directory } })
         index(sessionID)
       },
       diff: serverSync.session.diff,
@@ -121,17 +121,25 @@ export const createDirSyncContext = (
       fetch: async (count = 10) => {
         const [store, setStore] = current()
         setStore("limit", (value) => value + count)
-        const response = await client.session.list()
-        const sessions = (response.data ?? [])
-          .filter((session) => !!session?.id)
+        const backend = await serverSDK.backend
+        const response = await backend.common.sessions.list({
+          location: { directory },
+          roots: true,
+          limit: count,
+          cursor: sessionPage.cursor,
+        })
+        const sessions = [...new Map([...store.session, ...response.items].map((session) => [session.id, session])).values()]
           .sort((a, b) => cmp(a.id, b.id))
-          .slice(0, store.limit)
         sessions.forEach(serverSync.session.remember)
         setStore("session", reconcile(sessions, { key: "id" }))
+        setSessionPage({ cursor: response.older, complete: !response.older })
       },
-      more: createMemo(() => current()[0].session.length >= current()[0].limit),
+      more: createMemo(() => !sessionPage.complete),
       archive: async (sessionID: string) => {
-        await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
+        const backend = await serverSDK.backend
+        const capability = backend.capabilities.sessionExtrasV1
+        if (!capability) throw new Error("Server does not support session archiving")
+        await capability.archive({ sessionID, archivedAt: Date.now(), location: { directory } })
         current()[1](
           "session",
           produce((draft) => {

+ 33 - 9
packages/app/src/context/file.tsx

@@ -79,10 +79,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
     const tree = createFileTreeStore({
       scope,
       normalizeDir: path.normalizeDir,
-      list: (dir) =>
-        sdk()
-          .client.file.list({ path: dir })
-          .then((x) => x.data ?? []),
+      list: async (dir) =>
+        [...(await (await sdk().backend).common.files.list({ location: { directory: scope() }, path: dir }))].map(
+          (node) => ({
+            name: node.name ?? getFilename(node.path),
+            path: node.path,
+            absolute: node.absolute ?? node.path,
+            type: node.type,
+            ignored: node.ignored,
+          }),
+        ),
       onError: (message) => {
         showToast({
           variant: "error",
@@ -181,10 +187,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       setLoading(file)
 
       const promise = sdk()
-        .client.file.read({ path: file })
-        .then((x) => {
+        .backend.then(async (client) => {
           if (scope() !== directory) return
-          const content = x.data
+          const content: FileState["content"] = client.capabilities.decoratedFiles
+            ? await client.capabilities.decoratedFiles.read({ location: { directory }, path: file })
+            : await client.common.files.read({ location: { directory }, path: file }).then((value) => {
+                if (value.kind !== "text") return
+                return {
+                  type: "text" as const,
+                  content: new TextDecoder().decode(value.bytes),
+                  mimeType: value.mimeType,
+                }
+              })
           setLoaded(file, content)
 
           if (!content) return
@@ -205,9 +219,19 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
 
     const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
       sdk()
-        .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
+        .backend.then((client) =>
+          client.common.files.find(
+            {
+              location: { directory: scope() },
+              query,
+              type: dirs === "true" ? undefined : "file",
+              limit: options?.limit,
+            },
+            { signal: options?.signal },
+          ),
+        )
         .then(
-          (x) => (x.data ?? []).map(path.normalize),
+          (items) => items.map((item) => path.normalize(item.path)),
           (error) => {
             if (options?.signal?.aborted) throw error
             return []

+ 1 - 1
packages/app/src/context/file/content-cache.ts

@@ -1,4 +1,4 @@
-import type { FileContent } from "@opencode-ai/sdk/v2"
+import type { DecoratedFileContent as FileContent } from "../backend"
 
 const MAX_FILE_CONTENT_ENTRIES = 40
 const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024

+ 1 - 1
packages/app/src/context/file/tree-store.ts

@@ -1,5 +1,5 @@
 import { createStore, produce, reconcile } from "solid-js/store"
-import type { FileNode } from "@opencode-ai/sdk/v2"
+import type { AppFileNode as FileNode } from "../backend"
 
 type DirectoryState = {
   expanded: boolean

+ 1 - 1
packages/app/src/context/file/types.ts

@@ -1,4 +1,4 @@
-import type { FileContent } from "@opencode-ai/sdk/v2"
+import type { DecoratedFileContent as FileContent } from "../backend"
 
 export type FileSelection = {
   startLine: number

+ 7 - 5
packages/app/src/context/file/watcher.ts

@@ -1,8 +1,10 @@
-import type { FileNode } from "@opencode-ai/sdk/v2"
+import type { AppFileNode as FileNode } from "../backend"
 
 type WatcherEvent = {
   type: string
-  properties: unknown
+  path?: string
+  change?: string
+  properties?: unknown
 }
 
 type WatcherOps = {
@@ -16,11 +18,11 @@ type WatcherOps = {
 }
 
 export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
-  if (event.type !== "filesystem.changed") return
+  if (event.type !== "filesystem.changed" && event.type !== "file.changed" && event.type !== "file.watcher.updated") return
   const props =
     typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
-  const rawPath = typeof props?.file === "string" ? props.file : undefined
-  const kind = typeof props?.event === "string" ? props.event : undefined
+  const rawPath = event.path ?? (typeof props?.file === "string" ? props.file : undefined)
+  const kind = event.change ?? (typeof props?.event === "string" ? props.event : undefined)
   if (!rawPath) return
   if (!kind) return
 

+ 52 - 44
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -1,14 +1,15 @@
 import { describe, expect, test } from "bun:test"
 import { createStore } from "solid-js/store"
 import { QueryClient } from "@tanstack/solid-query"
-import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
-import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+import type { AppClient, AppProject as Project, AppSession as Session } from "../backend"
+import { createAppClient } from "../backend.test-fixture"
+import type { ProviderStore } from "./types"
 import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
 import type { State, VcsCache } from "./types"
 import { createServerSession } from "../server-session"
 import { ServerScope } from "@/utils/server-scope"
 
-const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
+const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
 
 function directoryState() {
   return createStore<State>({
@@ -30,6 +31,7 @@ function directoryState() {
       return this.session_status[id]?.type !== "idle"
     },
     session_diff: {},
+    todo: {},
     permission: {},
     question: {},
     mcp_ready: true,
@@ -55,33 +57,35 @@ describe("bootstrapDirectory", () => {
       scope: ServerScope.local,
       mcp: false,
       global: {
-        config: {} satisfies Config,
+        config: {},
         path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
         project: [{ id: "project", worktree: "/project" } as Project],
         provider,
       },
-      sdk: {
-        app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
-        config: { get: async () => ({ data: {} }) },
-        session: { status: async () => ({ data: {} }) },
-        vcs: { get: async () => ({ data: undefined }) },
-        command: {
-          list: async () => {
-            mcpReads.push("command")
-            return { data: [] }
-          },
+      backend: {
+        version: "v1",
+        capabilities: {
+          configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
+          vcsInfo: { get: async () => ({}) },
         },
-        permission: { list: async () => ({ data: [] }) },
-        question: { list: async () => ({ data: [] }) },
-        v2: { reference: { list: async () => ({ data: { data: [] } }) } },
-        mcp: {
-          status: async () => {
-            mcpReads.push("status")
-            return { data: {} }
+        common: {
+          catalog: {
+            agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
+            providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
+          },
+          sessions: { activity: async () => ({}) },
+          projects: { current: async () => ({ id: "project", directory: "/project" }) },
+          commands: {
+            list: async () => {
+              mcpReads.push("command")
+              return []
+            },
           },
+          permissions: { pending: async () => [] },
+          questions: { pending: async () => [] },
+          references: { list: async () => [] },
         },
-        provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
-      } as unknown as OpencodeClient,
+      } as unknown as AppClient,
       store,
       setStore,
       vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -101,22 +105,26 @@ describe("bootstrapDirectory", () => {
   test("seeds session status even while warming session info stalls", async () => {
     const [store, setStore] = directoryState()
     const stalled = Promise.withResolvers<never>()
-    const client = {
-      app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
-      config: { get: async () => ({ data: {} }) },
-      session: {
-        status: async () => ({ data: { ses_busy: { type: "busy" } } }),
-        get: () => stalled.promise,
+    const backend = createAppClient({
+      version: "v1",
+      capabilities: {
+        configuration: { get: async () => ({}), getGlobal: async () => ({}), updateGlobal: async () => {} },
+        vcsInfo: { get: async () => ({}) },
       },
-      vcs: { get: async () => ({ data: undefined }) },
-      command: { list: async () => ({ data: [] }) },
-      permission: { list: async () => ({ data: [] }) },
-      question: { list: async () => ({ data: [] }) },
-      v2: { reference: { list: async () => ({ data: { data: [] } }) } },
-      mcp: { status: async () => ({ data: {} }) },
-      provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
-    } as unknown as OpencodeClient
-    const session = createServerSession(client)
+      common: {
+        catalog: {
+          agents: async () => [{ id: "build", name: "build", mode: "primary", hidden: false }],
+          providers: async () => ({ providers: new Map(), connected: [], defaults: {} }),
+        },
+        sessions: { activity: async () => ({ ses_busy: { type: "busy" } }), get: () => stalled.promise },
+        projects: { current: async () => ({ id: "project", directory: "/project" }) },
+        commands: { list: async () => [] },
+        permissions: { pending: async () => [] },
+        questions: { pending: async () => [] },
+        references: { list: async () => [] },
+      },
+    })
+    const session = createServerSession(backend)
     const stale: Session = {
       id: "ses_stale",
       slug: "ses_stale",
@@ -134,12 +142,12 @@ describe("bootstrapDirectory", () => {
       scope: ServerScope.local,
       mcp: false,
       global: {
-        config: {} satisfies Config,
+        config: {},
         path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
         project: [{ id: "project", worktree: "/project" } as Project],
         provider,
       },
-      sdk: client,
+      backend,
       store,
       setStore,
       vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -161,12 +169,12 @@ describe("bootstrapDirectory", () => {
 
 describe("query keys", () => {
   test("partitions identical directories by server scope", () => {
-    const client = {} as OpencodeClient
+    const backend = Promise.resolve({} as AppClient)
     const remote = "https://debian.example" as typeof ServerScope.local
 
-    expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
-    expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
-    expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
+    expect([...loadPathQuery(ServerScope.local, "/repo", backend).queryKey]).toEqual(["local", "/repo", "path"])
+    expect([...loadPathQuery(remote, "/repo", backend).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
+    expect([...loadProvidersQuery(remote, null, backend).queryKey]).toEqual([
       "https://debian.example",
       null,
       "providers",

+ 167 - 94
packages/app/src/context/global-sync/bootstrap.ts

@@ -1,35 +1,35 @@
 import type {
-  Config,
-  OpencodeClient,
-  Path,
-  PermissionRequest,
-  Project,
-  ProviderAuthResponse,
-  QuestionRequest,
-  ReferenceInfo,
-  Session,
-} from "@opencode-ai/sdk/v2/client"
+  AppClient,
+  AppConfig,
+  AppPathInfo,
+  AppPermissionRequest,
+  AppProject,
+  AppProviderAuthResponse,
+  AppQuestionRequest,
+  AppReference,
+  AppSession,
+  ProviderCatalog,
+} from "../backend"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { retry } from "@opencode-ai/core/util/retry"
 import { batch } from "solid-js"
 import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
-import type { State, VcsCache } from "./types"
+import type { ProviderStore, State, StoreConfig, VcsCache } from "./types"
 import type { ServerSession } from "../server-session"
-import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
+import { cmp } from "./utils"
 import { formatServerError } from "@/utils/server-errors"
 import { QueryClient, queryOptions } from "@tanstack/solid-query"
 import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
-import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import { ScopedKey, type ServerScope } from "@/utils/server-scope"
 
 type GlobalStore = {
   ready: boolean
-  path: Path
-  project: Project[]
-  provider: NormalizedProviderListResponse
-  provider_auth: ProviderAuthResponse
-  config: Config
+  path: AppPathInfo
+  project: AppProject[]
+  provider: ProviderStore
+  provider_auth: AppProviderAuthResponse
+  config: StoreConfig
   reload: undefined | "pending" | "complete"
 }
 
@@ -82,29 +82,31 @@ function showErrors(input: {
   })
 }
 
-export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
+export const loadGlobalConfigQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
   queryOptions({
     queryKey: [scope, "config"],
-    queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
+    queryFn: () => retry(async () => (await backend).capabilities.configuration?.getGlobal() ?? {}),
   })
 
-export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
+export const loadProjectsQuery = (scope: ServerScope, backend: Promise<AppClient>) =>
   queryOptions({
     queryKey: [scope, "project"],
     queryFn: () =>
       retry(() =>
-        sdk.project.list().then((x) => {
-          return (x.data ?? [])
-            .filter((p) => !!p?.id)
-            .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
-            .slice()
-            .sort((a, b) => cmp(a.id, b.id))
-        }),
+        backend
+          .then((client) => client.capabilities.projectList?.list() ?? [])
+          .then((projects) => {
+            return projects
+              .filter((p) => !!p?.id)
+              .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+              .slice()
+              .sort((a, b) => cmp(a.id, b.id))
+          }),
       ),
   })
 
 export async function bootstrapGlobal(input: {
-  serverSDK: OpencodeClient
+  backend: Promise<AppClient>
   scope: ServerScope
   requestFailedTitle: string
   translate: (key: string, vars?: Record<string, string | number>) => string
@@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
   queryClient: QueryClient
 }) {
   const slow = [
-    () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
-    () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
-    () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
+    () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.backend)),
+    () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.backend)),
+    () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.backend)),
     () =>
       input.queryClient
-        .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
+        .fetchQuery(loadProjectsQuery(input.scope, input.backend))
         .then((data) => input.setGlobalStore("project", data)),
   ]
   await runAll(slow)
@@ -140,11 +142,11 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
   }, {})
 }
 
-function projectID(directory: string, projects: Project[]) {
+function projectID(directory: string, projects: readonly AppProject[]) {
   return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
 }
 
-function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
+function mergeSession(setStore: SetStoreFunction<State>, session: AppSession) {
   setStore("session", (list) => {
     const next = list.slice()
     const idx = next.findIndex((item) => item.id >= session.id)
@@ -162,44 +164,54 @@ function warmSessions(input: {
   ids: string[]
   store: Store<State>
   setStore: SetStoreFunction<State>
-  sdk: OpencodeClient
+  backend: AppClient
+  location: { directory: string }
 }) {
   const known = new Set(input.store.session.map((item) => item.id))
   const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
   if (ids.length === 0) return Promise.resolve()
   return Promise.all(
     ids.map((sessionID) =>
-      retry(() => input.sdk.session.get({ sessionID })).then((x) => {
-        const session = x.data
-        if (!session?.id) return
-        mergeSession(input.setStore, session)
+      retry(() => input.backend.common.sessions.get({ sessionID, location: input.location })).then((x) => {
+        if (!x?.id) return
+        mergeSession(input.setStore, x)
       }),
     ),
   ).then(() => undefined)
 }
 
-export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
+export const loadProvidersQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
   queryOptions({
     queryKey: [scope, directory, "providers"],
-    queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
+    queryFn: () =>
+      retry(() => backend.then((client) => client.common.catalog.providers(location(directory)).then(toProviderStore))),
   })
 
-export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
+export const loadAgentsQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
   queryOptions({
     queryKey: [scope, directory, "agents"],
-    queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
+    queryFn: () =>
+      retry(() =>
+        backend.then((client) => client.common.catalog.agents(location(directory)).then((agents) => [...agents])),
+      ),
   })
 
-export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
-  queryOptions<Path>({
+export const loadPathQuery = (scope: ServerScope, directory: string | null, backend: Promise<AppClient>) =>
+  queryOptions<AppPathInfo>({
     queryKey: [scope, directory, "path"],
-    queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
+    queryFn: async () => {
+      const client = await backend
+      return retry(
+        () => client.capabilities.pathInfo?.get(location(directory)) ?? Promise.resolve(emptyPath(directory)),
+      )
+    },
   })
 
-export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
-  queryOptions<ReferenceInfo[]>({
+export const loadReferencesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
+  queryOptions<readonly AppReference[]>({
     queryKey: [scope, directory, "references"] as const,
-    queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
+    queryFn: () =>
+      retry(() => backend.then((client) => client.common.references.list(location(directory)))).catch(() => []),
     placeholderData: [],
   })
 
@@ -207,17 +219,17 @@ export async function bootstrapDirectory(input: {
   directory: string
   scope: ServerScope
   mcp: boolean
-  sdk: OpencodeClient
+  backend: AppClient
   store: Store<State>
   setStore: SetStoreFunction<State>
   vcsCache: VcsCache
   loadSessions: (directory: string) => Promise<void> | void
   translate: (key: string, vars?: Record<string, string | number>) => string
   global: {
-    config: Config
-    path: Path
-    project: Project[]
-    provider: NormalizedProviderListResponse
+    config: StoreConfig
+    path: AppPathInfo
+    project: readonly AppProject[]
+    provider: ProviderStore
   }
   queryClient: QueryClient
   session?: ServerSession
@@ -240,66 +252,90 @@ export async function bootstrapDirectory(input: {
       () => Promise.resolve(input.loadSessions(input.directory)),
       () =>
         input.queryClient
-          .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
+          .ensureQueryData(loadAgentsQuery(input.scope, input.directory, Promise.resolve(input.backend)))
           .then((data) => input.setStore("agent", data)),
       () =>
-        retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
+        retry(
+          () =>
+            input.backend.capabilities.configuration
+              ?.get(location(input.directory))
+              .then((config) => input.setStore("config", reconcile(config, { merge: false }))) ?? Promise.resolve(),
+        ),
       () =>
         retry(() =>
-          input.sdk.session.status().then(async (x) => {
+          input.backend.common.sessions.activity(location(input.directory)).then(async (statuses) => {
             if (!input.session) {
-              input.setStore("session_status", x.data!)
+              input.setStore("session_status", mapActivity(statuses))
               return
             }
-            const statuses = x.data ?? {}
+            const mapped = mapActivity(statuses)
             input.session.set(
               "session_status",
               produce((draft) => {
                 for (const sessionID of Object.keys(draft)) {
-                  if (statuses[sessionID]) continue
+                  if (mapped[sessionID]) continue
                   if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
                 }
               }),
             )
-            for (const [sessionID, status] of Object.entries(statuses)) {
+            for (const [sessionID, status] of Object.entries(mapped)) {
               input.session.set("session_status", sessionID, reconcile(status))
             }
             // Warm session info only after seeding statuses so a stalled session
             // fetch cannot park busy indicators behind it, mirroring how live
             // session.status events apply first and resolve info in the background.
             await Promise.all(
-              Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
+              Object.keys(mapped).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
             )
           }),
         ),
       !seededProject &&
-        (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
+        (() =>
+          retry(() => input.backend.common.projects.current(location(input.directory))).then((project) =>
+            input.setStore("project", project.id),
+          )),
       !seededPath &&
         (() =>
-          input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
-            const next = projectID(data.directory ?? input.directory, input.global.project)
-            if (next) input.setStore("project", next)
-          })),
+          input.queryClient
+            .ensureQueryData(loadPathQuery(input.scope, input.directory, Promise.resolve(input.backend)))
+            .then((data) => {
+              const next = projectID(data.directory ?? input.directory, input.global.project)
+              if (next) input.setStore("project", next)
+            })),
       () =>
         retry(() =>
-          input.sdk.vcs.get().then((x) => {
-            const next = x.data ?? input.store.vcs
-            input.setStore("vcs", next)
-            if (next) input.vcsCache.setStore("value", next)
-          }),
+          (input.backend.capabilities.vcsInfo?.get(location(input.directory)) ?? Promise.resolve(undefined)).then(
+            (data) => {
+              const next = data ?? input.store.vcs
+              input.setStore("vcs", next)
+              if (next) input.vcsCache.setStore("value", next)
+            },
+          ),
         ),
-      input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
-      () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
+      input.mcp &&
+        (() =>
+          retry(() => input.backend.common.commands.list(location(input.directory))).then((commands) =>
+            input.setStore("command", reconcile(commands)),
+          )),
+      () =>
+        input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, Promise.resolve(input.backend))),
       () =>
         retry(() =>
-          input.sdk.permission.list().then((x) => {
-            const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
-            const grouped = groupBySession(
-              (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+          input.backend.common.permissions.pending(location(input.directory)).then((data) => {
+            const permissions = data.map(
+              (perm): AppPermissionRequest => perm,
             )
+            const ids = permissions.map((perm) => perm.sessionID)
+            const grouped = groupBySession(permissions)
             const warm = input.session
               ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
-              : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
+              : warmSessions({
+                  ids,
+                  store: input.store,
+                  setStore: input.setStore,
+                  backend: input.backend,
+                  location: { directory: input.directory },
+                })
             return warm.then(() =>
               batch(() => {
                 const current = input.session?.data.permission ?? input.store.permission
@@ -323,12 +359,21 @@ export async function bootstrapDirectory(input: {
         ),
       () =>
         retry(() =>
-          input.sdk.question.list().then((x) => {
-            const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
-            const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+          input.backend.common.questions.pending(location(input.directory)).then((data) => {
+            const questions = data.map(
+              (question): AppQuestionRequest => question,
+            )
+            const ids = questions.map((question) => question.sessionID)
+            const grouped = groupBySession(questions)
             const warm = input.session
               ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
-              : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
+              : warmSessions({
+                  ids,
+                  store: input.store,
+                  setStore: input.setStore,
+                  backend: input.backend,
+                  location: { directory: input.directory },
+                })
             return warm.then(() =>
               batch(() => {
                 const current = input.session?.data.question ?? input.store.question
@@ -351,17 +396,25 @@ export async function bootstrapDirectory(input: {
           }),
         ),
       () => Promise.resolve(input.loadSessions(input.directory)),
-      input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
-      input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
+      input.mcp &&
+        (() =>
+          input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, Promise.resolve(input.backend)))),
+      input.mcp &&
+        (() =>
+          input.queryClient.fetchQuery(
+            loadMcpResourcesQuery(input.scope, input.directory, Promise.resolve(input.backend)),
+          )),
       () =>
-        input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
-          const project = getFilename(input.directory)
-          showToast({
-            variant: "error",
-            title: input.translate("toast.project.reloadFailed.title", { project }),
-            description: formatServerError(err, input.translate),
-          })
-        }),
+        input.queryClient
+          .fetchQuery(loadProvidersQuery(input.scope, input.directory, Promise.resolve(input.backend)))
+          .catch((err) => {
+            const project = getFilename(input.directory)
+            showToast({
+              variant: "error",
+              title: input.translate("toast.project.reloadFailed.title", { project }),
+              description: formatServerError(err, input.translate),
+            })
+          }),
     ].filter(Boolean) as (() => Promise<any>)[]
 
     await waitForPaint()
@@ -379,3 +432,23 @@ export async function bootstrapDirectory(input: {
     if (loading && slowErrs.length === 0) input.setStore("status", "complete")
   })()
 }
+
+function location(directory: string | null) {
+  return directory === null ? undefined : { location: { directory } }
+}
+
+function emptyPath(directory: string | null): AppPathInfo {
+  return { home: "", directory: directory ?? "", state: "", config: "", worktree: "" }
+}
+
+function toProviderStore(input: ProviderCatalog): ProviderStore {
+  return {
+    all: input.providers,
+    connected: [...input.connected],
+    default: { ...input.defaults },
+  }
+}
+
+function mapActivity(input: Awaited<ReturnType<AppClient["common"]["sessions"]["activity"]>>) {
+  return input
+}

+ 2 - 3
packages/app/src/context/global-sync/child-store.test.ts

@@ -1,8 +1,7 @@
 import { beforeAll, describe, expect, mock, test } from "bun:test"
 import { createRoot, getOwner, type Owner } from "solid-js"
 import { createStore } from "solid-js/store"
-import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
-import type { State } from "./types"
+import type { ProviderStore, State } from "./types"
 import type { QueryOptionsApi } from "../server-sync"
 import { ServerScope } from "@/utils/server-scope"
 
@@ -16,7 +15,7 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
 ]
 
 const child = () => createStore({} as State)
-const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
+const provider = { all: new Map(), connected: [], default: {} } satisfies ProviderStore
 
 const queryOptionsApi = {
   globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),

+ 5 - 4
packages/app/src/context/global-sync/child-store.ts

@@ -1,7 +1,7 @@
 import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
 import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
 import { Persist, persisted } from "@/utils/persist"
-import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
+import type { AppVcsInfo } from "../backend"
 import {
   DIR_IDLE_TTL_MS,
   MAX_DIR_STORES,
@@ -10,6 +10,7 @@ import {
   type IconCache,
   type MetaCache,
   type ProjectMeta,
+  type ProviderStore,
   type State,
   type VcsCache,
 } from "./types"
@@ -17,7 +18,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
 import { useQuery } from "@tanstack/solid-query"
 import { QueryOptionsApi } from "../server-sync"
 import { directoryKey, type DirectoryKey } from "./utils"
-import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import type { ServerScope } from "@/utils/server-scope"
 
 export function createChildStoreManager(input: {
@@ -32,7 +32,7 @@ export function createChildStoreManager(input: {
   translate: (key: string, vars?: Record<string, string | number>) => string
   queryOptions: QueryOptionsApi
   global: {
-    provider: NormalizedProviderListResponse
+    provider: ProviderStore
   }
 }) {
   const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -152,7 +152,7 @@ export function createChildStoreManager(input: {
       const vcs = runWithOwner(input.owner, () =>
         input.persist(
           Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
-          createStore({ value: undefined as VcsInfo | undefined }),
+          createStore({ value: undefined as AppVcsInfo | undefined }),
         ),
       )
       if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
@@ -223,6 +223,7 @@ export function createChildStoreManager(input: {
               return (type ?? "idle") !== "idle"
             },
             session_diff: {},
+            todo: {},
             permission: {},
             question: {},
             get mcp_ready() {

+ 14 - 5
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -1,5 +1,12 @@
 import { describe, expect, test } from "bun:test"
-import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import type {
+  AppMessage as Message,
+  AppPart as Part,
+  AppPermissionRequest as PermissionRequest,
+  AppProject as Project,
+  AppQuestionRequest as QuestionRequest,
+  AppSession as Session,
+} from "../backend"
 import { createStore } from "solid-js/store"
 import type { State } from "./types"
 import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -39,7 +46,9 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
     id,
     sessionID,
     permission: title,
+    action: title,
     patterns: ["*"],
+    resources: ["*"],
     metadata: {},
     always: [],
   }) as PermissionRequest
@@ -523,9 +532,9 @@ describe("applyDirectoryEvent", () => {
   })
 
   test("updates vcs branch in store and cache", () => {
-    const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
+    const [store, setStore] = createStore(baseState({ vcs: { branch: "main", defaultBranch: "main" } }))
     const [cacheStore, setCacheStore] = createStore({
-      value: { branch: "main", default_branch: "main" } as State["vcs"],
+      value: { branch: "main", defaultBranch: "main" } as State["vcs"],
     })
 
     applyDirectoryEvent({
@@ -542,8 +551,8 @@ describe("applyDirectoryEvent", () => {
       },
     })
 
-    expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
-    expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
+    expect(store.vcs).toEqual({ branch: "feature/test", defaultBranch: "main" })
+    expect(cacheStore.value).toEqual({ branch: "feature/test", defaultBranch: "main" })
   })
 
   test("routes disposal and lsp events to side-effect handlers", () => {

+ 113 - 30
packages/app/src/context/global-sync/event-reducer.ts

@@ -1,15 +1,18 @@
 import { Binary } from "@opencode-ai/core/util/binary"
 import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
 import type {
-  Message,
-  Part,
-  PermissionRequest,
-  Project,
-  QuestionRequest,
-  Session,
-  SessionStatus,
-  FileDiffInfo,
-} from "@opencode-ai/sdk/v2/client"
+  AppEvent,
+  AppFileDiff,
+  AppMessage,
+  AppPart,
+  AppPermissionRequest,
+  AppProject,
+  AppQuestionRequest,
+  AppSession,
+  AppTodo,
+  SessionActivity,
+} from "../backend"
+import { timelineMessage } from "../backend"
 import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
 import { dropSessionCaches } from "./session-cache"
@@ -31,19 +34,76 @@ const SESSION_CONTENT_EVENTS = new Set([
   "question.rejected",
 ])
 
+type LegacyEvent = { type: string; properties?: unknown }
+type DirectoryLegacyEvent =
+  | { type: string; properties: unknown }
+  | { type: "server.instance.disposed"; properties?: undefined }
+
+function legacyEvent(event: AppEvent): LegacyEvent | undefined {
+  if (event.type === "instance.disposed") return { type: "server.instance.disposed", properties: event }
+  if (event.type === "session.created" || event.type === "session.updated")
+    return { type: event.type, properties: { info: event.session } }
+  if (event.type === "session.deleted") return { type: event.type, properties: { sessionID: event.sessionID } }
+  if (event.type === "session.activity")
+    return { type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } }
+  if (event.type === "session.diff" || event.type === "todo.updated") return { type: event.type, properties: event }
+  if (event.type === "timeline.updated") {
+    const message = timelineMessage(event.item)
+    return message ? { type: "message.updated", properties: { info: message } } : undefined
+  }
+  if (event.type === "timeline.content.updated") return undefined
+  if (event.type === "timeline.removed")
+    return {
+      type: "message.removed",
+      properties: { sessionID: event.sessionID, messageID: event.itemID },
+    }
+  if (event.type === "timeline.part.removed")
+    return {
+      type: "message.part.removed",
+      properties: { sessionID: event.sessionID, messageID: event.itemID, partID: event.contentID },
+    }
+  if (event.type === "timeline.delta")
+    return {
+      type: "message.part.delta",
+      properties: {
+        sessionID: event.sessionID,
+        messageID: event.itemID,
+        partID: event.contentID,
+        field: event.field,
+        delta: event.delta,
+      },
+    }
+  if (event.type === "permission.requested") return { type: "permission.asked", properties: event.request }
+  if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected")
+    return { type: event.type, properties: event }
+  if (event.type === "question.requested") return { type: "question.asked", properties: event.request }
+  return { type: event.type, properties: event }
+}
+
+function normalizeDirectoryEvent(event: AppEvent | DirectoryLegacyEvent) {
+  if ("properties" in event) return event
+  if (event.type === "server.instance.disposed") return event
+  return legacyEvent(event)
+}
+
 export function applyGlobalEvent(input: {
-  event: { type: string; properties?: unknown }
-  project: Project[]
-  setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void
+  event: AppEvent | LegacyEvent
+  project: AppProject[]
+  setGlobalProject: (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => void
   refresh: () => void
 }) {
-  if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
+  if (
+    input.event.type === "server.disposed" ||
+    input.event.type === "global.disposed" ||
+    input.event.type === "server.connected" ||
+    input.event.type === "provider.updated"
+  ) {
     input.refresh()
     return
   }
 
   if (input.event.type !== "project.updated") return
-  const properties = input.event.properties as Project
+  const properties = "project" in input.event ? input.event.project : (input.event.properties as AppProject)
   const result = Binary.search(input.project, properties.id, (s) => s.id)
   if (result.found) {
     input.setGlobalProject(
@@ -60,7 +120,11 @@ export function applyGlobalEvent(input: {
   )
 }
 
-function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: string) {
+function cleanupSessionCaches(
+  setStore: SetStoreFunction<State>,
+  sessionID: string,
+  setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
+) {
   if (!sessionID) return
   setStore(
     produce((draft) => {
@@ -69,7 +133,12 @@ function cleanupSessionCaches(setStore: SetStoreFunction<State>, sessionID: stri
   )
 }
 
-export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, next: Session[]) {
+export function cleanupDroppedSessionCaches(
+  store: Store<State>,
+  setStore: SetStoreFunction<State>,
+  next: AppSession[],
+  setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void,
+) {
   const keep = new Set(next.map((item) => item.id))
   const stale = [
     ...Object.keys(store.message),
@@ -90,7 +159,7 @@ export function cleanupDroppedSessionCaches(store: Store<State>, setStore: SetSt
 }
 
 export function applyDirectoryEvent(input: {
-  event: { type: string; properties?: unknown }
+  event: AppEvent | DirectoryLegacyEvent
   store: Store<State>
   setStore: SetStoreFunction<State>
   push: (directory: string) => void
@@ -98,11 +167,17 @@ export function applyDirectoryEvent(input: {
   loadLsp: () => void
   loadReferences?: () => void
   vcsCache?: VcsCache
+  setSessionTodo?: (sessionID: string, todos: AppTodo[] | undefined) => void
   retainedLimit?: number
   sessionContent?: boolean
   permission?: State["permission"]
 }) {
-  const event = input.event
+  if (input.event.type === "server.instance.disposed") {
+    input.push(input.directory)
+    return
+  }
+  const event = normalizeDirectoryEvent(input.event)
+  if (!event) return
   if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
   const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
   switch (event.type) {
@@ -111,7 +186,7 @@ export function applyDirectoryEvent(input: {
       return
     }
     case "session.created": {
-      const info = (event.properties as { info: Session }).info
+      const info = (event.properties as { info: AppSession }).info
       const result = Binary.search(input.store.session, info.id, (s) => s.id)
       if (result.found) {
         input.setStore("session", result.index, reconcile(info))
@@ -126,7 +201,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "session.updated": {
-      const info = (event.properties as { info: Session }).info
+      const info = (event.properties as { info: AppSession }).info
       const result = Binary.search(input.store.session, info.id, (s) => s.id)
       if (info.time.archived) {
         if (input.store.session[result.index]!.time.archived === info.time.archived) break
@@ -155,8 +230,10 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "session.deleted": {
-      const info = (event.properties as { info: Session }).info
-      const result = Binary.search(input.store.session, info.id, (s) => s.id)
+      const properties = event.properties as { sessionID?: string; info?: { id: string } }
+      const sessionID = properties.sessionID ?? properties.info?.id ?? ""
+      const result = Binary.search(input.store.session, sessionID, (s) => s.id)
+      const info = result.found ? input.store.session[result.index] : undefined
       if (result.found) {
         input.setStore(
           "session",
@@ -165,23 +242,29 @@ export function applyDirectoryEvent(input: {
           }),
         )
       }
-      cleanupSessionCaches(input.setStore, info.id)
-      if (info.parentID) break
+      cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
+      if (info?.parentID) break
       input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
       break
     }
     case "session.diff": {
-      const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
+      const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
       input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
       break
     }
+    case "todo.updated": {
+      const props = event.properties as { sessionID: string; todos: AppTodo[] }
+      input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+      input.setSessionTodo?.(props.sessionID, props.todos)
+      break
+    }
     case "session.status": {
-      const props = event.properties as { sessionID: string; status: SessionStatus }
+      const props = event.properties as { sessionID: string; status: SessionActivity }
       input.setStore("session_status", props.sessionID, reconcile(props.status))
       break
     }
     case "message.updated": {
-      const info = clean((event.properties as { info: Message }).info)
+      const info = clean((event.properties as { info: AppMessage }).info)
       const messages = input.store.message[info.sessionID]
       if (!messages) {
         input.setStore("message", info.sessionID, [info])
@@ -222,7 +305,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "message.part.updated": {
-      const part = (event.properties as { part: Part }).part
+      const part = (event.properties as { part: AppPart }).part
       if (SKIP_PARTS.has(part.type)) break
       input.setStore(
         produce((draft) => {
@@ -306,7 +389,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "permission.asked": {
-      const permission = event.properties as PermissionRequest
+      const permission = event.properties as AppPermissionRequest
       const permissions = input.store.permission[permission.sessionID]
       if (!permissions) {
         input.setStore("permission", permission.sessionID, [permission])
@@ -342,7 +425,7 @@ export function applyDirectoryEvent(input: {
       break
     }
     case "question.asked": {
-      const question = event.properties as QuestionRequest
+      const question = event.properties as AppQuestionRequest
       const questions = input.store.question[question.sessionID]
       if (!questions) {
         input.setStore("question", question.sessionID, [question])

+ 3 - 2
packages/app/src/context/global-sync/mcp.ts

@@ -1,7 +1,7 @@
-import type { McpStatus } from "@opencode-ai/sdk/v2/client"
+import type { AppMcpStatus } from "../backend"
 
 export async function toggleMcp(input: {
-  status: McpStatus["status"]
+  status: AppMcpStatus["status"]
   connect: () => Promise<void>
   disconnect: () => Promise<void>
   authenticate: () => Promise<void>
@@ -9,6 +9,7 @@ export async function toggleMcp(input: {
 }) {
   await {
     connected: input.disconnect,
+    pending: async () => {},
     needs_auth: input.authenticate,
     disabled: input.connect,
     failed: input.connect,

+ 28 - 9
packages/app/src/context/global-sync/session-cache.test.ts

@@ -1,12 +1,12 @@
 import { describe, expect, test } from "bun:test"
 import type {
-  Message,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  SessionStatus,
-  FileDiffInfo,
-} from "@opencode-ai/sdk/v2/client"
+  AppMessage as Message,
+  AppPart as Part,
+  AppPermissionRequest as PermissionRequest,
+  AppQuestionRequest as QuestionRequest,
+  AppFileDiff as SnapshotFileDiff,
+  SessionActivity as SessionStatus,
+} from "../backend"
 import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
 
 const msg = (id: string, sessionID: string) =>
@@ -32,7 +32,8 @@ describe("app session cache", () => {
   test("dropSessionCaches clears orphaned parts without message rows", () => {
     const store: {
       session_status: Record<string, SessionStatus | undefined>
-      session_diff: Record<string, FileDiffInfo[] | undefined>
+      session_diff: Record<string, SnapshotFileDiff[] | undefined>
+      todo: Record<string, unknown>
       message: Record<string, Message[] | undefined>
       part: Record<string, Part[] | undefined>
       permission: Record<string, PermissionRequest[] | undefined>
@@ -41,6 +42,7 @@ describe("app session cache", () => {
     } = {
       session_status: { ses_1: { type: "busy" } as SessionStatus },
       session_diff: { ses_1: [] },
+      todo: {},
       message: {},
       part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
       permission: { ses_1: [] as PermissionRequest[] },
@@ -63,7 +65,8 @@ describe("app session cache", () => {
     const m = msg("msg_1", "ses_1")
     const store: {
       session_status: Record<string, SessionStatus | undefined>
-      session_diff: Record<string, FileDiffInfo[] | undefined>
+      session_diff: Record<string, SnapshotFileDiff[] | undefined>
+      todo: Record<string, unknown>
       message: Record<string, Message[] | undefined>
       part: Record<string, Part[] | undefined>
       permission: Record<string, PermissionRequest[] | undefined>
@@ -72,6 +75,7 @@ describe("app session cache", () => {
     } = {
       session_status: {},
       session_diff: {},
+      todo: {},
       message: { ses_1: [m] },
       part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
       permission: {},
@@ -85,6 +89,21 @@ describe("app session cache", () => {
     expect(store.part[m.id]).toBeUndefined()
   })
 
+  test("dropSessionCaches accepts V2 stores without todo caches", () => {
+    const store = {
+      session_status: { ses_1: { type: "running" } },
+      session_diff: { ses_1: [] },
+      message: { ses_1: [] },
+      part: {},
+      permission: { ses_1: [] },
+      question: { ses_1: [] },
+      part_text_accum_delta: {},
+    }
+
+    expect(() => dropSessionCaches(store, ["ses_1"])).not.toThrow()
+    expect(store.session_status.ses_1).toBeUndefined()
+  })
+
   test("pickSessionCacheEvictions preserves requested sessions", () => {
     const seen = new Set(["ses_1", "ses_2", "ses_3"])
 

+ 8 - 15
packages/app/src/context/global-sync/session-cache.ts

@@ -1,21 +1,13 @@
-import type {
-  Message,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  SessionStatus,
-  FileDiffInfo,
-} from "@opencode-ai/sdk/v2/client"
-
 export const SESSION_CACHE_LIMIT = 40
 
 type SessionCache = {
-  session_status: Record<string, SessionStatus | undefined>
-  session_diff: Record<string, FileDiffInfo[] | undefined>
-  message: Record<string, Message[] | undefined>
-  part: Record<string, Part[] | undefined>
-  permission: Record<string, PermissionRequest[] | undefined>
-  question: Record<string, QuestionRequest[] | undefined>
+  session_status: Record<string, unknown>
+  session_diff: Record<string, unknown>
+  todo?: Record<string, unknown>
+  message: Record<string, readonly { id: string }[] | undefined>
+  part: Record<string, readonly { id: string; sessionID: string }[] | undefined>
+  permission: Record<string, unknown>
+  question: Record<string, unknown>
   part_text_accum_delta: Record<string, string | undefined>
 }
 
@@ -35,6 +27,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
   for (const sessionID of stale) {
     delete store.message[sessionID]
     delete store.session_diff[sessionID]
+    delete store.todo?.[sessionID]
     delete store.session_status[sessionID]
     delete store.permission[sessionID]
     delete store.question[sessionID]

+ 2 - 2
packages/app/src/context/global-sync/session-trim.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import type { AppSession as Session } from "../backend"
 import { trimSessions } from "./session-trim"
 
 const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -43,7 +43,7 @@ describe("trimSessions", () => {
     const result = trimSessions(list, {
       limit: 2,
       permission: {
-        "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
+        "child-kept-by-permission": [{ id: "perm-1" }],
       },
       now,
     })

+ 8 - 8
packages/app/src/context/global-sync/session-trim.ts

@@ -1,21 +1,21 @@
-import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import type { AppSession } from "../backend"
 import { cmp } from "./utils"
 import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
 
-export function sessionUpdatedAt(session: Session) {
+export function sessionUpdatedAt(session: AppSession) {
   return session.time.updated ?? session.time.created
 }
 
-export function compareSessionRecent(a: Session, b: Session) {
+export function compareSessionRecent(a: AppSession, b: AppSession) {
   const aUpdated = sessionUpdatedAt(a)
   const bUpdated = sessionUpdatedAt(b)
   if (aUpdated !== bUpdated) return bUpdated - aUpdated
   return cmp(a.id, b.id)
 }
 
-export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
-  if (limit <= 0) return [] as Session[]
-  const selected: Session[] = []
+export function takeRecentSessions(sessions: AppSession[], limit: number, cutoff: number) {
+  if (limit <= 0) return [] as AppSession[]
+  const selected: AppSession[] = []
   const seen = new Set<string>()
   for (const session of sessions) {
     if (!session?.id) continue
@@ -31,8 +31,8 @@ export function takeRecentSessions(sessions: Session[], limit: number, cutoff: n
 }
 
 export function trimSessions(
-  input: Session[],
-  options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
+  input: AppSession[],
+  options: { limit: number; permission: Record<string, readonly unknown[]>; now?: number },
 ) {
   const limit = Math.max(0, options.limit)
   const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW

+ 53 - 39
packages/app/src/context/global-sync/types.ts

@@ -1,22 +1,23 @@
 import type {
-  Agent,
-  Command,
-  Config,
-  LspStatus,
-  McpResource,
-  McpStatus,
-  Message,
-  Part,
-  Path,
-  PermissionRequest,
-  QuestionRequest,
-  ReferenceInfo,
-  Session,
-  SessionStatus,
-  FileDiffInfo,
-  VcsInfo,
-} from "@opencode-ai/sdk/v2/client"
-import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+  AppMessage,
+  AppPart,
+  AppPermissionRequest,
+  AppQuestionRequest,
+  AppSession,
+  AppFileDiff,
+  AppTodo,
+  AppCommand,
+  AppConfig,
+  AppAgent,
+  AppLspStatus,
+  AppMcpResource,
+  AppMcpStatus,
+  AppPathInfo,
+  AppProvider,
+  AppReference,
+  AppVcsInfo,
+  SessionActivity,
+} from "../backend"
 import type { Accessor } from "solid-js"
 import type { SetStoreFunction, Store } from "solid-js/store"
 
@@ -31,49 +32,62 @@ export type ProjectMeta = {
   }
 }
 
+export type StoreConfig = {
+  -readonly [Key in keyof AppConfig]: AppConfig[Key]
+}
+
+export type ProviderStore = {
+  all: ReadonlyMap<string, AppProvider>
+  connected: readonly string[]
+  default: Readonly<Record<string, string>>
+}
+
 export type State = {
   status: "loading" | "partial" | "complete"
-  agent: Agent[]
-  command: Command[]
-  reference: ReferenceInfo[]
+  agent: AppAgent[]
+  command: readonly AppCommand[]
+  reference: readonly AppReference[]
   project: string
   projectMeta: ProjectMeta | undefined
   icon: string | undefined
   provider_ready: boolean
-  provider: NormalizedProviderListResponse
-  config: Config
-  path: Path
-  session: Session[]
+  provider: ProviderStore
+  config: StoreConfig
+  path: AppPathInfo
+  session: AppSession[]
   sessionTotal: number
   session_status: {
-    [sessionID: string]: SessionStatus
+    [sessionID: string]: SessionActivity
   }
   session_working(id: string): boolean
   session_diff: {
-    [sessionID: string]: FileDiffInfo[]
+    [sessionID: string]: AppFileDiff[]
+  }
+  todo: {
+    [sessionID: string]: AppTodo[]
   }
   permission: {
-    [sessionID: string]: PermissionRequest[]
+    [sessionID: string]: AppPermissionRequest[]
   }
   question: {
-    [sessionID: string]: QuestionRequest[]
+    [sessionID: string]: AppQuestionRequest[]
   }
   mcp_ready: boolean
   mcp: {
-    [name: string]: McpStatus
+    [name: string]: AppMcpStatus
   }
   mcp_resource: {
-    [key: string]: McpResource
+    [key: string]: AppMcpResource
   }
   lsp_ready: boolean
-  lsp: LspStatus[]
-  vcs: VcsInfo | undefined
+  lsp: readonly AppLspStatus[]
+  vcs: AppVcsInfo | undefined
   limit: number
   message: {
-    [sessionID: string]: Message[]
+    [sessionID: string]: AppMessage[]
   }
   part: {
-    [messageID: string]: Part[]
+    [messageID: string]: AppPart[]
   }
   part_text_accum_delta: {
     [partID: string]: string
@@ -81,8 +95,8 @@ export type State = {
 }
 
 export type VcsCache = {
-  store: Store<{ value: VcsInfo | undefined }>
-  setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
+  store: Store<{ value: AppVcsInfo | undefined }>
+  setStore: SetStoreFunction<{ value: AppVcsInfo | undefined }>
   ready: Accessor<boolean>
 }
 
@@ -127,11 +141,11 @@ export type DisposeCheck = {
 export type RootLoadArgs = {
   directory: string
   limit: number
-  list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
+  list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: AppSession[] }>
 }
 
 export type RootLoadResult = {
-  data?: Session[]
+  data?: AppSession[]
   limit: number
   limited: boolean
 }

+ 1 - 34
packages/app/src/context/global-sync/utils.test.ts

@@ -1,38 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { Agent } from "@opencode-ai/sdk/v2/client"
-import { directoryKey, normalizeAgentList } from "./utils"
-
-const agent = (name = "build") =>
-  ({
-    name,
-    mode: "primary",
-    permission: {},
-    options: {},
-  }) as Agent
-
-describe("normalizeAgentList", () => {
-  test("keeps array payloads", () => {
-    expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
-  })
-
-  test("wraps a single agent payload", () => {
-    expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
-  })
-
-  test("extracts agents from keyed objects", () => {
-    expect(
-      normalizeAgentList({
-        build: agent("build"),
-        docs: agent("docs"),
-      }),
-    ).toEqual([agent("build"), agent("docs")])
-  })
-
-  test("drops invalid payloads", () => {
-    expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
-    expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
-  })
-})
+import { directoryKey } from "./utils"
 
 describe("directoryKey", () => {
   test("normalizes slashes", () => {

+ 1 - 36
packages/app/src/context/global-sync/utils.ts

@@ -1,43 +1,8 @@
-import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
-import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+import type { AppProject as Project } from "../backend"
 export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
 
 export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
-function isAgent(input: unknown): input is Agent {
-  if (!input || typeof input !== "object") return false
-  const item = input as { name?: unknown; mode?: unknown }
-  if (typeof item.name !== "string") return false
-  return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
-}
-
-export function normalizeAgentList(input: unknown): Agent[] {
-  if (Array.isArray(input)) return input.filter(isAgent)
-  if (isAgent(input)) return [input]
-  if (!input || typeof input !== "object") return []
-  return Object.values(input).filter(isAgent)
-}
-
-export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
-  return {
-    ...input,
-    all: new Map(
-      input.all.map(
-        (provider) =>
-          [
-            provider.id,
-            {
-              ...provider,
-              models: Object.fromEntries(
-                Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
-              ),
-            },
-          ] as const,
-      ),
-    ),
-  }
-}
-
 export function sanitizeProject(project: Project) {
   if (!project.icon?.url && !project.icon?.override) return project
   return {

+ 41 - 6
packages/app/src/context/global.tsx

@@ -3,20 +3,25 @@ import { createEffect, createMemo, createRoot } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
 import { pathKey } from "@/utils/path-key"
-import { useServerHealth } from "@/utils/server-health"
+import { useCheckServerHealth, useServerHealth } from "@/utils/server-health"
 import { createServerSdkContext } from "./server-sdk"
 import { createServerSyncContext } from "./server-sync"
 import { getOwner } from "solid-js/web"
 import { QueryClient } from "@tanstack/solid-query"
 import type { ServerScope } from "@/utils/server-scope"
+import { usePlatform } from "./platform"
+import { backendIdentity, createBackendForServer } from "./backend-client"
 
 export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
   name: "Global",
   init: () => {
     const server = useServer()
+    const platform = usePlatform()
+    const checkServerHealth = useCheckServerHealth()
     const serverHealth = useServerHealth(
       () => server.list,
       () => true,
+      checkServerHealth,
     )
     const [store, setStore] = createStore({
       settings: {
@@ -37,7 +42,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
 
     const serverCtxs = new Map<
       ServerConnection.Key,
-      { dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
+      { dispose: () => void; identity: string; serverCtx: ReturnType<typeof createServerCtx> }
     >()
 
     const owner = getOwner()
@@ -45,10 +50,26 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
     const ensureServerCtx = (conn: ServerConnection.Any) => {
       const key = ServerConnection.key(conn)
       const existing = serverCtxs.get(key)
-      if (existing) return existing.serverCtx
+      const identity = backendIdentity(conn)
+      if (existing?.identity === identity) return existing.serverCtx
+      if (existing) {
+        existing.dispose()
+        serverCtxs.delete(key)
+      }
       const root = createRoot((dispose) => {
-        const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
-        return { dispose, serverCtx }
+        const serverCtx = createServerCtx(
+          conn,
+          server.scope(key),
+          server.projects.forServer(key),
+          createBackendForServer({
+            server: conn,
+            browserUrl: location.href,
+            fetch: platform.fetch ?? globalThis.fetch,
+            eventFetch: eventStreamFetch(conn.http.url, platform.fetch),
+            health: checkServerHealth(conn.http),
+          }),
+        )
+        return { dispose, identity, serverCtx }
       }, owner as any)
       serverCtxs.set(key, root)
       return root.serverCtx
@@ -97,6 +118,7 @@ function createServerCtx(
   conn: ServerConnection.Any,
   scope: ServerScope,
   projects: ReturnType<typeof createServerProjects>,
+  backend: ReturnType<typeof createBackendForServer>,
 ) {
   const queryClient = new QueryClient({
     defaultOptions: {
@@ -107,7 +129,7 @@ function createServerCtx(
       },
     },
   })
-  const sdk = createServerSdkContext(conn, scope)
+  const sdk = createServerSdkContext(conn, scope, backend)
   const sync = createServerSyncContext(sdk)
 
   function enrich(project: { worktree: string; expanded: boolean }) {
@@ -141,6 +163,7 @@ function createServerCtx(
     (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
 
   return {
+    backend,
     queryClient,
     sdk,
     sync,
@@ -153,6 +176,18 @@ function createServerCtx(
   }
 }
 
+function eventStreamFetch(url: string, fetch?: typeof globalThis.fetch) {
+  if (!fetch) return globalThis.fetch
+  try {
+    const parsed = new URL(url)
+    const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1"
+    if (parsed.protocol === "http:" && !loopback) return fetch
+  } catch {
+    return globalThis.fetch
+  }
+  return globalThis.fetch
+}
+
 export type ServerCtx = ReturnType<typeof createServerCtx>
 
 function isLocalHost(url: string) {

+ 8 - 3
packages/app/src/context/layout.tsx

@@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
 import { useServerSDK } from "./server-sdk"
 import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
 import { usePlatform } from "./platform"
-import { Project } from "@opencode-ai/sdk/v2"
+import type { AppProject } from "./backend"
 import { Persist, persisted, removePersisted } from "@/utils/persist"
 import { pathKey } from "@/utils/path-key"
 import { decode64 } from "@/utils/base64"
@@ -72,7 +72,7 @@ type TabHandoff = {
   at: number
 }
 
-export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
+export type LocalProject = Partial<AppProject> & { worktree: string; expanded: boolean }
 export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
 
 export type ReviewDiffStyle = "unified" | "split"
@@ -550,6 +550,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setColors(worktree, color)
         }
         if (!project.id) continue
+        const projectID = project.id
 
         const requested = colorRequested.get(worktree)
         if (requested === color) continue
@@ -561,7 +562,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         }
 
         void serverSdk()
-          .client.project.update({ projectID: project.id, directory: worktree, icon: { color } })
+          .backend.then((client) => {
+            const editing = client.capabilities.projectEditing
+            if (!editing) throw new Error("Project editing is not supported by this server")
+            return editing.update({ projectID, location: { directory: worktree }, icon: { color } })
+          })
           .catch(() => {
             if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
           })

+ 9 - 1
packages/app/src/context/local.tsx

@@ -64,7 +64,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const models = useModels()
 
     const id = createMemo(() => params.id || undefined)
-    const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
+    const list = createMemo(() =>
+      sync()
+        .data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)
+        .map((item) => ({
+          ...item,
+          model: item.model && { providerID: item.model.providerID, modelID: item.model.id },
+          variant: item.model?.variant,
+        })),
+    )
     const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
 
     const [saved, setSaved, , savedReady] = persisted(

+ 2 - 2
packages/app/src/context/models.tsx

@@ -50,7 +50,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
       () =>
         new Map(
           available().map((model) => {
-            const parsed = DateTime.fromISO(model.release_date)
+            const parsed = DateTime.fromISO(model.releaseDate ?? "")
             return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
           }),
         ),
@@ -75,7 +75,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
             values(),
             (groups) =>
               groups.flatMap((g) => {
-                const first = firstBy(g, [(x) => x.release_date, "desc"])
+                const first = firstBy(g, [(x) => x.releaseDate ?? "", "desc"])
                 return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
               }),
           ),

+ 9 - 10
packages/app/src/context/notification.tsx

@@ -9,7 +9,6 @@ import { useLanguage } from "@/context/language"
 import { useSettings } from "@/context/settings"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { decode64 } from "@/utils/base64"
-import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { Persist, persisted } from "@/utils/persist"
 import { playSoundById } from "@/utils/sound"
 import { useGlobal } from "./global"
@@ -32,7 +31,7 @@ type TurnCompleteNotification = NotificationBase & {
 
 type ErrorNotification = NotificationBase & {
   type: "error"
-  error: EventSessionError["properties"]["error"]
+  error: unknown
 }
 
 export type Notification = TurnCompleteNotification | ErrorNotification
@@ -325,8 +324,7 @@ function createServerNotificationState(input: {
     return sessionID === activeSession
   }
 
-  const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
-    const sessionID = event.properties.sessionID
+  const handleSessionIdle = (directory: string, sessionID: string, time: number) => {
     void lookup(directory, sessionID).then((session) => {
       if (meta.disposed) return
       if (!session) return
@@ -353,10 +351,10 @@ function createServerNotificationState(input: {
 
   const handleSessionError = (
     directory: string,
-    event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
+    event: { sessionID?: string; error?: unknown },
     time: number,
   ) => {
-    const sessionID = event.properties.sessionID
+    const sessionID = event.sessionID
     void lookup(directory, sessionID).then((session) => {
       if (meta.disposed) return
       if (session?.parentID) return
@@ -365,7 +363,7 @@ function createServerNotificationState(input: {
         void playSoundById(settings.sounds.errors())
       }
 
-      const error = "error" in event.properties ? event.properties.error : undefined
+      const error = event.error
       append({
         directory,
         time,
@@ -386,12 +384,13 @@ function createServerNotificationState(input: {
 
   const unsub = serverSDK().event.listen((e) => {
     const event = e.details
-    if (event.type !== "session.idle" && event.type !== "session.error") return
+    if (event.type !== "session.activity" && event.type !== "session.error") return
 
     const directory = e.name
     const time = Date.now()
-    if (event.type === "session.idle") {
-      handleSessionIdle(directory, event, time)
+    if (event.type === "session.activity") {
+      if (event.activity.type !== "idle") return
+      handleSessionIdle(directory, event.sessionID, time)
       return
     }
     handleSessionError(directory, event, time)

+ 1 - 1
packages/app/src/context/permission-auto-respond.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import type { AppPermissionRequest as PermissionRequest, AppSession as Session } from "./backend"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
 

+ 20 - 10
packages/app/src/context/permission.tsx

@@ -1,7 +1,6 @@
 import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
 import { Persist, persisted } from "@/utils/persist"
 import { useServerSDK } from "@/context/server-sdk"
 import { useServerSync } from "./server-sync"
@@ -21,6 +20,8 @@ type PermissionRespondFn = (input: {
   directory?: string
 }) => void
 
+type PermissionRequest = { id: string; sessionID: string }
+
 function isNonAllowRule(rule: unknown) {
   if (!rule) return false
   if (typeof rule === "string") return rule !== "allow"
@@ -119,8 +120,17 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
     }
 
     const respond: PermissionRespondFn = (input) => {
+      const directory = input.directory ?? props.directory?.() ?? decode64(params.dir)
+      if (!directory) return
       serverSDK()
-        .client.permission.respond(input)
+        .backend.then((client) =>
+          client.common.permissions.reply({
+            sessionID: input.sessionID,
+            requestID: input.permissionID,
+            reply: input.response,
+            location: { directory },
+          }),
+        )
         .catch(() => {
           responded.delete(input.permissionID)
         })
@@ -164,9 +174,9 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
 
     const unsubscribe = serverSDK().event.listen((e) => {
       const event = e.details
-      if (event?.type !== "permission.asked") return
+      if (event?.type !== "permission.requested") return
 
-      const perm = event.properties
+      const perm = event.request
       if (!shouldAutoRespond(perm, e.name)) return
 
       respondOnce(perm, e.name)
@@ -182,10 +192,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       )
 
       serverSDK()
-        .client.permission.list({ directory })
-        .then((x) => {
+        .backend.then((client) => client.common.permissions.pending({ location: { directory } }))
+        .then((items) => {
           if (!isAutoAcceptingDirectory(directory)) return
-          for (const perm of x.data ?? []) {
+          for (const perm of items) {
             if (!perm?.id) continue
             if (!shouldAutoRespond(perm, directory)) continue
             respondOnce(perm, directory)
@@ -214,11 +224,11 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
       )
 
       serverSDK()
-        .client.permission.list({ directory })
-        .then((x) => {
+        .backend.then((client) => client.common.permissions.pending({ location: { directory } }))
+        .then((items) => {
           if (enableVersion.get(key) !== version) return
           if (!isAutoAccepting(sessionID, directory)) return
-          for (const perm of x.data ?? []) {
+          for (const perm of items) {
             if (!perm?.id) continue
             if (!shouldAutoRespond(perm, directory)) continue
             respondOnce(perm, directory)

+ 1 - 1
packages/app/src/context/prompt-state.ts

@@ -1,5 +1,5 @@
 import { checksum } from "@opencode-ai/core/util/encode"
-import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
+import type { AppFilePartSource as FilePartSource } from "./backend"
 import { batch, createMemo, type Accessor } from "solid-js"
 import { createStore, type SetStoreFunction } from "solid-js/store"
 import type { FileSelection } from "@/context/file"

+ 58 - 61
packages/app/src/context/server-sdk.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
-import type { Event } from "@opencode-ai/sdk/v2/client"
+import type { AppEvent } from "./backend"
 
 describe("resumeStreamAfterPageShow", () => {
   test("restarts a stream only after a back-forward cache restore", () => {
@@ -18,34 +18,36 @@ describe("coalesceServerEvents", () => {
   const delta = (value: string, field = "text", partID = "part") => ({
     directory: "/repo",
     payload: {
-      type: "message.part.delta",
-      properties: { messageID: "msg", partID, field, delta: value },
-    } as Event,
+      type: "timeline.delta",
+      sessionID: "ses",
+      itemID: "msg",
+      contentID: partID,
+      field,
+      delta: value,
+    } as AppEvent,
   })
 
   test("merges adjacent deltas for the same field", () => {
     const first = delta("hello ")
     const second = delta("world")
-    first.payload.id = "first"
-    second.payload.id = "second"
     const result = coalesceServerEvents([first, second])
 
     expect(result).toHaveLength(1)
-    expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
+    expect(result[0]?.payload).toMatchObject({ delta: "hello world" })
   })
 
   test("preserves event boundaries and distinct fields", () => {
     const status = {
       directory: "/repo",
-      payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event,
+      payload: { type: "session.activity", sessionID: "ses", activity: { type: "idle" } } as AppEvent,
     }
     const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")])
 
     expect(result.map((event) => event.payload.type)).toEqual([
-      "message.part.delta",
-      "message.part.delta",
-      "session.status",
-      "message.part.delta",
+      "timeline.delta",
+      "timeline.delta",
+      "session.activity",
+      "timeline.delta",
     ])
   })
 
@@ -53,104 +55,99 @@ describe("coalesceServerEvents", () => {
     const first = delta("a")
     const other = delta("b", "text", "other")
     const last = delta("c")
-    first.payload.id = "1"
-    other.payload.id = "2"
-    last.payload.id = "3"
-
     const result = coalesceServerEvents([first, other, last])
 
-    expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
+    expect(result.map((event) => event.payload.type)).toEqual(["timeline.delta", "timeline.delta", "timeline.delta"])
   })
 })
 
 describe("enqueueServerEvent", () => {
   const partUpdated = (text: string) =>
     ({
-      type: "message.part.updated",
-      properties: {
+      type: "timeline.updated",
+      item: {
+        type: "user",
+        id: "message",
         sessionID: "session",
-        part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
+        created: 1,
+        content: [{ id: "part", type: "text", text }],
       },
-    }) as Event
+    }) as AppEvent
 
   test("preserves part updates across message remove and re-add barriers", () => {
-    const events: Array<{ directory: string; payload: Event }> = []
-    const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
+    const events: Array<{ directory: string; payload: AppEvent }> = []
+    const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
 
     enqueue(partUpdated("old"))
-    enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
+    enqueue({ type: "timeline.removed", sessionID: "session", itemID: "message" })
     enqueue({
-      type: "message.updated",
-      properties: {
+      type: "timeline.updated",
+      item: {
+        type: "user",
+        created: 1,
+        content: [],
         sessionID: "session",
-        info: {
-          id: "message",
-          sessionID: "session",
-          role: "user",
-          time: { created: 1 },
-          agent: "build",
-          model: { providerID: "provider", modelID: "model" },
-        },
+        id: "message",
       },
-    } as Event)
+    })
     enqueue(partUpdated("new"))
 
     expect(events.map((event) => event.payload.type)).toEqual([
-      "message.part.updated",
-      "message.removed",
-      "message.updated",
-      "message.part.updated",
+      "timeline.updated",
+      "timeline.removed",
+      "timeline.updated",
     ])
   })
 
   test("preserves deltas after a replacement snapshot", () => {
-    const events: Array<{ directory: string; payload: Event }> = []
-    const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
+    const events: Array<{ directory: string; payload: AppEvent }> = []
+    const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
 
     enqueue(partUpdated("a"))
     enqueue(partUpdated("ab"))
     enqueue({
-      type: "message.part.delta",
-      properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
-    } as Event)
+      type: "timeline.delta",
+      sessionID: "session",
+      itemID: "message",
+      contentID: "part",
+      field: "text",
+      delta: "c",
+    })
 
     const result = coalesceServerEvents(events)
-    expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
-    expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
-    expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
+    expect(result.map((event) => event.payload.type)).toEqual(["timeline.updated", "timeline.delta"])
+    expect(result[0]?.payload).toMatchObject({ item: { content: [{ text: "ab" }] } })
+    expect(result[1]?.payload).toMatchObject({ delta: "c" })
   })
 
   test("preserves updates after session deletion", () => {
-    const events: Array<{ directory: string; payload: Event }> = []
-    const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
+    const events: Array<{ directory: string; payload: AppEvent }> = []
+    const enqueue = (payload: AppEvent) => enqueueServerEvent(events, { directory: "/repo", payload })
 
     enqueue(partUpdated("old"))
     enqueue({
-      id: "event-delete",
       type: "session.deleted",
-      properties: { sessionID: "session" },
-    } as Event)
+      sessionID: "session",
+    })
     enqueue(partUpdated("new"))
 
     expect(events.map((event) => event.payload.type)).toEqual([
-      "message.part.updated",
+      "timeline.updated",
       "session.deleted",
-      "message.part.updated",
+      "timeline.updated",
     ])
   })
 
   test("does not coalesce edge-triggered session statuses", () => {
-    const events: Array<{ directory: string; payload: Event }> = []
+    const events: Array<{ directory: string; payload: AppEvent }> = []
     const enqueue = (status: "retry" | "busy") =>
       enqueueServerEvent(events, {
         directory: "/repo",
         payload: {
-          type: "session.status",
-          properties: {
-            sessionID: "session",
-            status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
-          },
-        } as Event,
+          type: "session.activity",
+          sessionID: "session",
+          activity: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "running" },
+        },
       })
 
     enqueue("retry")

+ 29 - 68
packages/app/src/context/server-sdk.tsx

@@ -1,9 +1,8 @@
-import type { Event } from "@opencode-ai/sdk/v2/client"
+import type { AppClient, AppEvent } from "./backend"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
-import { createSdkForServer } from "@/utils/server"
 import { useLanguage } from "./language"
 import { usePlatform } from "./platform"
 import { ServerConnection, useServer } from "./server"
@@ -15,14 +14,15 @@ const isAbortError = (error: unknown) =>
   error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
 
 const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
-type QueuedServerEvent = { directory: string; payload: Event }
+type QueuedServerEvent = { directory: string; payload: AppEvent }
 
 const coalescedKey = (event: QueuedServerEvent) => {
   if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
-  if (event.payload.type === "message.part.updated") {
-    const part = event.payload.properties.part
-    return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
+  if (event.payload.type === "timeline.updated") {
+    return `timeline.updated:${event.directory}:${event.payload.item.id}`
   }
+  if (event.payload.type === "timeline.content.updated")
+    return `timeline.content.updated:${event.directory}:${event.payload.itemID}:${event.payload.content.id}`
   return undefined
 }
 
@@ -40,23 +40,22 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
 export function coalesceServerEvents(events: QueuedServerEvent[]) {
   const output: QueuedServerEvent[] = []
   events.forEach((event) => {
-    if (event.payload.type !== "message.part.delta") {
+    if (event.payload.type !== "timeline.delta") {
       output.push(event)
       return
     }
-    const props = event.payload.properties
     const previous = output[output.length - 1]
     if (
       !previous ||
-      previous.payload.type !== "message.part.delta" ||
+      previous.payload.type !== "timeline.delta" ||
       previous.directory !== event.directory ||
-      previous.payload.properties.messageID !== props.messageID ||
-      previous.payload.properties.partID !== props.partID ||
-      previous.payload.properties.field !== props.field
+      previous.payload.itemID !== event.payload.itemID ||
+      previous.payload.contentID !== event.payload.contentID ||
+      previous.payload.field !== event.payload.field
     ) {
       output.push({
         directory: event.directory,
-        payload: { ...event.payload, properties: { ...props } },
+        payload: { ...event.payload },
       })
       return
     }
@@ -64,7 +63,7 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
       directory: event.directory,
       payload: {
         ...event.payload,
-        properties: { ...props, delta: previous.payload.properties.delta + props.delta },
+        delta: previous.payload.delta + event.payload.delta,
       },
     }
   })
@@ -76,7 +75,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
   start()
 }
 
-function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
+function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope, backend: Promise<AppClient>) {
   const platform = usePlatform()
   const abort = new AbortController()
 
@@ -91,13 +90,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
     }
   })()
 
-  const eventSdk = createSdkForServer({
-    signal: abort.signal,
-    fetch: eventFetch,
-    server: server.http,
-  })
   const emitter = createGlobalEmitter<{
-    [key: string]: Event
+    [key: string]: AppEvent
   }>()
 
   type Queued = QueuedServerEvent
@@ -174,29 +168,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
         }
         abort.signal.addEventListener("abort", onAbort)
         try {
-          const events = await eventSdk.global.event({
-            signal: attempt.signal,
-            onSseError: (error) => {
-              if (isStreamClosed(error, attempt?.signal)) return
-              if (streamErrorLogged) return
-              streamErrorLogged = true
-              console.error("[global-sdk] event stream error", {
-                url: server.http.url,
-                fetch: eventFetch ? "platform" : "webview",
-                error,
-              })
-            },
-          })
           let yielded = Date.now()
           resetHeartbeat()
-          for await (const event of events.stream) {
+          for await (const envelope of (await backend).common.events.subscribe({ signal: attempt.signal })) {
             resetHeartbeat()
             streamErrorLogged = false
-            if (event.payload.type !== "sync") {
-              const directory = event.directory ?? "global"
-              const payload = event.payload as Event
-              if (enqueueServerEvent(queue, { directory, payload })) schedule()
-            }
+            const directory = envelope.location?.directory ?? "global"
+            if (enqueueServerEvent(queue, { directory, payload: envelope.event })) schedule()
 
             if (Date.now() - yielded < STREAM_YIELD_MS) continue
             yielded = Date.now()
@@ -253,39 +231,30 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
     flush()
   })
 
-  const sdk = createSdkForServer({
-    server: server.http,
-    fetch: platform.fetch,
-    throwOnError: true,
-  })
-
   return {
     server,
     scope,
     url: server.http.url,
-    client: sdk,
     event: {
       on: emitter.on.bind(emitter),
       listen: emitter.listen.bind(emitter),
       start,
     },
-    createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
-      return createSdkForServer({
-        server: server.http,
-        fetch: platform.fetch,
-        ...opts,
-      })
-    },
   }
 }
 
 type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
-export type ServerSDK = ServerSDKBase & {
+type ServerSDKWithBackend = ServerSDKBase & { backend: Promise<AppClient> }
+export type ServerSDK = ServerSDKWithBackend & {
   ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
 }
 
-export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
-  const sdk = createServerSdkContextBase(server, scope)
+export function createServerSdkContext(
+  server: ServerConnection.Any,
+  scope: ServerScope,
+  backend: Promise<AppClient>,
+): ServerSDK {
+  const sdk = Object.assign(createServerSdkContextBase(server, scope, backend), { backend })
   return Object.assign(sdk, {
     ensureDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
   })
@@ -309,15 +278,10 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
 })
 
 type SDKEventMap = {
-  [key in Event["type"]]: Extract<Event, { type: key }>
+  [key in AppEvent["type"]]: AppEvent
 }
 
-function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
-  const client = serverSDK.createClient({
-    directory,
-    throwOnError: true,
-  })
-
+function createDirSdkContext(directory: string, serverSDK: ServerSDKWithBackend) {
   const emitter = createGlobalEmitter<SDKEventMap>()
 
   const unsub = serverSDK.event.on(directory, (event) => {
@@ -328,13 +292,10 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
   return {
     scope: serverSDK.scope,
     directory,
-    client,
+    backend: serverSDK.backend,
     event: emitter,
     get url() {
       return serverSDK.url
     },
-    createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
-      return serverSDK.createClient(opts)
-    },
   }
 }

+ 177 - 9
packages/app/src/context/server-session.test.ts

@@ -1,7 +1,51 @@
 import { describe, expect, test } from "bun:test"
 import type { retry } from "@opencode-ai/core/util/retry"
-import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
-import { createServerSession } from "./server-session"
+import type {
+  AppFileDiff,
+  AppMessage as Message,
+  AppPart as Part,
+  AppSession as Session,
+  AppTodo,
+  TimelineContent,
+  TimelineItem,
+} from "./backend"
+import { createAppClient } from "./backend.test-fixture"
+import { createServerSession as createAppServerSession } from "./server-session"
+
+type FixtureClient = {
+  session: {
+    get(input: unknown): Promise<{ data: Session }>
+    messages(input: unknown): MessageResponse | Promise<MessageResponse>
+    message?(input: unknown): SingleMessageResponse | Promise<SingleMessageResponse>
+    diff?(input: unknown): Promise<{ data: AppFileDiff[] }>
+    todo?(input: unknown): Promise<{ data: AppTodo[] }>
+  }
+}
+
+const createServerSession = (client: FixtureClient, options?: { retry?: typeof retry }) =>
+  createAppServerSession(
+    createAppClient({
+      version: "v1",
+      common: {
+        sessions: {
+          get: async (input) => (await client.session.get(input)).data,
+          history: async (input) => {
+            const result = await client.session.messages(input)
+            return {
+              items: result.data.map((item) => timelineItem(item.info, item.parts)),
+              older: result.response.headers.get("x-next-cursor") ?? undefined,
+            }
+          },
+          message: async (input) => {
+            if (!client.session.message) throw new Error("Message fixture is not configured")
+            const result = await client.session.message(input)
+            return timelineItem(result.data.info, result.data.parts)
+          },
+        },
+      },
+    }),
+    options,
+  )
 
 const session = (id: string, parentID?: string): Session => ({
   id,
@@ -67,6 +111,81 @@ const singleResponse = (info: Message, parts: Part[] = []): SingleMessageRespons
 
 const deferredResponse = () => Promise.withResolvers<MessageResponse>()
 
+function timelineItem(info: Message, parts: Part[]): TimelineItem {
+  const content = parts.map(toTimelineContent)
+  if (info.role === "user")
+    return {
+      type: "user",
+      id: info.id,
+      sessionID: info.sessionID,
+      created: info.time.created,
+      content,
+      agent: info.agent,
+      model: { id: info.model.modelID, providerID: info.model.providerID, variant: info.model.variant },
+      format: info.format,
+      summary: info.summary,
+      system: info.system,
+      tools: info.tools,
+    }
+  return {
+    type: "assistant",
+    id: info.id,
+    sessionID: info.sessionID,
+    parentID: info.parentID,
+    created: info.time.created,
+    completed: info.time.completed,
+    content,
+    agent: info.agent,
+    model: { id: info.modelID, providerID: info.providerID, variant: info.variant },
+    tokens: info.tokens,
+    error: info.error,
+    mode: info.mode,
+    path: info.path,
+    cost: info.cost,
+    structured: info.structured,
+    finish: info.finish,
+    summary: info.summary,
+  }
+}
+
+function toTimelineContent(part: Part): TimelineContent {
+  if (part.type === "agent")
+    return {
+      type: part.type,
+      id: part.id,
+      name: part.name,
+      source: part.source && { text: part.source.value, start: part.source.start, end: part.source.end },
+    }
+  if (part.type === "subtask")
+    return {
+      ...part,
+      model: part.model && { id: part.model.modelID, providerID: part.model.providerID },
+    }
+  if (part.type !== "file") return { ...part }
+  return {
+    type: "file",
+    id: part.id,
+    uri: part.url,
+    name: part.filename,
+    mime: part.mime,
+    source:
+      part.source?.type === "resource"
+        ? {
+            type: part.source.type,
+            clientName: part.source.clientName,
+            uri: part.source.uri,
+            text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
+          }
+        : part.source && {
+            type: part.source.type,
+            path: part.source.path,
+            name: part.source.type === "symbol" ? part.source.name : undefined,
+            kind: part.source.type === "symbol" ? part.source.kind : undefined,
+            text: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end },
+          },
+  }
+}
+
 function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
   let index = 0
   const requests: unknown[] = []
@@ -81,7 +200,7 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
         return responses[index++]
       },
     },
-  } as unknown as OpencodeClient
+  } as FixtureClient
   return Object.assign(client, {
     requests,
     requested(count: number) {
@@ -114,7 +233,7 @@ function rootMessageClient(
         return roots[rootIndex++]
       },
     },
-  } as unknown as OpencodeClient
+  } as FixtureClient
   return Object.assign(client, {
     requests,
     rootRequests,
@@ -152,7 +271,7 @@ function setup(sessions: Record<string, Session>) {
       },
       diff: async () => ({ data: [] }),
     },
-  } as unknown as OpencodeClient
+  } as FixtureClient
   return { get, messages, store: createServerSession(client) }
 }
 
@@ -163,7 +282,10 @@ describe("server session", () => {
     const result = await ctx.store.lineage.resolve("child")
 
     expect(result.root.id).toBe("root")
-    expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }])
+    expect(ctx.get).toEqual([
+      { sessionID: "child", location: undefined },
+      { sessionID: "root", location: undefined },
+    ])
     expect(ctx.store.lineage.peek("child")).toEqual(result)
   })
 
@@ -194,7 +316,9 @@ describe("server session", () => {
     await store.sync("child")
 
     expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
-    expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
+    expect(client.rootRequests).toEqual([
+      { sessionID: "child", messageID: user.id, location: { directory: "/repo" } },
+    ])
     expect(store.data.message.child).toEqual([user, ...assistants])
     expect(store.history.more("child")).toBe(true)
   })
@@ -278,7 +402,9 @@ describe("server session", () => {
 
     await store.sync("child", { force: true })
 
-    expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
+    expect(client.rootRequests).toEqual([
+      { sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
+    ])
     expect(store.data.message.child).toEqual([fresh, assistant])
     expect(store.data.part[stale.id]).toEqual([freshPart])
   })
@@ -300,7 +426,9 @@ describe("server session", () => {
 
     await store.sync("child", { force: true })
 
-    expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
+    expect(client.rootRequests).toEqual([
+      { sessionID: "child", messageID: stale.id, location: { directory: "/repo" } },
+    ])
     expect(store.data.message.child).toEqual([fresh, assistant])
     expect(store.data.part[stale.id]).toEqual([refreshed, pending])
   })
@@ -636,6 +764,21 @@ describe("server session", () => {
     expect(store.data.part[message.id]).toEqual([fetched])
   })
 
+  test("reconciles semantically identical native parts to optimistic IDs without duplicates", async () => {
+    const pending = deferredResponse()
+    const message = userMessage("message")
+    const optimistic = textPart(message.id, { id: "optimistic", text: "hello" })
+    const fetched = textPart(message.id, { id: "message:text", text: "hello" })
+    const store = createServerSession(messageClient(pending.promise))
+    const loading = store.sync("child")
+
+    store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
+    pending.resolve(response([{ info: message, parts: [fetched] }]))
+    await loading
+
+    expect(store.data.part[message.id]).toEqual([optimistic])
+  })
+
   test("rolls back only unconfirmed optimistic parts", async () => {
     const pending = deferredResponse()
     const message = userMessage("message")
@@ -1207,6 +1350,7 @@ describe("server session", () => {
 
     await store.history.loadMore("child")
 
+    guard.active = false
     expect(store.data.message.child).toEqual([older, latest])
   })
 
@@ -1429,4 +1573,28 @@ describe("server session", () => {
     expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
     expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
   })
+
+  test("force-resyncs pinned sessions after a stream reconnect", async () => {
+    const first = userMessage("first")
+    const second = userMessage("second", { time: { created: 2 } })
+    let messages = response([{ info: first, parts: [] }])
+    let requests = 0
+    const store = createServerSession({
+      session: {
+        get: async () => ({ data: session("child") }),
+        messages: async () => {
+          requests++
+          return messages
+        },
+      },
+    })
+    store.pin("child")
+    await store.sync("child")
+    messages = response([{ info: first, parts: [] }, { info: second, parts: [] }])
+
+    await store.resync()
+
+    expect(requests).toBe(2)
+    expect(store.data.message.child.map((message) => message.id)).toEqual(["first", "second"])
+  })
 })

+ 240 - 71
packages/app/src/context/server-session.ts

@@ -1,15 +1,19 @@
 import { Binary } from "@opencode-ai/core/util/binary"
 import { retry } from "@opencode-ai/core/util/retry"
 import type {
-  Message,
-  OpencodeClient,
-  Part,
-  PermissionRequest,
-  QuestionRequest,
-  Session,
-  SessionStatus,
-  FileDiffInfo,
-} from "@opencode-ai/sdk/v2/client"
+  AppClient,
+  AppEvent,
+  AppFileDiff,
+  AppMessage,
+  AppPart,
+  AppPermissionRequest,
+  AppQuestionRequest,
+  AppSession,
+  AppTodo,
+  LocationRef,
+  SessionActivity,
+} from "./backend"
+import { timelineMessage, timelineParts } from "./backend"
 import { batch } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
@@ -18,7 +22,7 @@ import { rootSession } from "@/utils/session-route"
 import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
-const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
+const cmpMessage = (a: AppMessage, b: AppMessage) => a.time.created - b.time.created || cmp(a.id, b.id)
 const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
 const initialMessagePageSize = 2
 const historyMessagePageSize = 200
@@ -26,15 +30,15 @@ const sessionInfoLimit = 2_048
 const emptyIDs: ReadonlySet<string> = new Set()
 
 type OptimisticItem = {
-  message: Message
-  parts: Part[]
-  confirmedParts?: Part[]
+  message: AppMessage
+  parts: AppPart[]
+  confirmedParts?: AppPart[]
   confirmedMessage?: boolean
 }
 
 type MessagePage = {
-  session: Message[]
-  part: { id: string; part: Part[] }[]
+  session: AppMessage[]
+  part: { id: string; part: AppPart[] }[]
   cursor?: string
   complete: boolean
 }
@@ -59,22 +63,31 @@ type MessageLoadBaseline = Pick<
 >
 
 function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
-  if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
+  if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: AppPart[] }[] }
   const session = [...page.session]
   const part = new Map(page.part.map((item) => [item.id, item.part]))
-  const observed: { messageID: string; parts: Part[] }[] = []
+  const observed: { messageID: string; parts: AppPart[] }[] = []
   for (const item of items) {
     const result = Binary.search(session, item.message.id, (message) => message.id)
     if (!result.found) session.splice(result.index, 0, item.message)
-    const current = part.get(item.message.id)
-    const confirmed = result.found
-      ? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
+    const current = part.get(item.message.id) ?? []
+    const matched = result.found
+      ? item.parts.flatMap((optimistic) => {
+          const fetched = current.find((value) => value.id === optimistic.id || samePromptPart(value, optimistic))
+          return fetched ? [{ optimistic, fetched }] : []
+        })
       : []
+    const confirmed = matched.map((value) => value.optimistic)
     if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
     part.set(
       item.message.id,
       merge(
-        result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
+        result.found
+          ? current.map((value) => {
+              const match = matched.find((item) => item.fetched.id === value.id)
+              return match ? { ...value, id: match.optimistic.id } : value
+            })
+          : merge(item.confirmedParts ?? [], current),
         item.parts.filter((part) => !confirmed.includes(part)),
       ),
     )
@@ -87,6 +100,14 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
   }
 }
 
+function samePromptPart(a: AppPart, b: AppPart) {
+  if (a.type !== b.type) return false
+  if (a.type === "text" && b.type === "text") return a.text === b.text
+  if (a.type === "file" && b.type === "file") return a.url === b.url && a.filename === b.filename && a.mime === b.mime
+  if (a.type === "agent" && b.type === "agent") return a.name === b.name
+  return false
+}
+
 function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
   const pending = map.get(key)
   if (pending) return pending
@@ -134,21 +155,22 @@ function reconcileFetched<T extends { id: string }>(
   return [...result.values()].sort((a, b) => cmp(a.id, b.id))
 }
 
-export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
+export function createServerSession(backend: Promise<AppClient> | AppClient, options?: { retry?: typeof retry }) {
   const [data, setData] = createStore({
-    info: {} as Record<string, Session | undefined>,
-    session_status: {} as Record<string, SessionStatus>,
-    session_diff: {} as Record<string, FileDiffInfo[]>,
-    permission: {} as Record<string, PermissionRequest[]>,
-    question: {} as Record<string, QuestionRequest[]>,
-    message: {} as Record<string, Message[]>,
-    part: {} as Record<string, Part[]>,
+    info: {} as Record<string, AppSession | undefined>,
+    session_status: {} as Record<string, SessionActivity>,
+    session_diff: {} as Record<string, AppFileDiff[]>,
+    todo: {} as Record<string, AppTodo[]>,
+    permission: {} as Record<string, AppPermissionRequest[]>,
+    question: {} as Record<string, AppQuestionRequest[]>,
+    message: {} as Record<string, AppMessage[]>,
+    part: {} as Record<string, AppPart[]>,
     part_text_accum_delta: {} as Record<string, string>,
     session_working(id: string) {
       return (this.session_status[id]?.type ?? "idle") !== "idle"
     },
   })
-  const requests = new Map<string, Promise<Session>>()
+  const requests = new Map<string, Promise<AppSession>>()
   const inflight = new Map<string, Promise<void>>()
   const inflightDiff = new Map<string, Promise<void>>()
   const optimistic = new Map<string, Map<string, OptimisticItem>>()
@@ -158,7 +180,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
   const removedMessages = new Map<string, Set<string>>()
   const deltaBases = new Map<string, { base: string; sessionID: string }>()
   const deleteMessageParts = (
-    cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
+    cache: { part: Record<string, AppPart[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
     messageID: string,
   ) => {
     for (const part of cache.part[messageID] ?? []) {
@@ -186,7 +208,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     at: {} as Record<string, number | undefined>,
   })
 
-  const remember = (session: Session) => {
+  const locations = new Map<string, LocationRef>()
+  const location = (sessionID: string) => {
+    const session = data.info[sessionID]
+    return session?.location ?? locations.get(sessionID) ?? (session?.directory ? { directory: session.directory } : undefined)
+  }
+
+  const remember = (session: AppSession) => {
+    if (session.location) locations.set(session.id, session.location)
+    if (session.parentID && session.location) locations.set(session.parentID, session.location)
     setData("info", session.id, reconcile(session))
     infoSeen.delete(session.id)
     infoSeen.add(session.id)
@@ -236,11 +266,13 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     const pending = requests.get(sessionID)
     if (pending) return pending
     const active = generation(sessionID)
-    const request = client.session.get({ sessionID }).then((result) => {
-      if (!result.data) throw sessionNotFoundError(sessionID)
-      if (generations.get(sessionID) !== active) return result.data
-      return remember(result.data)
-    })
+    const request = Promise.resolve(backend)
+      .then((client) => client.common.sessions.get({ sessionID, location: location(sessionID) }))
+      .then((result) => {
+        if (!result) throw sessionNotFoundError(sessionID)
+        if (generations.get(sessionID) !== active) return result
+        return remember(result)
+      })
     requests.set(sessionID, request)
     const cleanup = () => {
       if (requests.get(sessionID) === request) requests.delete(sessionID)
@@ -297,7 +329,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
   }
 
-  const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
+  const confirmOptimisticPart = (sessionID: string, messageID: string, part: AppPart) => {
     const items = optimistic.get(sessionID)
     const item = items?.get(messageID)
     if (!items || !item) return
@@ -314,7 +346,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     })
   }
 
-  const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
+  const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: AppPart[]) => {
     const items = optimistic.get(sessionID)
     const item = items?.get(messageID)
     if (!items || !item) return
@@ -461,35 +493,38 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     )
 
   const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
-    const response = await (options?.retry ?? retry)(() => {
+    const response = await (options?.retry ?? retry)(async () => {
       onAttempt?.()
-      return client.session.messages({ sessionID, limit, before })
+      return (await backend).common.sessions.history({ sessionID, limit, cursor: before, location: location(sessionID) })
     })
-    const items = (response.data ?? []).filter((item) => !!item?.info?.id)
+    const items = response.items
+      .map((item) => ({ message: timelineMessage(item), parts: timelineParts(item) }))
+      .filter((item): item is { message: AppMessage; parts: AppPart[] } => !!item.message)
     return {
-      session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
+      session: items.map((item) => cleanMessage(item.message)).sort((a, b) => cmp(a.id, b.id)),
       part: items.map((item) => ({
-        id: item.info.id,
-        part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
+        id: item.message.id,
+        part: item.parts.sort((a, b) => cmp(a.id, b.id)),
       })),
-      cursor: response.response.headers.get("x-next-cursor") ?? undefined,
-      complete: !response.response.headers.get("x-next-cursor"),
+      cursor: response.older,
+      complete: !response.older,
     }
   }
 
   const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
-    const response = await (options?.retry ?? retry)(() => {
+    const response = await (options?.retry ?? retry)(async () => {
       onAttempt?.()
-      return client.session.message({ sessionID, messageID })
+      return (await backend).common.sessions.message({ sessionID, messageID, location: location(sessionID) })
     })
-    if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
+    const message = timelineMessage(response)
+    if (!message) throw new Error(`Message not found: ${messageID}`)
     return {
-      message: cleanMessage(response.data.info),
-      parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
+      message: cleanMessage(message),
+      parts: timelineParts(response).sort((a, b) => cmp(a.id, b.id)),
     }
   }
 
-  const replaceMessages = (sessionID: string, messages: Message[]) => {
+  const replaceMessages = (sessionID: string, messages: AppMessage[]) => {
     const messageIDs = new Set(messages.map((message) => message.id))
     const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
     setData("message", sessionID, reconcile(messages, { key: "id" }))
@@ -559,7 +594,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     sessionID: string,
     page: MessagePage,
     load: MessageLoadState | undefined,
-    preserveUnfetched: boolean | ((message: Message) => boolean),
+    preserveUnfetched: boolean | ((message: AppMessage) => boolean),
     cleanupOrphans: boolean,
   ) => {
     const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
@@ -609,7 +644,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     let applied = false
     try {
       const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
-      const first = page.session.reduce<Message | undefined>(
+      const first = page.session.reduce<AppMessage | undefined>(
         (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
         undefined,
       )
@@ -630,7 +665,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         const parentIDs = [
           ...new Set(
             page.session.flatMap((message) =>
-              message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
+              message.role === "assistant" && message.parentID && !users.has(message.parentID) ? [message.parentID] : [],
             ),
           ),
         ]
@@ -659,7 +694,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
               ),
             }
       const preserveUnfetched =
-        mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
+        mode === "prepend" || (!result.complete && (!first || ((message: AppMessage) => cmpMessage(message, first) < 0)))
       applyMessagePage(
         sessionID,
         result,
@@ -682,7 +717,11 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     }
   }
 
-  const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
+  const sync = (
+    sessionID: string,
+    options?: { force?: boolean; messageLimit?: number; location?: LocationRef },
+  ) => {
+    if (options?.location) locations.set(sessionID, options.location)
     touch(sessionID)
     return runInflight(inflight, sessionID, async () => {
       const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
@@ -729,7 +768,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       return properties.part.sessionID
   }
 
-  const apply = (event: { type: string; properties?: unknown }) => {
+  const applyLegacy = (event: { type: string; properties?: unknown }) => {
     const eventID = eventSessionID(event)
     if (eventID) {
       touch(eventID)
@@ -743,16 +782,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     }
     switch (event.type) {
       case "session.created":
-        remember((event.properties as { info: Session }).info)
+        remember((event.properties as { info: AppSession }).info)
         return
       case "session.updated": {
-        const info = (event.properties as { info: Session }).info
+        const info = (event.properties as { info: AppSession }).info
         remember(info)
         if (info.time.archived) evict([info.id])
         return
       }
       case "session.deleted": {
-        const sessionID = (event.properties as { info: Session }).info.id
+        const sessionID = (event.properties as { sessionID: string }).sessionID
         infoSeen.delete(sessionID)
         setData(
           "info",
@@ -762,17 +801,22 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         return
       }
       case "session.diff": {
-        const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
+        const props = event.properties as { sessionID: string; diff: AppFileDiff[] }
         setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
         return
       }
+      case "todo.updated": {
+        const props = event.properties as { sessionID: string; todos: AppTodo[] }
+        setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+        return
+      }
       case "session.status": {
-        const props = event.properties as { sessionID: string; status: SessionStatus }
+        const props = event.properties as { sessionID: string; status: SessionActivity }
         setData("session_status", props.sessionID, reconcile(props.status))
         return
       }
       case "message.updated": {
-        const info = cleanMessage((event.properties as { info: Message }).info)
+        const info = cleanMessage((event.properties as { info: AppMessage }).info)
         const load = messageLoads.get(info.sessionID)
         load?.touchedMessages.add(info.id)
         load?.removedMessages.delete(info.id)
@@ -832,7 +876,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         return
       }
       case "message.part.updated": {
-        const part = (event.properties as { part: Part }).part
+        const part = (event.properties as { part: AppPart }).part
         if (SKIP_PARTS.has(part.type)) return
         const messages = data.message[part.sessionID]
         const load = messageLoads.get(part.sessionID)
@@ -971,7 +1015,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         return
       }
       case "permission.asked": {
-        const permission = event.properties as PermissionRequest
+        const permission = event.properties as AppPermissionRequest
         const permissions = data.permission[permission.sessionID]
         if (!permissions) {
           setData("permission", permission.sessionID, [permission])
@@ -1001,7 +1045,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         return
       }
       case "question.asked": {
-        const question = event.properties as QuestionRequest
+        const question = event.properties as AppQuestionRequest
         const questions = data.question[question.sessionID]
         if (!questions) {
           setData("question", question.sessionID, [question])
@@ -1033,6 +1077,118 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     }
   }
 
+  const apply = (event: AppEvent | { type: string; properties: unknown }, eventLocation?: LocationRef) => {
+    if ("properties" in event) {
+      applyLegacy(event)
+      return
+    }
+    const sessionID =
+      event.type === "session.created" || event.type === "session.updated"
+        ? event.session.id
+        : "sessionID" in event && typeof event.sessionID === "string"
+          ? event.sessionID
+          : event.type === "timeline.updated"
+            ? event.item.sessionID
+            : undefined
+    if (sessionID && eventLocation) locations.set(sessionID, eventLocation)
+    if (event.type === "session.created" || event.type === "session.updated") {
+      applyLegacy({ type: event.type, properties: { info: event.session } })
+      return
+    }
+    if (event.type === "session.deleted") {
+      applyLegacy({ type: event.type, properties: { sessionID: event.sessionID } })
+      return
+    }
+    if (event.type === "session.moved") {
+      const current = data.info[event.sessionID]
+      if (!current) return
+      remember({
+        ...current,
+        location: event.location,
+        directory: event.location.directory,
+        workspaceID: event.location.workspaceID,
+      })
+      return
+    }
+    if (event.type === "session.revert") {
+      const current = data.info[event.sessionID]
+      if (current) remember({ ...current, revert: event.revert })
+      return
+    }
+    if (event.type === "session.activity") {
+      applyLegacy({ type: "session.status", properties: { sessionID: event.sessionID, status: event.activity } })
+      if (event.item) apply({ type: "timeline.updated", item: event.item }, eventLocation)
+      return
+    }
+    if (event.type === "session.diff" || event.type === "todo.updated") {
+      applyLegacy({ type: event.type, properties: event })
+      return
+    }
+    if (event.type === "timeline.updated") {
+      const message = timelineMessage(event.item)
+      if (!message) return
+      applyLegacy({ type: "message.updated", properties: { info: message } })
+      timelineParts(event.item).forEach((part) =>
+        applyLegacy({ type: "message.part.updated", properties: { part } }),
+      )
+      return
+    }
+    if (event.type === "timeline.content.updated") {
+      const message = data.message[event.sessionID]?.find((item) => item.id === event.itemID)
+      if (!message) return
+      const part = timelineParts({
+        type: message.role,
+        id: message.id,
+        sessionID: message.sessionID,
+        created: message.time.created,
+        completed: message.role === "assistant" ? message.time.completed : undefined,
+        content: [event.content],
+      })[0]
+      if (part) applyLegacy({ type: "message.part.updated", properties: { part } })
+      return
+    }
+    if (event.type === "timeline.removed") {
+      applyLegacy({
+        type: "message.removed",
+        properties: { sessionID: event.sessionID, messageID: event.itemID },
+      })
+      return
+    }
+    if (event.type === "timeline.part.removed") {
+      applyLegacy({
+        type: "message.part.removed",
+        properties: {
+          sessionID: event.sessionID,
+          messageID: event.itemID,
+          partID: event.contentID,
+        },
+      })
+      return
+    }
+    if (event.type === "timeline.delta") {
+      applyLegacy({
+        type: "message.part.delta",
+        properties: {
+          sessionID: event.sessionID,
+          messageID: event.itemID,
+          partID: event.contentID,
+          field: event.field,
+          delta: event.delta,
+        },
+      })
+      return
+    }
+    if (event.type === "permission.requested") {
+      applyLegacy({ type: "permission.asked", properties: event.request })
+      return
+    }
+    if (event.type === "permission.replied" || event.type === "question.replied" || event.type === "question.rejected") {
+      applyLegacy({ type: event.type, properties: event })
+      return
+    }
+    if (event.type === "question.requested") applyLegacy({ type: "question.asked", properties: event.request })
+  }
+
   return {
     data,
     set: setData,
@@ -1059,7 +1215,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
     },
     optimistic: {
-      add(input: { sessionID: string; message: Message; parts: Part[] }) {
+      add(input: { sessionID: string; message: AppMessage; parts: AppPart[] }) {
         const parts = input.parts
           .filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
           .sort((a, b) => cmp(a.id, b.id))
@@ -1122,9 +1278,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
       return runInflight(inflightDiff, sessionID, () => {
         const active = generation(sessionID)
-        return retry(() => client.session.diff({ sessionID })).then((result) => {
+        return retry(async () => {
+          const client = await backend
+          const capability = client.capabilities.sessionExtrasV1 ?? client.capabilities.sessionExtrasV2
+          if (!capability?.diff) return []
+          return capability.diff({ sessionID, location: location(sessionID) })
+        }).then((result) => {
           if (generations.get(sessionID) !== active) return
-          setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
+          setData("session_diff", sessionID, reconcile(cleanDiffs(result), { key: "file" }))
         })
       })
     },
@@ -1155,6 +1316,14 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
       if (!count || count === 1) pinned.delete(sessionID)
       if (count && count > 1) pinned.set(sessionID, count - 1)
     },
+    async resync() {
+      const sessionIDs = new Set([
+        ...pinned.keys(),
+        ...optimistic.keys(),
+        ...Object.entries(data.session_status).flatMap(([sessionID, status]) => status.type === "idle" ? [] : [sessionID]),
+      ])
+      await Promise.all([...sessionIDs].map((sessionID) => sync(sessionID, { force: true })))
+    },
     apply,
   }
 }

+ 95 - 70
packages/app/src/context/server-sync.tsx

@@ -1,11 +1,11 @@
 import type {
-  Config,
-  McpResource,
-  OpencodeClient,
-  Path,
-  Project,
-  ProviderAuthResponse,
-} from "@opencode-ai/sdk/v2/client"
+  AppClient,
+  AppConfig,
+  AppMcpResource,
+  AppPathInfo,
+  AppProject,
+  AppProviderAuthResponse,
+} from "./backend"
 import { showToast } from "@/utils/toast"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -28,7 +28,7 @@ import { createChildStoreManager } from "./global-sync/child-store"
 import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
 import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
 import { trimSessions } from "./global-sync/session-trim"
-import type { ProjectMeta } from "./global-sync/types"
+import type { ProjectMeta, ProviderStore, StoreConfig } from "./global-sync/types"
 import { SESSION_RECENT_LIMIT } from "./global-sync/types"
 import { formatServerError } from "@/utils/server-errors"
 import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
@@ -37,7 +37,6 @@ import { directoryKey } from "./global-sync/utils"
 import { PathKey } from "@/utils/path-key"
 import { createDirSyncContext } from "./directory-sync"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import { createRefCountMap } from "@/utils/refcount"
 import { useGlobal } from "./global"
 import { ServerConnection, useServer } from "./server"
@@ -47,53 +46,63 @@ import { persisted } from "@/utils/persist"
 import { toggleMcp } from "./global-sync/mcp"
 import { createServerSession } from "./server-session"
 
+type GlobalConfigUpdate = Pick<AppConfig, "shell" | "provider" | "disabledProviders">
+
 type GlobalStore = {
   ready: boolean
   error?: InitError
-  path: Path
-  project: Project[]
-  provider: NormalizedProviderListResponse
-  provider_auth: ProviderAuthResponse
-  config: Config
+  path: AppPathInfo
+  project: AppProject[]
+  provider: ProviderStore
+  provider_auth: AppProviderAuthResponse
+  config: StoreConfig
   reload: undefined | "pending" | "complete"
 }
 
-export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
+export const loadMcpQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
   queryOptions({
     queryKey: [scope, directory, "mcp"] as const,
-    queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
+    queryFn: () =>
+      backend
+        .then((client) => client.capabilities.mcp?.list({ location: { directory } }) ?? [])
+        .then((servers) => Object.fromEntries(servers.map((server) => [server.name, server.status]))),
   })
 
-export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
-  queryOptions<Record<string, McpResource>>({
+export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
+  queryOptions<Record<string, AppMcpResource>>({
     queryKey: [scope, directory, "mcpResources"] as const,
-    queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
+    queryFn: () =>
+      backend
+        .then(
+          (client) =>
+            client.capabilities.mcp?.resources({ location: { directory } }) ?? { resources: [], templates: [] },
+        )
+        .then((result) =>
+          Object.fromEntries(result.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
+        ),
     placeholderData: {},
   })
 
-export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
+export const loadLspQuery = (scope: ServerScope, directory: string, backend: Promise<AppClient>) =>
   queryOptions({
     queryKey: [scope, directory, "lsp"] as const,
-    queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
+    queryFn: async () => {
+      const client = await backend
+      return client.capabilities.lsp?.status({ location: { directory } }) ?? []
+    },
   })
 
-function makeQueryOptionsApi(
-  scope: ServerScope,
-  serverSDK: () => OpencodeClient,
-  sdkFor: (dir: PathKey) => OpencodeClient,
-) {
+function makeQueryOptionsApi(scope: ServerScope, backend: Promise<AppClient>) {
   return {
-    globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
-    projects: () => loadProjectsQuery(scope, serverSDK()),
-    providers: (directory: PathKey | null) =>
-      loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
-    path: (directory: PathKey | null) =>
-      loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
-    agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
-    references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
-    mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
-    mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
-    lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
+    globalConfig: () => loadGlobalConfigQuery(scope, backend),
+    projects: () => loadProjectsQuery(scope, backend),
+    providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, backend),
+    path: (directory: PathKey | null) => loadPathQuery(scope, directory, backend),
+    agents: (directory: PathKey) => loadAgentsQuery(scope, directory, backend),
+    references: (directory: PathKey) => loadReferencesQuery(scope, directory, backend),
+    mcp: (directory: PathKey) => loadMcpQuery(scope, directory, backend),
+    mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, backend),
+    lsp: (directory: PathKey) => loadLspQuery(scope, directory, backend),
     sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
   }
 }
@@ -104,24 +113,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
   const owner = getOwner()
   if (!owner) throw new Error("ServerSync must be created within owner")
 
-  const sdkCache = new Map<string, OpencodeClient>()
   const booting = new Map<string, Promise<void>>()
   const sessionLoads = new Map<string, Promise<void>>()
   const sessionMeta = new Map<string, { limit: number }>()
 
-  const sdkFor = (directory: string) => {
-    const key = directoryKey(directory)
-    const cached = sdkCache.get(key)
-    if (cached) return cached
-    const sdk = serverSDK.createClient({
-      directory,
-      throwOnError: true,
-    })
-    sdkCache.set(key, sdk)
-    return sdk
-  }
-
-  const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
+  const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.backend)
 
   const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
     queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -164,13 +160,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     if (eventTimer !== undefined) clearTimeout(eventTimer)
   })
 
-  const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
+  const setProjects = (next: AppProject[] | ((draft: AppProject[]) => AppProject[])) => {
     setGlobalStore("project", next)
   }
 
   const setBootStore = ((...input: unknown[]) => {
     if (input[0] === "project" && Array.isArray(input[1])) {
-      setProjects(input[1] as Project[])
+      setProjects(input[1] as AppProject[])
       return input[1]
     }
     return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@@ -180,7 +176,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     queryKey: [serverSDK.scope, "bootstrap"],
     queryFn: async () => {
       await bootstrapGlobal({
-        serverSDK: serverSDK.client,
+        backend: serverSDK.backend,
         scope: serverSDK.scope,
         requestFailedTitle: language.t("common.requestFailed"),
         translate: language.t,
@@ -195,7 +191,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
 
   const set = ((...input: unknown[]) => {
     if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
-      setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
+      setProjects(input[1] as AppProject[] | ((draft: AppProject[]) => AppProject[]))
       return input[1]
     }
     return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
@@ -210,7 +206,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     bootstrapInstance,
   })
 
-  const session = createServerSession(serverSDK.client)
+  const session = createServerSession(serverSDK.backend)
 
   const children = createChildStoreManager({
     owner,
@@ -223,9 +219,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     },
     onMcp: (directory, setStore) => {
       void retry(() =>
-        sdkFor(directory)
-          .command.list()
-          .then((x) => setStore("command", x.data ?? [])),
+        serverSDK.backend
+          .then((client) => client.common.commands.list({ location: { directory } }))
+          .then((commands) => setStore("command", reconcile(commands))),
       ).catch((err) => {
         showToast({
           variant: "error",
@@ -238,7 +234,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       const key = directoryKey(directory)
       queue.clear(key)
       sessionMeta.delete(key)
-      sdkCache.delete(key)
       clearProviderRev(serverSDK.scope, key)
     },
     translate: language.t,
@@ -280,7 +275,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
           loadRootSessionsWithFallback({
             directory,
             limit,
-            list: (query) => serverSDK.client.session.list(query),
+            list: (query) =>
+              serverSDK.backend.then((client) =>
+                client.common.sessions
+                  .list({ location: { directory: query.directory }, roots: query.roots, limit: query.limit })
+                  .then((page) => ({ data: [...page.items] })),
+              ),
           })
             .then((x) => {
               const nonArchived = (x.data ?? [])
@@ -339,7 +339,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
       const child = children.ensureChild(directory)
       const cache = children.vcsCache.get(key)
       if (!cache) return
-      const sdk = sdkFor(directory)
+      const backend = await serverSDK.backend
       await bootstrapDirectory({
         directory,
         scope: serverSDK.scope,
@@ -350,7 +350,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
           project: globalStore.project,
           provider: globalStore.provider,
         },
-        sdk,
+        backend,
         store: child[0],
         setStore: child[1],
         vcsCache: cache,
@@ -375,7 +375,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     const event = e.details
     const recent = bootingRoot || Date.now() - bootedAt < 1500
 
-    session.apply(event)
+    session.apply(event, directory === "global" ? undefined : { directory })
+
+    if (event.type === "server.connected") void session.resync()
+    if (event.type === "provider.updated") {
+      if (!recent) bootstrap.refetch()
+      if (directory !== "global") queue.push(directory)
+      return
+    }
 
     if (directory === "global") {
       applyGlobalEvent({
@@ -387,7 +394,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
         },
         setGlobalProject: setProjects,
       })
-      if (event.type === "server.connected" || event.type === "global.disposed") {
+      if (event.type === "server.connected" || event.type === "server.disposed") {
         if (recent) return
         for (const directory of Object.keys(children.children)) {
           queue.push(directory)
@@ -457,7 +464,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
   }
 
   const updateConfigMutation = useMutation(() => ({
-    mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
+    mutationFn: async (config: GlobalConfigUpdate) => {
+      const backend = await serverSDK.backend
+      const capability = backend.capabilities.configuration
+      if (!capability) throw new Error("Server does not support configuration updates")
+      await capability.updateGlobal({
+        shell: config.shell,
+        provider: config.provider,
+        disabledProviders: config.disabledProviders,
+      })
+    },
     onSuccess: () => {
       bootstrap.refetch()
       // Invalidate all provider queries so newly configured custom providers
@@ -484,23 +500,32 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     queryOptions: queryOptionsApi,
     // bootstrap,
     updateConfig: updateConfigMutation.mutateAsync,
+    refreshProviders: async () => {
+      await queryClient.invalidateQueries({
+        predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
+      })
+      await bootstrap.refetch()
+    },
     project: projectApi,
     session,
     mcp: {
       toggle: async (directory: string, name: string) => {
         const key = directoryKey(directory)
-        const sdk = sdkFor(key)
+        const backend = await serverSDK.backend
+        const capability = backend.capabilities.mcpControl
+        if (!capability) throw new Error("Server does not support MCP controls")
+        const location = { location: { directory: key } }
         const status = children.child(key, { bootstrap: false })[0].mcp[name].status
         await toggleMcp({
           status,
           connect: async () => {
-            await sdk.mcp.connect({ name })
+            await capability.connect({ ...location, name })
           },
           disconnect: async () => {
-            await sdk.mcp.disconnect({ name })
+            await capability.disconnect({ ...location, name })
           },
           authenticate: async () => {
-            await sdk.mcp.auth.authenticate({ name })
+            await capability.authenticate({ ...location, name })
           },
           refresh: async () => {
             await queryClient.refetchQueries(queryOptionsApi.mcp(key))

+ 1 - 1
packages/app/src/context/sync-optimistic.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import type { AppMessage as Message, AppPart as Part } from "./backend"
 import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
 
 type Text = Extract<Part, { type: "text" }>

+ 12 - 12
packages/app/src/context/sync.tsx

@@ -2,25 +2,25 @@ import { Binary } from "@opencode-ai/core/util/binary"
 import { createMemo } from "solid-js"
 import { useServerSync } from "./server-sync"
 import { useSDK } from "./sdk"
-import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import type { AppMessage, AppPart } from "./backend"
 
 const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
 
-function sortParts(parts: Part[]) {
+function sortParts(parts: AppPart[]) {
   return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
 }
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
 type OptimisticStore = {
-  message: Record<string, Message[] | undefined>
-  part: Record<string, Part[] | undefined>
+  message: Record<string, AppMessage[] | undefined>
+  part: Record<string, AppPart[] | undefined>
 }
 
 type OptimisticAddInput = {
   sessionID: string
-  message: Message
-  parts: Part[]
+  message: AppMessage
+  parts: AppPart[]
 }
 
 type OptimisticRemoveInput = {
@@ -29,23 +29,23 @@ type OptimisticRemoveInput = {
 }
 
 type OptimisticItem = {
-  message: Message
-  parts: Part[]
+  message: AppMessage
+  parts: AppPart[]
 }
 
 type MessagePage = {
-  session: Message[]
-  part: { id: string; part: Part[] }[]
+  session: AppMessage[]
+  part: { id: string; part: AppPart[] }[]
   cursor?: string
   complete: boolean
 }
 
-const hasParts = (parts: Part[] | undefined, want: Part[]) => {
+const hasParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
   if (!parts) return want.length === 0
   return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
 }
 
-const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
+const mergeParts = (parts: AppPart[] | undefined, want: AppPart[]) => {
   if (!parts) return sortParts(want)
   const next = [...parts]
   let changed = false

+ 3 - 3
packages/app/src/context/tabs.tsx

@@ -1,4 +1,4 @@
-import type { Session } from "@opencode-ai/sdk/v2/client"
+import type { AppSession } from "./backend"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createStore, produce } from "solid-js/store"
 import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
@@ -45,7 +45,7 @@ export const tabHref = (tab: Tab) =>
 
 export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
 
-export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
+export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: AppSession) {
   return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
 }
 
@@ -348,7 +348,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
         for (const key of removed) memory.remove(key)
         for (const key of removed) removeInfo(key)
       },
-      rememberSessionInfo(tab: SessionTab, session: Session) {
+      rememberSessionInfo(tab: SessionTab, session: AppSession) {
         const key = tabKey(tab)
         const next = { title: session.title, directory: session.directory }
         const current = info[key]

+ 35 - 31
packages/app/src/context/terminal.tsx

@@ -149,6 +149,7 @@ function createWorkspaceTerminalSession(
   scope: ServerScopeValue,
   legacySessionID?: string,
 ) {
+  const location = { directory: sdk.directory }
   const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
 
   const [store, setStore, _, ready] = persisted(
@@ -198,23 +199,27 @@ function createWorkspaceTerminalSession(
     })
   }
 
-  const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
-    removeExited(event.properties.id)
+  const unsub = sdk.event.on("pty.exited", (event) => {
+    if (event.type !== "pty.exited") return
+    removeExited(event.ptyID)
   })
   onCleanup(unsub)
 
-  const update = (client: DirectorySDK["client"], pty: Partial<LocalPTY> & { id: string }) => {
+  const update = (pty: Partial<LocalPTY> & { id: string }) => {
     const index = store.all.findIndex((x) => x.id === pty.id)
     const previous = index >= 0 ? store.all[index] : undefined
     if (index >= 0) {
       setStore("all", index, (item) => ({ ...item, ...pty }))
     }
-    client.pty
-      .update({
-        ptyID: pty.id,
-        title: pty.title,
-        size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
-      })
+    sdk.backend
+      .then((client) =>
+        client.common.pty.update({
+          ptyID: pty.id,
+          title: pty.title,
+          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+          location,
+        }),
+      )
       .catch((error: unknown) => {
         if (previous) {
           const currentIndex = store.all.findIndex((item) => item.id === pty.id)
@@ -224,26 +229,24 @@ function createWorkspaceTerminalSession(
       })
   }
 
-  const clone = async (client: DirectorySDK["client"], id: string) => {
+  const clone = async (id: string) => {
     const index = store.all.findIndex((x) => x.id === id)
     const pty = store.all[index]
     if (!pty) return
-    const next = await client.pty
-      .create({
-        title: pty.title,
-      })
+    const next = await sdk.backend
+      .then((client) => client.common.pty.create({ title: pty.title, location }))
       .catch((error: unknown) => {
         console.error("Failed to clone terminal", error)
         return undefined
       })
-    if (!next?.data) return
+    if (!next) return
 
     const active = store.active === pty.id
 
     batch(() => {
       setStore("all", index, {
-        id: next.data.id,
-        title: next.data.title ?? pty.title,
+        id: next.id,
+        title: next.title ?? pty.title,
         titleNumber: pty.titleNumber,
         buffer: undefined,
         cursor: undefined,
@@ -252,7 +255,7 @@ function createWorkspaceTerminalSession(
         cols: undefined,
       })
       if (active) {
-        setStore("active", next.data.id)
+        setStore("active", next.id)
       }
     })
   }
@@ -270,14 +273,14 @@ function createWorkspaceTerminalSession(
     new() {
       const nextNumber = pickNextTerminalNumber()
 
-      sdk.client.pty
-        .create({ title: defaultTitle(nextNumber) })
-        .then((pty: { data?: { id?: string; title?: string } }) => {
-          const id = pty.data?.id
+      sdk.backend
+        .then((client) => client.common.pty.create({ title: defaultTitle(nextNumber), location }))
+        .then((pty) => {
+          const id = pty.id
           if (!id) return
           const newTerminal = {
             id,
-            title: pty.data?.title ?? defaultTitle(nextNumber),
+            title: pty.title ?? defaultTitle(nextNumber),
             titleNumber: nextNumber,
           }
           setStore("all", store.all.length, newTerminal)
@@ -288,7 +291,7 @@ function createWorkspaceTerminalSession(
         })
     },
     update(pty: Partial<LocalPTY> & { id: string }) {
-      update(sdk.client, pty)
+      update(pty)
     },
     trim(id: string) {
       const index = store.all.findIndex((x) => x.id === id)
@@ -303,10 +306,9 @@ function createWorkspaceTerminalSession(
       })
     },
     async clone(id: string) {
-      await clone(sdk.client, id)
+      await clone(id)
     },
     bind() {
-      const client = sdk.client
       return {
         trim(id: string) {
           const index = store.all.findIndex((x) => x.id === id)
@@ -314,10 +316,10 @@ function createWorkspaceTerminalSession(
           setStore("all", index, (pty) => trimTerminal(pty))
         },
         update(pty: Partial<LocalPTY> & { id: string }) {
-          update(client, pty)
+          update(pty)
         },
         async clone(id: string) {
-          await clone(client, id)
+          await clone(id)
         },
       }
     },
@@ -353,9 +355,11 @@ function createWorkspaceTerminalSession(
         })
       }
 
-      await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
-        console.error("Failed to close terminal", error)
-      })
+      await sdk.backend
+        .then((client) => client.common.pty.remove({ ptyID: id, location }))
+        .catch((error: unknown) => {
+          console.error("Failed to close terminal", error)
+        })
     },
     move(id: string, to: number) {
       const index = store.all.findIndex((f) => f.id === id)

+ 3 - 3
packages/app/src/hooks/provider-catalog.test.ts

@@ -1,9 +1,9 @@
 import { expect, test } from "bun:test"
-import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+import type { ProviderStore } from "@/context/global-sync/types"
 import { selectProviderCatalog } from "./provider-catalog"
 
-const catalog = (id: string): NormalizedProviderListResponse => ({
-  all: new Map([[id, { id, name: id, source: "api", env: [], options: {}, models: {} }]]),
+const catalog = (id: string): ProviderStore => ({
+  all: new Map([[id, { id, name: id, source: "api", models: {} }]]),
   connected: [id],
   default: { [id]: `${id}-model` },
 })

+ 4 - 4
packages/app/src/hooks/provider-catalog.ts

@@ -1,10 +1,10 @@
-import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+import type { ProviderStore } from "@/context/global-sync/types"
 
-const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
+const emptyProviderCatalog: ProviderStore = { all: new Map(), connected: [], default: {} }
 
 type DirectoryCatalog = {
   ready: boolean
-  providers: NormalizedProviderListResponse
+  providers: ProviderStore
 }
 
 type ProviderCatalogInput =
@@ -17,7 +17,7 @@ type ProviderCatalogInput =
       explicit: false
       directory?: string
       catalog?: DirectoryCatalog
-      global: NormalizedProviderListResponse
+      global: ProviderStore
     }
 
 export function selectProviderCatalog(input: ProviderCatalogInput) {

+ 10 - 2
packages/app/src/pages/home.tsx

@@ -1,4 +1,4 @@
-import type { Session } from "@opencode-ai/sdk/v2/client"
+import type { AppSession as Session } from "@/context/backend"
 import {
   type ComponentProps,
   createEffect,
@@ -508,7 +508,15 @@ export function NewHome() {
     await archiveHomeSession({
       server: ServerConnection.key(conn),
       session,
-      update: (value) => ctx.sdk.client.session.update(value),
+      update: async (value) => {
+        const capability = (await ctx.sdk.backend).capabilities.sessionExtrasV1
+        if (!capability) throw new Error("Session archiving is not supported by this server")
+        await capability.archive({
+          location: { directory: value.directory },
+          sessionID: value.sessionID,
+          archivedAt: value.time.archived,
+        })
+      },
       remove: () =>
         setStore(
           produce((draft) => {

+ 105 - 44
packages/app/src/pages/layout.tsx

@@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { getFilename } from "@opencode-ai/core/util/path"
-import { Session } from "@opencode-ai/sdk/v2/client"
+import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
+import type { AppSession as Session } from "@/context/backend"
 import { usePlatform } from "@/context/platform"
 import { useSettings } from "@/context/settings"
 import { createStore, produce, reconcile } from "solid-js/store"
@@ -66,6 +66,7 @@ import {
   errorMessage,
   latestRootSession,
   sortedRootSessions,
+  workspaceCopyCreateInput,
 } from "./layout/helpers"
 import {
   collectNewSessionDeepLinks,
@@ -85,6 +86,11 @@ import { SidebarContent } from "./layout/sidebar-shell"
 
 export default function LegacyLayout(props: ParentProps) {
   const serverSDK = useServerSDK()
+  const [backend] = createResource(
+    () => serverSDK().backend,
+    (value) => value,
+  )
+  const canArchive = () => !!backend()?.capabilities.sessionExtrasV1
   const [store, setStore, , ready] = persisted(
     Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
     createStore({
@@ -398,7 +404,7 @@ export default function LegacyLayout(props: ParentProps) {
           WorktreeState.failed(
             serverSDK().scope,
             e.name,
-            e.details.properties?.message ?? language.t("common.requestFailed"),
+            e.details.message ?? language.t("common.requestFailed"),
           )
           return
         }
@@ -408,21 +414,21 @@ export default function LegacyLayout(props: ParentProps) {
           e.details?.type === "question.rejected" ||
           e.details?.type === "permission.replied"
         ) {
-          const props = e.details.properties as { sessionID: string }
+          const props = e.details
           const sessionKey = `${e.name}:${props.sessionID}`
           dismissSessionAlert(sessionKey)
           return
         }
 
-        if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
+        if (e.details?.type !== "permission.requested" && e.details?.type !== "question.requested") return
         const title =
-          e.details.type === "permission.asked"
+          e.details.type === "permission.requested"
             ? language.t("notification.permission.title")
             : language.t("notification.question.title")
-        const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
+        const icon = e.details.type === "permission.requested" ? ("checklist" as const) : ("bubble-5" as const)
         const directory = e.name
-        const props = e.details.properties
-        if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
+        const props = e.details.request
+        if (e.details.type === "permission.requested" && permission.autoResponds(e.details.request, directory)) return
 
         const [store] = serverSync().child(directory, { bootstrap: false })
         const session = store.session.find((s) => s.id === props.sessionID)
@@ -431,7 +437,7 @@ export default function LegacyLayout(props: ParentProps) {
         const sessionTitle = session?.title ?? language.t("command.session.new")
         const projectName = getFilename(directory)
         const description =
-          e.details.type === "permission.asked"
+          e.details.type === "permission.requested"
             ? language.t("notification.permission.description", { sessionTitle, projectName })
             : language.t("notification.question.description", { sessionTitle, projectName })
         const href = `/${base64Encode(directory)}/session/${props.sessionID}`
@@ -441,7 +447,7 @@ export default function LegacyLayout(props: ParentProps) {
         if (now - lastAlerted < cooldownMs) return
         alertedAtBySession.set(sessionKey, now)
 
-        if (e.details.type === "permission.asked") {
+        if (e.details.type === "permission.requested") {
           if (settings.sounds.permissionsEnabled()) {
             void playSoundById(settings.sounds.permissions())
           }
@@ -450,7 +456,7 @@ export default function LegacyLayout(props: ParentProps) {
           }
         }
 
-        if (e.details.type === "question.asked") {
+        if (e.details.type === "question.requested") {
           if (settings.notifications.agent()) {
             void platform.notify(title, description, href)
           }
@@ -874,10 +880,12 @@ export default function LegacyLayout(props: ParentProps) {
     const index = sessions.findIndex((s) => s.id === session.id)
     const nextSession = sessions[index + 1] ?? sessions[index - 1]
 
-    await serverSDK().client.session.update({
-      directory: session.directory,
+    const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
+    if (!capability) return
+    await capability.archive({
+      location: { directory: session.directory },
       sessionID: session.id,
-      time: { archived: Date.now() },
+      archivedAt: Date.now(),
     })
     setStore(
       produce((draft) => {
@@ -976,7 +984,7 @@ export default function LegacyLayout(props: ParentProps) {
         title: language.t("command.session.archive"),
         category: language.t("command.category.session"),
         keybind: "mod+shift+backspace",
-        disabled: !params.dir || !params.id,
+        disabled: !params.dir || !params.id || !canArchive(),
         onSelect: () => {
           const session = currentSessions().find((s) => s.id === params.id)
           if (session) void archiveSession(session)
@@ -1185,8 +1193,15 @@ export default function LegacyLayout(props: ParentProps) {
     const refreshDirs = async (target?: string) => {
       if (!target || target === root || canOpen(target)) return canOpen(target)
       const listed = await serverSDK()
-        .client.worktree.list({ directory: root })
-        .then((x) => x.data ?? [])
+        .backend.then((client) => {
+          const location = { directory: root }
+          if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.list({ location })
+          if (project?.id && client.capabilities.projectCopiesV2?.directories)
+            return client.capabilities.projectCopiesV2
+              .directories({ projectID: project.id, location })
+              .then((items) => items.map((item) => item.directory))
+          return []
+        })
         .catch(() => [] as string[])
       dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
       return canOpen(target)
@@ -1231,8 +1246,11 @@ export default function LegacyLayout(props: ParentProps) {
         dirs.map(async (item) => ({
           path: { directory: item },
           session: await serverSDK()
-            .client.session.list({ directory: item })
-            .then((x) => x.data ?? [])
+            .backend.then((client) =>
+              client.common.sessions
+                .list({ location: { directory: item } })
+                .then((page) => [...page.items]),
+            )
             .catch(() => []),
         })),
       ),
@@ -1293,7 +1311,9 @@ export default function LegacyLayout(props: ParentProps) {
     const name = next === getFilename(project.worktree) ? "" : next
 
     if (project.id && project.id !== "global") {
-      await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name })
+      const editing = (await serverSDK().backend).capabilities.projectEditing
+      if (!editing) throw new Error("Project editing is not supported by this server")
+      await editing.update({ projectID: project.id, location: { directory: project.worktree }, name })
       return
     }
 
@@ -1387,8 +1407,15 @@ export default function LegacyLayout(props: ParentProps) {
     setBusy(directory, true)
 
     const result = await serverSDK()
-      .client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
-      .then((x) => x.data)
+      .backend.then(async (client) => {
+        const location = { directory: root }
+        if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.remove({ location, directory })
+        const copies = client.capabilities.projectCopiesV2
+        const project = layout.projects.list().find((item) => item.worktree === root)
+        if (!copies || !project?.id) throw new Error("Project copy removal is not supported by this server")
+        await copies.remove({ projectID: project.id, location, directory, force: true })
+        return true
+      })
       .catch((err) => {
         showToast({
           title: language.t("workspace.delete.failed.title"),
@@ -1444,9 +1471,12 @@ export default function LegacyLayout(props: ParentProps) {
     })
     const dismiss = () => toaster.dismiss(progress)
 
-    const sessions: Session[] = await serverSDK()
-      .client.session.list({ directory })
-      .then((x) => x.data ?? [])
+    const sessions = await serverSDK()
+      .backend.then((client) =>
+        client.common.sessions
+          .list({ location: { directory } })
+          .then((page) => [...page.items]),
+      )
       .catch(() => [])
 
     clearWorkspaceTerminals(
@@ -1456,12 +1486,26 @@ export default function LegacyLayout(props: ParentProps) {
       serverSDK().scope,
     )
     await serverSDK()
-      .client.instance.dispose({ directory })
+      .backend.then((client) => client.capabilities.runtimeV1?.disposeLocation({ location: { directory } }))
       .catch(() => undefined)
 
     const result = await serverSDK()
-      .client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
-      .then((x) => x.data)
+      .backend.then(async (client) => {
+        const location = { directory: root }
+        if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.reset({ location, directory })
+        const copies = client.capabilities.projectCopiesV2
+        const project = layout.projects.list().find((item) => item.worktree === root)
+        if (!copies || !project?.id) throw new Error("Project copy reset is not supported by this server")
+        await copies.remove({ projectID: project.id, location, directory, force: true })
+        await copies.create({
+          projectID: project.id,
+          location,
+          strategy: "git_worktree",
+          directory: getDirectory(directory),
+          name: getFilename(directory),
+        })
+        return true
+      })
       .catch((err) => {
         showToast({
           title: language.t("workspace.reset.failed.title"),
@@ -1482,10 +1526,14 @@ export default function LegacyLayout(props: ParentProps) {
         .filter((session) => session.time.archived === undefined)
         .map((session) =>
           serverSDK()
-            .client.session.update({
-              sessionID: session.id,
-              directory: session.directory,
-              time: { archived: archivedAt },
+            .backend.then(async (client) => {
+              const capability = client.capabilities.sessionExtrasV1
+              if (!capability) return
+              await capability.archive({
+                location: { directory: session.directory },
+                sessionID: session.id,
+                archivedAt,
+              })
             })
             .catch(() => undefined),
         ),
@@ -1523,9 +1571,8 @@ export default function LegacyLayout(props: ParentProps) {
 
     onMount(() => {
       serverSDK()
-        .client.vcs.status({ directory: props.directory })
-        .then((x) => {
-          const files = x.data ?? []
+        .backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
+        .then((files) => {
           const dirty = files.length > 0
           setData({ status: "ready", dirty })
         })
@@ -1582,8 +1629,11 @@ export default function LegacyLayout(props: ParentProps) {
 
     const refresh = async () => {
       const sessions = await serverSDK()
-        .client.session.list({ directory: props.directory })
-        .then((x) => x.data ?? [])
+        .backend.then((client) =>
+          client.common.sessions
+            .list({ location: { directory: props.directory } })
+            .then((page) => [...page.items]),
+        )
         .catch(() => [])
       const active = sessions.filter((session) => session.time.archived === undefined)
       setState({ sessions: active })
@@ -1591,9 +1641,8 @@ export default function LegacyLayout(props: ParentProps) {
 
     onMount(() => {
       serverSDK()
-        .client.vcs.status({ directory: props.directory })
-        .then((x) => {
-          const files = x.data ?? []
+        .backend.then((client) => client.capabilities.vcs?.status({ location: { directory: props.directory } }) ?? [])
+        .then((files) => {
           const dirty = files.length > 0
           setState({ status: "ready", dirty })
           void refresh()
@@ -1822,8 +1871,17 @@ export default function LegacyLayout(props: ParentProps) {
   const createWorkspace = async (project: LocalProject) => {
     clearSidebarHoverState()
     const created = await serverSDK()
-      .client.worktree.create({ directory: project.worktree })
-      .then((x) => x.data)
+      .backend.then((client) => {
+        const location = { directory: project.worktree }
+        if (client.capabilities.worktreesV1) return client.capabilities.worktreesV1.create({ location })
+        const copies = client.capabilities.projectCopiesV2
+        const input = workspaceCopyCreateInput(project)
+        if (!copies || !input) throw new Error("Project copy creation is not supported by this server")
+        return copies.create({
+          ...input,
+          location,
+        })
+      })
       .catch((err) => {
         showToast({
           title: language.t("workspace.create.failed.title"),
@@ -1834,7 +1892,8 @@ export default function LegacyLayout(props: ParentProps) {
 
     if (!created?.directory) return
 
-    setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
+    const branch = "branch" in created && typeof created.branch === "string" ? created.branch : undefined
+    setWorkspaceName(created.directory, branch ?? getFilename(created.directory), project.id, branch)
 
     const local = project.worktree
     const key = pathKey(created.directory)
@@ -1867,6 +1926,7 @@ export default function LegacyLayout(props: ParentProps) {
     clearHoverProjectSoon,
     prefetchSession,
     archiveSession,
+    canArchive,
     workspaceName,
     renameWorkspace,
     editorOpen,
@@ -1913,6 +1973,7 @@ export default function LegacyLayout(props: ParentProps) {
       clearHoverProjectSoon,
       prefetchSession,
       archiveSession,
+      canArchive,
     },
   }
 

+ 11 - 1
packages/app/src/pages/layout/helpers.test.ts

@@ -6,7 +6,7 @@ import {
   parseDeepLink,
   parseNewSessionDeepLink,
 } from "./deep-links"
-import { type Session } from "@opencode-ai/sdk/v2/client"
+import type { AppSession as Session } from "@/context/backend"
 import {
   childSessionOnPath,
   closeHomeProject,
@@ -19,12 +19,22 @@ import {
   homeSessionServerStatus,
   latestRootSession,
   toggleHomeProjectSelection,
+  workspaceCopyCreateInput,
 } from "./helpers"
 import { pathKey } from "@/utils/path-key"
 import { ServerConnection } from "@/context/server"
 
 const serverKey = ServerConnection.Key.make
 
+test("builds a v2 git worktree copy request from the project root", () => {
+  expect(workspaceCopyCreateInput({ id: "project", worktree: "/repos/opencode" })).toEqual({
+    projectID: "project",
+    strategy: "git_worktree",
+    directory: "/repos/",
+  })
+  expect(workspaceCopyCreateInput({ worktree: "/repos/opencode" })).toBeUndefined()
+})
+
 const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
   ({
     title: "",

+ 11 - 2
packages/app/src/pages/layout/helpers.ts

@@ -1,5 +1,5 @@
-import { getFilename } from "@opencode-ai/core/util/path"
-import { type Session } from "@opencode-ai/sdk/v2/client"
+import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
+import type { AppSession as Session } from "@/context/backend"
 import { pathKey } from "@/utils/path-key"
 import type { ServerConnection } from "@/context/server"
 import type { HomeProjectSelection } from "@/context/layout"
@@ -9,6 +9,15 @@ type SessionStore = {
   path: { directory: string }
 }
 
+export function workspaceCopyCreateInput(project: { id?: string; worktree: string }) {
+  if (!project.id) return
+  return {
+    projectID: project.id,
+    strategy: "git_worktree",
+    directory: getDirectory(project.worktree),
+  }
+}
+
 function sortSessions(now: number) {
   const oneMinuteAgo = now - 60 * 1000
   return (a: Session, b: Session) => {

+ 3 - 2
packages/app/src/pages/layout/sidebar-items.tsx

@@ -1,4 +1,4 @@
-import type { Session } from "@opencode-ai/sdk/v2/client"
+import type { AppSession as Session } from "@/context/backend"
 import { Avatar } from "@opencode-ai/ui/avatar"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
@@ -87,6 +87,7 @@ export type SessionItemProps = {
   clearHoverProjectSoon: () => void
   prefetchSession: (session: Session, priority?: "high" | "low") => void
   archiveSession: (session: Session) => Promise<void>
+  canArchive: Accessor<boolean>
 }
 
 const SessionRow = (props: {
@@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
             </Show>
           </div>
 
-          <Show when={!props.level}>
+          <Show when={!props.level && props.canArchive()}>
             <div
               class="shrink-0 overflow-hidden transition-[width,opacity]"
               classList={{

+ 3 - 1
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -14,7 +14,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { type Session } from "@opencode-ai/sdk/v2/client"
+import type { AppSession as Session } from "@/context/backend"
 import { type LocalProject } from "@/context/layout"
 import { useServerSync, useQueryOptions } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
@@ -42,6 +42,7 @@ export type WorkspaceSidebarContext = {
   clearHoverProjectSoon: () => void
   prefetchSession: (session: Session, priority?: "high" | "low") => void
   archiveSession: (session: Session) => Promise<void>
+  canArchive: Accessor<boolean>
   workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
   renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
   editorOpen: (id: string) => boolean
@@ -272,6 +273,7 @@ const WorkspaceSessionList = (props: {
           clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
           prefetchSession={props.ctx.prefetchSession}
           archiveSession={props.ctx.archiveSession}
+          canArchive={props.ctx.canArchive}
         />
       )}
     </For>

+ 67 - 33
packages/app/src/pages/session.tsx

@@ -1,4 +1,10 @@
-import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type {
+  AppFilePart as FilePart,
+  AppProject as Project,
+  AppUserMessage as UserMessage,
+  AppVcsFileDiff as VcsFileDiff,
+} from "@/context/backend"
+import { getFilename } from "@opencode-ai/core/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
 import {
@@ -640,7 +646,7 @@ export default function Page() {
     const project = sync().project
     const vcs = sync().data.vcs
     if (project?.vcs === "git") list.push("git")
-    if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
+    if (project?.vcs === "git" && vcs?.branch && vcs?.defaultBranch && vcs.branch !== vcs.defaultBranch) {
       list.push("branch")
     }
     list.push("turn")
@@ -658,7 +664,7 @@ export default function Page() {
   })
   const vcsKey = createMemo(
     () =>
-      ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
+      ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.defaultBranch ?? ""] as const,
   )
   const vcsQuery = createQuery(() => {
     const mode = vcsMode()
@@ -670,8 +676,13 @@ export default function Page() {
       queryFn: mode
         ? () =>
             sdk()
-              .client.vcs.diff({ mode })
-              .then((result) => list(result.data))
+              .backend.then((client) =>
+                client.capabilities.vcs?.diff({
+                  location: { directory: sdk().directory },
+                  mode: mode === "git" ? "working" : mode,
+                }) ?? [],
+              )
+              .then(list)
               .catch((error) => {
                 console.debug("[session-review] failed to load vcs diff", { mode, error })
                 return []
@@ -717,9 +728,13 @@ export default function Page() {
           staleTime: Number.POSITIVE_INFINITY,
           retry: 2,
           queryFn: () =>
-            sdk()
-              .client.vcs.diff({ mode, directory: scope, context })
-              .then((result) => result.data ?? []),
+            sdk().backend.then((client) =>
+              client.capabilities.vcs?.diff({
+                location: { directory: scope },
+                mode: mode === "git" ? "working" : mode,
+                context,
+              }) ?? [],
+            ),
         })
         .then((diffs) => diffs.find((diff) => diff.file === file))
 
@@ -823,10 +838,13 @@ export default function Page() {
   }
 
   const gitMutation = useMutation(() => ({
-    mutationFn: () => sdk().client.project.initGit(),
+    mutationFn: async () => {
+      const editing = (await sdk().backend).capabilities.projectEditing
+      if (!editing) throw new Error("Git initialization is not supported by this server")
+      return editing.initGit({ location: { directory: sdk().directory } })
+    },
     onSuccess: (x) => {
-      if (!x.data) return
-      upsert(x.data)
+      upsert(x as Project)
     },
     onError: (err) => {
       showToast({
@@ -891,13 +909,7 @@ export default function Page() {
   )
 
   const stopVcs = sdk().event.listen((evt) => {
-    if (evt.details.type !== "filesystem.changed") return
-    const props =
-      typeof evt.details.properties === "object" && evt.details.properties
-        ? (evt.details.properties as Record<string, unknown>)
-        : undefined
-    const file = typeof props?.file === "string" ? props.file : undefined
-    if (!file || file.startsWith(".git/")) return
+    if (evt.details.type !== "file.changed" || evt.details.path.startsWith(".git/")) return
     refreshVcs()
   })
   onCleanup(stopVcs)
@@ -1659,8 +1671,6 @@ export default function Page() {
     })
   }
 
-  const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
-
   const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
     const session = target.session.get(sessionID)
     if (!session) return
@@ -1691,11 +1701,12 @@ export default function Page() {
       setFollowup("failed", input.sessionID, undefined)
 
       const ok = await sendFollowupDraft({
-        client: sdk().client,
+        backend: sdk().backend,
         sync: sync(),
         serverSync: serverSync(),
         draft: item,
         optimisticBusy: item.sessionDirectory === sdk().directory,
+        commitRevert: !!sync().session.get(input.sessionID)?.revert,
       }).catch((err) => {
         setFollowup("failed", input.sessionID, input.id)
         fail(err)
@@ -1787,13 +1798,14 @@ export default function Page() {
   const halt = (sessionID: string) =>
     busy(sessionID)
       ? sdk()
-          .client.session.abort({ sessionID })
+          .backend.then((client) =>
+            client.common.sessions.interrupt({ location: { directory: sdk().directory }, sessionID }),
+          )
           .catch(() => {})
       : Promise.resolve()
 
   const revertMutation = useMutation(() => ({
     mutationFn: async (input: { sessionID: string; messageID: string }) => {
-      const client = sdk().client
       const target = sync()
       const last = target.session.get(input.sessionID)?.revert
       const value = draft(input.messageID)
@@ -1803,10 +1815,18 @@ export default function Page() {
           roll(input.sessionID, { messageID: input.messageID }, target)
           prompt.set(value)
         },
-        request: () => halt(input.sessionID).then(() => client.session.revert(input)),
-        complete: (result) => {
-          if (result.data) merge(result.data, target)
-        },
+        request: () =>
+          halt(input.sessionID).then(() =>
+            sdk().backend.then((client) => {
+              const value = { ...input, location: { directory: sdk().directory } }
+              if (client.capabilities.sessionExtrasV1)
+                return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined)
+              if (client.capabilities.sessionExtrasV2)
+                return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined)
+              throw new Error("Session revert is not supported by this server")
+            }),
+          ),
+        complete: () => undefined,
         rollback: () => roll(input.sessionID, last, target),
         fail,
       })
@@ -1818,7 +1838,6 @@ export default function Page() {
       const sessionID = params.id
       if (!sessionID) return
 
-      const client = sdk().client
       const target = sync()
       const next = userMessages().find((item) => item.id > id)
       const last = target.session.get(sessionID)?.revert
@@ -1835,11 +1854,26 @@ export default function Page() {
         },
         request: () =>
           !next
-            ? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
-            : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
-        complete: (result) => {
-          if (result.data) merge(result.data, target)
-        },
+            ? halt(sessionID).then(() =>
+                sdk().backend.then((client) => {
+                  const value = { location: { directory: sdk().directory }, sessionID }
+                  if (client.capabilities.sessionExtrasV1)
+                    return client.capabilities.sessionExtrasV1.clearRevert(value).then(() => undefined)
+                  if (client.capabilities.sessionExtrasV2) return client.capabilities.sessionExtrasV2.clearRevert(value)
+                  throw new Error("Session revert is not supported by this server")
+                }),
+              )
+            : halt(sessionID).then(() =>
+                sdk().backend.then((client) => {
+                  const value = { location: { directory: sdk().directory }, sessionID, messageID: next.id }
+                  if (client.capabilities.sessionExtrasV1)
+                    return client.capabilities.sessionExtrasV1.revert(value).then(() => undefined)
+                  if (client.capabilities.sessionExtrasV2)
+                    return client.capabilities.sessionExtrasV2.stageRevert(value).then(() => undefined)
+                  throw new Error("Session revert is not supported by this server")
+                }),
+              ),
+        complete: () => undefined,
         rollback: () => roll(sessionID, last, target),
         fail,
       })

+ 11 - 1
packages/app/src/pages/session/composer/session-composer-state.test.ts

@@ -1,5 +1,9 @@
 import { describe, expect, test } from "bun:test"
-import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import type {
+  AppPermissionRequest as PermissionRequest,
+  AppQuestionRequest as QuestionRequest,
+  AppSession as Session,
+} from "@/context/backend"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
 const session = (input: { id: string; parentID?: string }) =>
@@ -12,6 +16,12 @@ const permission = (id: string, sessionID: string) =>
   ({
     id,
     sessionID,
+    action: "read",
+    resources: ["*"],
+    permission: "read",
+    patterns: ["*"],
+    always: [],
+    metadata: {},
   }) as PermissionRequest
 
 const question = (id: string, sessionID: string) =>

+ 12 - 2
packages/app/src/pages/session/composer/session-composer-state.ts

@@ -1,6 +1,9 @@
 import { createMemo } from "solid-js"
 import { createStore } from "solid-js/store"
-import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
+import type {
+  AppPermissionRequest as PermissionRequest,
+  AppQuestionRequest as QuestionRequest,
+} from "@/context/backend"
 import { useParams } from "@solidjs/router"
 import { showToast } from "@/utils/toast"
 import { useLanguage } from "@/context/language"
@@ -51,7 +54,14 @@ export function createSessionComposerController() {
 
     setStore("responding", perm.id)
     sdk()
-      .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
+      .backend.then((client) =>
+        client.common.permissions.reply({
+          sessionID: perm.sessionID,
+          requestID: perm.id,
+          reply: response,
+          location: { directory: sdk().directory },
+        }),
+      )
       .catch((err: unknown) => {
         const description = err instanceof Error ? err.message : String(err)
         showToast({ title: language.t("common.requestFailed"), description })

+ 1 - 1
packages/app/src/pages/session/composer/session-permission-dock.tsx

@@ -1,5 +1,5 @@
 import { For, Show } from "solid-js"
-import type { PermissionRequest } from "@opencode-ai/sdk/v2"
+import type { AppPermissionRequest as PermissionRequest } from "@/context/backend"
 import { Button } from "@opencode-ai/ui/button"
 import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
 import { Icon } from "@opencode-ai/ui/icon"

+ 18 - 3
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -6,7 +6,7 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
 import { Icon } from "@opencode-ai/ui/icon"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { showToast } from "@/utils/toast"
-import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
+import type { AppQuestionAnswer as QuestionAnswer, AppQuestionRequest as QuestionRequest } from "@/context/backend"
 import { useLanguage } from "@/context/language"
 import { useSDK } from "@/context/sdk"
 import { makeEventListener } from "@solid-primitives/event-listener"
@@ -223,7 +223,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }
 
   const replyMutation = useMutation(() => ({
-    mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
+    mutationFn: (answers: QuestionAnswer[]) =>
+      sdk().backend.then((client) =>
+        client.common.questions.reply({
+          sessionID: props.request.sessionID,
+          requestID: props.request.id,
+          answers,
+          location: { directory: sdk().directory },
+        }),
+      ),
     onMutate: () => {
       props.onSubmit()
     },
@@ -235,7 +243,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   }))
 
   const rejectMutation = useMutation(() => ({
-    mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
+    mutationFn: () =>
+      sdk().backend.then((client) =>
+        client.common.questions.reject({
+          sessionID: props.request.sessionID,
+          requestID: props.request.id,
+          location: { directory: sdk().directory },
+        }),
+      ),
     onMutate: () => {
       props.onSubmit()
     },

+ 5 - 1
packages/app/src/pages/session/composer/session-request-tree.ts

@@ -1,4 +1,8 @@
-import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import type {
+  AppPermissionRequest as PermissionRequest,
+  AppQuestionRequest as QuestionRequest,
+  AppSession as Session,
+} from "@/context/backend"
 
 function sessionTreeRequest<T>(
   session: Session[],

+ 11 - 3
packages/app/src/pages/session/review-tab.tsx

@@ -1,6 +1,9 @@
 import { createEffect, onCleanup, type JSX } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
-import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type {
+  AppFileDiff as FileDiffInfo,
+  AppVcsFileDiff as VcsFileDiff,
+} from "@/context/backend"
 import { SessionReview } from "@opencode-ai/session-ui/session-review"
 import type {
   SessionReviewCommentActions,
@@ -54,8 +57,13 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
 
   const readFile = async (path: string) => {
     return sdk()
-      .client.file.read({ path })
-      .then((x) => x.data)
+      .backend.then(async (client) => {
+        const input = { location: { directory: sdk().directory }, path }
+        if (client.capabilities.decoratedFiles) return client.capabilities.decoratedFiles.read(input)
+        const content = await client.common.files.read(input)
+        if (content.kind !== "text") return
+        return { type: "text" as const, content: new TextDecoder().decode(content.bytes), mimeType: content.mimeType }
+      })
       .catch((error) => {
         console.debug("[session-review] failed to read file", { path, error })
         return undefined

+ 1 - 1
packages/app/src/pages/session/session-model-helpers.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { UserMessage } from "@opencode-ai/sdk/v2"
+import type { AppUserMessage as UserMessage } from "@/context/backend"
 import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
 
 const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>

+ 1 - 1
packages/app/src/pages/session/session-model-helpers.ts

@@ -1,4 +1,4 @@
-import type { UserMessage } from "@opencode-ai/sdk/v2"
+import type { AppUserMessage as UserMessage } from "@/context/backend"
 
 type Local = {
   session: {

+ 1 - 1
packages/app/src/pages/session/session-side-panel.tsx

@@ -9,7 +9,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Mark } from "@opencode-ai/ui/logo"
 import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
-import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { AppFileDiff as FileDiffInfo, AppVcsFileDiff as VcsFileDiff } from "@/context/backend"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 

+ 58 - 20
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -1,6 +1,7 @@
 import {
   createEffect,
   createMemo,
+  createResource,
   createSignal,
   For,
   Index,
@@ -46,12 +47,12 @@ import { TextField } from "@opencode-ai/ui/text-field"
 import { TextReveal } from "@opencode-ai/ui/text-reveal"
 import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
 import type {
-  AssistantMessage,
-  Message as MessageType,
-  Part as PartType,
-  ToolPart,
-  UserMessage,
-} from "@opencode-ai/sdk/v2"
+  AppAssistantMessage as AssistantMessage,
+  AppMessage as MessageType,
+  AppPart as PartType,
+  AppUserMessage as UserMessage,
+} from "@/context/backend"
+type ToolPart = Extract<PartType, { type: "tool" }>
 import { showToast } from "@/utils/toast"
 import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
 import { Popover as KobaltePopover } from "@kobalte/core/popover"
@@ -290,7 +291,14 @@ export function MessageTimeline(props: {
   const titleValue = createMemo(() => info()?.title)
   const titleLabel = createMemo(() => sessionTitle(titleValue()))
   const shareUrl = createMemo(() => info()?.share?.url)
-  const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
+  const [backend] = createResource(
+    () => sdk().backend,
+    (value) => value,
+  )
+  const shareEnabled = createMemo(
+    () => sync().data.config.share !== "disabled" && !!backend()?.capabilities.sessionExtrasV1,
+  )
+  const canArchive = createMemo(() => !!backend()?.capabilities.sessionExtrasV1)
   const parentID = createMemo(() => info()?.parentID)
   const parent = createMemo(() => {
     const id = parentID()
@@ -647,14 +655,22 @@ export function MessageTimeline(props: {
   }
 
   const shareMutation = useMutation(() => ({
-    mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
+    mutationFn: async (id: string) => {
+      const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
+      if (!capability) throw new Error("Session sharing is not supported by this server")
+      return capability.share({ location: { directory: sdk().directory }, sessionID: id })
+    },
     onError: (err) => {
       console.error("Failed to share session", err)
     },
   }))
 
   const unshareMutation = useMutation(() => ({
-    mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
+    mutationFn: async (id: string) => {
+      const capability = (await serverSDK().backend).capabilities.sessionExtrasV1
+      if (!capability) throw new Error("Session sharing is not supported by this server")
+      return capability.unshare({ location: { directory: sdk().directory }, sessionID: id })
+    },
     onError: (err) => {
       console.error("Failed to unshare session", err)
     },
@@ -662,7 +678,15 @@ export function MessageTimeline(props: {
 
   const titleMutation = useMutation(() => ({
     mutationFn: (input: { id: string; title: string }) =>
-      sdk().client.session.update({ sessionID: input.id, title: input.title }),
+      sdk().backend.then((client) => {
+        const capability = client.capabilities.sessionActionsV1
+        if (!capability) throw new Error("Session renaming is not supported by this server")
+        return capability.rename({
+          location: { directory: sdk().directory },
+          sessionID: input.id,
+          title: input.title,
+        })
+      }),
     onSuccess: (_, input) => {
       sync().set(
         produce((draft) => {
@@ -806,7 +830,11 @@ export function MessageTimeline(props: {
     const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
 
     await sdk()
-      .client.session.update({ sessionID, time: { archived: Date.now() } })
+      .backend.then(async (client) => {
+        const capability = client.capabilities.sessionExtrasV1
+        if (!capability) throw new Error("Session archiving is not supported by this server")
+        await capability.archive({ location: { directory: sdk().directory }, sessionID, archivedAt: Date.now() })
+      })
       .then(() => {
         sync().set(
           produce((draft) => {
@@ -835,8 +863,11 @@ export function MessageTimeline(props: {
     const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
 
     const result = await sdk()
-      .client.session.delete({ sessionID })
-      .then((x) => x.data)
+      .backend.then((client) => {
+        const capability = client.capabilities.sessionActionsV1
+        if (!capability) throw new Error("Session deletion is not supported by this server")
+        return capability.remove({ location: { directory: sdk().directory }, sessionID })
+      })
       .catch((err) => {
         showToast({
           title: language.t("session.delete.failed.title"),
@@ -1203,7 +1234,10 @@ export function MessageTimeline(props: {
         return (
           <TimelineRowFrame row={retryRow}>
             <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
-              <SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
+              <SessionRetry
+                status={((status) => (status.type === "running" ? { type: "busy" as const } : status))(sessionStatus())}
+                show={activeMessageID() === retryRow().userMessageID}
+              />
             </div>
           </TimelineRowFrame>
         )
@@ -1549,9 +1583,11 @@ export function MessageTimeline(props: {
                                     </DropdownMenu.ItemLabel>
                                   </DropdownMenu.Item>
                                 </Show>
-                                <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
-                                  <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
-                                </DropdownMenu.Item>
+                                <Show when={canArchive()}>
+                                  <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
+                                    <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
+                                  </DropdownMenu.Item>
+                                </Show>
                                 <DropdownMenu.Separator />
                                 <DropdownMenu.Item
                                   onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
@@ -1620,9 +1656,11 @@ export function MessageTimeline(props: {
                                   {language.t("session.share.action.share")}...
                                 </MenuV2.Item>
                               </Show>
-                              <MenuV2.Item onSelect={() => void archiveSession(id)}>
-                                {language.t("common.archive")}
-                              </MenuV2.Item>
+                              <Show when={canArchive()}>
+                                <MenuV2.Item onSelect={() => void archiveSession(id)}>
+                                  {language.t("common.archive")}
+                                </MenuV2.Item>
+                              </Show>
                               <MenuV2.Separator />
                               <MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
                                 {language.t("common.delete")}...

+ 5 - 1
packages/app/src/pages/session/timeline/model.test.ts

@@ -1,5 +1,9 @@
 import { describe, expect, test } from "bun:test"
-import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
+import type {
+  AppAssistantMessage as AssistantMessage,
+  AppMessage as Message,
+  AppUserMessage as UserMessage,
+} from "@/context/backend"
 import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
 
 const user = (id: string) => ({ id, role: "user" }) as UserMessage

+ 1 - 1
packages/app/src/pages/session/timeline/model.ts

@@ -1,4 +1,4 @@
-import type { Message, UserMessage } from "@opencode-ai/sdk/v2"
+import type { AppMessage as Message, AppUserMessage as UserMessage } from "@/context/backend"
 import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
 import { useServerSync } from "@/context/server-sync"
 import { useSync } from "@/context/sync"

+ 7 - 1
packages/app/src/pages/session/timeline/projection.ts

@@ -1,5 +1,11 @@
 import { Binary } from "@opencode-ai/core/util/binary"
-import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
+import type {
+  AppAssistantMessage as AssistantMessage,
+  AppMessage as Message,
+  AppPart as Part,
+  AppUserMessage as UserMessage,
+  SessionActivity as SessionStatus,
+} from "@/context/backend"
 import { createMemo, mapArray, type Accessor } from "solid-js"
 import { reuseTimelineRows } from "./row-reconciliation"
 import { Timeline, TimelineRow } from "./rows"

+ 10 - 3
packages/app/src/pages/session/timeline/rows.ts

@@ -1,5 +1,10 @@
 import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
-import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
+import type {
+  AppAssistantMessage as AssistantMessage,
+  AppPart as Part,
+  AppUserMessage as UserMessage,
+  SessionActivity as SessionStatus,
+} from "@/context/backend"
 import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
 import { TimelineRow, type SummaryDiff } from "./timeline-row"
 
@@ -119,7 +124,7 @@ export namespace Timeline {
       assistantGroupIndex += 1
     })
 
-    if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
+    if (isActive && (status === "busy" || status === "running") && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
       const heading = assistantMessages
         .flatMap((message) => getMessageParts(message.id))
         .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
@@ -167,7 +172,9 @@ export namespace Timeline {
     return rows
   }
 
-  function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
+  function isSummaryDiff(
+    value: NonNullable<UserMessage["summary"]>["diffs"][number],
+  ): value is SummaryDiff {
     return typeof value.file === "string"
   }
 

+ 1 - 1
packages/app/src/pages/session/timeline/timeline-row.ts

@@ -1,4 +1,4 @@
-import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
+import type { AppFileDiff as SnapshotFileDiff } from "@/context/backend"
 import type { PartGroup } from "@opencode-ai/session-ui/message-part"
 import { Data, Equal } from "effect"
 

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません