Просмотр исходного кода

feat(app): support current pty transport (#38463)

Brendan Allan 1 месяц назад
Родитель
Сommit
ad78ef5a4c
26 измененных файлов с 640 добавлено и 156 удалено
  1. 220 0
      packages/app/V1_API_MIGRATION.md
  2. 36 5
      packages/app/e2e/regression/remote-session-settings.spec.ts
  3. 3 2
      packages/app/e2e/regression/review-line-comment.spec.ts
  4. 9 6
      packages/app/e2e/regression/review-state-persistence.spec.ts
  5. 63 21
      packages/app/e2e/regression/review-terminal-stacked.spec.ts
  6. 36 18
      packages/app/e2e/regression/terminal-composer-focus.spec.ts
  7. 42 5
      packages/app/e2e/regression/terminal-hidden.spec.ts
  8. 29 9
      packages/app/e2e/regression/terminal-tab-switch.spec.ts
  9. 7 5
      packages/app/src/components/settings-general.tsx
  10. 7 5
      packages/app/src/components/settings-v2/general.tsx
  11. 61 22
      packages/app/src/components/terminal.tsx
  12. 1 0
      packages/app/src/context/server-sdk.tsx
  13. 58 37
      packages/app/src/context/terminal.tsx
  14. 2 1
      packages/app/src/pages/session/review-tab.tsx
  15. 5 3
      packages/app/src/pages/session/session-side-panel.tsx
  16. 3 2
      packages/app/src/pages/session/v2/review-diff-kinds.ts
  17. 2 1
      packages/app/src/pages/session/v2/review-panel-v2.tsx
  18. 2 1
      packages/app/src/utils/diffs.test.ts
  19. 2 1
      packages/app/src/utils/diffs.ts
  20. 31 3
      packages/app/src/utils/terminal-websocket-url.test.ts
  21. 10 3
      packages/app/src/utils/terminal-websocket-url.ts
  22. 2 1
      packages/session-ui/src/components/session-diff.ts
  23. 3 2
      packages/session-ui/src/components/session-review.tsx
  24. 2 1
      packages/session-ui/src/components/session-turn.tsx
  25. 2 1
      packages/session-ui/src/context/data.tsx
  26. 2 1
      packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx

+ 220 - 0
packages/app/V1_API_MIGRATION.md

@@ -0,0 +1,220 @@
+# V1 API Migration Checklist
+
+The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
+
+## Events
+
+- [x] Replace `GET /global/event` with `GET /api/event`.
+  - `src/context/server-sdk.tsx`
+- [x] Reduce current granular session and message events into the existing app projections.
+  - `src/context/server-session-v2-reducer.ts`
+  - `src/context/server-session.ts`
+- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
+  - `src/context/global-sync/event-reducer.ts`
+  - `src/context/server-session.ts`
+  - `src/context/notification.tsx`
+  - `src/pages/session/usage-exceeded-dialogs.tsx`
+- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
+  - `src/context/global-sync/event-reducer.ts`
+  - `src/context/server-session.ts`
+- [x] Adapt current permission and question events to the existing request model.
+  - `src/context/global-sync/event-reducer.ts`
+  - `src/context/permission.tsx`
+- [x] Consume current file watcher events.
+  - `src/context/file.tsx`
+- [x] Consume current VCS events.
+  - `src/context/global-sync/event-reducer.ts`
+  - `src/pages/session.tsx`
+- [x] Consume current `pty.exited` events.
+  - `src/context/terminal.tsx`
+- [ ] Migrate LSP and reference events.
+  - `src/context/global-sync/event-reducer.ts`
+
+## Sessions
+
+- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
+  - `src/context/server-sync.tsx`
+- [x] Migrate session listing from `GET /session`.
+  - `src/context/server-sync.tsx`
+  - `src/context/directory-sync.ts`
+  - `src/pages/layout.tsx`
+- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
+  - `src/components/titlebar.tsx`
+- [x] Migrate session updates from `PATCH /session/:sessionID`.
+  - `src/context/directory-sync.ts`
+  - `src/context/layout.tsx`
+  - `src/pages/home.tsx`
+  - `src/pages/layout.tsx`
+  - `src/pages/session/timeline/message-timeline.tsx`
+  - `src/components/titlebar-tab-nav.tsx`
+  - Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
+- [x] Migrate session deletion from `DELETE /session/:sessionID`.
+  - `src/pages/session/timeline/message-timeline.tsx`
+- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
+  - Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
+- [x] Migrate abort from `POST /session/:sessionID/abort`.
+  - `src/components/prompt-input/submit.ts`
+  - `src/pages/session/use-session-commands.tsx`
+  - `src/pages/session.tsx`
+- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
+  - `src/pages/session/use-session-commands.tsx`
+  - `src/pages/session.tsx`
+- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
+  - `src/pages/session/use-session-commands.tsx`
+- [x] Migrate slash commands from `POST /session/:sessionID/command`.
+  - `src/components/prompt-input/submit.ts`
+- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
+  - `src/components/prompt-input/submit.ts`
+- [x] Migrate session fork from `POST /session/:sessionID/fork`.
+  - `src/components/dialog-fork.tsx`
+- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
+  - `src/pages/session/use-session-commands.tsx`
+  - `src/pages/session/timeline/message-timeline.tsx`
+  - Blocked: the current API has no sharing contract or implementation.
+
+## Session Compatibility Fallbacks
+
+These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
+
+- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
+  - `src/context/server-session.ts`
+- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
+  - `src/context/server-session.ts`
+- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
+  - `src/context/server-session.ts`
+
+## Filesystem
+
+- [ ] Migrate file listing from `GET /file`.
+  - `src/context/file.tsx`
+- [ ] Migrate file reads from `GET /file/content`.
+  - `src/context/file.tsx`
+  - `src/pages/session/review-tab.tsx`
+  - `src/pages/session/v2/review-panel-v2.tsx`
+- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
+  - `src/context/global-sync/bootstrap.ts`
+  - `src/components/dialog-select-directory.tsx`
+  - `src/components/dialog-select-directory-v2.tsx`
+
+## Projects And Worktrees
+
+- [x] Migrate project listing from `GET /project` to `GET /api/project`.
+  - `src/context/global-sync/bootstrap.ts`
+- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
+  - `src/context/global-sync/bootstrap.ts`
+- [ ] Migrate Git initialization from `POST /project/git/init`.
+  - `src/pages/session.tsx`
+- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
+  - `src/context/layout.tsx`
+  - `src/components/edit-project.ts`
+  - `src/pages/layout.tsx`
+- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
+  - `src/pages/layout.tsx`
+  - `src/components/prompt-input/submit.ts`
+  - Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
+- [ ] Migrate instance disposal from `POST /instance/dispose`.
+  - `src/pages/layout.tsx`
+
+## VCS
+
+- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
+  - `src/context/global-sync/bootstrap.ts`
+- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
+  - `src/pages/session.tsx`
+- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
+  - `src/pages/layout.tsx`
+
+## Configuration And Authentication
+
+- [ ] Migrate global configuration reads from `GET /global/config`.
+  - `src/context/global-sync/bootstrap.ts`
+- [ ] Migrate directory configuration reads from `GET /config`.
+  - `src/context/global-sync/bootstrap.ts`
+- [ ] Migrate global configuration updates from `PATCH /global/config`.
+  - `src/context/server-sync.tsx`
+- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
+  - `src/components/dialog-connect-provider.tsx`
+- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
+  - `src/components/dialog-connect-provider.tsx`
+- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
+  - Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
+  - `src/components/dialog-connect-provider.tsx`
+  - `src/components/dialog-custom-provider.tsx`
+  - `src/components/settings-providers.tsx`
+  - `src/components/settings-v2/providers.tsx`
+- [ ] Migrate global disposal from `POST /global/dispose`.
+  - `src/components/dialog-connect-provider.tsx`
+  - `src/components/settings-providers.tsx`
+  - `src/components/settings-v2/providers.tsx`
+
+## Permissions And Questions
+
+- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
+  - `src/context/global-sync/bootstrap.ts`
+  - `src/context/permission.tsx`
+- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
+  - `src/context/permission.tsx`
+  - `src/pages/session/composer/session-composer-state.ts`
+- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
+  - `src/context/global-sync/bootstrap.ts`
+- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
+  - `src/pages/session/composer/session-question-dock.tsx`
+
+## Commands, MCP, LSP, And References
+
+- [x] Migrate command listing from `GET /command` to `GET /api/command`.
+  - `src/context/global-sync/bootstrap.ts`
+  - `src/context/server-sync.tsx`
+- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
+  - `src/context/server-sync.tsx`
+- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
+  - `src/context/server-sync.tsx`
+- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
+  - `src/context/server-sync.tsx`
+- [ ] Migrate LSP status from `GET /lsp`.
+  - `src/context/server-sync.tsx`
+- [x] Move `GET /api/reference` off the legacy generated SDK transport.
+  - `src/context/global-sync/bootstrap.ts`
+
+## Search
+
+- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
+  - `src/components/command-palette.ts`
+  - `src/components/dialog-command-palette-v2.tsx`
+
+## PTY And Terminal
+
+- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
+  - `src/context/terminal.tsx`
+  - `src/components/terminal.tsx`
+- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
+  - `src/components/settings-general.tsx`
+  - `src/components/settings-v2/general.tsx`
+- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
+  - `src/components/terminal.tsx`
+- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
+  - `src/components/terminal.tsx`
+
+## Legacy Types And Adapters
+
+These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
+
+- [ ] Replace the current-session-to-legacy-session adapter.
+  - `src/utils/session.ts`
+- [ ] Replace the current-message-to-legacy-message-and-part adapter.
+  - `src/utils/session-message.ts`
+- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
+  - `src/context/global-sync/utils.ts`
+- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
+- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
+  - `package.json`
+
+## Test Infrastructure
+
+- [ ] Replace V1 endpoint mocks with current API mocks.
+  - `e2e/utils/mock-server.ts`
+- [x] Replace `/global/event` and `/event` interception with current event transport handling.
+  - `e2e/utils/sse-transport.ts`
+- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
+  - `e2e/performance/timeline-stability/fixture.ts`
+- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.

+ 36 - 5
packages/app/e2e/regression/remote-session-settings.spec.ts

@@ -1,6 +1,7 @@
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { expect, test, type Page, type Route } from "@playwright/test"
 import { installSseTransport } from "../utils/sse-transport"
+import { currentSession } from "../utils/mock-server"
 
 const serverA = "http://127.0.0.1:4096"
 const serverB = "http://127.0.0.1:4097"
@@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => {
 
   await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
   await expect(page.getByText(sessionB.title).first()).toBeVisible()
-  await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
+  await page.keyboard.press("Control+,")
 
   const dialog = page.locator(".settings-v2-dialog")
   const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
@@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
   const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
   await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
   await expect(page.getByText(sessionA.title).first()).toBeVisible()
-  await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
+  await page.keyboard.press("Control+,")
   const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
   await autoAccept.locator('[data-slot="switch-control"]').click()
   await expect(autoAccept.getByRole("switch")).toBeChecked()
@@ -180,10 +181,35 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
       return json(route, true)
     }
     if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
-    if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
+    if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route)
     if (url.pathname === "/global/health") return json(route, { healthy: true })
-    if (url.pathname === "/session/status") return json(route, {})
-    if (url.pathname === "/session") return json(route, sessions)
+    if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
+      return json(route, { data: [] })
+    if (url.pathname === "/api/model/default") return json(route, { data: null })
+    if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
+      return json(route, { location: { directory }, data: [] })
+    if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
+    if (url.pathname === "/api/mcp/resource")
+      return json(route, { location: { directory }, data: { resources: [], templates: [] } })
+    if (url.pathname === "/api/project") {
+      return json(route, [
+        {
+          id: remote ? sessionB.projectID : "project-server-a",
+          worktree: directory,
+          vcs: "git",
+          time: { created: 1, updated: 1 },
+          sandboxes: [],
+        },
+      ])
+    }
+    if (url.pathname === "/api/project/current")
+      return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
+    if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
+    if (url.pathname === "/api/session/active") return json(route, { data: {} })
+    const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
+    if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
+    if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
+      return json(route, { data: [], cursor: {} })
     const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
     if (current) return json(route, current)
     if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
@@ -216,7 +242,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
         directory,
         home: directory,
       })
+    if (url.pathname === "/api/path")
+      return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
     if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
+    if (url.pathname === "/api/vcs")
+      return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
+    if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
     return json(route, {})
   })
 }

+ 3 - 2
packages/app/e2e/regression/review-line-comment.spec.ts

@@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
 async function openReview(page: Page) {
   await page.setViewportSize({ width: 700, height: 900 })
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: "proj_review_line_comment_regression",
@@ -143,9 +144,9 @@ async function openReview(page: Page) {
 
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await expectSessionTitle(page, title)
-  const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
+  const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
   await page.getByRole("tab", { name: "Changes" }).click()
-  expect(await (await diffResponse).json()).toHaveLength(1)
+  expect((await (await diffResponse).json()).data).toHaveLength(1)
 
   const review = page.locator('[data-component="session-review"]')
   await expectAppVisible(review)

+ 9 - 6
packages/app/e2e/regression/review-state-persistence.spec.ts

@@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) {
 
 async function setup(page: Page) {
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -92,18 +93,20 @@ async function setup(page: Page) {
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
+      body: JSON.stringify({ location: { directory }, data: { branch: "feature", defaultBranch: "dev" } }),
     }),
   )
   await page.route("**/vcs/diff**", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify(
-        new URL(route.request().url()).searchParams.get("mode") === "branch"
-          ? [diff("src/alpha.ts"), diff("src/beta.ts")]
-          : [diff("src/alpha.ts"), diff("src/gamma.ts")],
-      ),
+      body: JSON.stringify({
+        location: { directory },
+        data:
+          new URL(route.request().url()).searchParams.get("mode") === "branch"
+            ? [diff("src/alpha.ts"), diff("src/beta.ts")]
+            : [diff("src/alpha.ts"), diff("src/gamma.ts")],
+      }),
     }),
   )
   await page.addInitScript(

+ 63 - 21
packages/app/e2e/regression/review-terminal-stacked.spec.ts

@@ -25,6 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
   let detailFailures = 1
   await page.setViewportSize({ width: 1400, height: 900 })
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -65,39 +66,80 @@ test("keeps the review tree and terminal sized when both panels are open", async
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
+      body: JSON.stringify({
+        location: { directory },
+        data: { branch: "review-pane-performance", defaultBranch: "dev" },
+      }),
     }),
   )
-  await page.route("**/vcs/diff**", (route) => {
+  await page.route("**/api/vcs/diff**", (route) => {
     const url = new URL(route.request().url())
-    const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
+    const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
     const detail = scope?.endsWith("/src/branch/d00027")
     if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
     return route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify(
-        url.searchParams.get("mode") === "branch"
-          ? detail
-            ? branchDiffs
-                .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
-                .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
-            : branchDiffs
-          : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
-      ),
+      body: JSON.stringify({
+        location: { directory: scope ?? directory, project: { id: projectID, directory } },
+        data:
+          url.searchParams.get("mode") === "branch"
+            ? detail
+              ? branchDiffs
+                  .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
+                  .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
+              : branchDiffs
+            : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
+      }),
     })
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/api/pty*", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: {
+          id: "pty_review_terminal",
+          title: "Terminal 1",
+          command: "cmd.exe",
+          args: [],
+          cwd: directory,
+          status: "running",
+          pid: 1,
+        },
+      }),
     }),
   )
-  await page.route("**/pty/pty_review_terminal", (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  await page.route("**/api/pty/pty_review_terminal*", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: {
+          id: "pty_review_terminal",
+          title: "Terminal 1",
+          command: "cmd.exe",
+          args: [],
+          cwd: directory,
+          status: "running",
+          pid: 1,
+        },
+      }),
+    }),
+  )
+  await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: { ticket: "e2e-ticket", expires_in: 60 },
+      }),
+    }),
   )
-  await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
+  await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
     localStorage.setItem(
@@ -135,8 +177,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
   const lazyDiff = page.waitForRequest((request) => {
     const url = new URL(request.url())
     return (
-      url.pathname === "/vcs/diff" &&
-      url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
+      url.pathname === "/api/vcs/diff" &&
+      url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
     )
   })
   await lastFile.click()
@@ -150,8 +192,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
   const refreshedDiff = page.waitForRequest((request) => {
     const url = new URL(request.url())
     return (
-      url.pathname === "/vcs/diff" &&
-      url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
+      url.pathname === "/api/vcs/diff" &&
+      url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
     )
   })
   sessionStatus[sessionID] = { type: "idle" }

+ 36 - 18
packages/app/e2e/regression/terminal-composer-focus.spec.ts

@@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
 
 test.beforeEach(async ({ page }) => {
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => {
     ],
     pageMessages: () => ({ items: [] }),
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/api/pty*", (route) => {
+    expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
+    return route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
+    })
+  })
+  await page.route(`**/api/pty/${ptyID}*`, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
     }),
   )
-  await page.route(`**/pty/${ptyID}`, (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
-  )
-  await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
+  await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
       headers: { "access-control-allow-origin": "*" },
-      body: JSON.stringify({ ticket: "e2e-ticket" }),
+      body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
     }),
   )
-  await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
+  await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
   })
@@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
   const ghostty = Promise.withResolvers<void>()
   const release = Promise.withResolvers<void>()
   const created = { count: 0 }
-  await page.route("**/pty", (route) => {
+  await page.route("**/api/pty*", (route) => {
     created.count += 1
     return route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
     })
   })
   await page.route(/ghostty-web/, async (route) => {
@@ -155,27 +161,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
 
 test("focuses a terminal created from the new-terminal button", async ({ page }) => {
   const created = { count: 0 }
-  await page.route("**/pty", (route) => {
+  await page.route("**/api/pty*", (route) => {
     created.count += 1
-    const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" }
+    const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
     return route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify(next),
+      body: JSON.stringify({ location: ptyLocation(), data: next }),
     })
   })
-  await page.route(`**/pty/${newPtyID}`, (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  await page.route(`**/api/pty/${newPtyID}*`, (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
+    }),
   )
-  await page.route(`**/pty/${newPtyID}/connect-token*`, (route) =>
+  await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
       headers: { "access-control-allow-origin": "*" },
-      body: JSON.stringify({ ticket: "e2e-ticket" }),
+      body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
     }),
   )
-  await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined)
+  await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
 
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await expectSessionTitle(page, "Terminal composer focus")
@@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) {
     { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
   )
 }
+
+function ptyLocation() {
+  return { directory, project: { id: projectID, directory } }
+}
+
+function ptyInfo(id: string, title: string) {
+  return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
+}

+ 42 - 5
packages/app/e2e/regression/terminal-hidden.spec.ts

@@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
 test("unmounts the terminal panel while it is hidden", async ({ page }) => {
   await page.setViewportSize({ width: 1400, height: 900 })
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
     ],
     pageMessages: () => ({ items: [] }),
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/api/pty*", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: {
+          id: "pty_hidden_terminal",
+          title: "Terminal 1",
+          command: "cmd.exe",
+          args: [],
+          cwd: directory,
+          status: "running",
+          pid: 1,
+        },
+      }),
     }),
   )
-  await page.route("**/pty/pty_hidden_terminal", (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
+  await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: {
+          id: "pty_hidden_terminal",
+          title: "Terminal 1",
+          command: "cmd.exe",
+          args: [],
+          cwd: directory,
+          status: "running",
+          pid: 1,
+        },
+      }),
+    }),
+  )
+  await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
+    route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      body: JSON.stringify({
+        location: { directory, project: { id: projectID, directory } },
+        data: { ticket: "e2e-ticket", expires_in: 60 },
+      }),
+    }),
   )
-  await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
+  await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
 
   await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
   await expectSessionTitle(page, title)

+ 29 - 9
packages/app/e2e/regression/terminal-tab-switch.spec.ts

@@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
   const terminal = page.locator('[data-component="terminal"]')
   await expect(terminal).toBeVisible()
   await expect.poll(() => connections.length).toBe(1)
+  const connection = new URL(connections[0]!)
+  expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
+  expect(connection.searchParams.get("location[directory]")).toBe(directory)
+  expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
   await writeProbe(page)
 
   await switchTab(page, titleB)
@@ -62,6 +66,7 @@ async function readProbe(page: Page) {
 
 async function setup(page: Page) {
   await mockOpenCodeServer(page, {
+    protocol: "v2",
     directory,
     project: {
       id: projectID,
@@ -85,26 +90,33 @@ async function setup(page: Page) {
     sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
     pageMessages: () => ({ items: [] }),
   })
-  await page.route("**/pty", (route) =>
+  await page.route("**/api/pty*", (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
     }),
   )
-  await page.route(`**/pty/${ptyID}`, (route) =>
-    route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
-  )
-  await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
+  await page.route(`**/api/pty/${ptyID}*`, (route) =>
     route.fulfill({
       status: 200,
       contentType: "application/json",
-      headers: { "access-control-allow-origin": "*" },
-      body: JSON.stringify({ ticket: "e2e-ticket" }),
+      body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
     }),
   )
+  await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
+    expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
+    const url = new URL(route.request().url())
+    expect(url.searchParams.get("location[directory]")).toBe(directory)
+    return route.fulfill({
+      status: 200,
+      contentType: "application/json",
+      headers: { "access-control-allow-origin": "*" },
+      body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
+    })
+  })
   const connections: string[] = []
-  await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
+  await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
     connections.push(ws.url())
   })
 
@@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
 function sessionHref(sessionID: string) {
   return `/server/${base64Encode(server)}/session/${sessionID}`
 }
+
+function ptyLocation() {
+  return { directory, project: { id: projectID, directory } }
+}
+
+function ptyInfo() {
+  return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
+}

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

@@ -127,11 +127,13 @@ export const SettingsGeneral: Component = () => {
   const serverSdk = useServerSDK()
 
   const [shells] = createResource(
-    () =>
-      serverSdk()
-        .client.pty.shells()
-        .then((res) => res.data ?? [])
-        .catch(() => [] as ShellOption[]),
+    async () => {
+      const sdk = serverSdk()
+      if ((await sdk.protocol) === "v1") {
+        return (await sdk.client.pty.shells()).data ?? []
+      }
+      return (await sdk.api.pty.shells()).data
+    },
     { initialValue: [] as ShellOption[] },
   )
 

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

@@ -122,11 +122,13 @@ 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 sdk = serverSdk()
+      if ((await sdk.protocol) === "v1") {
+        return (await sdk.client.pty.shells()).data ?? []
+      }
+      return (await sdk.api.pty.shells()).data
+    },
     { initialValue: [] as ShellOption[] },
   )
 

+ 61 - 22
packages/app/src/components/terminal.tsx

@@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => {
   // 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"
@@ -241,10 +240,21 @@ export const Terminal = (props: TerminalProps) => {
     }
   }
 
-  const pushSize = (cols: number, rows: number) => {
-    return client.pty
+  const pushSize = async (cols: number, rows: number) => {
+    if ((await sdk().protocol) === "v1") {
+      return sdk().client.pty
+        .update({
+          ptyID: id,
+          size: { cols, rows },
+        })
+        .catch((err) => {
+          debugTerminal("failed to sync terminal size", err)
+        })
+    }
+    return sdk().api.pty
       .update({
         ptyID: id,
+        location: { directory },
         size: { cols, rows },
       })
       .catch((err) => {
@@ -522,34 +532,60 @@ export const Terminal = (props: TerminalProps) => {
         local.onConnectError?.(err)
       }
 
-      const gone = () =>
-        client.pty
-          .get({ ptyID: id }, { throwOnError: false })
-          .then((result) => result.response.status === 404)
+      const gone = async () => {
+        if ((await sdk().protocol) === "v1") {
+          return sdk().client.pty
+            .get({ ptyID: id }, { throwOnError: false })
+            .then((result) => result.response.status === 404)
+            .catch((err) => {
+              debugTerminal("failed to inspect terminal session", err)
+              return false
+            })
+        }
+        return sdk().api.pty
+          .get({ ptyID: id, location: { directory } })
+          .then((result) => result.data.status === "exited")
           .catch((err) => {
+            if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
             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" },
-            },
-          )
+        if ((await sdk().protocol) === "v1") {
+          const result = await sdk().client.pty
+            .connectToken(
+              { ptyID: id, directory },
+              {
+                throwOnError: false,
+                headers: { "x-opencode-ticket": "1" },
+              },
+            )
+            .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)
+            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}`)
+        }
+        return sdk().api.pty
+          .connectToken({
+            ptyID: id,
+            location: { directory },
+            "x-opencode-ticket": "1",
+          })
+          .then((result) => result.data.ticket)
           .catch((err: unknown) => {
-            if (err instanceof Error && err.message.includes("Request is not supported")) return
+            if (err && typeof err === "object" && "_tag" in err && err._tag === "ForbiddenError") {
+              throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
+            }
             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)
-          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}`)
       }
 
       const retry = (err: unknown) => {
@@ -579,11 +615,14 @@ export const Terminal = (props: TerminalProps) => {
           fail(err)
           return undefined
         })
+        const protocol = await sdk().protocol
+        if (protocol === "v2" && !ticket) return
         if (once.value) return
         if (disposed) return
 
         const socket = new WebSocket(
           terminalWebSocketURL({
+            protocol,
             url,
             id,
             directory,

+ 1 - 0
packages/app/src/context/server-sdk.tsx

@@ -424,6 +424,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
 
   return {
     scope: serverSDK.scope,
+    protocol: serverSDK.protocol,
     directory,
     client,
     api: createCompatibleApi({

+ 58 - 37
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(
@@ -240,47 +241,61 @@ function createWorkspaceTerminalSession(
   })
   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,
-      })
-      .catch((error: unknown) => {
-        if (previous) {
-          const currentIndex = store.all.findIndex((item) => item.id === pty.id)
-          if (currentIndex >= 0) setStore("all", currentIndex, previous)
-        }
-        console.error("Failed to update terminal", error)
-      })
+    const doUpdate = async () => {
+      if ((await sdk.protocol) === "v1") {
+        await sdk.client.pty.update({
+          ptyID: pty.id,
+          title: pty.title,
+          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+        })
+      } else {
+        await sdk.api.pty.update({
+          ptyID: pty.id,
+          location,
+          title: pty.title,
+          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+        })
+      }
+    }
+    doUpdate().catch((error: unknown) => {
+      if (previous) {
+        const currentIndex = store.all.findIndex((item) => item.id === pty.id)
+        if (currentIndex >= 0) setStore("all", currentIndex, previous)
+      }
+      console.error("Failed to update terminal", error)
+    })
   }
 
-  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({
+    const data = await (async () => {
+      if ((await sdk.protocol) === "v1") {
+        return (await sdk.client.pty.create({ title: pty.title })).data
+      }
+      return (await sdk.api.pty.create({
+        location,
         title: pty.title,
-      })
-      .catch((error: unknown) => {
-        console.error("Failed to clone terminal", error)
-        return undefined
-      })
-    if (!next?.data) return
+      })).data
+    })().catch((error: unknown) => {
+      console.error("Failed to clone terminal", error)
+      return undefined
+    })
+    if (!data?.id) return
 
     const active = store.active === pty.id
 
     batch(() => {
       setStore("all", index, {
-        id: next.data.id,
-        title: next.data.title ?? pty.title,
+        id: data.id,
+        title: data.title ?? pty.title,
         titleNumber: pty.titleNumber,
         buffer: undefined,
         cursor: undefined,
@@ -289,7 +304,7 @@ function createWorkspaceTerminalSession(
         cols: undefined,
       })
       if (active) {
-        setStore("active", next.data.id)
+        setStore("active", data.id)
       }
     })
   }
@@ -308,17 +323,22 @@ function createWorkspaceTerminalSession(
       const nextNumber = pickNextTerminalNumber()
       const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
 
-      sdk.client.pty
-        .create({ title: defaultTitle(nextNumber) })
-        .then((pty: { data?: { id?: string; title?: string } }) => {
-          const id = pty.data?.id
+      const doCreate = async () => {
+        if ((await sdk.protocol) === "v1") {
+          return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data
+        }
+        return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data
+      }
+      doCreate()
+        .then((data) => {
+          const id = data?.id
           if (!id) {
             if (focusRequest !== undefined) cancelFocus(focusRequest)
             return
           }
           const newTerminal = {
             id,
-            title: pty.data?.title ?? defaultTitle(nextNumber),
+            title: data?.title ?? defaultTitle(nextNumber),
             titleNumber: nextNumber,
           }
           batch(() => {
@@ -335,7 +355,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)
@@ -350,10 +370,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)
@@ -361,10 +380,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)
         },
       }
     },
@@ -412,7 +431,9 @@ function createWorkspaceTerminalSession(
         })
       }
 
-      await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
+      const removePromise =
+        (await sdk.protocol) === "v1" ? sdk.client.pty.remove({ ptyID: id }) : sdk.api.pty.remove({ ptyID: id, location })
+      await removePromise.catch((error: unknown) => {
         console.error("Failed to close terminal", error)
       })
     },

+ 2 - 1
packages/app/src/pages/session/review-tab.tsx

@@ -1,6 +1,7 @@
 import { createEffect, onCleanup, type JSX } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { SessionReview } from "@opencode-ai/session-ui/session-review"
 import type {
   SessionReviewCommentActions,
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
 
 export type DiffStyle = "unified" | "split"
 
-type ReviewDiff = SnapshotFileDiff | VcsFileDiff
+type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
 
 export interface SessionReviewTabProps {
   title?: JSX.Element

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

@@ -24,6 +24,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 
@@ -56,15 +57,16 @@ import { setSessionHandoff } from "@/pages/session/handoff"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
 
-type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
+type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
+type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
 
-function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
+function renderDiff(value: ReviewDiff): value is RenderDiff {
   return typeof value.file === "string"
 }
 
 export function SessionSidePanel(props: {
   canReview: () => boolean
-  diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
+  diffs: () => ReviewDiff[]
   diffsReady: () => boolean
   empty: () => string
   hasReview: () => boolean

+ 3 - 2
packages/app/src/pages/session/v2/review-diff-kinds.ts

@@ -1,14 +1,15 @@
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import type { Kind } from "@/components/file-tree-v2"
 import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
 
-export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
+export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff
 
 export function normalizePath(p: string) {
   return normalizeFileTreeV2Path(p)
 }
 
-export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
+export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
   return typeof value.file === "string"
 }
 

+ 2 - 1
packages/app/src/pages/session/v2/review-panel-v2.tsx

@@ -1,5 +1,6 @@
 import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import {
   SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
   SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@@ -30,7 +31,7 @@ import {
 import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
 import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
 
-type ReviewDiff = SnapshotFileDiff | VcsFileDiff
+type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
 
 export type ReviewPanelV2Props = {
   title?: JSX.Element

+ 2 - 1
packages/app/src/utils/diffs.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import type { Message } from "@opencode-ai/sdk/v2/client"
 import { diffs, message } from "./diffs"
 
@@ -9,7 +10,7 @@ const item = {
   additions: 1,
   deletions: 1,
   status: "modified",
-} satisfies SnapshotFileDiff
+} satisfies FileDiffInfo & SnapshotFileDiff
 
 describe("diffs", () => {
   test("keeps valid arrays", () => {

+ 2 - 1
packages/app/src/utils/diffs.ts

@@ -1,7 +1,8 @@
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import type { Message } from "@opencode-ai/sdk/v2/client"
 
-type Diff = SnapshotFileDiff | VcsFileDiff
+type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
 
 function diff(value: unknown): value is Diff {
   if (!value || typeof value !== "object" || Array.isArray(value)) return false

+ 31 - 3
packages/app/src/utils/terminal-websocket-url.test.ts

@@ -2,8 +2,28 @@ import { describe, expect, test } from "bun:test"
 import { terminalWebSocketURL } from "./terminal-websocket-url"
 
 describe("terminalWebSocketURL", () => {
-  test("uses query auth without embedding credentials in websocket URL", () => {
+  test("uses the current ticketed PTY route", () => {
     const url = terminalWebSocketURL({
+      url: "http://127.0.0.1:49365",
+      id: "pty_test",
+      directory: "/tmp/project",
+      cursor: 0,
+      ticket: "connect-ticket",
+    })
+
+    expect(url.protocol).toBe("ws:")
+    expect(url.username).toBe("")
+    expect(url.password).toBe("")
+    expect(url.pathname).toBe("/api/pty/pty_test/connect")
+    expect(url.searchParams.get("location[directory]")).toBe("/tmp/project")
+    expect(url.searchParams.get("cursor")).toBe("0")
+    expect(url.searchParams.get("ticket")).toBe("connect-ticket")
+    expect(url.searchParams.has("auth_token")).toBe(false)
+  })
+
+  test("uses query auth without embedding credentials in websocket URL for v1", () => {
+    const url = terminalWebSocketURL({
+      protocol: "v1",
       url: "http://127.0.0.1:49365",
       id: "pty_test",
       directory: "/tmp/project",
@@ -16,11 +36,14 @@ describe("terminalWebSocketURL", () => {
     expect(url.protocol).toBe("ws:")
     expect(url.username).toBe("")
     expect(url.password).toBe("")
+    expect(url.pathname).toBe("/pty/pty_test/connect")
+    expect(url.searchParams.get("directory")).toBe("/tmp/project")
     expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
   })
 
-  test("omits query auth for same-origin saved credentials", () => {
+  test("omits query auth for same-origin saved credentials for v1", () => {
     const url = terminalWebSocketURL({
+      protocol: "v1",
       url: "https://app.example.test",
       id: "pty_test",
       directory: "/tmp/project",
@@ -31,11 +54,14 @@ describe("terminalWebSocketURL", () => {
     })
 
     expect(url.protocol).toBe("wss:")
+    expect(url.pathname).toBe("/pty/pty_test/connect")
+    expect(url.searchParams.get("directory")).toBe("/tmp/project")
     expect(url.searchParams.has("auth_token")).toBe(false)
   })
 
-  test("uses query auth for same-origin credentials from auth_token", () => {
+  test("uses query auth for same-origin credentials from auth_token for v1", () => {
     const url = terminalWebSocketURL({
+      protocol: "v1",
       url: "https://app.example.test",
       id: "pty_test",
       directory: "/tmp/project",
@@ -47,6 +73,8 @@ describe("terminalWebSocketURL", () => {
     })
 
     expect(url.protocol).toBe("wss:")
+    expect(url.pathname).toBe("/pty/pty_test/connect")
+    expect(url.searchParams.get("directory")).toBe("/tmp/project")
     expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
   })
 })

+ 10 - 3
packages/app/src/utils/terminal-websocket-url.ts

@@ -1,6 +1,7 @@
 import { authTokenFromCredentials } from "@/utils/server"
 
 export function terminalWebSocketURL(input: {
+  protocol?: "v1" | "v2"
   url: string
   id: string
   directory: string
@@ -11,18 +12,24 @@ export function terminalWebSocketURL(input: {
   password?: string
   authToken?: boolean
 }) {
-  const next = new URL(`${input.url}/pty/${input.id}/connect`)
-  next.searchParams.set("directory", input.directory)
+  const isV1 = input.protocol === "v1"
+  const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`)
+  if (isV1) {
+    next.searchParams.set("directory", input.directory)
+  } else {
+    next.searchParams.set("location[directory]", input.directory)
+  }
   next.searchParams.set("cursor", String(input.cursor))
   next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
   if (input.ticket) {
     next.searchParams.set("ticket", input.ticket)
     return next
   }
-  if (input.password && (!input.sameOrigin || input.authToken))
+  if (isV1 && input.password && (!input.sameOrigin || input.authToken)) {
     next.searchParams.set(
       "auth_token",
       authTokenFromCredentials({ username: input.username, password: input.password }),
     )
+  }
   return next
 }

+ 2 - 1
packages/session-ui/src/components/session-diff.ts

@@ -1,6 +1,7 @@
 import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
 import { parsePatch } from "diff"
 import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 
 type LegacyDiff = {
   file: string
@@ -13,7 +14,7 @@ type LegacyDiff = {
 }
 
 type SnapshotDiff = SnapshotFileDiff & { file: string }
-type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
+type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff
 export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
 
 export type ViewDiff = {

+ 3 - 2
packages/session-ui/src/components/session-review.tsx

@@ -16,6 +16,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
 import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { type SelectedLineRange } from "@pierre/diffs"
 import { Dynamic } from "solid-js/web"
@@ -62,10 +63,10 @@ export type SessionReviewCommentActions = {
 
 export type SessionReviewFocus = { file: string; id: string }
 
-type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & {
+type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & {
   preloaded?: PreloadMultiFileDiffResult<any>
 }
-type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & {
+type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & {
   preloaded?: PreloadMultiFileDiffResult<any>
 }
 type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }

+ 2 - 1
packages/session-ui/src/components/session-turn.tsx

@@ -4,6 +4,7 @@ import {
   Message as MessageType,
   Part as PartType,
 } from "@opencode-ai/sdk/v2/client"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import type { SessionStatus } from "@opencode-ai/sdk/v2"
 import { useData } from "../context"
 import { useFileComponent } from "@opencode-ai/ui/context/file"
@@ -90,7 +91,7 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
   return fallback
 }
 
-type SummaryDiff = SnapshotFileDiff & { file: string }
+type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo
 
 function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
   return typeof value.file === "string"

+ 2 - 1
packages/session-ui/src/context/data.tsx

@@ -1,4 +1,5 @@
 import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 
@@ -21,7 +22,7 @@ type Data = {
     [sessionID: string]: SessionStatus
   }
   session_diff: {
-    [sessionID: string]: SnapshotFileDiff[]
+    [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[]
   }
   session_diff_preload?: {
     [sessionID: string]: PreloadMultiFileDiffResult<any>[]

+ 2 - 1
packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx

@@ -7,6 +7,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
 import { mediaKindFromPath } from "../../pierre/media"
 import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge"
 import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
+import type { FileDiffInfo } from "@opencode-ai/client/promise"
 import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
@@ -27,7 +28,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
 import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
 import "./session-review-v2.css"
 
-type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
+type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff
 
 export type SessionReviewFilePreviewV2Props = {
   file: string