Bladeren bron

fix(core): revert form service and mcp elicitation (#35080)

Aiden Cline 1 maand geleden
bovenliggende
commit
ef2140d121
73 gewijzigde bestanden met toevoegingen van 5681 en 5261 verwijderingen
  1. 10 19
      packages/app/e2e/regression/session-request-docks.spec.ts
  2. 0 12
      packages/app/e2e/utils/mock-server.ts
  3. 0 2
      packages/app/src/context/directory-sync.ts
  4. 1 4
      packages/app/src/context/global-sync/bootstrap.test.ts
  5. 4 17
      packages/app/src/context/global-sync/bootstrap.ts
  6. 10 44
      packages/app/src/context/global-sync/event-reducer.test.ts
  7. 11 23
      packages/app/src/context/global-sync/event-reducer.ts
  8. 4 4
      packages/app/src/context/global-sync/session-cache.test.ts
  9. 2 2
      packages/app/src/context/global-sync/session-cache.ts
  10. 2 2
      packages/app/src/context/global-sync/types.ts
  11. 8 10
      packages/app/src/context/server-session.ts
  12. 2 2
      packages/app/src/context/server-sync.tsx
  13. 8 6
      packages/app/src/pages/layout.tsx
  14. 3 6
      packages/app/src/pages/session/composer/session-composer-state.test.ts
  15. 2 3
      packages/app/src/pages/session/composer/session-composer-state.ts
  16. 82 120
      packages/app/src/pages/session/composer/session-question-dock.tsx
  17. 4 5
      packages/app/src/pages/session/composer/session-request-tree.ts
  18. 0 36
      packages/app/src/utils/question-form.test.ts
  19. 0 72
      packages/app/src/utils/question-form.ts
  20. 1 1
      packages/client/src/effect.ts
  21. 186 244
      packages/client/src/generated-effect/client.ts
  22. 56 109
      packages/client/src/generated/client.ts
  23. 24 1028
      packages/client/src/generated/types.ts
  24. 1 48
      packages/client/test/promise.test.ts
  25. 0 308
      packages/core/src/form.ts
  26. 2 2
      packages/core/src/location-services.ts
  27. 0 32
      packages/core/src/mcp/client.ts
  28. 2 147
      packages/core/src/mcp/index.ts
  29. 151 0
      packages/core/src/question.ts
  30. 2 4
      packages/core/src/session/runner/llm.ts
  31. 13 70
      packages/core/src/tool/question.ts
  32. 0 58
      packages/core/test/form.test.ts
  33. 114 0
      packages/core/test/question.test.ts
  34. 7 13
      packages/core/test/session-runner.test.ts
  35. 15 41
      packages/core/test/tool-question.test.ts
  36. 0 3
      packages/opencode/script/httpapi-exercise.ts
  37. 4 4
      packages/opencode/src/cli/cmd/run/noninteractive.ts
  38. 4 3
      packages/opencode/src/cli/cmd/run/runtime.ts
  39. 15 5
      packages/opencode/src/cli/cmd/run/stream-v2.transport.ts
  40. 10 0
      packages/opencode/src/server/routes/instance/httpapi/api.ts
  41. 13 0
      packages/opencode/src/server/routes/instance/httpapi/server.ts
  42. 2 2
      packages/opencode/test/cli/run/stream-v2.transport.test.ts
  43. 37 3
      packages/opencode/test/server/httpapi-exercise/index.ts
  44. 14 1
      packages/opencode/test/server/httpapi-public-openapi.test.ts
  45. 176 214
      packages/plugin/src/v2/effect/generated/api.ts
  46. 12 52
      packages/protocol/src/api.ts
  47. 2 7
      packages/protocol/src/client.ts
  48. 3 21
      packages/protocol/src/errors.ts
  49. 0 151
      packages/protocol/src/groups/form.ts
  50. 84 0
      packages/protocol/src/groups/question.ts
  51. 2 2
      packages/schema/src/event-manifest.ts
  52. 0 149
      packages/schema/src/form.ts
  53. 1 1
      packages/schema/src/index.ts
  54. 86 0
      packages/schema/src/question.ts
  55. 2 2
      packages/schema/test/contract-hygiene.test.ts
  56. 4 8
      packages/schema/test/event-manifest.test.ts
  57. 41 4
      packages/sdk/js/script/build.ts
  58. 153 282
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  59. 3644 778
      packages/sdk/js/src/v2/gen/types.gen.ts
  60. 5 5
      packages/sdk/js/test/session-history.test.ts
  61. 0 4
      packages/server/src/api.ts
  62. 2 2
      packages/server/src/handlers.ts
  63. 0 136
      packages/server/src/handlers/form.ts
  64. 62 0
      packages/server/src/handlers/question.ts
  65. 2 2
      packages/server/src/location.ts
  66. 0 76
      packages/server/src/middleware/form-location.ts
  67. 0 2
      packages/server/src/routes.ts
  68. 28 78
      packages/tui/src/context/data.tsx
  69. 0 19
      packages/tui/src/feature-plugins/system/notifications.ts
  70. 0 710
      packages/tui/src/routes/session/form.tsx
  71. 15 16
      packages/tui/src/routes/session/index.tsx
  72. 514 0
      packages/tui/src/routes/session/question.tsx
  73. 17 25
      packages/tui/test/cli/tui/data.test.tsx

+ 10 - 19
packages/app/e2e/regression/session-request-docks.spec.ts

@@ -10,21 +10,17 @@ const title = "Request dock regression"
 
 test("shows a pending question dock", async ({ page }) => {
   await mockServer(page, {
-    forms: [
+    questions: [
       {
-        id: "frm_question_request",
+        id: "question-request",
         sessionID,
-        mode: "form",
-        metadata: { kind: "question" },
-        fields: [
+        questions: [
           {
-            key: "question_0",
-            type: "string",
-            title: "Which implementation should be used?",
-            description: "Implementation",
+            header: "Implementation",
+            question: "Which implementation should be used?",
             options: [
-              { value: "Minimal", label: "Minimal", description: "Use the smallest correct change" },
-              { value: "Extended", label: "Extended", description: "Include additional behavior" },
+              { label: "Minimal", description: "Use the smallest correct change" },
+              { label: "Extended", description: "Include additional behavior" },
             ],
           },
         ],
@@ -45,8 +41,7 @@ test("shows a pending question dock", async ({ page }) => {
   const rejectRequests: string[] = []
   page.on("request", (request) => {
     if (request.method() !== "POST") return
-    if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
-      rejectRequests.push(request.url())
+    if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
   })
 
   await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
@@ -68,12 +63,10 @@ test("shows a pending question dock", async ({ page }) => {
 
   await question.getByRole("radio", { name: /Minimal/ }).click()
   const reply = page.waitForRequest(
-    (request) =>
-      request.method() === "POST" &&
-      new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
+    (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
   )
   await question.getByRole("button", { name: "Submit" }).click()
-  expect((await reply).postDataJSON()).toEqual({ answer: { question_0: "Minimal" } })
+  expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
 })
 
 test("shows a pending permission dock", async ({ page }) => {
@@ -112,7 +105,6 @@ async function mockServer(
   requests: {
     permissions?: unknown[] | (() => unknown[])
     questions?: unknown[] | (() => unknown[])
-    forms?: unknown[] | (() => unknown[])
   },
 ) {
   await mockOpenCodeServer(page, {
@@ -156,7 +148,6 @@ async function mockServer(
     pageMessages: () => ({ items: [] }),
     permissions: requests.permissions,
     questions: requests.questions,
-    forms: requests.forms,
   })
   await page.addInitScript(() => {
     localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))

+ 0 - 12
packages/app/e2e/utils/mock-server.ts

@@ -17,7 +17,6 @@ export interface MockServerConfig {
   todos?: (sessionID: string) => unknown[]
   permissions?: unknown[] | (() => unknown[])
   questions?: unknown[] | (() => unknown[])
-  forms?: unknown[] | (() => unknown[])
 }
 
 export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
@@ -54,17 +53,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
     if (path === "/question")
       return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
-    if (path === "/api/form/request")
-      return json(route, {
-        location: { directory: config.directory, project: config.project },
-        data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
-      })
-    if (/^\/api\/session\/[^/]+\/form$/.test(path))
-      return json(route, {
-        location: { directory: config.directory, project: config.project },
-        data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
-      })
-    if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path)) return json(route, undefined, undefined, 204)
     if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
     if (emptyObject.has(path)) return json(route, {})
     if (emptyList.has(path)) return json(route, [])

+ 0 - 2
packages/app/src/context/directory-sync.ts

@@ -30,13 +30,11 @@ export const createDirSyncContext = (
   const data = new Proxy({} as State, {
     get(_, property: keyof State) {
       if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
-      if (property === "question") return { ...serverSync.session.data.question, global: current()[0].question.global ?? [] }
       if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
       return current()[0][property]
     },
   })
   const set = ((...input: unknown[]) => {
-    if (input[0] === "question" && input[1] === "global") return (current()[1] as (...args: unknown[]) => unknown)(...input)
     if (typeof input[0] === "string" && sessionFields.has(input[0])) {
       return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
     }

+ 1 - 4
packages/app/src/context/global-sync/bootstrap.test.ts

@@ -69,10 +69,7 @@ describe("bootstrapDirectory", () => {
         },
         permission: { list: async () => ({ data: [] }) },
         question: { list: async () => ({ data: [] }) },
-        v2: {
-          form: { request: { list: async () => ({ data: { data: [] } }) } },
-          reference: { list: async () => ({ data: { data: [] } }) },
-        },
+        v2: { reference: { list: async () => ({ data: { data: [] } }) } },
         mcp: {
           status: async () => {
             mcpReads.push("status")

+ 4 - 17
packages/app/src/context/global-sync/bootstrap.ts

@@ -5,6 +5,7 @@ import type {
   PermissionRequest,
   Project,
   ProviderAuthResponse,
+  QuestionRequest,
   ReferenceInfo,
   Session,
 } from "@opencode-ai/sdk/v2/client"
@@ -21,7 +22,6 @@ 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"
-import { isQuestionForm, type QuestionForm } from "@/utils/question-form"
 
 type GlobalStore = {
   ready: boolean
@@ -319,11 +319,9 @@ export async function bootstrapDirectory(input: {
         ),
       () =>
         retry(() =>
-          input.sdk.v2.form.request.list().then((x) => {
-            const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : []))
-            const global = forms.filter((question) => question.sessionID === "global")
-            const grouped = groupBySession(forms.filter((question) => question.sessionID !== "global"))
-            const ids = Object.keys(grouped)
+          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))
             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 })
@@ -331,22 +329,11 @@ export async function bootstrapDirectory(input: {
               batch(() => {
                 const current = input.session?.data.question ?? input.store.question
                 for (const sessionID of Object.keys(current)) {
-                  if (sessionID === "global") continue
                   if (grouped[sessionID]) continue
                   if (input.session?.get(sessionID)?.directory !== input.directory) continue
                   if (input.session) input.session.set("question", sessionID, [])
                   if (!input.session) input.setStore("question", sessionID, [])
                 }
-                if (global.length > 0 || input.store.question.global) {
-                  input.setStore(
-                    "question",
-                    "global",
-                    reconcile(
-                      global.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
-                      { key: "id" },
-                    ),
-                  )
-                }
                 for (const [sessionID, questions] of Object.entries(grouped)) {
                   const value = reconcile(
                     questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),

+ 10 - 44
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -1,9 +1,8 @@
 import { describe, expect, test } from "bun:test"
-import type { Message, Part, PermissionRequest, Project, Session } from "@opencode-ai/sdk/v2/client"
+import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
 import { createStore } from "solid-js/store"
 import type { State } from "./types"
 import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
-import type { QuestionForm } from "@/utils/question-form"
 
 const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
   ({
@@ -49,18 +48,14 @@ const questionRequest = (id: string, sessionID: string, title = id) =>
   ({
     id,
     sessionID,
-    mode: "form",
-    metadata: { kind: "question" },
-    fields: [
+    questions: [
       {
-        key: "question_0",
-        title,
-        description: title,
-        type: "string",
-        options: [{ value: title, label: title, description: title }],
+        question: title,
+        header: title,
+        options: [{ label: title, description: title }],
       },
     ],
-  }) as QuestionForm
+  }) as QuestionRequest
 
 const baseState = (input: Partial<State> = {}) =>
   ({
@@ -510,7 +505,7 @@ describe("applyDirectoryEvent", () => {
     expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
 
     applyDirectoryEvent({
-      event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID) } },
+      event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
       store,
       setStore,
       push() {},
@@ -520,18 +515,17 @@ describe("applyDirectoryEvent", () => {
     expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
 
     applyDirectoryEvent({
-      event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID, "updated") } },
+      event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
       store,
       setStore,
       push() {},
       directory: "/tmp",
       loadLsp() {},
     })
-    const form = store.question[sessionID]?.find((x) => x.id === "q_2")
-    expect(form?.mode === "form" ? form.fields[0]?.description : undefined).toBe("updated")
+    expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
 
     applyDirectoryEvent({
-      event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } },
+      event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
       store,
       setStore,
       push() {},
@@ -541,34 +535,6 @@ describe("applyDirectoryEvent", () => {
     expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
   })
 
-  test("tracks global form lifecycles when session content is delegated", () => {
-    const [store, setStore] = createStore(baseState())
-
-    applyDirectoryEvent({
-      event: { type: "form.created", properties: { form: questionRequest("q_1", "global") } },
-      store,
-      setStore,
-      push() {},
-      directory: "/tmp",
-      loadLsp() {},
-      sessionContent: false,
-    })
-
-    expect(store.question.global?.map((x) => x.id)).toEqual(["q_1"])
-
-    applyDirectoryEvent({
-      event: { type: "form.cancelled", properties: { sessionID: "global", id: "q_1" } },
-      store,
-      setStore,
-      push() {},
-      directory: "/tmp",
-      loadLsp() {},
-      sessionContent: false,
-    })
-
-    expect(store.question.global).toEqual([])
-  })
-
   test("updates vcs branch in store and cache", () => {
     const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
     const [cacheStore, setCacheStore] = createStore({

+ 11 - 23
packages/app/src/context/global-sync/event-reducer.ts

@@ -5,6 +5,7 @@ import type {
   Part,
   PermissionRequest,
   Project,
+  QuestionRequest,
   Session,
   SessionStatus,
   SnapshotFileDiff,
@@ -14,7 +15,6 @@ import type { State, VcsCache } from "./types"
 import { trimSessions } from "./session-trim"
 import { dropSessionCaches } from "./session-cache"
 import { diffs as list, message as clean } from "@/utils/diffs"
-import { isQuestionForm } from "@/utils/question-form"
 
 const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
 const SESSION_CONTENT_EVENTS = new Set([
@@ -28,9 +28,9 @@ const SESSION_CONTENT_EVENTS = new Set([
   "message.part.delta",
   "permission.asked",
   "permission.replied",
-  "form.created",
-  "form.replied",
-  "form.cancelled",
+  "question.asked",
+  "question.replied",
+  "question.rejected",
 ])
 
 export function applyGlobalEvent(input: {
@@ -120,7 +120,7 @@ export function applyDirectoryEvent(input: {
   permission?: State["permission"]
 }) {
   const event = input.event
-  if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type) && !isGlobalQuestionEvent(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) {
     case "server.instance.disposed": {
@@ -364,10 +364,8 @@ export function applyDirectoryEvent(input: {
       )
       break
     }
-    case "form.created": {
-      const properties = event.properties as { form?: unknown }
-      if (!isQuestionForm(properties.form)) break
-      const question = properties.form
+    case "question.asked": {
+      const question = event.properties as QuestionRequest
       const questions = input.store.question[question.sessionID]
       if (!questions) {
         input.setStore("question", question.sessionID, [question])
@@ -387,12 +385,12 @@ export function applyDirectoryEvent(input: {
       )
       break
     }
-    case "form.replied":
-    case "form.cancelled": {
-      const props = event.properties as { sessionID: string; id: string }
+    case "question.replied":
+    case "question.rejected": {
+      const props = event.properties as { sessionID: string; requestID: string }
       const questions = input.store.question[props.sessionID]
       if (!questions) break
-      const result = Binary.search(questions, props.id, (q) => q.id)
+      const result = Binary.search(questions, props.requestID, (q) => q.id)
       if (!result.found) break
       input.setStore(
         "question",
@@ -413,13 +411,3 @@ export function applyDirectoryEvent(input: {
     }
   }
 }
-
-export function isGlobalQuestionEvent(event: { type: string; properties?: unknown }) {
-  if (event.type === "form.created") {
-    const form = (event.properties as { form?: unknown } | undefined)?.form
-    return isQuestionForm(form) && form.sessionID === "global"
-  }
-  if (event.type !== "form.replied" && event.type !== "form.cancelled") return false
-  const properties = event.properties as { sessionID?: unknown } | undefined
-  return properties?.sessionID === "global"
-}

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

@@ -3,12 +3,12 @@ import type {
   Message,
   Part,
   PermissionRequest,
+  QuestionRequest,
   SessionStatus,
   SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
 import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
-import type { QuestionForm } from "@/utils/question-form"
 
 const msg = (id: string, sessionID: string) =>
   ({
@@ -38,7 +38,7 @@ describe("app session cache", () => {
       message: Record<string, Message[] | undefined>
       part: Record<string, Part[] | undefined>
       permission: Record<string, PermissionRequest[] | undefined>
-      question: Record<string, QuestionForm[] | undefined>
+      question: Record<string, QuestionRequest[] | undefined>
       part_text_accum_delta: Record<string, string | undefined>
     } = {
       session_status: { ses_1: { type: "busy" } as SessionStatus },
@@ -47,7 +47,7 @@ describe("app session cache", () => {
       message: {},
       part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
       permission: { ses_1: [] as PermissionRequest[] },
-      question: { ses_1: [] as QuestionForm[] },
+      question: { ses_1: [] as QuestionRequest[] },
       part_text_accum_delta: { prt_1: "streamed text" },
     }
 
@@ -72,7 +72,7 @@ describe("app session cache", () => {
       message: Record<string, Message[] | undefined>
       part: Record<string, Part[] | undefined>
       permission: Record<string, PermissionRequest[] | undefined>
-      question: Record<string, QuestionForm[] | undefined>
+      question: Record<string, QuestionRequest[] | undefined>
       part_text_accum_delta: Record<string, string | undefined>
     } = {
       session_status: {},

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

@@ -2,11 +2,11 @@ import type {
   Message,
   Part,
   PermissionRequest,
+  QuestionRequest,
   SessionStatus,
   SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
-import type { QuestionForm } from "@/utils/question-form"
 
 export const SESSION_CACHE_LIMIT = 40
 
@@ -17,7 +17,7 @@ type SessionCache = {
   message: Record<string, Message[] | undefined>
   part: Record<string, Part[] | undefined>
   permission: Record<string, PermissionRequest[] | undefined>
-  question: Record<string, QuestionForm[] | undefined>
+  question: Record<string, QuestionRequest[] | undefined>
   part_text_accum_delta: Record<string, string | undefined>
 }
 

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

@@ -9,6 +9,7 @@ import type {
   Part,
   Path,
   PermissionRequest,
+  QuestionRequest,
   ReferenceInfo,
   Session,
   SessionStatus,
@@ -16,7 +17,6 @@ import type {
   Todo,
   VcsInfo,
 } from "@opencode-ai/sdk/v2/client"
-import type { QuestionForm } from "@/utils/question-form"
 import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
 import type { Accessor } from "solid-js"
 import type { SetStoreFunction, Store } from "solid-js/store"
@@ -60,7 +60,7 @@ export type State = {
     [sessionID: string]: PermissionRequest[]
   }
   question: {
-    [sessionID: string]: QuestionForm[]
+    [sessionID: string]: QuestionRequest[]
   }
   mcp_ready: boolean
   mcp: {

+ 8 - 10
packages/app/src/context/server-session.ts

@@ -5,12 +5,12 @@ import type {
   OpencodeClient,
   Part,
   PermissionRequest,
+  QuestionRequest,
   Session,
   SessionStatus,
   SnapshotFileDiff,
   Todo,
 } from "@opencode-ai/sdk/v2/client"
-import { isQuestionForm, type QuestionForm } from "@/utils/question-form"
 import { batch } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
@@ -136,7 +136,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
     session_diff: {} as Record<string, SnapshotFileDiff[]>,
     todo: {} as Record<string, Todo[]>,
     permission: {} as Record<string, PermissionRequest[]>,
-    question: {} as Record<string, QuestionForm[]>,
+    question: {} as Record<string, QuestionRequest[]>,
     message: {} as Record<string, Message[]>,
     part: {} as Record<string, Part[]>,
     part_text_accum_delta: {} as Record<string, string>,
@@ -932,10 +932,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
         )
         return
       }
-      case "form.created": {
-        const properties = event.properties as { form?: unknown }
-        if (!isQuestionForm(properties.form)) return
-        const question = properties.form
+      case "question.asked": {
+        const question = event.properties as QuestionRequest
         const questions = data.question[question.sessionID]
         if (!questions) {
           setData("question", question.sessionID, [question])
@@ -951,15 +949,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
           )
         return
       }
-      case "form.replied":
-      case "form.cancelled": {
-        const props = event.properties as { sessionID: string; id: string }
+      case "question.replied":
+      case "question.rejected": {
+        const props = event.properties as { sessionID: string; requestID: string }
         setData(
           "question",
           props.sessionID,
           produce((draft) => {
             if (!draft) return
-            const result = Binary.search(draft, props.id, (item) => item.id)
+            const result = Binary.search(draft, props.requestID, (item) => item.id)
             if (result.found) draft.splice(result.index, 1)
           }),
         )

+ 2 - 2
packages/app/src/context/server-sync.tsx

@@ -25,7 +25,7 @@ import {
   loadReferencesQuery,
 } from "./global-sync/bootstrap"
 import { createChildStoreManager } from "./global-sync/child-store"
-import { applyDirectoryEvent, applyGlobalEvent, isGlobalQuestionEvent } from "./global-sync/event-reducer"
+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"
@@ -375,7 +375,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
     const event = e.details
     const recent = bootingRoot || Date.now() - bootedAt < 1500
 
-    if (!isGlobalQuestionEvent(event)) session.apply(event)
+    session.apply(event)
 
     if (directory === "global") {
       applyGlobalEvent({

+ 8 - 6
packages/app/src/pages/layout.tsx

@@ -48,7 +48,6 @@ import { setNavigate } from "@/utils/notification-click"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import { setSessionHandoff } from "@/pages/session/handoff"
 import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
-import { isQuestionForm } from "@/utils/question-form"
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
@@ -404,22 +403,25 @@ export default function LegacyLayout(props: ParentProps) {
           return
         }
 
-        if (e.details?.type === "form.replied" || e.details?.type === "form.cancelled" || e.details?.type === "permission.replied") {
+        if (
+          e.details?.type === "question.replied" ||
+          e.details?.type === "question.rejected" ||
+          e.details?.type === "permission.replied"
+        ) {
           const props = e.details.properties as { sessionID: string }
           const sessionKey = `${e.name}:${props.sessionID}`
           dismissSessionAlert(sessionKey)
           return
         }
 
-        const questionForm = e.details?.type === "form.created" && isQuestionForm(e.details.properties?.form) ? e.details.properties.form : undefined
-        if (e.details?.type !== "permission.asked" && !questionForm) return
+        if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
         const title =
           e.details.type === "permission.asked"
             ? 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 directory = e.name
-        const props = questionForm ?? (e.details.properties as { sessionID: string })
+        const props = e.details.properties
         if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
 
         const [store] = serverSync().child(directory, { bootstrap: false })
@@ -448,7 +450,7 @@ export default function LegacyLayout(props: ParentProps) {
           }
         }
 
-        if (questionForm) {
+        if (e.details.type === "question.asked") {
           if (settings.notifications.agent()) {
             void platform.notify(title, description, href)
           }

+ 3 - 6
packages/app/src/pages/session/composer/session-composer-state.test.ts

@@ -1,6 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
-import type { QuestionForm } from "@/utils/question-form"
+import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
 import { todoDockAtBoundary, todoState } from "./session-composer-state"
 import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
 
@@ -20,10 +19,8 @@ const question = (id: string, sessionID: string) =>
   ({
     id,
     sessionID,
-    mode: "form",
-    metadata: { kind: "question" },
-    fields: [],
-  }) as QuestionForm
+    questions: [],
+  }) as QuestionRequest
 
 describe("sessionPermissionRequest", () => {
   test("prefers the current session permission", () => {

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

@@ -1,7 +1,6 @@
 import { createEffect, createMemo, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import type { PermissionRequest, Todo } from "@opencode-ai/sdk/v2"
-import type { QuestionForm } from "@/utils/question-form"
+import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
 import { useParams } from "@solidjs/router"
 import { showToast } from "@/utils/toast"
 import { useServerSync } from "@/context/server-sync"
@@ -34,7 +33,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
   const language = useLanguage()
   const permission = usePermission()
 
-  const questionRequest = createMemo((): QuestionForm | undefined => {
+  const questionRequest = createMemo((): QuestionRequest | undefined => {
     return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
   })
 

+ 82 - 120
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -6,21 +6,13 @@ 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 { useLanguage } from "@/context/language"
 import { useSDK } from "@/context/sdk"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { useServerSDK } from "@/context/server-sdk"
 import { ScopedKey } from "@/utils/server-scope"
-import {
-  questionAllowsCustom,
-  questionAnswer,
-  questionLabel,
-  questionMessage,
-  questionOptions,
-  type QuestionAnswer,
-  type QuestionForm,
-} from "@/utils/question-form"
 
 const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
 
@@ -69,14 +61,14 @@ function Option(props: {
   )
 }
 
-export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: () => void }> = (props) => {
+export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
   const sdk = useSDK()
   const serverSDK = useServerSDK()
   const language = useLanguage()
   const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
 
-  const questions = createMemo(() => (props.request.mode === "form" ? props.request.fields : []))
-  const total = createMemo(() => (props.request.mode === "url" ? 1 : questions().length))
+  const questions = createMemo(() => props.request.questions)
+  const total = createMemo(() => questions().length)
 
   const cached = cache.get(cacheKey)
   const [store, setStore] = createStore({
@@ -98,23 +90,16 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
   let focusFrame: number | undefined
 
   const question = createMemo(() => questions()[store.tab])
-  const options = createMemo(() => questionOptions(question()))
-  const custom = createMemo(() => questionAllowsCustom(question()))
+  const options = createMemo(() => question()?.options ?? [])
   const input = createMemo(() => store.custom[store.tab] ?? "")
-  const on = createMemo(() => custom() && store.customOn[store.tab] === true)
-  const multi = createMemo(() => question()?.type === "multiselect")
-  const count = createMemo(() => options().length + (custom() ? 1 : 0))
+  const on = createMemo(() => store.customOn[store.tab] === true)
+  const multi = createMemo(() => question()?.multiple === true)
+  const count = createMemo(() => options().length + 1)
 
   const summary = createMemo(() => {
-    if (props.request.title) return props.request.title
     const n = Math.min(store.tab + 1, total())
     return language.t("session.question.progress", { current: n, total: total() })
   })
-  const body = createMemo(() => {
-    if (props.request.mode === "url") return props.request.title ?? "Open URL request"
-    return questionLabel(question())
-  })
-  const message = createMemo(() => questionMessage(props.request))
   const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
   const customPlaceholder = () => language.t("ui.question.custom.placeholder")
 
@@ -168,11 +153,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
   const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
 
   const pickFocus = (tab: number = store.tab) => {
-    const list = questionOptions(questions()[tab])
-    if (questionAllowsCustom(questions()[tab]) && store.customOn[tab] === true) return list.length
+    const list = questions()[tab]?.options ?? []
+    if (store.customOn[tab] === true) return list.length
     return Math.max(
       0,
-      list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false),
+      list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
     )
   }
 
@@ -238,12 +223,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
   }
 
   const replyMutation = useMutation(() => ({
-    mutationFn: (answers: QuestionAnswer[]) =>
-      sdk().client.v2.session.form.reply({
-        sessionID: props.request.sessionID,
-        formID: props.request.id,
-        formReply: { answer: props.request.mode === "url" ? {} : questionAnswer(props.request.fields, answers) },
-      }),
+    mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
     onMutate: () => {
       props.onSubmit()
     },
@@ -255,7 +235,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
   }))
 
   const rejectMutation = useMutation(() => ({
-    mutationFn: () => sdk().client.v2.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
+    mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
     onMutate: () => {
       props.onSubmit()
     },
@@ -282,7 +262,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
 
   const answered = (i: number) => {
     if ((store.answers[i]?.length ?? 0) > 0) return true
-    return questionAllowsCustom(questions()[i]) && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
+    return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
   }
 
   const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
@@ -303,7 +283,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
 
   const customToggle = () => {
     if (sending()) return
-    if (!custom()) return
     setStore("focus", options().length)
 
     if (!multi()) {
@@ -329,7 +308,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
 
   const customOpen = () => {
     if (sending()) return
-    if (!custom()) return
     setStore("focus", options().length)
     if (!on()) setStore("customOn", store.tab, true)
     setStore("editing", true)
@@ -391,7 +369,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
     if (sending()) return
 
     if (optIndex === options().length) {
-      if (!custom()) return
       customOpen()
       return
     }
@@ -400,10 +377,10 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
     if (!opt) return
     if (multi()) {
       setStore("editing", false)
-      toggle(opt.value)
+      toggle(opt.label)
       return
     }
-    pick(opt.value)
+    pick(opt.label)
   }
 
   const commitCustom = () => {
@@ -549,24 +526,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
             overflow: store.minimized ? "hidden" : undefined,
           }}
         >
-          {body()}
+          {question()?.question}
         </div>
-        <Show when={message()}>
-          <div data-slot="question-hint">{message()}</div>
-        </Show>
         <Show when={!store.minimized}>
-          <Show
-            when={props.request.mode === "url"}
-            fallback={
-              <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
-                <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
-              </Show>
-            }
-          >
-            <div data-slot="question-hint">Open this URL, complete the request, then submit.</div>
-            <a href={props.request.mode === "url" ? props.request.url : undefined} target="_blank" rel="noreferrer">
-              {props.request.mode === "url" ? props.request.url : ""}
-            </a>
+          <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
+            <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
           </Show>
         </Show>
         <div
@@ -584,7 +548,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
             {(opt, i) => (
               <Option
                 multi={multi()}
-                picked={picked(opt.value)}
+                picked={picked(opt.label)}
                 label={opt.label}
                 description={opt.description}
                 disabled={sending()}
@@ -595,80 +559,78 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
             )}
           </For>
 
-          <Show when={custom()}>
-            <Show
-              when={store.editing}
-              fallback={
-                <button
-                  type="button"
-                  ref={customRef}
-                  data-slot="question-option"
-                  data-custom="true"
-                  data-picked={on()}
-                  role={multi() ? "checkbox" : "radio"}
-                  aria-checked={on()}
-                  disabled={sending()}
-                  onFocus={() => setStore("focus", options().length)}
-                  onClick={customOpen}
-                >
-                  <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
-                  <span data-slot="question-option-main">
-                    <span data-slot="option-label">{customLabel()}</span>
-                    <span data-slot="option-description">{input() || customPlaceholder()}</span>
-                  </span>
-                </button>
-              }
-            >
-              <form
+          <Show
+            when={store.editing}
+            fallback={
+              <button
+                type="button"
+                ref={customRef}
                 data-slot="question-option"
                 data-custom="true"
                 data-picked={on()}
                 role={multi() ? "checkbox" : "radio"}
                 aria-checked={on()}
-                onMouseDown={(e) => {
-                  if (sending()) {
-                    e.preventDefault()
-                    return
-                  }
-                  if (e.target instanceof HTMLTextAreaElement) return
-                  const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
-                  if (input instanceof HTMLTextAreaElement) input.focus()
-                }}
-                onSubmit={(e) => {
-                  e.preventDefault()
-                  commitCustom()
-                }}
+                disabled={sending()}
+                onFocus={() => setStore("focus", options().length)}
+                onClick={customOpen}
               >
                 <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
                 <span data-slot="question-option-main">
                   <span data-slot="option-label">{customLabel()}</span>
-                  <textarea
-                    ref={focusCustom}
-                    data-slot="question-custom-input"
-                    placeholder={customPlaceholder()}
-                    value={input()}
-                    rows={1}
-                    disabled={sending()}
-                    onKeyDown={(e) => {
-                      if (e.key === "Escape") {
-                        e.preventDefault()
-                        setStore("editing", false)
-                        focus(options().length)
-                        return
-                      }
-                      if ((e.metaKey || e.ctrlKey) && !e.altKey) return
-                      if (e.key !== "Enter" || e.shiftKey) return
-                      e.preventDefault()
-                      commitCustom()
-                    }}
-                    onInput={(e) => {
-                      customUpdate(e.currentTarget.value)
-                      resizeInput(e.currentTarget)
-                    }}
-                  />
+                  <span data-slot="option-description">{input() || customPlaceholder()}</span>
                 </span>
-              </form>
-            </Show>
+              </button>
+            }
+          >
+            <form
+              data-slot="question-option"
+              data-custom="true"
+              data-picked={on()}
+              role={multi() ? "checkbox" : "radio"}
+              aria-checked={on()}
+              onMouseDown={(e) => {
+                if (sending()) {
+                  e.preventDefault()
+                  return
+                }
+                if (e.target instanceof HTMLTextAreaElement) return
+                const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
+                if (input instanceof HTMLTextAreaElement) input.focus()
+              }}
+              onSubmit={(e) => {
+                e.preventDefault()
+                commitCustom()
+              }}
+            >
+              <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
+              <span data-slot="question-option-main">
+                <span data-slot="option-label">{customLabel()}</span>
+                <textarea
+                  ref={focusCustom}
+                  data-slot="question-custom-input"
+                  placeholder={customPlaceholder()}
+                  value={input()}
+                  rows={1}
+                  disabled={sending()}
+                  onKeyDown={(e) => {
+                    if (e.key === "Escape") {
+                      e.preventDefault()
+                      setStore("editing", false)
+                      focus(options().length)
+                      return
+                    }
+                    if ((e.metaKey || e.ctrlKey) && !e.altKey) return
+                    if (e.key !== "Enter" || e.shiftKey) return
+                    e.preventDefault()
+                    commitCustom()
+                  }}
+                  onInput={(e) => {
+                    customUpdate(e.currentTarget.value)
+                    resizeInput(e.currentTarget)
+                  }}
+                />
+              </span>
+            </form>
           </Show>
         </div>
       </DockPrompt>

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

@@ -1,5 +1,4 @@
-import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
-import type { QuestionForm } from "@/utils/question-form"
+import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
 
 function sessionTreeRequest<T>(
   session: Session[],
@@ -45,9 +44,9 @@ export function sessionPermissionRequest(
 
 export function sessionQuestionRequest(
   session: Session[],
-  request: Record<string, QuestionForm[] | undefined>,
+  request: Record<string, QuestionRequest[] | undefined>,
   sessionID?: string,
-  include?: (item: QuestionForm) => boolean,
+  include?: (item: QuestionRequest) => boolean,
 ) {
-  return sessionTreeRequest(session, request, sessionID, include) ?? request.global?.find(include ?? (() => true))
+  return sessionTreeRequest(session, request, sessionID, include)
 }

+ 0 - 36
packages/app/src/utils/question-form.test.ts

@@ -1,36 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { questionAnswer, type QuestionField } from "./question-form"
-
-describe("questionAnswer", () => {
-  test("falls back to field defaults", () => {
-    const fields = [
-      { key: "name", type: "string", default: "Ada" },
-      { key: "age", type: "integer", default: 42 },
-      { key: "newsletter", type: "boolean", default: false },
-      { key: "colors", type: "multiselect", options: [], default: ["red"] },
-    ] satisfies QuestionField[]
-
-    expect(questionAnswer(fields, [[], [], [], []])).toEqual({
-      name: "Ada",
-      age: 42,
-      newsletter: false,
-      colors: ["red"],
-    })
-  })
-
-  test("uses explicit answers over defaults", () => {
-    const fields = [
-      { key: "name", type: "string", default: "Ada" },
-      { key: "age", type: "number", default: 42 },
-      { key: "newsletter", type: "boolean", default: false },
-      { key: "colors", type: "multiselect", options: [], default: ["red"] },
-    ] satisfies QuestionField[]
-
-    expect(questionAnswer(fields, [["Grace"], ["36"], ["true"], ["blue"]])).toEqual({
-      name: "Grace",
-      age: 36,
-      newsletter: true,
-      colors: ["blue"],
-    })
-  })
-})

+ 0 - 72
packages/app/src/utils/question-form.ts

@@ -1,72 +0,0 @@
-import type { FormAnswer, FormFormInfo, FormUrlInfo } from "@opencode-ai/sdk/v2"
-
-export type QuestionOption = {
-  value: string
-  label: string
-  description?: string
-}
-
-export type QuestionField = FormFormInfo["fields"][number]
-
-export type QuestionForm = FormFormInfo | FormUrlInfo
-
-export type QuestionAnswer = string[]
-
-export function isQuestionForm(value: unknown): value is QuestionForm {
-  if (typeof value !== "object" || value === null) return false
-  const form = value as { id?: unknown; sessionID?: unknown; mode?: unknown; fields?: unknown; url?: unknown }
-  if (typeof form.id !== "string" || typeof form.sessionID !== "string") return false
-  if (form.mode === "url") return typeof form.url === "string"
-  if (form.mode !== "form") return false
-  return Array.isArray(form.fields) && form.fields.every(isQuestionField)
-}
-
-function isQuestionField(value: unknown): value is QuestionField {
-  if (typeof value !== "object" || value === null) return false
-  const field = value as { type?: unknown }
-  return ["string", "number", "integer", "boolean", "multiselect"].includes(String(field.type))
-}
-
-export function questionAnswer(fields: ReadonlyArray<QuestionField>, answers: ReadonlyArray<QuestionAnswer>): FormAnswer {
-  const entries = fields.flatMap((field, index): ReadonlyArray<readonly [string, FormAnswer[string]]> => {
-    const answer = answers[index] ?? []
-    if (answer.length === 0) {
-      if (field.default === undefined) return []
-      if (field.type === "multiselect") return [[field.key, [...field.default]]]
-      return [[field.key, field.default]]
-    }
-    if (field.type === "multiselect") return [[field.key, answer]]
-    if (field.type === "boolean") return [[field.key, answer[0] === "true"]]
-    if (field.type === "number" || field.type === "integer") return [[field.key, Number(answer[0])]]
-    return [[field.key, answer[0] ?? ""]]
-  })
-  return Object.fromEntries(entries)
-}
-
-export function questionOptions(field: QuestionField | undefined): QuestionOption[] {
-  if (!field) return []
-  if (field.type === "boolean")
-    return [
-      { value: "false", label: "No" },
-      { value: "true", label: "Yes" },
-    ]
-  if (field.type === "string") return field.options ?? []
-  if (field.type === "multiselect") return field.options
-  return []
-}
-
-export function questionAllowsCustom(field: QuestionField | undefined) {
-  if (!field) return false
-  if (field.type === "number" || field.type === "integer") return true
-  if (field.type !== "string" && field.type !== "multiselect") return false
-  return questionOptions(field).length === 0 || field.custom === true
-}
-
-export function questionLabel(field: QuestionField | undefined) {
-  return field?.title ?? field?.description ?? field?.key ?? "Form"
-}
-
-export function questionMessage(form: QuestionForm) {
-  const message = form.metadata?.message
-  return typeof message === "string" && message !== form.title ? message : undefined
-}

+ 1 - 1
packages/client/src/effect.ts

@@ -7,7 +7,6 @@ export { Credential } from "@opencode-ai/schema/credential"
 export { Event } from "@opencode-ai/schema/event"
 export { EventLog } from "@opencode-ai/schema/event-log"
 export { FileSystem } from "@opencode-ai/schema/filesystem"
-export { Form } from "@opencode-ai/schema/form"
 export { Integration } from "@opencode-ai/schema/integration"
 export { Location } from "@opencode-ai/schema/location"
 export { Model } from "@opencode-ai/schema/model"
@@ -17,6 +16,7 @@ export { Project } from "@opencode-ai/schema/project"
 export { ProjectCopy } from "@opencode-ai/schema/project-copy"
 export { Provider } from "@opencode-ai/schema/provider"
 export { Pty } from "@opencode-ai/schema/pty"
+export { Question } from "@opencode-ai/schema/question"
 export { Reference } from "@opencode-ai/schema/reference"
 export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
 export { Session } from "@opencode-ai/schema/session"

+ 186 - 244
packages/client/src/generated-effect/client.ts

@@ -552,136 +552,36 @@ const adaptGroup12 = (raw: RawClient["server.project"]) => ({
   directories: Endpoint12_1(raw),
 })
 
-type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
+type Endpoint13_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
 type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
-const Endpoint13_0 = (raw: RawClient["server.form"]) => (input?: Endpoint13_0Input) =>
-  raw["form.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
-
-type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
-type Endpoint13_1Input = {
-  readonly sessionID: Endpoint13_1Request["params"]["sessionID"]
-  readonly location?: Endpoint13_1Request["query"]["location"]
-}
-const Endpoint13_1 = (raw: RawClient["server.form"]) => (input: Endpoint13_1Input) =>
-  raw["session.form.list"]({ params: { sessionID: input["sessionID"] }, query: { location: input["location"] } }).pipe(
-    Effect.mapError(mapClientError),
-  )
-
-type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.create"]>[0]
-type Endpoint13_2Input = {
-  readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
-  readonly location?: Endpoint13_2Request["query"]["location"]
-  readonly id?: Endpoint13_2Request["payload"]["id"]
-  readonly title?: Endpoint13_2Request["payload"]["title"]
-  readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
-  readonly mode: Endpoint13_2Request["payload"]["mode"]
-  readonly fields?: Endpoint13_2Request["payload"]["fields"]
-  readonly url?: Endpoint13_2Request["payload"]["url"]
-}
-const Endpoint13_2 = (raw: RawClient["server.form"]) => (input: Endpoint13_2Input) =>
-  raw["session.form.create"]({
-    params: { sessionID: input["sessionID"] },
-    query: { location: input["location"] },
-    payload: {
-      id: input["id"],
-      title: input["title"],
-      metadata: input["metadata"],
-      mode: input["mode"],
-      fields: input["fields"],
-      url: input["url"],
-    },
-  }).pipe(Effect.mapError(mapClientError))
-
-type Endpoint13_3Request = Parameters<RawClient["server.form"]["session.form.get"]>[0]
-type Endpoint13_3Input = {
-  readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
-  readonly formID: Endpoint13_3Request["params"]["formID"]
-  readonly location?: Endpoint13_3Request["query"]["location"]
-}
-const Endpoint13_3 = (raw: RawClient["server.form"]) => (input: Endpoint13_3Input) =>
-  raw["session.form.get"]({
-    params: { sessionID: input["sessionID"], formID: input["formID"] },
-    query: { location: input["location"] },
-  }).pipe(Effect.mapError(mapClientError))
-
-type Endpoint13_4Request = Parameters<RawClient["server.form"]["session.form.state"]>[0]
-type Endpoint13_4Input = {
-  readonly sessionID: Endpoint13_4Request["params"]["sessionID"]
-  readonly formID: Endpoint13_4Request["params"]["formID"]
-  readonly location?: Endpoint13_4Request["query"]["location"]
-}
-const Endpoint13_4 = (raw: RawClient["server.form"]) => (input: Endpoint13_4Input) =>
-  raw["session.form.state"]({
-    params: { sessionID: input["sessionID"], formID: input["formID"] },
-    query: { location: input["location"] },
-  }).pipe(Effect.mapError(mapClientError))
-
-type Endpoint13_5Request = Parameters<RawClient["server.form"]["session.form.reply"]>[0]
-type Endpoint13_5Input = {
-  readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
-  readonly formID: Endpoint13_5Request["params"]["formID"]
-  readonly location?: Endpoint13_5Request["query"]["location"]
-  readonly answer: Endpoint13_5Request["payload"]["answer"]
-}
-const Endpoint13_5 = (raw: RawClient["server.form"]) => (input: Endpoint13_5Input) =>
-  raw["session.form.reply"]({
-    params: { sessionID: input["sessionID"], formID: input["formID"] },
-    query: { location: input["location"] },
-    payload: { answer: input["answer"] },
-  }).pipe(Effect.mapError(mapClientError))
-
-type Endpoint13_6Request = Parameters<RawClient["server.form"]["session.form.cancel"]>[0]
-type Endpoint13_6Input = {
-  readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
-  readonly formID: Endpoint13_6Request["params"]["formID"]
-  readonly location?: Endpoint13_6Request["query"]["location"]
-}
-const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Input) =>
-  raw["session.form.cancel"]({
-    params: { sessionID: input["sessionID"], formID: input["formID"] },
-    query: { location: input["location"] },
-  }).pipe(Effect.mapError(mapClientError))
-
-const adaptGroup13 = (raw: RawClient["server.form"]) => ({
-  listRequests: Endpoint13_0(raw),
-  list: Endpoint13_1(raw),
-  create: Endpoint13_2(raw),
-  get: Endpoint13_3(raw),
-  state: Endpoint13_4(raw),
-  reply: Endpoint13_5(raw),
-  cancel: Endpoint13_6(raw),
-})
-
-type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
-type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
-const Endpoint14_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint14_0Input) =>
+const Endpoint13_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint13_0Input) =>
   raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint14_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
-type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["query"]["projectID"] }
-const Endpoint14_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint14_1Input) =>
+type Endpoint13_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
+type Endpoint13_1Input = { readonly projectID?: Endpoint13_1Request["query"]["projectID"] }
+const Endpoint13_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint13_1Input) =>
   raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
     Effect.mapError(mapClientError),
     Effect.map((value) => value.data),
   )
 
-type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
-type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] }
-const Endpoint14_2 = (raw: RawClient["server.permission"]) => (input: Endpoint14_2Input) =>
+type Endpoint13_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
+type Endpoint13_2Input = { readonly id: Endpoint13_2Request["params"]["id"] }
+const Endpoint13_2 = (raw: RawClient["server.permission"]) => (input: Endpoint13_2Input) =>
   raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint14_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
-type Endpoint14_3Input = {
-  readonly sessionID: Endpoint14_3Request["params"]["sessionID"]
-  readonly id?: Endpoint14_3Request["payload"]["id"]
-  readonly action: Endpoint14_3Request["payload"]["action"]
-  readonly resources: Endpoint14_3Request["payload"]["resources"]
-  readonly save?: Endpoint14_3Request["payload"]["save"]
-  readonly metadata?: Endpoint14_3Request["payload"]["metadata"]
-  readonly source?: Endpoint14_3Request["payload"]["source"]
-  readonly agent?: Endpoint14_3Request["payload"]["agent"]
-}
-const Endpoint14_3 = (raw: RawClient["server.permission"]) => (input: Endpoint14_3Input) =>
+type Endpoint13_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
+type Endpoint13_3Input = {
+  readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
+  readonly id?: Endpoint13_3Request["payload"]["id"]
+  readonly action: Endpoint13_3Request["payload"]["action"]
+  readonly resources: Endpoint13_3Request["payload"]["resources"]
+  readonly save?: Endpoint13_3Request["payload"]["save"]
+  readonly metadata?: Endpoint13_3Request["payload"]["metadata"]
+  readonly source?: Endpoint13_3Request["payload"]["source"]
+  readonly agent?: Endpoint13_3Request["payload"]["agent"]
+}
+const Endpoint13_3 = (raw: RawClient["server.permission"]) => (input: Endpoint13_3Input) =>
   raw["session.permission.create"]({
     params: { sessionID: input["sessionID"] },
     payload: {
@@ -698,87 +598,87 @@ const Endpoint14_3 = (raw: RawClient["server.permission"]) => (input: Endpoint14
     Effect.map((value) => value.data),
   )
 
-type Endpoint14_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
-type Endpoint14_4Input = { readonly sessionID: Endpoint14_4Request["params"]["sessionID"] }
-const Endpoint14_4 = (raw: RawClient["server.permission"]) => (input: Endpoint14_4Input) =>
+type Endpoint13_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
+type Endpoint13_4Input = { readonly sessionID: Endpoint13_4Request["params"]["sessionID"] }
+const Endpoint13_4 = (raw: RawClient["server.permission"]) => (input: Endpoint13_4Input) =>
   raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
     Effect.mapError(mapClientError),
     Effect.map((value) => value.data),
   )
 
-type Endpoint14_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
-type Endpoint14_5Input = {
-  readonly sessionID: Endpoint14_5Request["params"]["sessionID"]
-  readonly requestID: Endpoint14_5Request["params"]["requestID"]
+type Endpoint13_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
+type Endpoint13_5Input = {
+  readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
+  readonly requestID: Endpoint13_5Request["params"]["requestID"]
 }
-const Endpoint14_5 = (raw: RawClient["server.permission"]) => (input: Endpoint14_5Input) =>
+const Endpoint13_5 = (raw: RawClient["server.permission"]) => (input: Endpoint13_5Input) =>
   raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
     Effect.mapError(mapClientError),
     Effect.map((value) => value.data),
   )
 
-type Endpoint14_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
-type Endpoint14_6Input = {
-  readonly sessionID: Endpoint14_6Request["params"]["sessionID"]
-  readonly requestID: Endpoint14_6Request["params"]["requestID"]
-  readonly reply: Endpoint14_6Request["payload"]["reply"]
-  readonly message?: Endpoint14_6Request["payload"]["message"]
+type Endpoint13_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
+type Endpoint13_6Input = {
+  readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
+  readonly requestID: Endpoint13_6Request["params"]["requestID"]
+  readonly reply: Endpoint13_6Request["payload"]["reply"]
+  readonly message?: Endpoint13_6Request["payload"]["message"]
 }
-const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14_6Input) =>
+const Endpoint13_6 = (raw: RawClient["server.permission"]) => (input: Endpoint13_6Input) =>
   raw["session.permission.reply"]({
     params: { sessionID: input["sessionID"], requestID: input["requestID"] },
     payload: { reply: input["reply"], message: input["message"] },
   }).pipe(Effect.mapError(mapClientError))
 
-const adaptGroup14 = (raw: RawClient["server.permission"]) => ({
-  listRequests: Endpoint14_0(raw),
-  listSaved: Endpoint14_1(raw),
-  removeSaved: Endpoint14_2(raw),
-  create: Endpoint14_3(raw),
-  list: Endpoint14_4(raw),
-  get: Endpoint14_5(raw),
-  reply: Endpoint14_6(raw),
+const adaptGroup13 = (raw: RawClient["server.permission"]) => ({
+  listRequests: Endpoint13_0(raw),
+  listSaved: Endpoint13_1(raw),
+  removeSaved: Endpoint13_2(raw),
+  create: Endpoint13_3(raw),
+  list: Endpoint13_4(raw),
+  get: Endpoint13_5(raw),
+  reply: Endpoint13_6(raw),
 })
 
-type Endpoint15_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
-type Endpoint15_0Input = {
-  readonly location?: Endpoint15_0Request["query"]["location"]
-  readonly path?: Endpoint15_0Request["query"]["path"]
+type Endpoint14_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
+type Endpoint14_0Input = {
+  readonly location?: Endpoint14_0Request["query"]["location"]
+  readonly path?: Endpoint14_0Request["query"]["path"]
 }
-const Endpoint15_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint15_0Input) =>
+const Endpoint14_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint14_0Input) =>
   raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
     Effect.mapError(mapClientError),
   )
 
-type Endpoint15_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
-type Endpoint15_1Input = {
-  readonly location?: Endpoint15_1Request["query"]["location"]
-  readonly query: Endpoint15_1Request["query"]["query"]
-  readonly type?: Endpoint15_1Request["query"]["type"]
-  readonly limit?: Endpoint15_1Request["query"]["limit"]
+type Endpoint14_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
+type Endpoint14_1Input = {
+  readonly location?: Endpoint14_1Request["query"]["location"]
+  readonly query: Endpoint14_1Request["query"]["query"]
+  readonly type?: Endpoint14_1Request["query"]["type"]
+  readonly limit?: Endpoint14_1Request["query"]["limit"]
 }
-const Endpoint15_1 = (raw: RawClient["server.fs"]) => (input: Endpoint15_1Input) =>
+const Endpoint14_1 = (raw: RawClient["server.fs"]) => (input: Endpoint14_1Input) =>
   raw["fs.find"]({
     query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
   }).pipe(Effect.mapError(mapClientError))
 
-const adaptGroup15 = (raw: RawClient["server.fs"]) => ({ list: Endpoint15_0(raw), find: Endpoint15_1(raw) })
+const adaptGroup14 = (raw: RawClient["server.fs"]) => ({ list: Endpoint14_0(raw), find: Endpoint14_1(raw) })
 
-type Endpoint16_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
-type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
-const Endpoint16_0 = (raw: RawClient["server.command"]) => (input?: Endpoint16_0Input) =>
+type Endpoint15_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
+type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
+const Endpoint15_0 = (raw: RawClient["server.command"]) => (input?: Endpoint15_0Input) =>
   raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
 
-const adaptGroup16 = (raw: RawClient["server.command"]) => ({ list: Endpoint16_0(raw) })
+const adaptGroup15 = (raw: RawClient["server.command"]) => ({ list: Endpoint15_0(raw) })
 
-type Endpoint17_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
-type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
-const Endpoint17_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint17_0Input) =>
+type Endpoint16_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
+type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
+const Endpoint16_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint16_0Input) =>
   raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
 
-const adaptGroup17 = (raw: RawClient["server.skill"]) => ({ list: Endpoint17_0(raw) })
+const adaptGroup16 = (raw: RawClient["server.skill"]) => ({ list: Endpoint16_0(raw) })
 
-const Endpoint18_0 = (raw: RawClient["server.event"]) => () =>
+const Endpoint17_0 = (raw: RawClient["server.event"]) => () =>
   Stream.unwrap(
     raw["event.subscribe"]({}).pipe(
       Effect.mapError(mapClientError),
@@ -786,7 +686,7 @@ const Endpoint18_0 = (raw: RawClient["server.event"]) => () =>
     ),
   )
 
-const Endpoint18_1 = (raw: RawClient["server.event"]) => () =>
+const Endpoint17_1 = (raw: RawClient["server.event"]) => () =>
   Stream.unwrap(
     raw["event.changes"]({}).pipe(
       Effect.mapError(mapClientError),
@@ -794,23 +694,23 @@ const Endpoint18_1 = (raw: RawClient["server.event"]) => () =>
     ),
   )
 
-const adaptGroup18 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint18_0(raw), changes: Endpoint18_1(raw) })
+const adaptGroup17 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint17_0(raw), changes: Endpoint17_1(raw) })
 
-type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
-type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
-const Endpoint19_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_0Input) =>
+type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
+type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
+const Endpoint18_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint18_0Input) =>
   raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint19_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
-type Endpoint19_1Input = {
-  readonly location?: Endpoint19_1Request["query"]["location"]
-  readonly command?: Endpoint19_1Request["payload"]["command"]
-  readonly args?: Endpoint19_1Request["payload"]["args"]
-  readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
-  readonly title?: Endpoint19_1Request["payload"]["title"]
-  readonly env?: Endpoint19_1Request["payload"]["env"]
+type Endpoint18_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
+type Endpoint18_1Input = {
+  readonly location?: Endpoint18_1Request["query"]["location"]
+  readonly command?: Endpoint18_1Request["payload"]["command"]
+  readonly args?: Endpoint18_1Request["payload"]["args"]
+  readonly cwd?: Endpoint18_1Request["payload"]["cwd"]
+  readonly title?: Endpoint18_1Request["payload"]["title"]
+  readonly env?: Endpoint18_1Request["payload"]["env"]
 }
-const Endpoint19_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_1Input) =>
+const Endpoint18_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint18_1Input) =>
   raw["pty.create"]({
     query: { location: input?.["location"] },
     payload: {
@@ -822,106 +722,148 @@ const Endpoint19_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_1Inpu
     },
   }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint19_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
-type Endpoint19_2Input = {
-  readonly ptyID: Endpoint19_2Request["params"]["ptyID"]
-  readonly location?: Endpoint19_2Request["query"]["location"]
+type Endpoint18_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
+type Endpoint18_2Input = {
+  readonly ptyID: Endpoint18_2Request["params"]["ptyID"]
+  readonly location?: Endpoint18_2Request["query"]["location"]
 }
-const Endpoint19_2 = (raw: RawClient["server.pty"]) => (input: Endpoint19_2Input) =>
+const Endpoint18_2 = (raw: RawClient["server.pty"]) => (input: Endpoint18_2Input) =>
   raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
     Effect.mapError(mapClientError),
   )
 
-type Endpoint19_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
-type Endpoint19_3Input = {
-  readonly ptyID: Endpoint19_3Request["params"]["ptyID"]
-  readonly location?: Endpoint19_3Request["query"]["location"]
-  readonly title?: Endpoint19_3Request["payload"]["title"]
-  readonly size?: Endpoint19_3Request["payload"]["size"]
+type Endpoint18_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
+type Endpoint18_3Input = {
+  readonly ptyID: Endpoint18_3Request["params"]["ptyID"]
+  readonly location?: Endpoint18_3Request["query"]["location"]
+  readonly title?: Endpoint18_3Request["payload"]["title"]
+  readonly size?: Endpoint18_3Request["payload"]["size"]
 }
-const Endpoint19_3 = (raw: RawClient["server.pty"]) => (input: Endpoint19_3Input) =>
+const Endpoint18_3 = (raw: RawClient["server.pty"]) => (input: Endpoint18_3Input) =>
   raw["pty.update"]({
     params: { ptyID: input["ptyID"] },
     query: { location: input["location"] },
     payload: { title: input["title"], size: input["size"] },
   }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint19_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
-type Endpoint19_4Input = {
-  readonly ptyID: Endpoint19_4Request["params"]["ptyID"]
-  readonly location?: Endpoint19_4Request["query"]["location"]
+type Endpoint18_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
+type Endpoint18_4Input = {
+  readonly ptyID: Endpoint18_4Request["params"]["ptyID"]
+  readonly location?: Endpoint18_4Request["query"]["location"]
 }
-const Endpoint19_4 = (raw: RawClient["server.pty"]) => (input: Endpoint19_4Input) =>
+const Endpoint18_4 = (raw: RawClient["server.pty"]) => (input: Endpoint18_4Input) =>
   raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
     Effect.mapError(mapClientError),
   )
 
-const adaptGroup19 = (raw: RawClient["server.pty"]) => ({
-  list: Endpoint19_0(raw),
-  create: Endpoint19_1(raw),
-  get: Endpoint19_2(raw),
-  update: Endpoint19_3(raw),
-  remove: Endpoint19_4(raw),
+const adaptGroup18 = (raw: RawClient["server.pty"]) => ({
+  list: Endpoint18_0(raw),
+  create: Endpoint18_1(raw),
+  get: Endpoint18_2(raw),
+  update: Endpoint18_3(raw),
+  remove: Endpoint18_4(raw),
 })
 
-type Endpoint20_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
-type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
-const Endpoint20_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint20_0Input) =>
+type Endpoint19_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
+type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
+const Endpoint19_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint19_0Input) =>
   raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint20_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
-type Endpoint20_1Input = {
-  readonly location?: Endpoint20_1Request["query"]["location"]
-  readonly command: Endpoint20_1Request["payload"]["command"]
-  readonly cwd?: Endpoint20_1Request["payload"]["cwd"]
-  readonly timeout?: Endpoint20_1Request["payload"]["timeout"]
-  readonly metadata?: Endpoint20_1Request["payload"]["metadata"]
+type Endpoint19_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
+type Endpoint19_1Input = {
+  readonly location?: Endpoint19_1Request["query"]["location"]
+  readonly command: Endpoint19_1Request["payload"]["command"]
+  readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
+  readonly timeout?: Endpoint19_1Request["payload"]["timeout"]
+  readonly metadata?: Endpoint19_1Request["payload"]["metadata"]
 }
-const Endpoint20_1 = (raw: RawClient["server.shell"]) => (input: Endpoint20_1Input) =>
+const Endpoint19_1 = (raw: RawClient["server.shell"]) => (input: Endpoint19_1Input) =>
   raw["shell.create"]({
     query: { location: input["location"] },
     payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
   }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint20_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
-type Endpoint20_2Input = {
-  readonly id: Endpoint20_2Request["params"]["id"]
-  readonly location?: Endpoint20_2Request["query"]["location"]
+type Endpoint19_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
+type Endpoint19_2Input = {
+  readonly id: Endpoint19_2Request["params"]["id"]
+  readonly location?: Endpoint19_2Request["query"]["location"]
 }
-const Endpoint20_2 = (raw: RawClient["server.shell"]) => (input: Endpoint20_2Input) =>
+const Endpoint19_2 = (raw: RawClient["server.shell"]) => (input: Endpoint19_2Input) =>
   raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
     Effect.mapError(mapClientError),
   )
 
-type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
-type Endpoint20_3Input = {
-  readonly id: Endpoint20_3Request["params"]["id"]
-  readonly location?: Endpoint20_3Request["query"]["location"]
-  readonly cursor?: Endpoint20_3Request["query"]["cursor"]
-  readonly limit?: Endpoint20_3Request["query"]["limit"]
+type Endpoint19_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
+type Endpoint19_3Input = {
+  readonly id: Endpoint19_3Request["params"]["id"]
+  readonly location?: Endpoint19_3Request["query"]["location"]
+  readonly cursor?: Endpoint19_3Request["query"]["cursor"]
+  readonly limit?: Endpoint19_3Request["query"]["limit"]
 }
-const Endpoint20_3 = (raw: RawClient["server.shell"]) => (input: Endpoint20_3Input) =>
+const Endpoint19_3 = (raw: RawClient["server.shell"]) => (input: Endpoint19_3Input) =>
   raw["shell.output"]({
     params: { id: input["id"] },
     query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
   }).pipe(Effect.mapError(mapClientError))
 
-type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
-type Endpoint20_4Input = {
-  readonly id: Endpoint20_4Request["params"]["id"]
-  readonly location?: Endpoint20_4Request["query"]["location"]
+type Endpoint19_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
+type Endpoint19_4Input = {
+  readonly id: Endpoint19_4Request["params"]["id"]
+  readonly location?: Endpoint19_4Request["query"]["location"]
 }
-const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) =>
+const Endpoint19_4 = (raw: RawClient["server.shell"]) => (input: Endpoint19_4Input) =>
   raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
     Effect.mapError(mapClientError),
   )
 
-const adaptGroup20 = (raw: RawClient["server.shell"]) => ({
-  list: Endpoint20_0(raw),
-  create: Endpoint20_1(raw),
-  get: Endpoint20_2(raw),
-  output: Endpoint20_3(raw),
-  remove: Endpoint20_4(raw),
+const adaptGroup19 = (raw: RawClient["server.shell"]) => ({
+  list: Endpoint19_0(raw),
+  create: Endpoint19_1(raw),
+  get: Endpoint19_2(raw),
+  output: Endpoint19_3(raw),
+  remove: Endpoint19_4(raw),
+})
+
+type Endpoint20_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
+type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
+const Endpoint20_0 = (raw: RawClient["server.question"]) => (input?: Endpoint20_0Input) =>
+  raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint20_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
+type Endpoint20_1Input = { readonly sessionID: Endpoint20_1Request["params"]["sessionID"] }
+const Endpoint20_1 = (raw: RawClient["server.question"]) => (input: Endpoint20_1Input) =>
+  raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
+    Effect.mapError(mapClientError),
+    Effect.map((value) => value.data),
+  )
+
+type Endpoint20_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
+type Endpoint20_2Input = {
+  readonly sessionID: Endpoint20_2Request["params"]["sessionID"]
+  readonly requestID: Endpoint20_2Request["params"]["requestID"]
+  readonly answers: Endpoint20_2Request["payload"]["answers"]
+}
+const Endpoint20_2 = (raw: RawClient["server.question"]) => (input: Endpoint20_2Input) =>
+  raw["session.question.reply"]({
+    params: { sessionID: input["sessionID"], requestID: input["requestID"] },
+    payload: { answers: input["answers"] },
+  }).pipe(Effect.mapError(mapClientError))
+
+type Endpoint20_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
+type Endpoint20_3Input = {
+  readonly sessionID: Endpoint20_3Request["params"]["sessionID"]
+  readonly requestID: Endpoint20_3Request["params"]["requestID"]
+}
+const Endpoint20_3 = (raw: RawClient["server.question"]) => (input: Endpoint20_3Input) =>
+  raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
+    Effect.mapError(mapClientError),
+  )
+
+const adaptGroup20 = (raw: RawClient["server.question"]) => ({
+  listRequests: Endpoint20_0(raw),
+  list: Endpoint20_1(raw),
+  reply: Endpoint20_2(raw),
+  reject: Endpoint20_3(raw),
 })
 
 type Endpoint21_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
@@ -991,14 +933,14 @@ const adaptClient = (raw: RawClient) => ({
   "server.mcp": adaptGroup10(raw["server.mcp"]),
   credential: adaptGroup11(raw["server.credential"]),
   project: adaptGroup12(raw["server.project"]),
-  form: adaptGroup13(raw["server.form"]),
-  permission: adaptGroup14(raw["server.permission"]),
-  file: adaptGroup15(raw["server.fs"]),
-  command: adaptGroup16(raw["server.command"]),
-  skill: adaptGroup17(raw["server.skill"]),
-  event: adaptGroup18(raw["server.event"]),
-  pty: adaptGroup19(raw["server.pty"]),
-  shell: adaptGroup20(raw["server.shell"]),
+  permission: adaptGroup13(raw["server.permission"]),
+  file: adaptGroup14(raw["server.fs"]),
+  command: adaptGroup15(raw["server.command"]),
+  skill: adaptGroup16(raw["server.skill"]),
+  event: adaptGroup17(raw["server.event"]),
+  pty: adaptGroup18(raw["server.pty"]),
+  shell: adaptGroup19(raw["server.shell"]),
+  question: adaptGroup20(raw["server.question"]),
   reference: adaptGroup21(raw["server.reference"]),
   projectCopy: adaptGroup22(raw["server.projectCopy"]),
 })

+ 56 - 109
packages/client/src/generated/client.ts

@@ -91,20 +91,6 @@ import type {
   ProjectCurrentOutput,
   ProjectDirectoriesInput,
   ProjectDirectoriesOutput,
-  FormListRequestsInput,
-  FormListRequestsOutput,
-  FormListInput,
-  FormListOutput,
-  FormCreateInput,
-  FormCreateOutput,
-  FormGetInput,
-  FormGetOutput,
-  FormStateInput,
-  FormStateOutput,
-  FormReplyInput,
-  FormReplyOutput,
-  FormCancelInput,
-  FormCancelOutput,
   PermissionListRequestsInput,
   PermissionListRequestsOutput,
   PermissionListSavedInput,
@@ -151,6 +137,14 @@ import type {
   ShellOutputOutput,
   ShellRemoveInput,
   ShellRemoveOutput,
+  QuestionListRequestsInput,
+  QuestionListRequestsOutput,
+  QuestionListInput,
+  QuestionListOutput,
+  QuestionReplyInput,
+  QuestionReplyOutput,
+  QuestionRejectInput,
+  QuestionRejectOutput,
   ReferenceListInput,
   ReferenceListOutput,
   ProjectCopyCreateInput,
@@ -897,101 +891,6 @@ export function make(options: ClientOptions) {
           requestOptions,
         ),
     },
-    form: {
-      listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) =>
-        request<FormListRequestsOutput>(
-          {
-            method: "GET",
-            path: `/api/form/request`,
-            query: { location: input?.["location"] },
-            successStatus: 200,
-            declaredStatuses: [401, 400],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      list: (input: FormListInput, requestOptions?: RequestOptions) =>
-        request<FormListOutput>(
-          {
-            method: "GET",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
-            query: { location: input["location"] },
-            successStatus: 200,
-            declaredStatuses: [400, 404, 401],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      create: (input: FormCreateInput, requestOptions?: RequestOptions) =>
-        request<FormCreateOutput>(
-          {
-            method: "POST",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
-            query: { location: input["location"] },
-            body: {
-              id: input["id"],
-              title: input["title"],
-              metadata: input["metadata"],
-              mode: input["mode"],
-              fields: input["fields"],
-              url: input["url"],
-            },
-            successStatus: 200,
-            declaredStatuses: [409, 400, 404, 401],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      get: (input: FormGetInput, requestOptions?: RequestOptions) =>
-        request<FormGetOutput>(
-          {
-            method: "GET",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
-            query: { location: input["location"] },
-            successStatus: 200,
-            declaredStatuses: [404, 400, 401],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      state: (input: FormStateInput, requestOptions?: RequestOptions) =>
-        request<FormStateOutput>(
-          {
-            method: "GET",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/state`,
-            query: { location: input["location"] },
-            successStatus: 200,
-            declaredStatuses: [404, 400, 401],
-            empty: false,
-          },
-          requestOptions,
-        ),
-      reply: (input: FormReplyInput, requestOptions?: RequestOptions) =>
-        request<FormReplyOutput>(
-          {
-            method: "POST",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
-            query: { location: input["location"] },
-            body: { answer: input["answer"] },
-            successStatus: 204,
-            declaredStatuses: [409, 400, 404, 401],
-            empty: true,
-          },
-          requestOptions,
-        ),
-      cancel: (input: FormCancelInput, requestOptions?: RequestOptions) =>
-        request<FormCancelOutput>(
-          {
-            method: "POST",
-            path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/cancel`,
-            query: { location: input["location"] },
-            successStatus: 204,
-            declaredStatuses: [409, 404, 400, 401],
-            empty: true,
-          },
-          requestOptions,
-        ),
-    },
     permission: {
       listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) =>
         request<PermissionListRequestsOutput>(
@@ -1300,6 +1199,54 @@ export function make(options: ClientOptions) {
           requestOptions,
         ),
     },
+    question: {
+      listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) =>
+        request<QuestionListRequestsOutput>(
+          {
+            method: "GET",
+            path: `/api/question/request`,
+            query: { location: input?.["location"] },
+            successStatus: 200,
+            declaredStatuses: [401, 400],
+            empty: false,
+          },
+          requestOptions,
+        ),
+      list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
+        request<{ readonly data: QuestionListOutput }>(
+          {
+            method: "GET",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
+            successStatus: 200,
+            declaredStatuses: [404, 400, 401],
+            empty: false,
+          },
+          requestOptions,
+        ).then((value) => value.data),
+      reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) =>
+        request<QuestionReplyOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
+            body: { answers: input["answers"] },
+            successStatus: 204,
+            declaredStatuses: [404, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
+      reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) =>
+        request<QuestionRejectOutput>(
+          {
+            method: "POST",
+            path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
+            successStatus: 204,
+            declaredStatuses: [404, 400, 401],
+            empty: true,
+          },
+          requestOptions,
+        ),
+    },
     reference: {
       list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
         request<ReferenceListOutput>(

File diff suppressed because it is too large
+ 24 - 1028
packages/client/src/generated/types.ts


+ 1 - 48
packages/client/test/promise.test.ts

@@ -18,7 +18,6 @@ test("exposes every standard HTTP API group", () => {
     "server.mcp",
     "credential",
     "project",
-    "form",
     "permission",
     "file",
     "command",
@@ -26,6 +25,7 @@ test("exposes every standard HTTP API group", () => {
     "event",
     "pty",
     "shell",
+    "question",
     "reference",
     "projectCopy",
   ])
@@ -43,53 +43,6 @@ test("exposes every standard HTTP API group", () => {
   expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
   expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
   expect(Object.keys(client.project)).toEqual(["current", "directories"])
-  expect(Object.keys(client.form)).toEqual(["listRequests", "list", "create", "get", "state", "reply", "cancel"])
-})
-
-test("form methods use the public HTTP contract", async () => {
-  const requests: Array<{ method: string; url: string }> = []
-  const form = {
-    id: "frm_test",
-    sessionID: "ses_test",
-    mode: "form",
-    fields: [{ key: "choice", type: "string", options: [{ value: "yes", label: "Yes" }] }],
-  }
-  const client = OpenCode.make({
-    baseUrl: "http://localhost:3000",
-    fetch: async (input, init) => {
-      const request = input instanceof Request ? input : new Request(input, init)
-      requests.push({ method: request.method, url: request.url })
-      if (request.url.endsWith("/reply") || request.url.endsWith("/cancel")) return new Response(null, { status: 204 })
-      if (request.url.endsWith("/state")) return Response.json({ data: { status: "pending" } })
-      if (request.url.includes("/form/frm_test")) return Response.json({ data: form })
-      if (request.method === "POST") return Response.json({ data: form })
-      return Response.json({
-        location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
-        data: [form],
-      })
-    },
-  })
-
-  await client.form.listRequests({ location: { directory: "/tmp/project" } })
-  await client.form.list({ sessionID: "ses_test", location: { directory: "/tmp/project" } })
-  await client.form.create({ sessionID: "ses_test", mode: "form", fields: form.fields })
-  await client.form.get({ sessionID: "ses_test", formID: "frm_test" })
-  await client.form.state({ sessionID: "ses_test", formID: "frm_test" })
-  await client.form.reply({ sessionID: "ses_test", formID: "frm_test", answer: { choice: "yes" } })
-  await client.form.cancel({ sessionID: "ses_test", formID: "frm_test" })
-
-  expect(requests).toEqual([
-    { method: "GET", url: "http://localhost:3000/api/form/request?location%5Bdirectory%5D=%2Ftmp%2Fproject" },
-    {
-      method: "GET",
-      url: "http://localhost:3000/api/session/ses_test/form?location%5Bdirectory%5D=%2Ftmp%2Fproject",
-    },
-    { method: "POST", url: "http://localhost:3000/api/session/ses_test/form" },
-    { method: "GET", url: "http://localhost:3000/api/session/ses_test/form/frm_test" },
-    { method: "GET", url: "http://localhost:3000/api/session/ses_test/form/frm_test/state" },
-    { method: "POST", url: "http://localhost:3000/api/session/ses_test/form/frm_test/reply" },
-    { method: "POST", url: "http://localhost:3000/api/session/ses_test/form/frm_test/cancel" },
-  ])
 })
 
 test("file.read returns binary content from the public HTTP contract", async () => {

+ 0 - 308
packages/core/src/form.ts

@@ -1,308 +0,0 @@
-export * as Form from "./form"
-
-import { makeLocationNode } from "./effect/app-node"
-import { Cache, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema } from "effect"
-import { Form } from "@opencode-ai/schema/form"
-import { EventV2 } from "./event"
-
-const RETENTION = Duration.minutes(10)
-
-export const ID = Form.ID
-export type ID = typeof ID.Type
-
-export const Info = Form.Info
-export type Info = typeof Info.Type
-
-export const Field = Form.Field
-export type Field = Form.Field
-
-export const State = Form.State
-export type State = typeof State.Type
-
-export const Answer = Form.Answer
-export type Answer = typeof Answer.Type
-
-export const Reply = Form.Reply
-export type Reply = typeof Reply.Type
-
-export const Event = Form.Event
-
-export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Form.NotFoundError", {
-  id: ID,
-}) {
-  override get message() {
-    return `Form not found: ${this.id}`
-  }
-}
-
-export class AlreadySettledError extends Schema.TaggedErrorClass<AlreadySettledError>()("Form.AlreadySettledError", {
-  id: ID,
-}) {
-  override get message() {
-    return `Form already settled: ${this.id}`
-  }
-}
-
-export class AlreadyExistsError extends Schema.TaggedErrorClass<AlreadyExistsError>()("Form.AlreadyExistsError", {
-  id: ID,
-}) {
-  override get message() {
-    return `Form already exists: ${this.id}`
-  }
-}
-
-export class InvalidAnswerError extends Schema.TaggedErrorClass<InvalidAnswerError>()("Form.InvalidAnswerError", {
-  id: ID,
-  message: Schema.String,
-}) {}
-
-export type CreateInput =
-  | (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
-  | (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
-
-export interface ReplyInput {
-  readonly id: ID
-  readonly answer: Answer
-}
-
-export interface ListInput {
-  readonly sessionID?: string
-}
-
-export interface Interface {
-  readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError>
-  readonly ask: (input: CreateInput) => Effect.Effect<State, AlreadyExistsError>
-  readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
-  readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
-  readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
-  readonly reply: (input: ReplyInput) => Effect.Effect<void, AlreadySettledError | InvalidAnswerError | NotFoundError>
-  readonly cancel: (id: ID) => Effect.Effect<void, AlreadySettledError | NotFoundError>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@opencode/Form") {}
-
-interface Entry {
-  readonly form: Info
-  readonly state: State
-  readonly deferred: Deferred.Deferred<State>
-}
-
-export const layer = Layer.effect(
-  Service,
-  Effect.gen(function* () {
-    const events = yield* EventV2.Service
-    const forms = yield* Cache.makeWith<ID, Entry>(() => Effect.die("Form cache must be used via set/getSuccess, never get"), {
-      capacity: Number.MAX_SAFE_INTEGER,
-      timeToLive: (exit) => (Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION),
-    })
-
-    const find = Effect.fn("Form.find")(function* (id: ID) {
-      return yield* Cache.getSuccess(forms, id).pipe(
-        Effect.flatMap((entry) =>
-          Option.match(entry, {
-            onNone: () => Effect.fail(new NotFoundError({ id })),
-            onSome: Effect.succeed,
-          }),
-        ),
-      )
-    })
-
-    const create = Effect.fn("Form.create")((input: CreateInput) =>
-      Effect.uninterruptible(
-        Effect.gen(function* () {
-          const id = input.id ?? ID.create()
-          const existing = yield* Cache.getSuccess(forms, id)
-          if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
-          const base = {
-            id,
-            sessionID: input.sessionID,
-            title: input.title,
-            ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
-          }
-          const form: Info =
-            input.mode === "form"
-              ? { ...base, mode: "form", fields: input.fields }
-              : {
-                  ...base,
-                  mode: "url",
-                  url: input.url,
-                }
-          const entry: Entry = {
-            form,
-            state: { status: "pending" },
-            deferred: yield* Deferred.make<State>(),
-          }
-          yield* Cache.set(forms, id, entry)
-          yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
-          return form
-        }),
-      ),
-    )
-
-    const ask = Effect.fn("Form.ask")((input: CreateInput) =>
-      Effect.uninterruptibleMask((restore) =>
-        Effect.gen(function* () {
-          const form = yield* create(input)
-          const entry = yield* find(form.id).pipe(Effect.orDie)
-          return yield* restore(Deferred.await(entry.deferred)).pipe(Effect.onInterrupt(() => Effect.ignore(cancel(form.id))))
-        }),
-      ),
-    )
-
-    const get = Effect.fn("Form.get")(function* (id: ID) {
-      return (yield* find(id)).form
-    })
-
-    const list = Effect.fn("Form.list")(function* (input?: ListInput) {
-      const entries = yield* Cache.values(forms)
-      return Array.from(entries)
-        .filter((entry) => entry.state.status === "pending")
-        .filter((entry) => input?.sessionID === undefined || entry.form.sessionID === input.sessionID)
-        .map((entry) => entry.form)
-    })
-
-    const state = Effect.fn("Form.state")(function* (id: ID) {
-      return (yield* find(id)).state
-    })
-
-    const reply = Effect.fn("Form.reply")((input: ReplyInput) =>
-      Effect.uninterruptible(
-        Effect.gen(function* () {
-          const entry = yield* find(input.id)
-          if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
-          const invalid = validateAnswer(entry.form, input.answer)
-          if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
-          const next: State = { status: "answered", answer: input.answer }
-          yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
-          yield* Cache.set(forms, input.id, { ...entry, state: next })
-          yield* Deferred.succeed(entry.deferred, next)
-        }),
-      ),
-    )
-
-    const cancel = Effect.fn("Form.cancel")((id: ID) =>
-      Effect.uninterruptible(
-        Effect.gen(function* () {
-          const entry = yield* find(id)
-          if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
-          const next: State = { status: "cancelled" }
-          yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID })
-          yield* Cache.set(forms, id, { ...entry, state: next })
-          yield* Deferred.succeed(entry.deferred, next)
-        }),
-      ),
-    )
-
-    yield* Effect.addFinalizer(() =>
-      Cache.values(forms).pipe(
-        Effect.flatMap((entries) =>
-          Effect.forEach(
-            Array.from(entries).filter((entry) => entry.state.status === "pending"),
-            (entry) => cancel(entry.form.id).pipe(Effect.ignore),
-            { discard: true },
-          ),
-        ),
-      ),
-    )
-
-    return Service.of({ create, ask, get, list, state, reply, cancel })
-  }),
-)
-
-export const locationLayer = layer
-
-export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
-
-function validateAnswer(form: Info, answer: Answer) {
-  if (form.mode === "url") {
-    if (Object.keys(answer).length === 0) return
-    return "URL forms must be answered with an empty answer"
-  }
-  const fields = new Map(form.fields.map((field) => [field.key, field]))
-  for (const key of Object.keys(answer)) {
-    if (!fields.has(key)) return `Unknown form field: ${key}`
-  }
-  for (const field of form.fields) {
-    const value = answer[field.key]
-    if (value === undefined) {
-      if (field.required && isActive(field, answer)) return `Missing required form field: ${field.key}`
-      continue
-    }
-    const invalid = validateField(field, value)
-    if (invalid) return invalid
-  }
-}
-
-function isActive(field: Form.Field, answer: Answer) {
-  if (!field.when) return true
-  const value = answer[field.when.key]
-  if (field.when.op === "eq") return value === field.when.value
-  return value !== field.when.value
-}
-
-function validateField(field: Form.Field, value: Form.Value): string | undefined {
-  if (field.type === "string") {
-    if (typeof value !== "string") return `Expected string for form field: ${field.key}`
-    if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
-    if (field.minLength !== undefined && value.length < field.minLength) return `Form field is too short: ${field.key}`
-    if (field.maxLength !== undefined && value.length > field.maxLength) return `Form field is too long: ${field.key}`
-    if (field.pattern !== undefined) {
-      try {
-        if (!new RegExp(field.pattern).test(value)) return `Form field does not match pattern: ${field.key}`
-      } catch {
-        return `Form field has invalid pattern: ${field.key}`
-      }
-    }
-    if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `Expected email for form field: ${field.key}`
-    if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}`
-    if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}`
-    if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}`
-    if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
-      return `Invalid option for form field: ${field.key}`
-    }
-    return
-  }
-  if (field.type === "number" || field.type === "integer") {
-    if (typeof value !== "number" || !Number.isFinite(value)) return `Expected number for form field: ${field.key}`
-    if (field.type === "integer" && !Number.isInteger(value)) return `Expected integer for form field: ${field.key}`
-    if (field.minimum !== undefined && value < field.minimum) return `Form field is too small: ${field.key}`
-    if (field.maximum !== undefined && value > field.maximum) return `Form field is too large: ${field.key}`
-    return
-  }
-  if (field.type === "boolean") {
-    if (typeof value !== "boolean") return `Expected boolean for form field: ${field.key}`
-    return
-  }
-  if (field.type === "multiselect") {
-    if (!isStringArray(value)) return `Expected string array for form field: ${field.key}`
-    if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
-    if (field.minItems !== undefined && value.length < field.minItems) return `Too few selections for form field: ${field.key}`
-    if (field.maxItems !== undefined && value.length > field.maxItems) return `Too many selections for form field: ${field.key}`
-    if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) {
-      return `Invalid option for form field: ${field.key}`
-    }
-  }
-}
-
-function isStringArray(value: Form.Value): value is ReadonlyArray<string> {
-  return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
-}
-
-function isUri(value: string) {
-  try {
-    new URL(value)
-    return true
-  } catch {
-    return false
-  }
-}
-
-function isDate(value: string) {
-  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
-  const date = new Date(`${value}T00:00:00.000Z`)
-  return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
-}
-
-function isDateTime(value: string) {
-  return !Number.isNaN(new Date(value).getTime())
-}

+ 2 - 2
packages/core/src/location-services.ts

@@ -9,7 +9,6 @@ import { Node } from "./effect/app-node"
 import { FileMutation } from "./file-mutation"
 import { FileSystem } from "./filesystem"
 import { FileSystemSearch } from "./filesystem/search"
-import { Form } from "./form"
 import { Generate } from "./generate"
 import { Watcher } from "./filesystem/watcher"
 import { Image } from "./image"
@@ -25,6 +24,7 @@ import { Policy } from "./policy"
 import { Project } from "./project"
 import { ProjectCopy } from "./project/copy"
 import { Pty } from "./pty"
+import { QuestionV2 } from "./question"
 import { Shell } from "./shell"
 import { Reference } from "./reference"
 import { ReferenceGuidance } from "./reference/guidance"
@@ -83,7 +83,7 @@ export const locationServices = LayerNode.group([
   ReferenceGuidance.node,
   SessionTodo.node,
   SessionContextEntry.node,
-  Form.node,
+  QuestionV2.node,
   Generate.node,
   ReadToolFileSystem.node,
   BuiltInTools.node,

+ 0 - 32
packages/core/src/mcp/client.ts

@@ -9,13 +9,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
 import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
 import {
   CallToolResultSchema,
-  ElicitationCompleteNotificationSchema,
-  ElicitRequestSchema,
   GetPromptResultSchema,
-  type ElicitRequestFormParams,
-  type ElicitRequestParams,
-  type ElicitRequestURLParams,
-  type ElicitResult,
   ListPromptsResultSchema,
   ListRootsRequestSchema,
   ListToolsResultSchema,
@@ -88,22 +82,6 @@ export interface CallToolResult {
   readonly content: ReadonlyArray<CallToolContent>
 }
 
-export type ElicitationFormParams = ElicitRequestFormParams
-export type ElicitationParams = ElicitRequestParams
-export type ElicitationResult = ElicitResult
-
-export interface ElicitationHandler {
-  readonly create: (input: {
-    readonly server: string
-    readonly params: ElicitationParams
-    readonly signal: AbortSignal
-  }) => Effect.Effect<ElicitationResult, Error>
-  readonly complete: (input: {
-    readonly server: string
-    readonly elicitationID: ElicitRequestURLParams["elicitationId"]
-  }) => Effect.Effect<void>
-}
-
 export interface LogMessage {
   readonly level: LoggingMessageNotification["params"]["level"]
   readonly logger?: LoggingMessageNotification["params"]["logger"]
@@ -145,7 +123,6 @@ export const connect = Effect.fnUntraced(function* (
   // Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
   // stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
   authProvider?: OAuthClientProvider,
-  elicitation?: ElicitationHandler,
 ) {
   const transport: Transport = yield* Effect.gen(function* () {
     if (config.type === "local") {
@@ -172,7 +149,6 @@ export const connect = Effect.fnUntraced(function* (
     { name: "opencode", version: InstallationVersion },
     {
       capabilities: {
-        ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
         // https://github.com/anomalyco/opencode/issues/2308
         roots: {},
       },
@@ -181,14 +157,6 @@ export const connect = Effect.fnUntraced(function* (
   client.setRequestHandler(ListRootsRequestSchema, () =>
     Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
   )
-  if (elicitation) {
-    client.setRequestHandler(ElicitRequestSchema, (request, extra) =>
-      Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })),
-    )
-    client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) =>
-      Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })),
-    )
-  }
 
   const exit = yield* Effect.tryPromise({
     try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),

+ 2 - 147
packages/core/src/mcp/index.ts

@@ -10,11 +10,9 @@ import { Config } from "../config"
 import { ConfigMCP } from "../config/mcp"
 import { Credential } from "../credential"
 import { EventV2 } from "../event"
-import { Form } from "../form"
 import { Integration } from "../integration"
 import { IntegrationConnection } from "../integration/connection"
 import { Location } from "../location"
-import { waitForAbort } from "../process"
 import { MCPClient } from "./client"
 import { MCPOAuth } from "./oauth"
 
@@ -147,10 +145,6 @@ type ServerEntry = {
   integrationID?: Integration.ID
 }
 
-// Temporary MCP elicitation escape hatch. Public Form routes remain session-shaped, but MCP
-// elicitations are still Location-scoped until the protocol path can attribute them to a Session.
-const GLOBAL_ELICITATION_SESSION_ID = "global"
-
 export interface Interface {
   readonly servers: () => Effect.Effect<ServerInfo[]>
   readonly tools: () => Effect.Effect<Tool[]>
@@ -181,7 +175,6 @@ export const layer = Layer.effect(
     const config = yield* Config.Service
     const location = yield* Location.Service
     const events = yield* EventV2.Service
-    const forms = yield* Form.Service
     const integration = yield* Integration.Service
     const credentials = yield* Credential.Service
     const root = yield* Scope.make()
@@ -196,7 +189,6 @@ export const layer = Layer.effect(
     )
     // Later config files win for duplicate server names; per-server timeout overrides globals.
     const runtime = new Map<ServerName, ServerEntry>()
-    const urlElicitations = new Map<string, Form.ID>()
     for (const entry of documents) {
       for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
         runtime.set(ServerName.make(name), {
@@ -316,74 +308,6 @@ export const layer = Layer.effect(
       })
     })
 
-    const elicitation = {
-      create: (input: {
-        readonly server: string
-        readonly params: MCPClient.ElicitationParams
-        readonly signal: AbortSignal
-      }) =>
-        Effect.gen(function* () {
-          const toResult = (state: Form.State): MCPClient.ElicitationResult => {
-            if (state.status !== "answered") return { action: "cancel" }
-            if (input.params.mode === "url") return { action: "accept" }
-            return {
-              action: "accept",
-              content: Object.fromEntries(
-                Object.entries(state.answer).map(
-                  ([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] => {
-                    if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
-                      return [key, value]
-                    return [key, Array.from(value)]
-                  },
-                ),
-              ),
-            }
-          }
-          if (input.params.mode === "url") {
-            const formID = Form.ID.create()
-            const key = input.server + "\u0000" + input.params.elicitationId
-            urlElicitations.set(key, formID)
-            return yield* forms
-              .ask({
-                id: formID,
-                sessionID: GLOBAL_ELICITATION_SESSION_ID,
-                title: `${input.server} is requesting input`,
-                metadata: {
-                  kind: "mcp-elicitation",
-                  server: input.server,
-                  elicitationID: input.params.elicitationId,
-                  message: input.params.message,
-                },
-                mode: "url",
-                url: input.params.url,
-              })
-              .pipe(
-                Effect.raceFirst(waitForAbort(input.signal)),
-                Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
-                Effect.map(toResult),
-              )
-          }
-          const params = input.params
-          return yield* forms
-            .ask({
-              sessionID: GLOBAL_ELICITATION_SESSION_ID,
-              title: `${input.server} is requesting input`,
-              metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
-              mode: "form",
-              fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
-                toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
-              ),
-            })
-            .pipe(Effect.raceFirst(waitForAbort(input.signal)), Effect.map(toResult))
-        }),
-      complete: (input: { readonly server: string; readonly elicitationID: string }) =>
-        Effect.gen(function* () {
-          const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
-          if (!formID) return
-          yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
-        }),
-    } satisfies MCPClient.ElicitationHandler
-
     const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
       new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
 
@@ -472,7 +396,7 @@ export const layer = Layer.effect(
         const authProvider = yield* connectProvider(entry)
         // List tools as part of connect so a failure here marks the server failed rather than
         // leaving it connected with a silently empty tool list and no path to recover.
-        const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe(
+        const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
           Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
           Scope.provide(scope),
           Effect.exit,
@@ -637,74 +561,5 @@ export const layer = Layer.effect(
 export const node = makeLocationNode({
   service: Service,
   layer,
-  deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
+  deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
 })
-
-function toElicitationField(key: string, property: ElicitationProperty, required: boolean): Form.Field {
-  const title = elicitationFieldTitle(key, property)
-  const description = property.description === title ? undefined : property.description
-  const base = {
-    key,
-    ...(title === undefined ? {} : { title }),
-    ...(description === undefined ? {} : { description }),
-    ...(required ? { required: true } : {}),
-  }
-  switch (property.type) {
-    case "boolean":
-      return { ...base, type: "boolean", ...(property.default === undefined ? {} : { default: property.default }) }
-    case "number":
-    case "integer":
-      return {
-        ...base,
-        type: property.type,
-        ...(property.minimum === undefined ? {} : { minimum: property.minimum }),
-        ...(property.maximum === undefined ? {} : { maximum: property.maximum }),
-        ...(property.default === undefined ? {} : { default: property.default }),
-      }
-    case "array":
-      return {
-        ...base,
-        type: "multiselect",
-        options:
-          "anyOf" in property.items
-            ? property.items.anyOf.map((option) => ({ value: option.const, label: option.title }))
-            : property.items.enum.map((value) => ({ value, label: value })),
-        custom: false,
-        ...(property.minItems === undefined ? {} : { minItems: property.minItems }),
-        ...(property.maxItems === undefined ? {} : { maxItems: property.maxItems }),
-        ...(property.default === undefined ? {} : { default: property.default }),
-      }
-    case "string": {
-      const options =
-        "oneOf" in property
-          ? property.oneOf.map((option) => ({ value: option.const, label: option.title }))
-          : "enum" in property
-            ? property.enum.map((value, index) => ({
-                value,
-                label: ("enumNames" in property ? property.enumNames?.[index] : undefined) ?? value,
-              }))
-            : undefined
-      return {
-        ...base,
-        type: "string",
-        ...(!("format" in property) || property.format === undefined ? {} : { format: property.format }),
-        ...(!("minLength" in property) || property.minLength === undefined ? {} : { minLength: property.minLength }),
-        ...(!("maxLength" in property) || property.maxLength === undefined ? {} : { maxLength: property.maxLength }),
-        ...(property.default === undefined ? {} : { default: property.default }),
-        ...(options === undefined ? {} : { options, custom: false }),
-      }
-    }
-  }
-}
-
-function elicitationFieldTitle(key: string, property: ElicitationProperty) {
-  if (property.title && !isSchemaTypeTitle(property.title)) return property.title
-  if (property.description) return property.description
-  return key
-}
-
-function isSchemaTypeTitle(title: string) {
-  return /^(boolean|string|number|integer|array|object)(\s+with\b.*|\s+in\b.*)?$/i.test(title.trim())
-}
-
-type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]

+ 151 - 0
packages/core/src/question.ts

@@ -0,0 +1,151 @@
+export * as QuestionV2 from "./question"
+
+import { makeLocationNode } from "./effect/app-node"
+import { Context, Deferred, Effect, Layer, Schema } from "effect"
+import { Question } from "@opencode-ai/schema/question"
+import { EventV2 } from "./event"
+import { SessionSchema } from "./session/schema"
+
+export const ID = Question.ID
+export type ID = typeof ID.Type
+
+export const Option = Question.Option
+export type Option = typeof Option.Type
+
+export const Info = Question.Info
+export type Info = typeof Info.Type
+
+export const Prompt = Question.Prompt
+export type Prompt = typeof Prompt.Type
+
+export const Tool = Question.Tool
+export type Tool = typeof Tool.Type
+
+export const Request = Question.Request
+export type Request = typeof Request.Type
+
+export const Answer = Question.Answer
+export type Answer = typeof Answer.Type
+
+export const Reply = Question.Reply
+export type Reply = typeof Reply.Type
+
+export const Event = Question.Event
+
+export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionV2.RejectedError", {}) {
+  override get message() {
+    return "The user dismissed this question"
+  }
+}
+
+export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("QuestionV2.NotFoundError", {
+  requestID: ID,
+}) {}
+
+export interface AskInput {
+  readonly sessionID: SessionSchema.ID
+  readonly questions: ReadonlyArray<Info>
+  readonly tool?: Tool
+}
+
+export interface ReplyInput {
+  readonly requestID: ID
+  readonly answers: ReadonlyArray<Answer>
+}
+
+export interface Interface {
+  readonly ask: (input: AskInput) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
+  readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
+  readonly reject: (requestID: ID) => Effect.Effect<void, NotFoundError>
+  readonly list: () => Effect.Effect<ReadonlyArray<Request>>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Question") {}
+
+interface Pending {
+  readonly request: Request
+  readonly deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
+}
+
+/**
+ * Location-owned pending prompts. The Location layer map must materialize this
+ * layer once per embedded Location so replies cannot settle another Location's
+ * deferred request.
+ */
+const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const events = yield* EventV2.Service
+    const pending = new Map<ID, Pending>()
+
+    yield* Effect.addFinalizer(() =>
+      Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
+        discard: true,
+      }).pipe(
+        Effect.ensuring(
+          Effect.sync(() => {
+            pending.clear()
+          }),
+        ),
+      ),
+    )
+
+    const ask = Effect.fn("QuestionV2.ask")((input: AskInput) =>
+      Effect.uninterruptibleMask((restore) =>
+        Effect.gen(function* () {
+          const id = ID.ascending()
+          const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
+          const request: Request = { id, ...input }
+          pending.set(id, { request, deferred })
+          return yield* events.publish(Event.Asked, request).pipe(
+            Effect.andThen(restore(Deferred.await(deferred))),
+            Effect.ensuring(
+              Effect.sync(() => {
+                pending.delete(id)
+              }),
+            ),
+          )
+        }),
+      ),
+    )
+
+    const reply = Effect.fn("QuestionV2.reply")((input: ReplyInput) =>
+      Effect.uninterruptible(
+        Effect.gen(function* () {
+          const existing = pending.get(input.requestID)
+          if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
+          yield* events.publish(Event.Replied, {
+            sessionID: existing.request.sessionID,
+            requestID: existing.request.id,
+            answers: input.answers.map((answer) => [...answer]),
+          })
+          yield* Deferred.succeed(existing.deferred, input.answers)
+          pending.delete(input.requestID)
+        }),
+      ),
+    )
+
+    const reject = Effect.fn("QuestionV2.reject")((requestID: ID) =>
+      Effect.uninterruptible(
+        Effect.gen(function* () {
+          const existing = pending.get(requestID)
+          if (!existing) return yield* new NotFoundError({ requestID })
+          yield* events.publish(Event.Rejected, {
+            sessionID: existing.request.sessionID,
+            requestID: existing.request.id,
+          })
+          yield* Deferred.fail(existing.deferred, new RejectedError())
+          pending.delete(requestID)
+        }),
+      ),
+    )
+
+    const list = Effect.fn("QuestionV2.list")(function* () {
+      return Array.from(pending.values(), (item) => item.request)
+    })
+
+    return Service.of({ ask, reply, reject, list })
+  }),
+)
+
+export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })

+ 2 - 4
packages/core/src/session/runner/llm.ts

@@ -14,9 +14,7 @@ import { Config } from "../../config"
 import { Database } from "../../database/database"
 import { EventV2 } from "../../event"
 import { Location } from "../../location"
-import { ModelV2 } from "../../model"
-import { ProviderV2 } from "../../provider"
-import { QuestionTool } from "../../tool/question"
+import { QuestionV2 } from "../../question"
 import { SystemContext } from "../../system-context/index"
 import { SystemContextBuiltIns } from "../../system-context/builtins"
 import { InstructionContext } from "../../instruction-context"
@@ -152,7 +150,7 @@ const layer = Layer.effect(
 
     // Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
     const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
-      cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionTool.RejectedError)
+      cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
 
     const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
       Effect.all(

+ 13 - 70
packages/core/src/tool/question.ts

@@ -2,10 +2,9 @@ export * as QuestionTool from "./question"
 
 import { ToolFailure } from "@opencode-ai/llm"
 import { Effect, Layer, Schema } from "effect"
-import { optional } from "@opencode-ai/schema/schema"
-import { Form } from "../form"
 import { makeLocationNode } from "../effect/app-node"
 import { PermissionV2 } from "../permission"
+import { QuestionV2 } from "../question"
 import { ToolRegistry } from "./registry"
 import { Tool } from "./tool"
 import { Tools } from "./tools"
@@ -23,40 +22,19 @@ Usage notes:
 - Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
 - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
 
-export const Option = Schema.Struct({
-  label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
-  description: Schema.String.annotate({ description: "Explanation of choice" }),
-}).annotate({ identifier: "QuestionTool.Option" })
-export interface Option extends Schema.Schema.Type<typeof Option> {}
-
-export const Prompt = Schema.Struct({
-  question: Schema.String.annotate({ description: "Complete question" }),
-  header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
-  options: Schema.Array(Option).annotate({ description: "Available choices" }),
-  multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }),
-  custom: Schema.Boolean.pipe(optional).annotate({ description: "Allow typing a custom answer (default: true)" }),
-}).annotate({ identifier: "QuestionTool.Prompt" })
-export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
-
-export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionTool.Answer" })
-export type Answer = typeof Answer.Type
-
 export const Input = Schema.Struct({
-  questions: Schema.Array(Prompt).annotate({ description: "Questions to ask" }),
+  questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
 })
 
 export const Output = Schema.Struct({
-  answers: Schema.Array(Answer),
+  answers: Schema.Array(QuestionV2.Answer),
 })
 export type Output = typeof Output.Type
 
-export class RejectedError extends Error {
-  constructor() {
-    super("The user dismissed this question")
-  }
-}
-
-export const toModelOutput = (questions: ReadonlyArray<Prompt>, answers: ReadonlyArray<Answer>) => {
+export const toModelOutput = (
+  questions: ReadonlyArray<QuestionV2.Prompt>,
+  answers: ReadonlyArray<QuestionV2.Answer>,
+) => {
   const formatted = questions
     .map(
       (question, index) =>
@@ -69,7 +47,7 @@ export const toModelOutput = (questions: ReadonlyArray<Prompt>, answers: Readonl
 const layer = Layer.effectDiscard(
   Effect.gen(function* () {
     const tools = yield* Tools.Service
-    const form = yield* Form.Service
+    const question = yield* QuestionV2.Service
     const permission = yield* PermissionV2.Service
 
     yield* tools
@@ -93,22 +71,15 @@ const layer = Layer.effectDiscard(
               .pipe(
                 Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
                 Effect.andThen(
-                  form
+                  question
                     .ask({
                       sessionID: context.sessionID,
-                      ...(input.questions.length === 1 ? {} : { title: "Questions" }),
-                      metadata: {
-                        kind: "question",
-                        tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
-                      },
-                      mode: "form",
-                      fields: input.questions.map(questionToField),
+                      questions: input.questions,
+                      tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
                     })
                     .pipe(Effect.orDie),
                 ),
-                Effect.flatMap((state) =>
-                  state.status === "answered" ? Effect.succeed({ answers: formToAnswers(input.questions, state.answer) }) : Effect.die(new RejectedError()),
-                ),
+                Effect.map((answers) => ({ answers })),
               ),
         }),
       })
@@ -119,33 +90,5 @@ const layer = Layer.effectDiscard(
 export const node = makeLocationNode({
   name: "tool/question",
   layer,
-  deps: [ToolRegistry.node, PermissionV2.node, Form.node],
+  deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
 })
-
-function questionToField(question: Prompt, index: number): Form.Field {
-  const base = {
-    key: key(index),
-    title: question.question,
-    description: question.header,
-  }
-  const options = question.options.map((option) => ({
-    value: option.label,
-    label: option.label,
-    description: option.description,
-  }))
-  if (question.multiple) return { ...base, type: "multiselect", options, custom: question.custom ?? true }
-  return { ...base, type: "string", options, custom: question.custom ?? true }
-}
-
-function formToAnswers(questions: ReadonlyArray<Prompt>, answer: Form.Answer): ReadonlyArray<Answer> {
-  return questions.map((_, index) => {
-    const value = answer[key(index)]
-    if (Array.isArray(value)) return value
-    if (typeof value === "string" && value.length > 0) return [value]
-    return []
-  })
-}
-
-function key(index: number) {
-  return `question_${index}`
-}

+ 0 - 58
packages/core/test/form.test.ts

@@ -1,58 +0,0 @@
-import { describe, expect } from "bun:test"
-import { Effect, Exit } from "effect"
-import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
-import { LayerNode } from "@opencode-ai/core/effect/layer-node"
-import { EventV2 } from "@opencode-ai/core/event"
-import { Form } from "@opencode-ai/core/form"
-import { testEffect } from "./lib/effect"
-
-const forms = AppNodeBuilder.build(LayerNode.group([EventV2.node, Form.node]))
-const it = testEffect(forms)
-
-const formID = Form.ID.create("frm_test")
-const input = {
-  id: formID,
-  sessionID: "ses_test",
-  mode: "form",
-  fields: [{ key: "name", type: "string", required: true }],
-} satisfies Form.CreateInput
-
-describe("Form", () => {
-  it.effect("cleans up created forms when event publication fails", () =>
-    Effect.gen(function* () {
-      const service = yield* Form.Service
-      const events = yield* EventV2.Service
-      const unsubscribe = yield* events.listen((event) =>
-        event.type === Form.Event.Created.type ? Effect.die("create listener failed") : Effect.void,
-      )
-      yield* Effect.addFinalizer(() => unsubscribe)
-
-      expect(Exit.isFailure(yield* Effect.exit(service.create(input)))).toBe(true)
-      expect(yield* service.get(formID).pipe(Effect.flip)).toEqual(new Form.NotFoundError({ id: formID }))
-
-      yield* unsubscribe
-      expect(yield* service.create(input)).toMatchObject({ id: formID })
-    }),
-  )
-
-  it.effect("keeps forms pending when reply event publication fails", () =>
-    Effect.gen(function* () {
-      const service = yield* Form.Service
-      const events = yield* EventV2.Service
-      yield* service.create(input)
-      const unsubscribe = yield* events.listen((event) =>
-        event.type === Form.Event.Replied.type ? Effect.die("reply listener failed") : Effect.void,
-      )
-      yield* Effect.addFinalizer(() => unsubscribe)
-
-      expect(
-        Exit.isFailure(yield* Effect.exit(service.reply({ id: formID, answer: { name: "Ava" } }))),
-      ).toBe(true)
-      expect(yield* service.state(formID)).toEqual({ status: "pending" })
-
-      yield* unsubscribe
-      yield* service.reply({ id: formID, answer: { name: "Ava" } })
-      expect(yield* service.state(formID)).toEqual({ status: "answered", answer: { name: "Ava" } })
-    }),
-  )
-})

+ 114 - 0
packages/core/test/question.test.ts

@@ -0,0 +1,114 @@
+import { describe, expect } from "bun:test"
+import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
+import { LayerNode } from "@opencode-ai/core/effect/layer-node"
+import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { EventV2 } from "@opencode-ai/core/event"
+import { QuestionV2 } from "@opencode-ai/core/question"
+import { SessionV2 } from "@opencode-ai/core/session"
+import { testEffect } from "./lib/effect"
+
+const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node]))
+const it = testEffect(questions)
+
+const sessionID = SessionV2.ID.make("ses_question_test")
+const question: QuestionV2.Info = {
+  question: "Which option?",
+  header: "Option",
+  options: [{ label: "One", description: "First option" }],
+}
+
+const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
+  service: QuestionV2.Interface,
+  input: QuestionV2.AskInput,
+) {
+  const events = yield* EventV2.Service
+  const asked = yield* Deferred.make<QuestionV2.Request>()
+  const unsubscribe = yield* events.listen((event) =>
+    event.type === QuestionV2.Event.Asked.type
+      ? Deferred.succeed(asked, event.data as QuestionV2.Request).pipe(Effect.asVoid)
+      : Effect.void,
+  )
+  yield* Effect.addFinalizer(() => unsubscribe)
+  const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
+  return { fiber, request: yield* Deferred.await(asked) }
+})
+
+describe("QuestionV2", () => {
+  it.effect("publishes lifecycle events and settles a pending reply", () =>
+    Effect.gen(function* () {
+      const service = yield* QuestionV2.Service
+      const events = yield* EventV2.Service
+      const published: EventV2.Payload[] = []
+      const unsubscribe = yield* events.listen((event) =>
+        Effect.sync(() => {
+          if (event.type.startsWith("question.v2.")) published.push(event)
+        }),
+      )
+      yield* Effect.addFinalizer(() => unsubscribe)
+      const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
+
+      expect(request.id).toMatch(/^que_/)
+      expect(yield* service.list()).toEqual([request])
+      yield* service.reply({ requestID: request.id, answers: [["One"]] })
+
+      expect(yield* Fiber.join(fiber)).toEqual([["One"]])
+      expect(yield* service.list()).toEqual([])
+      expect(published.map((event) => [event.type, event.data])).toEqual([
+        [QuestionV2.Event.Asked.type, request],
+        [QuestionV2.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
+      ])
+    }),
+  )
+
+  it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
+    Effect.gen(function* () {
+      const service = yield* QuestionV2.Service
+      const events = yield* EventV2.Service
+      const published: EventV2.Payload[] = []
+      const unsubscribe = yield* events.listen((event) =>
+        Effect.sync(() => {
+          if (event.type === QuestionV2.Event.Rejected.type) published.push(event)
+        }),
+      )
+      yield* Effect.addFinalizer(() => unsubscribe)
+      const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
+
+      yield* service.reject(request.id)
+      const exit = yield* Fiber.await(fiber)
+      expect(Exit.isFailure(exit)).toBe(true)
+      if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
+      expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
+
+      const unknown = QuestionV2.ID.ascending("que_unknown")
+      expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
+        new QuestionV2.NotFoundError({ requestID: unknown }),
+      )
+      expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
+        new QuestionV2.NotFoundError({ requestID: unknown }),
+      )
+    }),
+  )
+
+  it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
+    Effect.gen(function* () {
+      const firstScope = yield* Scope.make()
+      const secondScope = yield* Scope.make()
+      const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service)
+      const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service)
+      const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
+      yield* Effect.yieldNow
+      const request = (yield* first.list())[0]!
+
+      expect(yield* second.list()).toEqual([])
+      expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
+        new QuestionV2.NotFoundError({ requestID: request.id }),
+      )
+
+      yield* Scope.close(firstScope, Exit.void)
+      const exit = yield* Fiber.await(fiber)
+      expect(Exit.isFailure(exit)).toBe(true)
+      if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
+      yield* Scope.close(secondScope, Exit.void)
+    }),
+  )
+})

+ 7 - 13
packages/core/test/session-runner.test.ts

@@ -16,12 +16,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
-import { Form } from "@opencode-ai/core/form"
 import { Job } from "@opencode-ai/core/job"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
+import { QuestionV2 } from "@opencode-ai/core/question"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { Snapshot } from "@opencode-ai/core/snapshot"
@@ -39,7 +39,6 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
 import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
-import { QuestionTool } from "@opencode-ai/core/tool/question"
 import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
 import { AgentV2 } from "@opencode-ai/core/agent"
 import { Config } from "@opencode-ai/core/config"
@@ -277,7 +276,7 @@ const it = testEffect(
     LayerNode.group([
       Database.node,
       EventV2.node,
-      Form.node,
+      QuestionV2.node,
       SessionProjector.node,
       SessionStore.node,
       AgentV2.node,
@@ -2856,19 +2855,14 @@ describe("SessionRunnerLLM", () => {
       yield* setup
       const session = yield* SessionV2.Service
       const registry = yield* ToolRegistry.Service
-      const forms = yield* Form.Service
+      const questions = yield* QuestionV2.Service
       yield* registry.register({
         question: Tool.make({
           description: "Ask the user",
           input: Schema.Struct({}),
           output: Schema.Struct({}),
           execute: (_, context) =>
-            forms.ask({ sessionID: context.sessionID, mode: "form", fields: [] }).pipe(
-              Effect.orDie,
-              Effect.flatMap((state) =>
-                state.status === "answered" ? Effect.succeed({}) : Effect.die(new QuestionTool.RejectedError()),
-              ),
-            ),
+            questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
         }),
       })
       yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
@@ -2885,12 +2879,12 @@ describe("SessionRunnerLLM", () => {
       ]
 
       const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
-      let pending = yield* forms.list({ sessionID })
+      let pending = yield* questions.list()
       while (pending.length === 0) {
         yield* Effect.yieldNow
-        pending = yield* forms.list({ sessionID })
+        pending = yield* questions.list()
       }
-      yield* forms.cancel(pending[0]!.id)
+      yield* questions.reject(pending[0]!.id)
       const exit = yield* Fiber.join(run)
 
       expect(exit._tag).toBe("Failure")

+ 15 - 41
packages/core/test/tool-question.test.ts

@@ -2,8 +2,8 @@ import { describe, expect } from "bun:test"
 import { Effect, Exit, Fiber, Layer } from "effect"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNode } from "@opencode-ai/core/effect/layer-node"
-import { Form } from "@opencode-ai/core/form"
 import { PermissionV2 } from "@opencode-ai/core/permission"
+import { QuestionV2 } from "@opencode-ai/core/question"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { QuestionTool } from "@opencode-ai/core/tool/question"
@@ -13,7 +13,7 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to
 
 const sessionID = SessionV2.ID.make("ses_question_tool_test")
 const assertions: PermissionV2.AssertInput[] = []
-let captured: Form.CreateInput | undefined
+let captured: QuestionV2.AskInput | undefined
 let reject = false
 let deny = false
 const capturedInput = () => captured
@@ -31,27 +31,22 @@ const permission = Layer.succeed(
     list: () => Effect.die("unused"),
   }),
 )
-const form = Layer.succeed(
-  Form.Service,
-  Form.Service.of({
-    create: () => Effect.die("unused"),
-    ask: (input: Form.CreateInput) =>
-      Effect.gen(function* () {
+const question = Layer.succeed(
+  QuestionV2.Service,
+  QuestionV2.Service.of({
+    ask: (input: QuestionV2.AskInput) =>
+      Effect.sync(() => {
         captured = input
-        if (reject) return { status: "cancelled" } as const
-        return { status: "answered", answer: { question_0: "Build" } } as const
-      }),
-    get: () => Effect.die("unused"),
-    list: () => Effect.die("unused"),
-    state: () => Effect.die("unused"),
+      }).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
     reply: () => Effect.die("unused"),
-    cancel: () => Effect.die("unused"),
+    reject: () => Effect.die("unused"),
+    list: () => Effect.die("unused"),
   }),
 )
 const it = testEffect(
   AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
     [PermissionV2.node, permission],
-    [Form.node, form],
+    [QuestionV2.node, question],
     [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
   ]),
 )
@@ -122,27 +117,8 @@ describe("QuestionTool", () => {
       expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
       expect(capturedInput()).toEqual({
         sessionID,
-        title: "Questions",
-        metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
-        mode: "form",
-        fields: [
-          {
-            key: "question_0",
-            title: "What should happen?",
-            description: "Action",
-            type: "string",
-            options: [{ value: "Build", label: "Build", description: "Build it" }],
-            custom: true,
-          },
-          {
-            key: "question_1",
-            title: "Which environment?",
-            description: "Environment",
-            type: "string",
-            options: [{ value: "Dev", label: "Dev", description: "Development" }],
-            custom: true,
-          },
-        ],
+        questions,
+        tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
       })
     }),
   )
@@ -161,10 +137,8 @@ describe("QuestionTool", () => {
       })
       expect(capturedInput()).toEqual({
         sessionID,
-        title: "Questions",
-        metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
-        mode: "form",
-        fields: [],
+        questions: [],
+        tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
       })
     }),
   )

+ 0 - 3
packages/opencode/script/httpapi-exercise.ts

@@ -1,4 +1 @@
-delete process.env["OTEL_EXPORTER_OTLP_ENDPOINT"]
-delete process.env["OTEL_EXPORTER_OTLP_HEADERS"]
-
 await import("../test/server/httpapi-exercise/index")

+ 4 - 4
packages/opencode/src/cli/cmd/run/noninteractive.ts

@@ -114,7 +114,7 @@ export async function runNonInteractivePrompt(input: Input) {
 
   const rejectQuestion = async (request: { id: string }) => {
     questionRejected = true
-    await input.client.question.reject({ requestID: request.id }).catch(() => {})
+    await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
   }
 
   const consume = async () => {
@@ -127,7 +127,7 @@ export async function runNonInteractivePrompt(input: Input) {
         await replyPermission(event.data)
         continue
       }
-      if (event.type === "question.asked" && submitted && event.data.sessionID === input.sessionID) {
+      if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
         await rejectQuestion(event.data)
         continue
       }
@@ -408,11 +408,11 @@ export async function runNonInteractivePrompt(input: Input) {
 
     const [permissions, questions] = await Promise.all([
       input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
-      input.client.question.list().catch(() => undefined),
+      input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
     ])
     await Promise.all([
       ...(permissions?.data?.data ?? []).map(replyPermission),
-      ...(questions?.data ?? []).filter((question) => question.sessionID === input.sessionID).map(rejectQuestion),
+      ...(questions?.data?.data ?? []).map(rejectQuestion),
     ])
     await completed
   } finally {

+ 4 - 3
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -255,9 +255,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
         return
       }
 
-      await ctx.sdk.question.reply({
+      await ctx.sdk.v2.session.question.reply({
+        sessionID: state.sessionID,
         requestID: next.requestID,
-        answers: next.answers ?? [],
+        questionV2Reply: { answers: next.answers ?? [] },
       })
     },
     onQuestionReject: async (next) => {
@@ -265,7 +266,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
         return
       }
 
-      await ctx.sdk.question.reject(next)
+      await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
     },
     onCycleVariant: () => {
       if (!state.model || state.variants.length === 0) {

+ 15 - 5
packages/opencode/src/cli/cmd/run/stream-v2.transport.ts

@@ -3,6 +3,7 @@ import type {
   PermissionRequest,
   PermissionV2Request,
   QuestionRequest,
+  QuestionV2Request,
   SessionMessage,
   SessionMessageAssistant,
   SessionMessageAssistantTool,
@@ -132,6 +133,15 @@ function permission(request: PermissionV2Request): PermissionRequest {
   }
 }
 
+function question(request: QuestionV2Request): QuestionRequest {
+  return {
+    id: request.id,
+    sessionID: request.sessionID,
+    questions: request.questions,
+    tool: request.tool,
+  }
+}
+
 function sessionID(event: RunV2Event) {
   return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
 }
@@ -359,13 +369,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
         { throwOnError: true },
       ),
       input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }),
-      input.sdk.question.list({ directory: input.directory }, { throwOnError: true }),
+      input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }),
       input.sdk.v2.session.active({ throwOnError: true }),
     ])
     const projected = messages.data.data.toReversed()
     for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
     state.permissions = permissions.data.data.map(permission)
-    state.questions = questions.data.filter((item) => item.sessionID === input.sessionID)
+    state.questions = questions.data.data.map(question)
     syncBlockers()
     await subagents.hydrate({ messages: projected, active: active.data.data })
     const running = input.sessionID in active.data.data
@@ -534,12 +544,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       syncBlockers()
       return
     }
-    if (event.type === "question.asked") {
-      if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(event.data)
+    if (event.type === "question.v2.asked") {
+      if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
       syncBlockers()
       return
     }
-    if (event.type === "question.replied" || event.type === "question.rejected") {
+    if (event.type === "question.v2.replied" || event.type === "question.v2.rejected") {
       state.questions = state.questions.filter((item) => item.id !== event.data.requestID)
       syncBlockers()
       return

+ 10 - 0
packages/opencode/src/server/routes/instance/httpapi/api.ts

@@ -25,6 +25,9 @@ import { SessionApi } from "./groups/session"
 import { SyncApi } from "./groups/sync"
 import { TuiApi } from "./groups/tui"
 import { WorkspaceApi } from "./groups/workspace"
+import { makeApi } from "@opencode-ai/protocol/api"
+import { LocationMiddleware } from "@opencode-ai/server/location"
+import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location"
 import { GlobalApi } from "./groups/global"
 import { Authorization } from "./middleware/authorization"
 import { SchemaErrorMiddleware } from "./middleware/schema-error"
@@ -42,6 +45,12 @@ const EventSchema = Schema.Union([
   InstanceDisposed,
 ]).annotate({ identifier: "Event" })
 
+export const ServerApi = makeApi({
+  definitions: EventManifest.Latest.values().toArray(),
+  locationMiddleware: LocationMiddleware,
+  sessionLocationMiddleware: SessionLocationMiddleware,
+})
+
 export const RootHttpApi = HttpApi.make("opencode-root")
   .addHttpApi(ControlApi)
   .addHttpApi(ControlPlaneApi)
@@ -71,6 +80,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
   .addHttpApi(RootHttpApi)
   .addHttpApi(EventApi)
   .addHttpApi(InstanceHttpApi)
+  .addHttpApi(ServerApi)
   .addHttpApi(PtyConnectApi)
   .annotate(HttpApi.AdditionalSchemas, [
     EventSchema,

+ 13 - 0
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -21,6 +21,7 @@ import { MCP } from "@/mcp"
 import { McpAuth } from "@/mcp/auth"
 import { Permission } from "@/permission"
 import { Plugin } from "@/plugin"
+import { PluginPtyEnvironment } from "@/plugin/pty-environment"
 import { InstanceStore } from "@/project/instance-store"
 import { Project } from "@/project/project"
 import { Vcs } from "@/project/vcs"
@@ -71,11 +72,13 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/
 import { serveUIEffect } from "@/server/shared/ui"
 import { ServerAuth } from "@/server/auth"
 import { InstanceHttpApi, RootHttpApi } from "./api"
+import { Api } from "@opencode-ai/server/api"
 import { PublicApi } from "./public"
 import {
   authorizationLayer,
   authorizationRouterMiddleware,
   ptyConnectAuthorizationLayer,
+  serverAuthorizationLayer,
 } from "./middleware/authorization"
 import { EventApi } from "./groups/event"
 import { PtyConnectApi } from "./groups/pty"
@@ -97,10 +100,12 @@ import { questionHandlers } from "./handlers/question"
 import { sessionHandlers } from "./handlers/session"
 import { syncHandlers } from "./handlers/sync"
 import { tuiHandlers } from "./handlers/tui"
+import { handlers } from "@opencode-ai/server/handlers"
 import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services"
 import { layer as locationLayer } from "@opencode-ai/server/location"
 import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location"
 import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
+import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
 import { workspaceHandlers } from "./handlers/workspace"
 import { instanceContextLayer } from "./middleware/instance-context"
 import { workspaceRoutingLayer } from "./middleware/workspace-routing"
@@ -132,6 +137,7 @@ const cors = (corsOptions?: CorsOptions) =>
 const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.layer))
 const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
 const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
+const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
 const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
 const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
   Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
@@ -169,6 +175,12 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
 const instanceRoutes = instanceApiRoutes.pipe(
   Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
 )
+const serverRoutes = HttpApiBuilder.layer(Api).pipe(
+  Layer.provide(handlers),
+  Layer.provide(PluginPtyEnvironment.layer),
+  Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
+)
+
 // `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
 // processes that never serve it (CLI, scripts) don't pay at module load.
 // `HttpServerResponse.jsonUnsafe` runs JSON.stringify eagerly, so caching
@@ -267,6 +279,7 @@ export function createRoutes(
     eventApiRoutes,
     ptyConnectApiRoutes,
     instanceRoutes,
+    serverRoutes,
     docRoute,
     uiRoute,
   ).pipe(

+ 2 - 2
packages/opencode/test/cli/run/stream-v2.transport.test.ts

@@ -112,8 +112,8 @@ function sdk(input: {
     }),
   )
   spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }))
-  spyOn(client.question, "list").mockImplementation(() => ok([]))
-  spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {} }))
+  spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] }))
+  spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
   spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined))
   spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
   // The generated methods have conditional return types for throwOnError; the

+ 37 - 3
packages/opencode/test/server/httpapi-exercise/index.ts

@@ -811,6 +811,11 @@ const scenarios: Scenario[] = [
     object(body.location)
     array(body.data)
   }),
+  http.protected.get("/api/question/request", "v2.question.request.list").json(200, (body) => {
+    object(body)
+    object(body.location)
+    array(body.data)
+  }),
   http.protected
     .post("/api/session/{sessionID}/permission", "v2.session.permission.create")
     .seeded((ctx) => ctx.session({ title: "Permission create owner" }))
@@ -844,6 +849,14 @@ const scenarios: Scenario[] = [
       headers: ctx.headers(),
     }))
     .json(404, object, "status"),
+  http.protected
+    .get("/api/session/{sessionID}/question", "v2.session.question.list")
+    .seeded((ctx) => ctx.session({ title: "Question list owner" }))
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/question", { sessionID: ctx.state.id }),
+      headers: ctx.headers(),
+    }))
+    .json(200, data(array)),
   http.protected
     .post("/api/session/{sessionID}/permission/{requestID}/reply", "v2.session.permission.reply")
     .seeded((ctx) => ctx.session({ title: "Permission owner" }))
@@ -856,6 +869,29 @@ const scenarios: Scenario[] = [
       body: { reply: "once" },
     }))
     .json(404, object, "status"),
+  http.protected
+    .post("/api/session/{sessionID}/question/{requestID}/reply", "v2.session.question.reply")
+    .seeded((ctx) => ctx.session({ title: "Question reply owner" }))
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/question/{requestID}/reply", {
+        sessionID: ctx.state.id,
+        requestID: "que_httpapi_missing",
+      }),
+      headers: ctx.headers(),
+      body: { answers: [] },
+    }))
+    .json(404, object, "status"),
+  http.protected
+    .post("/api/session/{sessionID}/question/{requestID}/reject", "v2.session.question.reject")
+    .seeded((ctx) => ctx.session({ title: "Question reject owner" }))
+    .at((ctx) => ({
+      path: route("/api/session/{sessionID}/question/{requestID}/reject", {
+        sessionID: ctx.state.id,
+        requestID: "que_httpapi_missing",
+      }),
+      headers: ctx.headers(),
+    }))
+    .json(404, object, "status"),
   http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => {
     object(body)
     array(body.data)
@@ -1733,9 +1769,7 @@ const main = Effect.gen(function* () {
   const options = parseOptions(Bun.argv.slice(2))
   const modules = yield* Effect.promise(() => runtime())
   const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
-  const selected = selectedScenarios(options, scenarios).filter((scenario) =>
-    effectRoutes.includes(routeKey(scenario)),
-  )
+  const selected = selectedScenarios(options, scenarios)
   const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
   const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
 

+ 14 - 1
packages/opencode/test/server/httpapi-public-openapi.test.ts

@@ -137,7 +137,11 @@ describe("PublicApi OpenAPI v2 errors", () => {
   test("preserves required request bodies for v2 mutations", () => {
     const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
 
-    for (const path of ["/api/session/{sessionID}/prompt", "/api/session/{sessionID}/permission/{requestID}/reply"]) {
+    for (const path of [
+      "/api/session/{sessionID}/prompt",
+      "/api/session/{sessionID}/permission/{requestID}/reply",
+      "/api/session/{sessionID}/question/{requestID}/reply",
+    ]) {
       expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
     }
   })
@@ -288,6 +292,15 @@ describe("PublicApi OpenAPI v2 errors", () => {
         "QuestionNotFoundError",
       )
     }
+    for (const route of [
+      ["post", "/api/session/{sessionID}/question/{requestID}/reply"],
+      ["post", "/api/session/{sessionID}/question/{requestID}/reject"],
+    ] as const) {
+      expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
+        "QuestionNotFoundError",
+        "SessionNotFoundError",
+      ])
+    }
   })
 
   test("documents MCP server not-found errors", () => {

+ 176 - 214
packages/plugin/src/v2/effect/generated/api.ts

@@ -458,145 +458,71 @@ export interface ProjectApi<E = never> {
   readonly directories: ProjectDirectoriesOperation<E>
 }
 
-type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
+type Endpoint13_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
 export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
-export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.form"]["form.request.list"]>>
-export type FormListRequestsOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E>
-
-type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
-export type Endpoint13_1Input = {
-  readonly sessionID: Endpoint13_1Request["params"]["sessionID"]
-  readonly location?: Endpoint13_1Request["query"]["location"]
-}
-export type Endpoint13_1Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.list"]>>
-export type FormListOperation<E = never> = (input: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
-
-type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.create"]>[0]
-export type Endpoint13_2Input = {
-  readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
-  readonly location?: Endpoint13_2Request["query"]["location"]
-  readonly id?: Endpoint13_2Request["payload"]["id"]
-  readonly title?: Endpoint13_2Request["payload"]["title"]
-  readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
-  readonly mode: Endpoint13_2Request["payload"]["mode"]
-  readonly fields?: Endpoint13_2Request["payload"]["fields"]
-  readonly url?: Endpoint13_2Request["payload"]["url"]
-}
-export type Endpoint13_2Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.create"]>>
-export type FormCreateOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
-
-type Endpoint13_3Request = Parameters<RawClient["server.form"]["session.form.get"]>[0]
-export type Endpoint13_3Input = {
-  readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
-  readonly formID: Endpoint13_3Request["params"]["formID"]
-  readonly location?: Endpoint13_3Request["query"]["location"]
-}
-export type Endpoint13_3Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.get"]>>
-export type FormGetOperation<E = never> = (input: Endpoint13_3Input) => Effect.Effect<Endpoint13_3Output, E>
-
-type Endpoint13_4Request = Parameters<RawClient["server.form"]["session.form.state"]>[0]
-export type Endpoint13_4Input = {
-  readonly sessionID: Endpoint13_4Request["params"]["sessionID"]
-  readonly formID: Endpoint13_4Request["params"]["formID"]
-  readonly location?: Endpoint13_4Request["query"]["location"]
-}
-export type Endpoint13_4Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.state"]>>
-export type FormStateOperation<E = never> = (input: Endpoint13_4Input) => Effect.Effect<Endpoint13_4Output, E>
-
-type Endpoint13_5Request = Parameters<RawClient["server.form"]["session.form.reply"]>[0]
-export type Endpoint13_5Input = {
-  readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
-  readonly formID: Endpoint13_5Request["params"]["formID"]
-  readonly location?: Endpoint13_5Request["query"]["location"]
-  readonly answer: Endpoint13_5Request["payload"]["answer"]
-}
-export type Endpoint13_5Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.reply"]>>
-export type FormReplyOperation<E = never> = (input: Endpoint13_5Input) => Effect.Effect<Endpoint13_5Output, E>
-
-type Endpoint13_6Request = Parameters<RawClient["server.form"]["session.form.cancel"]>[0]
-export type Endpoint13_6Input = {
-  readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
-  readonly formID: Endpoint13_6Request["params"]["formID"]
-  readonly location?: Endpoint13_6Request["query"]["location"]
-}
-export type Endpoint13_6Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.cancel"]>>
-export type FormCancelOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E>
-
-export interface FormApi<E = never> {
-  readonly listRequests: FormListRequestsOperation<E>
-  readonly list: FormListOperation<E>
-  readonly create: FormCreateOperation<E>
-  readonly get: FormGetOperation<E>
-  readonly state: FormStateOperation<E>
-  readonly reply: FormReplyOperation<E>
-  readonly cancel: FormCancelOperation<E>
-}
-
-type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
-export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
-export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>>
+export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>>
 export type PermissionListRequestsOperation<E = never> = (
-  input?: Endpoint14_0Input,
-) => Effect.Effect<Endpoint14_0Output, E>
+  input?: Endpoint13_0Input,
+) => Effect.Effect<Endpoint13_0Output, E>
 
-type Endpoint14_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
-export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["query"]["projectID"] }
-export type Endpoint14_1Output = EffectValue<
+type Endpoint13_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
+export type Endpoint13_1Input = { readonly projectID?: Endpoint13_1Request["query"]["projectID"] }
+export type Endpoint13_1Output = EffectValue<
   ReturnType<RawClient["server.permission"]["permission.saved.list"]>
 >["data"]
 export type PermissionListSavedOperation<E = never> = (
-  input?: Endpoint14_1Input,
-) => Effect.Effect<Endpoint14_1Output, E>
+  input?: Endpoint13_1Input,
+) => Effect.Effect<Endpoint13_1Output, E>
 
-type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
-export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] }
-export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>>
+type Endpoint13_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
+export type Endpoint13_2Input = { readonly id: Endpoint13_2Request["params"]["id"] }
+export type Endpoint13_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>>
 export type PermissionRemoveSavedOperation<E = never> = (
-  input: Endpoint14_2Input,
-) => Effect.Effect<Endpoint14_2Output, E>
-
-type Endpoint14_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
-export type Endpoint14_3Input = {
-  readonly sessionID: Endpoint14_3Request["params"]["sessionID"]
-  readonly id?: Endpoint14_3Request["payload"]["id"]
-  readonly action: Endpoint14_3Request["payload"]["action"]
-  readonly resources: Endpoint14_3Request["payload"]["resources"]
-  readonly save?: Endpoint14_3Request["payload"]["save"]
-  readonly metadata?: Endpoint14_3Request["payload"]["metadata"]
-  readonly source?: Endpoint14_3Request["payload"]["source"]
-  readonly agent?: Endpoint14_3Request["payload"]["agent"]
-}
-export type Endpoint14_3Output = EffectValue<
+  input: Endpoint13_2Input,
+) => Effect.Effect<Endpoint13_2Output, E>
+
+type Endpoint13_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
+export type Endpoint13_3Input = {
+  readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
+  readonly id?: Endpoint13_3Request["payload"]["id"]
+  readonly action: Endpoint13_3Request["payload"]["action"]
+  readonly resources: Endpoint13_3Request["payload"]["resources"]
+  readonly save?: Endpoint13_3Request["payload"]["save"]
+  readonly metadata?: Endpoint13_3Request["payload"]["metadata"]
+  readonly source?: Endpoint13_3Request["payload"]["source"]
+  readonly agent?: Endpoint13_3Request["payload"]["agent"]
+}
+export type Endpoint13_3Output = EffectValue<
   ReturnType<RawClient["server.permission"]["session.permission.create"]>
 >["data"]
-export type PermissionCreateOperation<E = never> = (input: Endpoint14_3Input) => Effect.Effect<Endpoint14_3Output, E>
+export type PermissionCreateOperation<E = never> = (input: Endpoint13_3Input) => Effect.Effect<Endpoint13_3Output, E>
 
-type Endpoint14_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
-export type Endpoint14_4Input = { readonly sessionID: Endpoint14_4Request["params"]["sessionID"] }
-export type Endpoint14_4Output = EffectValue<
+type Endpoint13_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
+export type Endpoint13_4Input = { readonly sessionID: Endpoint13_4Request["params"]["sessionID"] }
+export type Endpoint13_4Output = EffectValue<
   ReturnType<RawClient["server.permission"]["session.permission.list"]>
 >["data"]
-export type PermissionListOperation<E = never> = (input: Endpoint14_4Input) => Effect.Effect<Endpoint14_4Output, E>
+export type PermissionListOperation<E = never> = (input: Endpoint13_4Input) => Effect.Effect<Endpoint13_4Output, E>
 
-type Endpoint14_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
-export type Endpoint14_5Input = {
-  readonly sessionID: Endpoint14_5Request["params"]["sessionID"]
-  readonly requestID: Endpoint14_5Request["params"]["requestID"]
+type Endpoint13_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
+export type Endpoint13_5Input = {
+  readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
+  readonly requestID: Endpoint13_5Request["params"]["requestID"]
 }
-export type Endpoint14_5Output = EffectValue<
+export type Endpoint13_5Output = EffectValue<
   ReturnType<RawClient["server.permission"]["session.permission.get"]>
 >["data"]
-export type PermissionGetOperation<E = never> = (input: Endpoint14_5Input) => Effect.Effect<Endpoint14_5Output, E>
+export type PermissionGetOperation<E = never> = (input: Endpoint13_5Input) => Effect.Effect<Endpoint13_5Output, E>
 
-type Endpoint14_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
-export type Endpoint14_6Input = {
-  readonly sessionID: Endpoint14_6Request["params"]["sessionID"]
-  readonly requestID: Endpoint14_6Request["params"]["requestID"]
-  readonly reply: Endpoint14_6Request["payload"]["reply"]
-  readonly message?: Endpoint14_6Request["payload"]["message"]
+type Endpoint13_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
+export type Endpoint13_6Input = {
+  readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
+  readonly requestID: Endpoint13_6Request["params"]["requestID"]
+  readonly reply: Endpoint13_6Request["payload"]["reply"]
+  readonly message?: Endpoint13_6Request["payload"]["message"]
 }
-export type Endpoint14_6Output = EffectValue<ReturnType<RawClient["server.permission"]["session.permission.reply"]>>
-export type PermissionReplyOperation<E = never> = (input: Endpoint14_6Input) => Effect.Effect<Endpoint14_6Output, E>
+export type Endpoint13_6Output = EffectValue<ReturnType<RawClient["server.permission"]["session.permission.reply"]>>
+export type PermissionReplyOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E>
 
 export interface PermissionApi<E = never> {
   readonly listRequests: PermissionListRequestsOperation<E>
@@ -608,100 +534,100 @@ export interface PermissionApi<E = never> {
   readonly reply: PermissionReplyOperation<E>
 }
 
-type Endpoint15_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
-export type Endpoint15_0Input = {
-  readonly location?: Endpoint15_0Request["query"]["location"]
-  readonly path?: Endpoint15_0Request["query"]["path"]
+type Endpoint14_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
+export type Endpoint14_0Input = {
+  readonly location?: Endpoint14_0Request["query"]["location"]
+  readonly path?: Endpoint14_0Request["query"]["path"]
 }
-export type Endpoint15_0Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.list"]>>
-export type FileListOperation<E = never> = (input?: Endpoint15_0Input) => Effect.Effect<Endpoint15_0Output, E>
+export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.list"]>>
+export type FileListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
 
-type Endpoint15_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
-export type Endpoint15_1Input = {
-  readonly location?: Endpoint15_1Request["query"]["location"]
-  readonly query: Endpoint15_1Request["query"]["query"]
-  readonly type?: Endpoint15_1Request["query"]["type"]
-  readonly limit?: Endpoint15_1Request["query"]["limit"]
+type Endpoint14_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
+export type Endpoint14_1Input = {
+  readonly location?: Endpoint14_1Request["query"]["location"]
+  readonly query: Endpoint14_1Request["query"]["query"]
+  readonly type?: Endpoint14_1Request["query"]["type"]
+  readonly limit?: Endpoint14_1Request["query"]["limit"]
 }
-export type Endpoint15_1Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.find"]>>
-export type FileFindOperation<E = never> = (input: Endpoint15_1Input) => Effect.Effect<Endpoint15_1Output, E>
+export type Endpoint14_1Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.find"]>>
+export type FileFindOperation<E = never> = (input: Endpoint14_1Input) => Effect.Effect<Endpoint14_1Output, E>
 
 export interface FileApi<E = never> {
   readonly list: FileListOperation<E>
   readonly find: FileFindOperation<E>
 }
 
-type Endpoint16_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
-export type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
-export type Endpoint16_0Output = EffectValue<ReturnType<RawClient["server.command"]["command.list"]>>
-export type CommandListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
+type Endpoint15_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
+export type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
+export type Endpoint15_0Output = EffectValue<ReturnType<RawClient["server.command"]["command.list"]>>
+export type CommandListOperation<E = never> = (input?: Endpoint15_0Input) => Effect.Effect<Endpoint15_0Output, E>
 
 export interface CommandApi<E = never> {
   readonly list: CommandListOperation<E>
 }
 
-type Endpoint17_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
-export type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
-export type Endpoint17_0Output = EffectValue<ReturnType<RawClient["server.skill"]["skill.list"]>>
-export type SkillListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
+type Endpoint16_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
+export type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
+export type Endpoint16_0Output = EffectValue<ReturnType<RawClient["server.skill"]["skill.list"]>>
+export type SkillListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
 
 export interface SkillApi<E = never> {
   readonly list: SkillListOperation<E>
 }
 
-export type Endpoint18_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>>
-export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint18_0Output, E>
+export type Endpoint17_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>>
+export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint17_0Output, E>
 
-export type Endpoint18_1Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.changes"]>>>
-export type EventChangesOperation<E = never> = () => Stream.Stream<Endpoint18_1Output, E>
+export type Endpoint17_1Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.changes"]>>>
+export type EventChangesOperation<E = never> = () => Stream.Stream<Endpoint17_1Output, E>
 
 export interface EventApi<E = never> {
   readonly subscribe: EventSubscribeOperation<E>
   readonly changes: EventChangesOperation<E>
 }
 
-type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
-export type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
-export type Endpoint19_0Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.list"]>>
-export type PtyListOperation<E = never> = (input?: Endpoint19_0Input) => Effect.Effect<Endpoint19_0Output, E>
+type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
+export type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
+export type Endpoint18_0Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.list"]>>
+export type PtyListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
 
-type Endpoint19_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
-export type Endpoint19_1Input = {
-  readonly location?: Endpoint19_1Request["query"]["location"]
-  readonly command?: Endpoint19_1Request["payload"]["command"]
-  readonly args?: Endpoint19_1Request["payload"]["args"]
-  readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
-  readonly title?: Endpoint19_1Request["payload"]["title"]
-  readonly env?: Endpoint19_1Request["payload"]["env"]
+type Endpoint18_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
+export type Endpoint18_1Input = {
+  readonly location?: Endpoint18_1Request["query"]["location"]
+  readonly command?: Endpoint18_1Request["payload"]["command"]
+  readonly args?: Endpoint18_1Request["payload"]["args"]
+  readonly cwd?: Endpoint18_1Request["payload"]["cwd"]
+  readonly title?: Endpoint18_1Request["payload"]["title"]
+  readonly env?: Endpoint18_1Request["payload"]["env"]
 }
-export type Endpoint19_1Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.create"]>>
-export type PtyCreateOperation<E = never> = (input?: Endpoint19_1Input) => Effect.Effect<Endpoint19_1Output, E>
+export type Endpoint18_1Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.create"]>>
+export type PtyCreateOperation<E = never> = (input?: Endpoint18_1Input) => Effect.Effect<Endpoint18_1Output, E>
 
-type Endpoint19_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
-export type Endpoint19_2Input = {
-  readonly ptyID: Endpoint19_2Request["params"]["ptyID"]
-  readonly location?: Endpoint19_2Request["query"]["location"]
+type Endpoint18_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
+export type Endpoint18_2Input = {
+  readonly ptyID: Endpoint18_2Request["params"]["ptyID"]
+  readonly location?: Endpoint18_2Request["query"]["location"]
 }
-export type Endpoint19_2Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.get"]>>
-export type PtyGetOperation<E = never> = (input: Endpoint19_2Input) => Effect.Effect<Endpoint19_2Output, E>
+export type Endpoint18_2Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.get"]>>
+export type PtyGetOperation<E = never> = (input: Endpoint18_2Input) => Effect.Effect<Endpoint18_2Output, E>
 
-type Endpoint19_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
-export type Endpoint19_3Input = {
-  readonly ptyID: Endpoint19_3Request["params"]["ptyID"]
-  readonly location?: Endpoint19_3Request["query"]["location"]
-  readonly title?: Endpoint19_3Request["payload"]["title"]
-  readonly size?: Endpoint19_3Request["payload"]["size"]
+type Endpoint18_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
+export type Endpoint18_3Input = {
+  readonly ptyID: Endpoint18_3Request["params"]["ptyID"]
+  readonly location?: Endpoint18_3Request["query"]["location"]
+  readonly title?: Endpoint18_3Request["payload"]["title"]
+  readonly size?: Endpoint18_3Request["payload"]["size"]
 }
-export type Endpoint19_3Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.update"]>>
-export type PtyUpdateOperation<E = never> = (input: Endpoint19_3Input) => Effect.Effect<Endpoint19_3Output, E>
+export type Endpoint18_3Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.update"]>>
+export type PtyUpdateOperation<E = never> = (input: Endpoint18_3Input) => Effect.Effect<Endpoint18_3Output, E>
 
-type Endpoint19_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
-export type Endpoint19_4Input = {
-  readonly ptyID: Endpoint19_4Request["params"]["ptyID"]
-  readonly location?: Endpoint19_4Request["query"]["location"]
+type Endpoint18_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
+export type Endpoint18_4Input = {
+  readonly ptyID: Endpoint18_4Request["params"]["ptyID"]
+  readonly location?: Endpoint18_4Request["query"]["location"]
 }
-export type Endpoint19_4Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.remove"]>>
-export type PtyRemoveOperation<E = never> = (input: Endpoint19_4Input) => Effect.Effect<Endpoint19_4Output, E>
+export type Endpoint18_4Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.remove"]>>
+export type PtyRemoveOperation<E = never> = (input: Endpoint18_4Input) => Effect.Effect<Endpoint18_4Output, E>
 
 export interface PtyApi<E = never> {
   readonly list: PtyListOperation<E>
@@ -711,47 +637,47 @@ export interface PtyApi<E = never> {
   readonly remove: PtyRemoveOperation<E>
 }
 
-type Endpoint20_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
-export type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
-export type Endpoint20_0Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.list"]>>
-export type ShellListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
+type Endpoint19_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
+export type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
+export type Endpoint19_0Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.list"]>>
+export type ShellListOperation<E = never> = (input?: Endpoint19_0Input) => Effect.Effect<Endpoint19_0Output, E>
 
-type Endpoint20_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
-export type Endpoint20_1Input = {
-  readonly location?: Endpoint20_1Request["query"]["location"]
-  readonly command: Endpoint20_1Request["payload"]["command"]
-  readonly cwd?: Endpoint20_1Request["payload"]["cwd"]
-  readonly timeout?: Endpoint20_1Request["payload"]["timeout"]
-  readonly metadata?: Endpoint20_1Request["payload"]["metadata"]
+type Endpoint19_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
+export type Endpoint19_1Input = {
+  readonly location?: Endpoint19_1Request["query"]["location"]
+  readonly command: Endpoint19_1Request["payload"]["command"]
+  readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
+  readonly timeout?: Endpoint19_1Request["payload"]["timeout"]
+  readonly metadata?: Endpoint19_1Request["payload"]["metadata"]
 }
-export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.create"]>>
-export type ShellCreateOperation<E = never> = (input: Endpoint20_1Input) => Effect.Effect<Endpoint20_1Output, E>
+export type Endpoint19_1Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.create"]>>
+export type ShellCreateOperation<E = never> = (input: Endpoint19_1Input) => Effect.Effect<Endpoint19_1Output, E>
 
-type Endpoint20_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
-export type Endpoint20_2Input = {
-  readonly id: Endpoint20_2Request["params"]["id"]
-  readonly location?: Endpoint20_2Request["query"]["location"]
+type Endpoint19_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
+export type Endpoint19_2Input = {
+  readonly id: Endpoint19_2Request["params"]["id"]
+  readonly location?: Endpoint19_2Request["query"]["location"]
 }
-export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.get"]>>
-export type ShellGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
+export type Endpoint19_2Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.get"]>>
+export type ShellGetOperation<E = never> = (input: Endpoint19_2Input) => Effect.Effect<Endpoint19_2Output, E>
 
-type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
-export type Endpoint20_3Input = {
-  readonly id: Endpoint20_3Request["params"]["id"]
-  readonly location?: Endpoint20_3Request["query"]["location"]
-  readonly cursor?: Endpoint20_3Request["query"]["cursor"]
-  readonly limit?: Endpoint20_3Request["query"]["limit"]
+type Endpoint19_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
+export type Endpoint19_3Input = {
+  readonly id: Endpoint19_3Request["params"]["id"]
+  readonly location?: Endpoint19_3Request["query"]["location"]
+  readonly cursor?: Endpoint19_3Request["query"]["cursor"]
+  readonly limit?: Endpoint19_3Request["query"]["limit"]
 }
-export type Endpoint20_3Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>>
-export type ShellOutputOperation<E = never> = (input: Endpoint20_3Input) => Effect.Effect<Endpoint20_3Output, E>
+export type Endpoint19_3Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>>
+export type ShellOutputOperation<E = never> = (input: Endpoint19_3Input) => Effect.Effect<Endpoint19_3Output, E>
 
-type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
-export type Endpoint20_4Input = {
-  readonly id: Endpoint20_4Request["params"]["id"]
-  readonly location?: Endpoint20_4Request["query"]["location"]
+type Endpoint19_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
+export type Endpoint19_4Input = {
+  readonly id: Endpoint19_4Request["params"]["id"]
+  readonly location?: Endpoint19_4Request["query"]["location"]
 }
-export type Endpoint20_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.remove"]>>
-export type ShellRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
+export type Endpoint19_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.remove"]>>
+export type ShellRemoveOperation<E = never> = (input: Endpoint19_4Input) => Effect.Effect<Endpoint19_4Output, E>
 
 export interface ShellApi<E = never> {
   readonly list: ShellListOperation<E>
@@ -761,6 +687,42 @@ export interface ShellApi<E = never> {
   readonly remove: ShellRemoveOperation<E>
 }
 
+type Endpoint20_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
+export type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
+export type Endpoint20_0Output = EffectValue<ReturnType<RawClient["server.question"]["question.request.list"]>>
+export type QuestionListRequestsOperation<E = never> = (
+  input?: Endpoint20_0Input,
+) => Effect.Effect<Endpoint20_0Output, E>
+
+type Endpoint20_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
+export type Endpoint20_1Input = { readonly sessionID: Endpoint20_1Request["params"]["sessionID"] }
+export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.list"]>>["data"]
+export type QuestionListOperation<E = never> = (input: Endpoint20_1Input) => Effect.Effect<Endpoint20_1Output, E>
+
+type Endpoint20_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
+export type Endpoint20_2Input = {
+  readonly sessionID: Endpoint20_2Request["params"]["sessionID"]
+  readonly requestID: Endpoint20_2Request["params"]["requestID"]
+  readonly answers: Endpoint20_2Request["payload"]["answers"]
+}
+export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.reply"]>>
+export type QuestionReplyOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
+
+type Endpoint20_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
+export type Endpoint20_3Input = {
+  readonly sessionID: Endpoint20_3Request["params"]["sessionID"]
+  readonly requestID: Endpoint20_3Request["params"]["requestID"]
+}
+export type Endpoint20_3Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.reject"]>>
+export type QuestionRejectOperation<E = never> = (input: Endpoint20_3Input) => Effect.Effect<Endpoint20_3Output, E>
+
+export interface QuestionApi<E = never> {
+  readonly listRequests: QuestionListRequestsOperation<E>
+  readonly list: QuestionListOperation<E>
+  readonly reply: QuestionReplyOperation<E>
+  readonly reject: QuestionRejectOperation<E>
+}
+
 type Endpoint21_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
 export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] }
 export type Endpoint21_0Output = EffectValue<ReturnType<RawClient["server.reference"]["reference.list"]>>
@@ -819,7 +781,6 @@ export interface AppApi<E = never> {
   readonly "server.mcp": ServerMcpApi<E>
   readonly credential: CredentialApi<E>
   readonly project: ProjectApi<E>
-  readonly form: FormApi<E>
   readonly permission: PermissionApi<E>
   readonly file: FileApi<E>
   readonly command: CommandApi<E>
@@ -827,6 +788,7 @@ export interface AppApi<E = never> {
   readonly event: EventApi<E>
   readonly pty: PtyApi<E>
   readonly shell: ShellApi<E>
+  readonly question: QuestionApi<E>
   readonly reference: ReferenceApi<E>
   readonly projectCopy: ProjectCopyApi<E>
 }

+ 12 - 52
packages/protocol/src/api.ts

@@ -8,7 +8,6 @@ import { ProviderGroup } from "./groups/provider.js"
 import { makeSessionGroup } from "./groups/session.js"
 import { makePermissionGroup } from "./groups/permission.js"
 import { FileSystemGroup } from "./groups/fs.js"
-import { makeFormGroup } from "./groups/form.js"
 import { CommandGroup } from "./groups/command.js"
 import { SkillGroup } from "./groups/skill.js"
 import { EventGroup, makeEventGroup } from "./groups/event.js"
@@ -18,6 +17,7 @@ import { PluginGroup } from "./groups/plugin.js"
 import { HealthGroup } from "./groups/health.js"
 import { PtyGroup } from "./groups/pty.js"
 import { ShellGroup } from "./groups/shell.js"
+import { makeQuestionGroup } from "./groups/question.js"
 import { ReferenceGroup } from "./groups/reference.js"
 import { Authorization } from "./middleware/authorization.js"
 import { LocationGroup } from "./groups/location.js"
@@ -50,35 +50,26 @@ type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLoc
   | ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
   | HttpApiGroup.AddMiddleware<typeof MessageGroup, SessionLocationId>
 
-type FormGroups<
-  LocationId extends HttpApiMiddleware.AnyId,
-  LocationService,
-  FormLocationId extends HttpApiMiddleware.AnyId,
-  FormLocationService,
-> = ReturnType<
-  typeof makeFormGroup<LocationId, LocationService, FormLocationId, FormLocationService>
->
-
 type MixedMiddlewareGroups<
   LocationId extends HttpApiMiddleware.AnyId,
   LocationService,
   SessionLocationId extends HttpApiMiddleware.AnyId,
   SessionLocationService,
 > =
-  ReturnType<typeof makePermissionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
+  | ReturnType<
+      typeof makePermissionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>
+    >
+  | ReturnType<typeof makeQuestionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
 
 type ApiGroups<
   LocationId extends HttpApiMiddleware.AnyId,
   LocationService,
-  FormLocationId extends HttpApiMiddleware.AnyId,
-  FormLocationService,
   SessionLocationId extends HttpApiMiddleware.AnyId,
   SessionLocationService,
   Event extends HttpApiGroup.Any,
 > =
   | typeof HealthGroup
   | LocationGroups<LocationId>
-  | FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
   | SessionGroups<SessionLocationId, SessionLocationService>
   | MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
   | Event
@@ -88,8 +79,6 @@ type EventGroupFor<Definitions extends ReadonlyArray<Definition>> = ReturnType<t
 export type Api<
   LocationId extends HttpApiMiddleware.AnyId,
   LocationService,
-  FormLocationId extends HttpApiMiddleware.AnyId,
-  FormLocationService,
   SessionLocationId extends HttpApiMiddleware.AnyId,
   SessionLocationService,
   Event extends HttpApiGroup.Any,
@@ -97,7 +86,7 @@ export type Api<
   "server",
   HttpApiGroup.AddMiddleware<
     HttpApiGroup.AddMiddleware<
-      ApiGroups<LocationId, LocationService, FormLocationId, FormLocationService, SessionLocationId, SessionLocationService, Event>,
+      ApiGroups<LocationId, LocationService, SessionLocationId, SessionLocationService, Event>,
       Authorization
     >,
     SchemaErrorMiddleware
@@ -109,16 +98,13 @@ const makeApiFromGroup = <
   const Group extends HttpApiGroup.Any,
   LocationId extends HttpApiMiddleware.AnyId,
   LocationService,
-  FormLocationId extends HttpApiMiddleware.AnyId,
-  FormLocationService,
   SessionLocationId extends HttpApiMiddleware.AnyId,
   SessionLocationService,
 >(
   eventGroup: Group,
   locationMiddleware: Context.Key<LocationId, LocationService>,
-  formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>,
   sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
-): Api<LocationId, LocationService, FormLocationId, FormLocationService, SessionLocationId, SessionLocationService, Group> =>
+): Api<LocationId, LocationService, SessionLocationId, SessionLocationService, Group> =>
   HttpApi.make("server")
     .add(HealthGroup)
     .add(LocationGroup.middleware(locationMiddleware))
@@ -133,7 +119,6 @@ const makeApiFromGroup = <
     .add(McpGroup.middleware(locationMiddleware))
     .add(CredentialGroup.middleware(locationMiddleware))
     .add(ProjectGroup.middleware(locationMiddleware))
-    .add(makeFormGroup(locationMiddleware, formLocationMiddleware))
     .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
     .add(FileSystemGroup.middleware(locationMiddleware))
     .add(CommandGroup.middleware(locationMiddleware))
@@ -141,6 +126,7 @@ const makeApiFromGroup = <
     .add(eventGroup)
     .add(PtyGroup.middleware(locationMiddleware))
     .add(ShellGroup.middleware(locationMiddleware))
+    .add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
     .add(ReferenceGroup.middleware(locationMiddleware))
     .add(ProjectCopyGroup.middleware(locationMiddleware))
     .annotateMerge(
@@ -157,48 +143,22 @@ export const makeApi = <
   const Definitions extends ReadonlyArray<Definition>,
   LocationId extends HttpApiMiddleware.AnyId,
   LocationService,
-  FormLocationId extends HttpApiMiddleware.AnyId,
-  FormLocationService,
   SessionLocationId extends HttpApiMiddleware.AnyId,
   SessionLocationService,
 >(options: {
   readonly definitions: Definitions
   readonly locationMiddleware: Context.Key<LocationId, LocationService>
-  readonly formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>
   readonly sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>
-}): Api<
-  LocationId,
-  LocationService,
-  FormLocationId,
-  FormLocationService,
-  SessionLocationId,
-  SessionLocationService,
-  EventGroupFor<Definitions>
-> =>
-  makeApiFromGroup(
-    makeEventGroup(options.definitions),
-    options.locationMiddleware,
-    options.formLocationMiddleware,
-    options.sessionLocationMiddleware,
-  )
+}): Api<LocationId, LocationService, SessionLocationId, SessionLocationService, EventGroupFor<Definitions>> =>
+  makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware)
 
 export const makeDefaultApi = <
   LocationId extends HttpApiMiddleware.AnyId,
   LocationService,
-  FormLocationId extends HttpApiMiddleware.AnyId,
-  FormLocationService,
   SessionLocationId extends HttpApiMiddleware.AnyId,
   SessionLocationService,
 >(options: {
   readonly locationMiddleware: Context.Key<LocationId, LocationService>
-  readonly formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>
   readonly sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>
-}): Api<
-  LocationId,
-  LocationService,
-  FormLocationId,
-  FormLocationService,
-  SessionLocationId,
-  SessionLocationService,
-  typeof EventGroup
-> => makeApiFromGroup(EventGroup, options.locationMiddleware, options.formLocationMiddleware, options.sessionLocationMiddleware)
+}): Api<LocationId, LocationService, SessionLocationId, SessionLocationService, typeof EventGroup> =>
+  makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware)

+ 2 - 7
packages/protocol/src/client.ts

@@ -19,16 +19,11 @@ type ClientApiShape = Api<
   Context.Service.Shape<typeof LocationMiddleware>,
   Context.Service.Identifier<typeof SessionLocationMiddleware>,
   Context.Service.Shape<typeof SessionLocationMiddleware>,
-  Context.Service.Identifier<typeof SessionLocationMiddleware>,
-  Context.Service.Shape<typeof SessionLocationMiddleware>,
   typeof EventGroup
 >
 
 export const ClientApi: ClientApiShape = makeDefaultApi({
   locationMiddleware: LocationMiddleware,
-  // The real server uses a form-specific middleware with an undocumented `global` sentinel branch.
-  // The generated client only needs a middleware identity for API typing.
-  formLocationMiddleware: SessionLocationMiddleware,
   sessionLocationMiddleware: SessionLocationMiddleware,
 })
 
@@ -45,13 +40,13 @@ export const groupNames = {
   "server.integration": "integration",
   "server.credential": "credential",
   "server.permission": "permission",
-  "server.form": "form",
   "server.fs": "file",
   "server.command": "command",
   "server.skill": "skill",
   "server.event": "event",
   "server.pty": "pty",
   "server.shell": "shell",
+  "server.question": "question",
   "server.reference": "reference",
   "server.project": "project",
   "server.projectCopy": "projectCopy",
@@ -73,7 +68,7 @@ export const endpointNames = {
   "permission.request.list": "listRequests",
   "permission.saved.list": "listSaved",
   "permission.saved.remove": "removeSaved",
-  "form.request.list": "listRequests",
+  "question.request.list": "listRequests",
 } as const
 
 export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])

+ 3 - 21
packages/protocol/src/errors.ts

@@ -122,33 +122,15 @@ export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionN
   { httpApiStatus: 404 },
 ) {}
 
-export class FormNotFoundError extends Schema.TaggedErrorClass<FormNotFoundError>()(
-  "FormNotFoundError",
+export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
+  "QuestionNotFoundError",
   {
-    id: Schema.String,
+    requestID: Schema.String,
     message: Schema.String,
   },
   { httpApiStatus: 404 },
 ) {}
 
-export class FormAlreadySettledError extends Schema.TaggedErrorClass<FormAlreadySettledError>()(
-  "FormAlreadySettledError",
-  {
-    id: Schema.String,
-    message: Schema.String,
-  },
-  { httpApiStatus: 409 },
-) {}
-
-export class FormInvalidAnswerError extends Schema.TaggedErrorClass<FormInvalidAnswerError>()(
-  "FormInvalidAnswerError",
-  {
-    id: Schema.String,
-    message: Schema.String,
-  },
-  { httpApiStatus: 400 },
-) {}
-
 export class ForbiddenError extends Schema.TaggedErrorClass<ForbiddenError>()(
   "ForbiddenError",
   { message: Schema.String },

+ 0 - 151
packages/protocol/src/groups/form.ts

@@ -1,151 +0,0 @@
-import { Form } from "@opencode-ai/schema/form"
-import { Location } from "@opencode-ai/schema/location"
-import { Context, Schema } from "effect"
-import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
-import { ConflictError, FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError, InvalidRequestError } from "../errors.js"
-import { LocationQuery, locationQueryOpenApi } from "./location.js"
-
-const CreatePayload = Schema.Struct({
-  id: Form.ID.pipe(Schema.optional),
-  title: Form.FormInfo.fields.title,
-  metadata: Form.FormInfo.fields.metadata,
-  mode: Schema.Literals(["form", "url"]),
-  fields: Form.FormInfo.fields.fields.pipe(Schema.optional),
-  url: Form.UrlInfo.fields.url.pipe(Schema.optional),
-}).annotate({ identifier: "Form.CreatePayload" })
-
-export type CreatePayload = typeof CreatePayload.Type
-
-// Form routes intentionally look session-scoped, but use a form-specific middleware instead of
-// SessionLocationMiddleware. The middleware treats real session IDs normally and has an
-// undocumented `global` sentinel branch for MCP elicitation forms that are still Location-scoped
-// but not session-owned. This is temporary and should disappear once elicitations are attributable.
-export const makeFormGroup = <
-  LocationId extends HttpApiMiddleware.AnyId,
-  LocationService,
-  FormLocationId extends HttpApiMiddleware.AnyId,
-  FormLocationService,
->(
-  locationMiddleware: Context.Key<LocationId, LocationService>,
-  formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>,
-) =>
-  HttpApiGroup.make("server.form")
-    .add(
-      HttpApiEndpoint.get("form.request.list", "/api/form/request", {
-        query: LocationQuery,
-        success: Location.response(Schema.Array(Form.Info)),
-      })
-        .annotateMerge(locationQueryOpenApi)
-        .annotateMerge(
-          OpenApi.annotations({
-            identifier: "v2.form.request.list",
-            summary: "List pending form requests",
-            description: "Retrieve pending forms for a location.",
-          }),
-        ),
-    )
-    .middleware(locationMiddleware)
-    .add(
-      HttpApiEndpoint.get("session.form.list", "/api/session/:sessionID/form", {
-        params: { sessionID: Schema.String },
-        query: LocationQuery,
-        success: Location.response(Schema.Array(Form.Info)),
-      })
-        .annotateMerge(locationQueryOpenApi)
-        .annotateMerge(
-          OpenApi.annotations({
-            identifier: "v2.session.form.list",
-            summary: "List session forms",
-            description: "Retrieve pending forms for a session.",
-          }),
-        )
-        .middleware(formLocationMiddleware),
-    )
-    .add(
-      HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
-        params: { sessionID: Schema.String },
-        query: LocationQuery,
-        payload: CreatePayload,
-        success: Location.response(Form.Info),
-        error: [ConflictError, InvalidRequestError],
-      })
-        .annotateMerge(locationQueryOpenApi)
-        .annotateMerge(
-          OpenApi.annotations({
-            identifier: "v2.session.form.create",
-            summary: "Create session form",
-            description: "Create a form for a session.",
-          }),
-        )
-        .middleware(formLocationMiddleware),
-    )
-    .add(
-      HttpApiEndpoint.get("session.form.get", "/api/session/:sessionID/form/:formID", {
-        params: { sessionID: Schema.String, formID: Form.ID },
-        query: LocationQuery,
-        success: Location.response(Form.Info),
-        error: FormNotFoundError,
-      })
-        .annotateMerge(locationQueryOpenApi)
-        .annotateMerge(
-          OpenApi.annotations({
-            identifier: "v2.session.form.get",
-            summary: "Get session form",
-            description: "Retrieve a form for a session.",
-          }),
-        )
-        .middleware(formLocationMiddleware),
-    )
-    .add(
-      HttpApiEndpoint.get("session.form.state", "/api/session/:sessionID/form/:formID/state", {
-        params: { sessionID: Schema.String, formID: Form.ID },
-        query: LocationQuery,
-        success: Location.response(Form.State),
-        error: FormNotFoundError,
-      })
-        .annotateMerge(locationQueryOpenApi)
-        .annotateMerge(
-          OpenApi.annotations({
-            identifier: "v2.session.form.state",
-            summary: "Get form state",
-            description: "Retrieve the current state for a form.",
-          }),
-        )
-        .middleware(formLocationMiddleware),
-    )
-    .add(
-      HttpApiEndpoint.post("session.form.reply", "/api/session/:sessionID/form/:formID/reply", {
-        params: { sessionID: Schema.String, formID: Form.ID },
-        query: LocationQuery,
-        payload: Form.Reply,
-        success: HttpApiSchema.NoContent,
-        error: [FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError],
-      })
-        .annotateMerge(locationQueryOpenApi)
-        .annotateMerge(
-          OpenApi.annotations({
-            identifier: "v2.session.form.reply",
-            summary: "Reply to form",
-            description: "Submit an answer to a pending form.",
-          }),
-        )
-        .middleware(formLocationMiddleware),
-    )
-    .add(
-      HttpApiEndpoint.post("session.form.cancel", "/api/session/:sessionID/form/:formID/cancel", {
-        params: { sessionID: Schema.String, formID: Form.ID },
-        query: LocationQuery,
-        success: HttpApiSchema.NoContent,
-        error: [FormAlreadySettledError, FormNotFoundError],
-      })
-        .annotateMerge(locationQueryOpenApi)
-        .annotateMerge(
-          OpenApi.annotations({
-            identifier: "v2.session.form.cancel",
-            summary: "Cancel form",
-            description: "Cancel a pending form.",
-          }),
-        )
-        .middleware(formLocationMiddleware),
-    )
-    .annotateMerge(OpenApi.annotations({ title: "forms", description: "Session form routes." }))

+ 84 - 0
packages/protocol/src/groups/question.ts

@@ -0,0 +1,84 @@
+import { Question } from "@opencode-ai/schema/question"
+import { Location } from "@opencode-ai/schema/location"
+import { Session } from "@opencode-ai/schema/session"
+import { Context, Schema } from "effect"
+import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { QuestionNotFoundError, SessionNotFoundError } from "../errors.js"
+import { LocationQuery, locationQueryOpenApi } from "./location.js"
+
+export const makeQuestionGroup = <
+  LocationId extends HttpApiMiddleware.AnyId,
+  LocationService,
+  SessionLocationId extends HttpApiMiddleware.AnyId,
+  SessionLocationService,
+>(
+  locationMiddleware: Context.Key<LocationId, LocationService>,
+  sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
+) =>
+  HttpApiGroup.make("server.question")
+    .add(
+      HttpApiEndpoint.get("question.request.list", "/api/question/request", {
+        query: LocationQuery,
+        success: Location.response(Schema.Array(Question.Request)),
+      })
+        .annotateMerge(locationQueryOpenApi)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.question.request.list",
+            summary: "List pending question requests",
+            description: "Retrieve pending question requests for a location.",
+          }),
+        ),
+    )
+    .annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
+    // Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
+    .middleware(locationMiddleware)
+    .add(
+      HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
+        params: { sessionID: Session.ID },
+        success: Schema.Struct({ data: Schema.Array(Question.Request) }),
+        error: SessionNotFoundError,
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.question.list",
+            summary: "List session question requests",
+            description: "Retrieve pending question requests owned by a session.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
+        params: { sessionID: Session.ID, requestID: Question.ID },
+        payload: Question.Reply,
+        success: HttpApiSchema.NoContent,
+        error: [SessionNotFoundError, QuestionNotFoundError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.question.reply",
+            summary: "Reply to pending question request",
+            description: "Answer a pending question request owned by a session.",
+          }),
+        ),
+    )
+    .add(
+      HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
+        params: { sessionID: Session.ID, requestID: Question.ID },
+        success: HttpApiSchema.NoContent,
+        error: [SessionNotFoundError, QuestionNotFoundError],
+      })
+        .middleware(sessionLocationMiddleware)
+        .annotateMerge(
+          OpenApi.annotations({
+            identifier: "v2.session.question.reject",
+            summary: "Reject pending question request",
+            description: "Reject a pending question request owned by a session.",
+          }),
+        ),
+    )
+    .annotateMerge(
+      OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }),
+    )

+ 2 - 2
packages/schema/src/event-manifest.ts

@@ -7,7 +7,6 @@ import { Durable } from "./durable-event-manifest.js"
 import { Event } from "./event.js"
 import { FileSystem } from "./filesystem.js"
 import { FileSystemWatcher } from "./filesystem-watcher.js"
-import { Form } from "./form.js"
 import { InstallationEvent } from "./installation-event.js"
 import { Integration } from "./integration.js"
 import { LegacyEvent } from "./legacy-event.js"
@@ -20,6 +19,7 @@ import { Plugin } from "./plugin.js"
 import { Project } from "./project.js"
 import { ProjectDirectories } from "./project-directories.js"
 import { Pty } from "./pty.js"
+import { Question } from "./question.js"
 import { QuestionV1 } from "./question-v1.js"
 import { Reference } from "./reference.js"
 import { ServerEvent } from "./server-event.js"
@@ -59,7 +59,7 @@ const featureDefinitions = Event.inventory(
   ...FileSystemWatcher.Event.Definitions,
   ...Pty.Event.Definitions,
   ...Shell.Event.Definitions,
-  ...Form.Event.Definitions,
+  ...Question.Event.Definitions,
 )
 
 export const ServerDefinitions = Event.inventory(

+ 0 - 149
packages/schema/src/form.ts

@@ -1,149 +0,0 @@
-export * as Form from "./form.js"
-
-import { Schema } from "effect"
-import { define, inventory } from "./event.js"
-import { ascending } from "./identifier.js"
-import { NonNegativeInt, optional, statics } from "./schema.js"
-
-const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
-
-export const ID = IDSchema.pipe(
-  statics((schema: typeof IDSchema) => ({ create: (id?: string) => schema.make(id ?? "frm_" + ascending()) })),
-)
-export type ID = typeof ID.Type
-
-export const Metadata = Schema.Record(Schema.String, Schema.Unknown).annotate({ identifier: "Form.Metadata" })
-export type Metadata = typeof Metadata.Type
-
-export const Option = Schema.Struct({
-  value: Schema.String,
-  label: Schema.String,
-  description: Schema.String.pipe(optional),
-}).annotate({ identifier: "Form.Option" })
-export interface Option extends Schema.Schema.Type<typeof Option> {}
-
-export const When = Schema.Struct({
-  key: Schema.String,
-  op: Schema.Literals(["eq", "neq"]),
-  value: Schema.String,
-}).annotate({ identifier: "Form.When" })
-export interface When extends Schema.Schema.Type<typeof When> {}
-
-const FieldBase = {
-  key: Schema.String,
-  title: Schema.String.pipe(optional),
-  description: Schema.String.pipe(optional),
-  required: Schema.Boolean.pipe(optional),
-  when: When.pipe(optional),
-}
-
-export const StringField = Schema.Struct({
-  ...FieldBase,
-  type: Schema.Literal("string"),
-  format: Schema.Literals(["email", "uri", "date", "date-time"]).pipe(optional),
-  minLength: NonNegativeInt.pipe(optional),
-  maxLength: NonNegativeInt.pipe(optional),
-  pattern: Schema.String.pipe(optional),
-  placeholder: Schema.String.pipe(optional),
-  default: Schema.String.pipe(optional),
-  options: Schema.Array(Option).pipe(optional),
-  custom: Schema.Boolean.pipe(optional),
-}).annotate({ identifier: "Form.StringField" })
-export interface StringField extends Schema.Schema.Type<typeof StringField> {}
-
-export const NumberField = Schema.Struct({
-  ...FieldBase,
-  type: Schema.Literal("number"),
-  minimum: Schema.Number.pipe(optional),
-  maximum: Schema.Number.pipe(optional),
-  default: Schema.Number.pipe(optional),
-}).annotate({ identifier: "Form.NumberField" })
-export interface NumberField extends Schema.Schema.Type<typeof NumberField> {}
-
-export const IntegerField = Schema.Struct({
-  ...FieldBase,
-  type: Schema.Literal("integer"),
-  minimum: Schema.Number.pipe(optional),
-  maximum: Schema.Number.pipe(optional),
-  default: Schema.Number.pipe(optional),
-}).annotate({ identifier: "Form.IntegerField" })
-export interface IntegerField extends Schema.Schema.Type<typeof IntegerField> {}
-
-export const BooleanField = Schema.Struct({
-  ...FieldBase,
-  type: Schema.Literal("boolean"),
-  default: Schema.Boolean.pipe(optional),
-}).annotate({ identifier: "Form.BooleanField" })
-export interface BooleanField extends Schema.Schema.Type<typeof BooleanField> {}
-
-export const MultiselectField = Schema.Struct({
-  ...FieldBase,
-  type: Schema.Literal("multiselect"),
-  options: Schema.Array(Option),
-  minItems: NonNegativeInt.pipe(optional),
-  maxItems: NonNegativeInt.pipe(optional),
-  custom: Schema.Boolean.pipe(optional),
-  default: Schema.Array(Schema.String).pipe(optional),
-}).annotate({ identifier: "Form.MultiselectField" })
-export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
-
-export const Field = Schema.Union([StringField, NumberField, IntegerField, BooleanField, MultiselectField]).pipe(
-  Schema.toTaggedUnion("type"),
-)
-export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField
-
-const InfoBase = {
-  id: ID,
-  // Public form flows are session-owned. This is intentionally `string` because the server
-  // currently accepts an undocumented `global` sentinel for MCP elicitation forms that cannot
-  // be attributed to a concrete session yet. Do not document or rely on `global` outside our
-  // own clients; remove the sentinel path once MCP elicitation can carry session ownership.
-  sessionID: Schema.String,
-  title: Schema.String.pipe(optional),
-  metadata: Metadata.pipe(optional),
-}
-
-export const FormInfo = Schema.Struct({
-  ...InfoBase,
-  mode: Schema.Literal("form"),
-  fields: Schema.Array(Field),
-}).annotate({ identifier: "Form.FormInfo" })
-export interface FormInfo extends Schema.Schema.Type<typeof FormInfo> {}
-
-export const UrlInfo = Schema.Struct({
-  ...InfoBase,
-  mode: Schema.Literal("url"),
-  url: Schema.String,
-}).annotate({ identifier: "Form.UrlInfo" })
-export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
-
-export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
-export type Info = FormInfo | UrlInfo
-
-export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate({
-  identifier: "Form.Value",
-})
-export type Value = typeof Value.Type
-
-export const Answer = Schema.Record(Schema.String, Value).annotate({ identifier: "Form.Answer" })
-export type Answer = typeof Answer.Type
-
-export const State = Schema.Union([
-  Schema.Struct({ status: Schema.Literal("pending") }),
-  Schema.Struct({ status: Schema.Literal("answered"), answer: Answer }),
-  Schema.Struct({ status: Schema.Literal("cancelled") }),
-])
-  .pipe(Schema.toTaggedUnion("status"))
-  .annotate({ identifier: "Form.State" })
-export type State = typeof State.Type
-
-export const Reply = Schema.Struct({
-  answer: Answer,
-}).annotate({ identifier: "Form.Reply" })
-export interface Reply extends Schema.Schema.Type<typeof Reply> {}
-
-const Created = define({ type: "form.created", schema: { form: Info } })
-const Replied = define({ type: "form.replied", schema: { id: ID, sessionID: Schema.String, answer: Answer } })
-const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } })
-
-export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) }

+ 1 - 1
packages/schema/src/index.ts

@@ -4,7 +4,6 @@ export { Connection } from "./connection.js"
 export { Credential } from "./credential.js"
 export { Event } from "./event.js"
 export { FileSystem } from "./filesystem.js"
-export { Form } from "./form.js"
 export { Integration } from "./integration.js"
 export { LLM } from "./llm.js"
 export { Location } from "./location.js"
@@ -23,6 +22,7 @@ export { Shell } from "./shell.js"
 export { Skill } from "./skill.js"
 export { Pty } from "./pty.js"
 export { PtyTicket } from "./pty-ticket.js"
+export { Question } from "./question.js"
 export { Workspace } from "./workspace.js"
 export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt.js"
 export { PromptInput } from "./prompt-input.js"

+ 86 - 0
packages/schema/src/question.ts

@@ -0,0 +1,86 @@
+export * as Question from "./question.js"
+
+import { Schema } from "effect"
+import { optional } from "./schema.js"
+import { define, inventory } from "./event.js"
+import { ascending } from "./identifier.js"
+import { SessionID } from "./session-id.js"
+import { statics } from "./schema.js"
+
+export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
+  Schema.brand("QuestionV2.ID"),
+  statics((schema) => {
+    const create = () => schema.make("que_" + ascending())
+    return {
+      create,
+      ascending: (id?: string) => (id === undefined ? create() : schema.make(id)),
+    }
+  }),
+)
+export type ID = typeof ID.Type
+
+export const Option = Schema.Struct({
+  label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
+  description: Schema.String.annotate({ description: "Explanation of choice" }),
+}).annotate({ identifier: "QuestionV2.Option" })
+export interface Option extends Schema.Schema.Type<typeof Option> {}
+
+const base = {
+  question: Schema.String.annotate({ description: "Complete question" }),
+  header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
+  options: Schema.Array(Option).annotate({ description: "Available choices" }),
+  multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }),
+}
+
+export const Info = Schema.Struct({
+  ...base,
+  custom: Schema.Boolean.pipe(optional).annotate({
+    description: "Allow typing a custom answer (default: true)",
+  }),
+}).annotate({ identifier: "QuestionV2.Info" })
+export interface Info extends Schema.Schema.Type<typeof Info> {}
+
+export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" })
+export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
+
+export const Tool = Schema.Struct({
+  messageID: Schema.String,
+  callID: Schema.String,
+}).annotate({ identifier: "QuestionV2.Tool" })
+export interface Tool extends Schema.Schema.Type<typeof Tool> {}
+
+export const Request = Schema.Struct({
+  id: ID,
+  sessionID: SessionID,
+  questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
+  tool: Tool.pipe(optional),
+}).annotate({ identifier: "QuestionV2.Request" })
+export interface Request extends Schema.Schema.Type<typeof Request> {}
+
+export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" })
+export type Answer = typeof Answer.Type
+
+export const Reply = Schema.Struct({
+  answers: Schema.Array(Answer).annotate({
+    description: "User answers in order of questions (each answer is an array of selected labels)",
+  }),
+}).annotate({ identifier: "QuestionV2.Reply" })
+export interface Reply extends Schema.Schema.Type<typeof Reply> {}
+
+const Asked = define({ type: "question.v2.asked", schema: Request.fields })
+const Replied = define({
+  type: "question.v2.replied",
+  schema: {
+    sessionID: SessionID,
+    requestID: ID,
+    answers: Schema.Array(Answer),
+  },
+})
+const Rejected = define({
+  type: "question.v2.rejected",
+  schema: {
+    sessionID: SessionID,
+    requestID: ID,
+  },
+})
+export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) }

+ 2 - 2
packages/schema/test/contract-hygiene.test.ts

@@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test"
 import { Schema } from "effect"
 import { Agent } from "../src/agent.js"
 import { FileSystem } from "../src/filesystem.js"
-import { Form } from "../src/form.js"
 import { Model } from "../src/model.js"
 import { Project } from "../src/project.js"
 import { Pty } from "../src/pty.js"
+import { Question } from "../src/question.js"
 import { Session } from "../src/session.js"
 import { SessionTodo } from "../src/session-todo.js"
 import { optional } from "../src/schema.js"
@@ -28,7 +28,7 @@ describe("contract hygiene", () => {
   })
 
   test("current ID constructors expose create", () => {
-    expect(Form.ID.create()).toStartWith("frm_")
+    expect(Question.ID.create()).toStartWith("que_")
     expect(Pty.ID.create()).toStartWith("pty_")
   })
 

+ 4 - 8
packages/schema/test/event-manifest.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { Agent, FileSystem, Form, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js"
+import { Agent, FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js"
 import { EventManifest } from "../src/event-manifest.js"
 import { IdeEvent } from "../src/ide-event.js"
 import { SessionEvent } from "../src/session-event.js"
@@ -9,11 +9,11 @@ import { WorkspaceEvent } from "../src/workspace-event.js"
 
 describe("public event manifest", () => {
   test("owns the complete public event surface", () => {
-    expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(65)
+    expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(63)
     expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([
       Agent.Event.Updated,
     ])
-    expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(96)
+    expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(93)
     expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([
       Agent.Event.Updated,
     ])
@@ -29,7 +29,7 @@ describe("public event manifest", () => {
       SessionV1.Event.Diff,
       SessionV1.Event.Error,
     ])
-    expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(96)
+    expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
     expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
     expect(Agent.Event.Updated.durable).toBeUndefined()
     expect(EventManifest.Durable.has("agent.updated")).toBe(false)
@@ -43,16 +43,12 @@ describe("public event manifest", () => {
     expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
     expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated)
     expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
-    expect(EventManifest.Latest.get("form.created")).toBe(Form.Event.Created)
-    expect(EventManifest.Latest.get("form.replied")).toBe(Form.Event.Replied)
-    expect(EventManifest.Latest.get("form.cancelled")).toBe(Form.Event.Cancelled)
     expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
     expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated])
     expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
     expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited])
     expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
     expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
-    expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
     expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
     expect(EventManifest.Latest.has("ide.installed")).toBe(false)
     expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])

+ 41 - 4
packages/sdk/js/script/build.ts

@@ -91,7 +91,8 @@ await createClient({
   ],
 })
 
-const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text()
+const generatedTypesPath = "./src/v2/gen/types.gen.ts"
+const generatedTypes = await Bun.file(generatedTypesPath).text()
 if (/export type SessionNext\w+1 =/.test(generatedTypes)) {
   throw new Error("Session history generated duplicate Session event variants")
 }
@@ -102,7 +103,28 @@ const logTypesPatched = generatedTypes.replace(
 if (logTypesPatched === generatedTypes) {
   throw new Error("Session log numeric query patch did not apply")
 }
-await Bun.write("./src/v2/gen/types.gen.ts", logTypesPatched)
+const sessionListTypesPatched = logTypesPatched.replace(
+  /(export type V2SessionListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/,
+  "$1number$2",
+)
+if (sessionListTypesPatched === logTypesPatched) {
+  throw new Error("Session list numeric query patch did not apply")
+}
+const sessionMessagesTypesPatched = sessionListTypesPatched.replace(
+  /(export type V2SessionMessagesData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/,
+  "$1number$2",
+)
+if (sessionMessagesTypesPatched === sessionListTypesPatched) {
+  throw new Error("Session messages numeric query patch did not apply")
+}
+const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace(
+  /(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStreamV2;?\s*\};?/,
+  "$1V2Event",
+)
+if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) {
+  throw new Error("Event subscribe response patch did not apply")
+}
+await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
 
 const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
 const querySerializerSource = await Bun.file(querySerializerPath).text()
@@ -117,7 +139,8 @@ if (querySerializerPatched === querySerializerSource) {
 }
 await Bun.write(querySerializerPath, querySerializerPatched)
 
-const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text()
+const generatedSdkPath = "./src/v2/gen/sdk.gen.ts"
+const generatedSdk = await Bun.file(generatedSdkPath).text()
 const logSdkPatched = generatedSdk.replace(
   /(Read the session log[\s\S]*?parameters: \{[\s\S]*?after\?: )string(\s*\|\s*null)?/,
   "$1number$2",
@@ -125,7 +148,21 @@ const logSdkPatched = generatedSdk.replace(
 if (logSdkPatched === generatedSdk) {
   throw new Error("Session log numeric SDK patch did not apply")
 }
-await Bun.write("./src/v2/gen/sdk.gen.ts", logSdkPatched)
+const sessionListSdkPatched = logSdkPatched.replace(
+  /(List sessions[\s\S]*?parameters\?: \{[\s\S]*?limit\?: )string( \| null)/,
+  "$1number$2",
+)
+if (sessionListSdkPatched === logSdkPatched) {
+  throw new Error("Session list numeric SDK patch did not apply")
+}
+const sessionMessagesSdkPatched = sessionListSdkPatched.replace(
+  /(Get session messages[\s\S]*?parameters: \{[\s\S]*?limit\?: )string( \| null)/,
+  "$1number$2",
+)
+if (sessionMessagesSdkPatched === sessionListSdkPatched) {
+  throw new Error("Session messages numeric SDK patch did not apply")
+}
+await Bun.write(generatedSdkPath, sessionMessagesSdkPatched)
 
 // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
 // endpoint's TError into the second generic of ServerSentEventsResult, which

+ 153 - 282
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -76,8 +76,6 @@ import type {
   FindTextResponses,
   FormatterStatusErrors,
   FormatterStatusResponses,
-  FormCreatePayload,
-  FormReply,
   GlobalConfigGetErrors,
   GlobalConfigGetResponses,
   GlobalConfigUpdateErrors,
@@ -145,8 +143,8 @@ import type {
   ProjectUpdateErrors,
   ProjectUpdateResponses,
   PromptAgentAttachment2,
-  PromptInput,
-  PromptInputFileAttachment,
+  PromptInputFileAttachment2,
+  PromptInputV2,
   ProviderAuthErrors,
   ProviderAuthResponses,
   ProviderListErrors,
@@ -178,13 +176,14 @@ import type {
   QuestionRejectResponses,
   QuestionReplyErrors,
   QuestionReplyResponses,
+  QuestionV2Reply2,
   SessionAbortErrors,
   SessionAbortResponses,
   SessionChildrenErrors,
   SessionChildrenResponses,
   SessionCommandErrors,
   SessionCommandResponses,
-  SessionContextEntryKey,
+  SessionContextEntryKey2,
   SessionCreateErrors,
   SessionCreateResponses,
   SessionDeleteErrors,
@@ -279,8 +278,6 @@ import type {
   V2EventChangesResponses,
   V2EventSubscribeErrors,
   V2EventSubscribeResponses,
-  V2FormRequestListErrors,
-  V2FormRequestListResponses,
   V2FsFindErrors,
   V2FsFindResponses,
   V2FsListErrors,
@@ -349,6 +346,8 @@ import type {
   V2PtyRemoveResponses,
   V2PtyUpdateErrors,
   V2PtyUpdateResponses,
+  V2QuestionRequestListErrors,
+  V2QuestionRequestListResponses,
   V2ReferenceListErrors,
   V2ReferenceListResponses,
   V2SessionActiveErrors,
@@ -371,18 +370,6 @@ import type {
   V2SessionCreateResponses,
   V2SessionForkErrors,
   V2SessionForkResponses,
-  V2SessionFormCancelErrors,
-  V2SessionFormCancelResponses,
-  V2SessionFormCreateErrors,
-  V2SessionFormCreateResponses,
-  V2SessionFormGetErrors,
-  V2SessionFormGetResponses,
-  V2SessionFormListErrors,
-  V2SessionFormListResponses,
-  V2SessionFormReplyErrors,
-  V2SessionFormReplyResponses,
-  V2SessionFormStateErrors,
-  V2SessionFormStateResponses,
   V2SessionGetErrors,
   V2SessionGetResponses,
   V2SessionInterruptErrors,
@@ -405,6 +392,12 @@ import type {
   V2SessionPermissionReplyResponses,
   V2SessionPromptErrors,
   V2SessionPromptResponses,
+  V2SessionQuestionListErrors,
+  V2SessionQuestionListResponses,
+  V2SessionQuestionRejectErrors,
+  V2SessionQuestionRejectResponses,
+  V2SessionQuestionReplyErrors,
+  V2SessionQuestionReplyResponses,
   V2SessionRenameErrors,
   V2SessionRenameResponses,
   V2SessionRevertClearErrors,
@@ -5274,7 +5267,7 @@ export class Entry extends HeyApiClient {
   public remove<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
-      key: SessionContextEntryKey
+      key: SessionContextEntryKey2
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -5308,7 +5301,7 @@ export class Entry extends HeyApiClient {
   public put<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
-      key: SessionContextEntryKey
+      key: SessionContextEntryKey2
       value?: unknown
     },
     options?: Options<never, ThrowOnError>,
@@ -5349,95 +5342,47 @@ export class Context extends HeyApiClient {
   }
 }
 
-export class Form extends HeyApiClient {
+export class Permission2 extends HeyApiClient {
   /**
-   * List session forms
+   * List session permission requests
    *
-   * Retrieve pending forms for a session.
+   * Retrieve pending permission requests owned by a session.
    */
   public list<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
-      location?: {
-        directory?: string | null
-        workspace?: string | null
-      } | null
     },
     options?: Options<never, ThrowOnError>,
   ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "sessionID" },
-            { in: "query", key: "location" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).get<V2SessionFormListResponses, V2SessionFormListErrors, ThrowOnError>({
-      url: "/api/session/{sessionID}/form",
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
+    return (options?.client ?? this.client).get<
+      V2SessionPermissionListResponses,
+      V2SessionPermissionListErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/permission",
       ...options,
       ...params,
     })
   }
 
   /**
-   * Create session form
+   * Create permission request
    *
-   * Create a form for a session.
+   * Evaluate and, when approval is required, create a permission request for a session.
    */
   public create<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
-      location?: {
-        directory?: string | null
-        workspace?: string | null
-      } | null
-      formCreatePayload: FormCreatePayload
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "sessionID" },
-            { in: "query", key: "location" },
-            { key: "formCreatePayload", map: "body" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).post<V2SessionFormCreateResponses, V2SessionFormCreateErrors, ThrowOnError>(
-      {
-        url: "/api/session/{sessionID}/form",
-        ...options,
-        ...params,
-        headers: {
-          "Content-Type": "application/json",
-          ...options?.headers,
-          ...params.headers,
-        },
-      },
-    )
-  }
-
-  /**
-   * Get session form
-   *
-   * Retrieve a form for a session.
-   */
-  public get<ThrowOnError extends boolean = false>(
-    parameters: {
-      sessionID: string
-      formID: string
-      location?: {
-        directory?: string | null
-        workspace?: string | null
-      } | null
+      id?: string | null
+      action?: string
+      resources?: Array<string>
+      save?: Array<string>
+      metadata?: {
+        [key: string]: unknown
+      }
+      source?: PermissionV2Source2
+      agent?: string | null
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -5447,32 +5392,42 @@ export class Form extends HeyApiClient {
         {
           args: [
             { in: "path", key: "sessionID" },
-            { in: "path", key: "formID" },
-            { in: "query", key: "location" },
+            { in: "body", key: "id" },
+            { in: "body", key: "action" },
+            { in: "body", key: "resources" },
+            { in: "body", key: "save" },
+            { in: "body", key: "metadata" },
+            { in: "body", key: "source" },
+            { in: "body", key: "agent" },
           ],
         },
       ],
     )
-    return (options?.client ?? this.client).get<V2SessionFormGetResponses, V2SessionFormGetErrors, ThrowOnError>({
-      url: "/api/session/{sessionID}/form/{formID}",
+    return (options?.client ?? this.client).post<
+      V2SessionPermissionCreateResponses,
+      V2SessionPermissionCreateErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/permission",
       ...options,
       ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
     })
   }
 
   /**
-   * Get form state
+   * Get permission request
    *
-   * Retrieve the current state for a form.
+   * Retrieve a pending permission request owned by a session.
    */
-  public state<ThrowOnError extends boolean = false>(
+  public get<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
-      formID: string
-      location?: {
-        directory?: string | null
-        workspace?: string | null
-      } | null
+      requestID: string
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -5482,33 +5437,33 @@ export class Form extends HeyApiClient {
         {
           args: [
             { in: "path", key: "sessionID" },
-            { in: "path", key: "formID" },
-            { in: "query", key: "location" },
+            { in: "path", key: "requestID" },
           ],
         },
       ],
     )
-    return (options?.client ?? this.client).get<V2SessionFormStateResponses, V2SessionFormStateErrors, ThrowOnError>({
-      url: "/api/session/{sessionID}/form/{formID}/state",
+    return (options?.client ?? this.client).get<
+      V2SessionPermissionGetResponses,
+      V2SessionPermissionGetErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/permission/{requestID}",
       ...options,
       ...params,
     })
   }
 
   /**
-   * Reply to form
+   * Reply to pending permission request
    *
-   * Submit an answer to a pending form.
+   * Respond to a pending permission request owned by a session.
    */
   public reply<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
-      formID: string
-      location?: {
-        directory?: string | null
-        workspace?: string | null
-      } | null
-      formReply: FormReply
+      requestID: string
+      reply?: PermissionV2Reply2
+      message?: string | null
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -5518,15 +5473,19 @@ export class Form extends HeyApiClient {
         {
           args: [
             { in: "path", key: "sessionID" },
-            { in: "path", key: "formID" },
-            { in: "query", key: "location" },
-            { key: "formReply", map: "body" },
+            { in: "path", key: "requestID" },
+            { in: "body", key: "reply" },
+            { in: "body", key: "message" },
           ],
         },
       ],
     )
-    return (options?.client ?? this.client).post<V2SessionFormReplyResponses, V2SessionFormReplyErrors, ThrowOnError>({
-      url: "/api/session/{sessionID}/form/{formID}/reply",
+    return (options?.client ?? this.client).post<
+      V2SessionPermissionReplyResponses,
+      V2SessionPermissionReplyErrors,
+      ThrowOnError
+    >({
+      url: "/api/session/{sessionID}/permission/{requestID}/reply",
       ...options,
       ...params,
       headers: {
@@ -5536,50 +5495,13 @@ export class Form extends HeyApiClient {
       },
     })
   }
-
-  /**
-   * Cancel form
-   *
-   * Cancel a pending form.
-   */
-  public cancel<ThrowOnError extends boolean = false>(
-    parameters: {
-      sessionID: string
-      formID: string
-      location?: {
-        directory?: string | null
-        workspace?: string | null
-      } | null
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "sessionID" },
-            { in: "path", key: "formID" },
-            { in: "query", key: "location" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).post<V2SessionFormCancelResponses, V2SessionFormCancelErrors, ThrowOnError>(
-      {
-        url: "/api/session/{sessionID}/form/{formID}/cancel",
-        ...options,
-        ...params,
-      },
-    )
-  }
 }
 
-export class Permission2 extends HeyApiClient {
+export class Question2 extends HeyApiClient {
   /**
-   * List session permission requests
+   * List session question requests
    *
-   * Retrieve pending permission requests owned by a session.
+   * Retrieve pending question requests owned by a session.
    */
   public list<ThrowOnError extends boolean = false>(
     parameters: {
@@ -5589,33 +5511,26 @@ export class Permission2 extends HeyApiClient {
   ) {
     const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
     return (options?.client ?? this.client).get<
-      V2SessionPermissionListResponses,
-      V2SessionPermissionListErrors,
+      V2SessionQuestionListResponses,
+      V2SessionQuestionListErrors,
       ThrowOnError
     >({
-      url: "/api/session/{sessionID}/permission",
+      url: "/api/session/{sessionID}/question",
       ...options,
       ...params,
     })
   }
 
   /**
-   * Create permission request
+   * Reply to pending question request
    *
-   * Evaluate and, when approval is required, create a permission request for a session.
+   * Answer a pending question request owned by a session.
    */
-  public create<ThrowOnError extends boolean = false>(
+  public reply<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
-      id?: string | null
-      action?: string
-      resources?: Array<string>
-      save?: Array<string>
-      metadata?: {
-        [key: string]: unknown
-      }
-      source?: PermissionV2Source2
-      agent?: string | null
+      requestID: string
+      questionV2Reply: QuestionV2Reply2
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -5625,23 +5540,18 @@ export class Permission2 extends HeyApiClient {
         {
           args: [
             { in: "path", key: "sessionID" },
-            { in: "body", key: "id" },
-            { in: "body", key: "action" },
-            { in: "body", key: "resources" },
-            { in: "body", key: "save" },
-            { in: "body", key: "metadata" },
-            { in: "body", key: "source" },
-            { in: "body", key: "agent" },
+            { in: "path", key: "requestID" },
+            { key: "questionV2Reply", map: "body" },
           ],
         },
       ],
     )
     return (options?.client ?? this.client).post<
-      V2SessionPermissionCreateResponses,
-      V2SessionPermissionCreateErrors,
+      V2SessionQuestionReplyResponses,
+      V2SessionQuestionReplyErrors,
       ThrowOnError
     >({
-      url: "/api/session/{sessionID}/permission",
+      url: "/api/session/{sessionID}/question/{requestID}/reply",
       ...options,
       ...params,
       headers: {
@@ -5653,50 +5563,14 @@ export class Permission2 extends HeyApiClient {
   }
 
   /**
-   * Get permission request
-   *
-   * Retrieve a pending permission request owned by a session.
-   */
-  public get<ThrowOnError extends boolean = false>(
-    parameters: {
-      sessionID: string
-      requestID: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "sessionID" },
-            { in: "path", key: "requestID" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).get<
-      V2SessionPermissionGetResponses,
-      V2SessionPermissionGetErrors,
-      ThrowOnError
-    >({
-      url: "/api/session/{sessionID}/permission/{requestID}",
-      ...options,
-      ...params,
-    })
-  }
-
-  /**
-   * Reply to pending permission request
+   * Reject pending question request
    *
-   * Respond to a pending permission request owned by a session.
+   * Reject a pending question request owned by a session.
    */
-  public reply<ThrowOnError extends boolean = false>(
+  public reject<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
       requestID: string
-      reply?: PermissionV2Reply2
-      message?: string | null
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -5707,25 +5581,18 @@ export class Permission2 extends HeyApiClient {
           args: [
             { in: "path", key: "sessionID" },
             { in: "path", key: "requestID" },
-            { in: "body", key: "reply" },
-            { in: "body", key: "message" },
           ],
         },
       ],
     )
     return (options?.client ?? this.client).post<
-      V2SessionPermissionReplyResponses,
-      V2SessionPermissionReplyErrors,
+      V2SessionQuestionRejectResponses,
+      V2SessionQuestionRejectErrors,
       ThrowOnError
     >({
-      url: "/api/session/{sessionID}/permission/{requestID}/reply",
+      url: "/api/session/{sessionID}/question/{requestID}/reject",
       ...options,
       ...params,
-      headers: {
-        "Content-Type": "application/json",
-        ...options?.headers,
-        ...params.headers,
-      },
     })
   }
 }
@@ -5739,7 +5606,7 @@ export class Session3 extends HeyApiClient {
   public list<ThrowOnError extends boolean = false>(
     parameters?: {
       workspace?: string | null
-      limit?: string | null
+      limit?: number | null
       order?: "asc" | "desc" | null
       search?: string | null
       parentID?: string | "null" | null
@@ -6002,7 +5869,7 @@ export class Session3 extends HeyApiClient {
     parameters: {
       sessionID: string
       id?: string | null
-      prompt?: PromptInput
+      prompt?: PromptInputV2
       delivery?: "steer" | "queue" | null
       resume?: boolean | null
     },
@@ -6047,7 +5914,7 @@ export class Session3 extends HeyApiClient {
       arguments?: string | null
       agent?: string | null
       model?: ModelRef2 | null
-      files?: Array<PromptInputFileAttachment>
+      files?: Array<PromptInputFileAttachment2>
       agents?: Array<PromptAgentAttachment2>
       delivery?: "steer" | "queue" | null
       resume?: boolean | null
@@ -6225,7 +6092,7 @@ export class Session3 extends HeyApiClient {
   /**
    * Read the session log
    *
-   * Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a caught-up marker once the replay reaches the end of the log, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.
+   * Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.
    */
   public log<ThrowOnError extends boolean = false>(
     parameters: {
@@ -6332,7 +6199,7 @@ export class Session3 extends HeyApiClient {
   public messages<ThrowOnError extends boolean = false>(
     parameters: {
       sessionID: string
-      limit?: string | null
+      limit?: number | null
       order?: "asc" | "desc" | null
       cursor?: string | null
     },
@@ -6368,15 +6235,15 @@ export class Session3 extends HeyApiClient {
     return (this._context ??= new Context({ client: this.client }))
   }
 
-  private _form?: Form
-  get form(): Form {
-    return (this._form ??= new Form({ client: this.client }))
-  }
-
   private _permission?: Permission2
   get permission(): Permission2 {
     return (this._permission ??= new Permission2({ client: this.client }))
   }
+
+  private _question?: Question2
+  get question(): Question2 {
+    return (this._question ??= new Question2({ client: this.client }))
+  }
 }
 
 export class Model extends HeyApiClient {
@@ -6972,37 +6839,6 @@ export class Project2 extends HeyApiClient {
 }
 
 export class Request extends HeyApiClient {
-  /**
-   * List pending form requests
-   *
-   * Retrieve pending forms for a location.
-   */
-  public list<ThrowOnError extends boolean = false>(
-    parameters?: {
-      location?: {
-        directory?: string | null
-        workspace?: string | null
-      } | null
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
-    return (options?.client ?? this.client).get<V2FormRequestListResponses, V2FormRequestListErrors, ThrowOnError>({
-      url: "/api/form/request",
-      ...options,
-      ...params,
-    })
-  }
-}
-
-export class Form2 extends HeyApiClient {
-  private _request?: Request
-  get request(): Request {
-    return (this._request ??= new Request({ client: this.client }))
-  }
-}
-
-export class Request2 extends HeyApiClient {
   /**
    * List pending permission requests
    *
@@ -7079,9 +6915,9 @@ export class Saved extends HeyApiClient {
 }
 
 export class Permission3 extends HeyApiClient {
-  private _request?: Request2
-  get request(): Request2 {
-    return (this._request ??= new Request2({ client: this.client }))
+  private _request?: Request
+  get request(): Request {
+    return (this._request ??= new Request({ client: this.client }))
   }
 
   private _saved?: Saved
@@ -7683,6 +7519,41 @@ export class Shell extends HeyApiClient {
   }
 }
 
+export class Request2 extends HeyApiClient {
+  /**
+   * List pending question requests
+   *
+   * Retrieve pending question requests for a location.
+   */
+  public list<ThrowOnError extends boolean = false>(
+    parameters?: {
+      location?: {
+        directory?: string | null
+        workspace?: string | null
+      } | null
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
+    return (options?.client ?? this.client).get<
+      V2QuestionRequestListResponses,
+      V2QuestionRequestListErrors,
+      ThrowOnError
+    >({
+      url: "/api/question/request",
+      ...options,
+      ...params,
+    })
+  }
+}
+
+export class Question3 extends HeyApiClient {
+  private _request?: Request2
+  get request(): Request2 {
+    return (this._request ??= new Request2({ client: this.client }))
+  }
+}
+
 export class Reference extends HeyApiClient {
   /**
    * List references
@@ -7884,11 +7755,6 @@ export class V2 extends HeyApiClient {
     return (this._project ??= new Project2({ client: this.client }))
   }
 
-  private _form?: Form2
-  get form(): Form2 {
-    return (this._form ??= new Form2({ client: this.client }))
-  }
-
   private _permission?: Permission3
   get permission(): Permission3 {
     return (this._permission ??= new Permission3({ client: this.client }))
@@ -7924,6 +7790,11 @@ export class V2 extends HeyApiClient {
     return (this._shell ??= new Shell({ client: this.client }))
   }
 
+  private _question?: Question3
+  get question(): Question3 {
+    return (this._question ??= new Question3({ client: this.client }))
+  }
+
   private _reference?: Reference
   get reference(): Reference {
     return (this._reference ??= new Reference({ client: this.client }))

File diff suppressed because it is too large
+ 3644 - 778
packages/sdk/js/src/v2/gen/types.gen.ts


+ 5 - 5
packages/sdk/js/test/session-history.test.ts

@@ -1,12 +1,12 @@
 import { expect, test } from "bun:test"
-import type { V2SessionLogData } from "../src/v2/gen/types.gen"
+import type { V2SessionHistoryData } from "../src/v2/gen/types.gen"
 
-test("uses numeric Session log positions", () => {
+test("uses numeric Session history positions", () => {
   const input = {
     path: { sessionID: "ses_test" },
-    query: { after: 1, follow: "false" },
-    url: "/api/session/{sessionID}/log",
-  } satisfies V2SessionLogData
+    query: { after: 1, limit: 50 },
+    url: "/api/session/{sessionID}/history",
+  } satisfies V2SessionHistoryData
 
   expect(input.query.after).toBe(1)
 })

+ 0 - 4
packages/server/src/api.ts

@@ -1,12 +1,8 @@
 import { makeDefaultApi } from "@opencode-ai/protocol/api"
 import { LocationMiddleware } from "./location"
-import { FormLocationMiddleware } from "./middleware/form-location"
 import { SessionLocationMiddleware } from "./middleware/session-location"
 
 export const Api = makeDefaultApi({
   locationMiddleware: LocationMiddleware,
-  // FormLocationMiddleware contains the temporary `sessionID === "global"` MCP elicitation hack.
-  // Do not use that sentinel with general session APIs.
-  formLocationMiddleware: FormLocationMiddleware,
   sessionLocationMiddleware: SessionLocationMiddleware,
 })

+ 2 - 2
packages/server/src/handlers.ts

@@ -6,7 +6,6 @@ import { ProviderHandler } from "./handlers/provider"
 import { SessionHandler } from "./handlers/session"
 import { PermissionHandler } from "./handlers/permission"
 import { FileSystemHandler } from "./handlers/fs"
-import { FormHandler } from "./handlers/form"
 import { CommandHandler } from "./handlers/command"
 import { SkillHandler } from "./handlers/skill"
 import { EventHandler } from "./handlers/event"
@@ -15,6 +14,7 @@ import { PluginHandler } from "./handlers/plugin"
 import { HealthHandler } from "./handlers/health"
 import { PtyHandler } from "./handlers/pty"
 import { ShellHandler } from "./handlers/shell"
+import { QuestionHandler } from "./handlers/question"
 import { ReferenceHandler } from "./handlers/reference"
 import { LocationHandler } from "./handlers/location"
 import { IntegrationHandler } from "./handlers/integration"
@@ -37,7 +37,6 @@ export const handlers = Layer.mergeAll(
   McpHandler,
   CredentialHandler,
   ProjectHandler,
-  FormHandler,
   PermissionHandler,
   FileSystemHandler,
   CommandHandler,
@@ -45,6 +44,7 @@ export const handlers = Layer.mergeAll(
   EventHandler,
   PtyHandler,
   ShellHandler,
+  QuestionHandler,
   ReferenceHandler,
   ProjectCopyHandler,
 )

+ 0 - 136
packages/server/src/handlers/form.ts

@@ -1,136 +0,0 @@
-import { Form } from "@opencode-ai/core/form"
-import { Effect } from "effect"
-import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
-import {
-  ConflictError,
-  FormAlreadySettledError,
-  FormInvalidAnswerError,
-  FormNotFoundError,
-  InvalidRequestError,
-} from "@opencode-ai/protocol/errors"
-import { Api } from "../api"
-import { response } from "../location"
-
-function missingForm(id: Form.ID) {
-  return new FormNotFoundError({ id, message: `Form not found: ${id}` })
-}
-
-function alreadySettled(error: Form.AlreadySettledError) {
-  return new FormAlreadySettledError({ id: error.id, message: error.message })
-}
-
-function alreadyExists(error: Form.AlreadyExistsError) {
-  return new ConflictError({ resource: error.id, message: error.message })
-}
-
-function invalidAnswer(error: Form.InvalidAnswerError) {
-  return new FormInvalidAnswerError({ id: error.id, message: error.message })
-}
-
-export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
-  Effect.gen(function* () {
-    const withOwnedForm = Effect.fnUntraced(function* <A, E>(
-      sessionID: string,
-      formID: Form.ID,
-      use: (service: Form.Interface, info: Form.Info) => Effect.Effect<A, E>,
-    ) {
-      const form = yield* Form.Service
-      const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
-      if (info.sessionID !== sessionID) return yield* missingForm(formID)
-      return yield* use(form, info)
-    })
-
-    return handlers
-      .handle(
-        "form.request.list",
-        Effect.fn(function* () {
-          const form = yield* Form.Service
-          return yield* response(form.list())
-        }),
-      )
-      .handle(
-        "session.form.list",
-        Effect.fn(function* (ctx) {
-          const form = yield* Form.Service
-          return yield* response(form.list({ sessionID: ctx.params.sessionID }))
-        }),
-      )
-      .handle(
-        "session.form.create",
-        Effect.fn(function* (ctx) {
-          const form = yield* Form.Service
-          if (ctx.payload.mode === "form") {
-            if (!ctx.payload.fields) {
-              return yield* new InvalidRequestError({ message: "Form fields are required", field: "fields" })
-            }
-            return yield* response(
-              form.create({
-                id: ctx.payload.id,
-                sessionID: ctx.params.sessionID,
-                title: ctx.payload.title,
-                metadata: ctx.payload.metadata,
-                mode: "form",
-                fields: ctx.payload.fields,
-              }).pipe(Effect.catchTag("Form.AlreadyExistsError", alreadyExists)),
-            )
-          }
-          if (!ctx.payload.url) return yield* new InvalidRequestError({ message: "Form URL is required", field: "url" })
-          return yield* response(
-            form.create({
-              id: ctx.payload.id,
-              sessionID: ctx.params.sessionID,
-              title: ctx.payload.title,
-              metadata: ctx.payload.metadata,
-              mode: "url",
-              url: ctx.payload.url,
-            }).pipe(Effect.catchTag("Form.AlreadyExistsError", alreadyExists)),
-          )
-        }),
-      )
-      .handle(
-        "session.form.get",
-        Effect.fn(function* (ctx) {
-          return yield* response(withOwnedForm(ctx.params.sessionID, ctx.params.formID, (_, info) => Effect.succeed(info)))
-        }),
-      )
-      .handle(
-        "session.form.state",
-        Effect.fn(function* (ctx) {
-          return yield* response(
-            withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
-              form.state(ctx.params.formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(ctx.params.formID))),
-            ),
-          )
-        }),
-      )
-      .handle(
-        "session.form.reply",
-        Effect.fn(function* (ctx) {
-          yield* withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
-            form.reply({ id: ctx.params.formID, answer: ctx.payload.answer }).pipe(
-              Effect.catchTags({
-                "Form.AlreadySettledError": alreadySettled,
-                "Form.InvalidAnswerError": invalidAnswer,
-                "Form.NotFoundError": () => missingForm(ctx.params.formID),
-              }),
-            ),
-          )
-          return HttpApiSchema.NoContent.make()
-        }),
-      )
-      .handle(
-        "session.form.cancel",
-        Effect.fn(function* (ctx) {
-          yield* withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
-            form.cancel(ctx.params.formID).pipe(
-              Effect.catchTags({
-                "Form.AlreadySettledError": alreadySettled,
-                "Form.NotFoundError": () => missingForm(ctx.params.formID),
-              }),
-            ),
-          )
-          return HttpApiSchema.NoContent.make()
-        }),
-      )
-  }),
-)

+ 62 - 0
packages/server/src/handlers/question.ts

@@ -0,0 +1,62 @@
+import { QuestionV2 } from "@opencode-ai/core/question"
+import { Effect } from "effect"
+import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
+import { Api } from "../api"
+import { QuestionNotFoundError } from "@opencode-ai/protocol/errors"
+import { response } from "../location"
+
+function missingRequest(id: QuestionV2.ID) {
+  return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
+}
+
+export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
+  Effect.gen(function* () {
+    const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
+      sessionID: QuestionV2.Request["sessionID"],
+      requestID: QuestionV2.ID,
+      use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
+    ) {
+      const question = yield* QuestionV2.Service
+      const request = (yield* question.list()).find((request) => request.id === requestID)
+      if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
+      return yield* use(question)
+    })
+
+    return handlers
+      .handle(
+        "question.request.list",
+        Effect.fn(function* () {
+          return yield* response((yield* QuestionV2.Service).list())
+        }),
+      )
+      .handle(
+        "session.question.list",
+        Effect.fn(function* (ctx) {
+          const requests = yield* (yield* QuestionV2.Service).list()
+          return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
+        }),
+      )
+      .handle(
+        "session.question.reply",
+        Effect.fn(function* (ctx) {
+          yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
+            question
+              .reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
+              .pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
+          )
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
+      .handle(
+        "session.question.reject",
+        Effect.fn(function* (ctx) {
+          yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
+            question
+              .reject(ctx.params.requestID)
+              .pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
+          )
+          return HttpApiSchema.NoContent.make()
+        }),
+      )
+  }),
+)

+ 2 - 2
packages/server/src/location.ts

@@ -26,7 +26,7 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
   })
 }
 
-export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
+function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
   const query = new URL(request.url, "http://localhost").searchParams
   const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
   const directory =
@@ -53,7 +53,7 @@ export const layer = Layer.effect(
     return LocationMiddleware.of((effect) =>
       Effect.gen(function* () {
         const request = yield* HttpServerRequest.HttpServerRequest
-        return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
+        return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
       }),
     )
   }),

+ 0 - 76
packages/server/src/middleware/form-location.ts

@@ -1,76 +0,0 @@
-import { Database } from "@opencode-ai/core/database/database"
-import { Location } from "@opencode-ai/core/location"
-import { LocationServiceMap } from "@opencode-ai/core/location-services"
-import { AbsolutePath } from "@opencode-ai/core/schema"
-import { SessionV2 } from "@opencode-ai/core/session"
-import { SessionTable } from "@opencode-ai/core/session/sql"
-import { WorkspaceV2 } from "@opencode-ai/core/workspace"
-import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
-import { eq } from "drizzle-orm"
-import { Effect, Layer, Schema } from "effect"
-import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
-import { HttpApiMiddleware } from "effect/unstable/httpapi"
-import { requestRef, type LocationServices } from "../location"
-
-export class FormLocationMiddleware extends HttpApiMiddleware.Service<
-  FormLocationMiddleware,
-  { provides: LocationServices }
->()("@opencode/HttpApiFormLocation", {
-  error: [InvalidRequestError, SessionNotFoundError],
-}) {}
-
-const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID)
-
-export const formLocationLayer = Layer.effect(
-  FormLocationMiddleware,
-  Effect.gen(function* () {
-    const { db } = yield* Database.Service
-    const locations = yield* LocationServiceMap.Service
-
-    return FormLocationMiddleware.of((effect) =>
-      Effect.gen(function* () {
-        const route = yield* HttpRouter.RouteContext
-        if (route.params.sessionID === "global") {
-          // Temporary MCP elicitation escape hatch. This is still Location-scoped; it only bypasses
-          // the session row lookup because some MCP elicitations cannot currently be attributed to
-          // a real session. Keep this undocumented and remove once elicitations carry session ownership.
-          const request = yield* HttpServerRequest.HttpServerRequest
-          return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
-        }
-
-        const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
-          Effect.mapError(
-            () =>
-              new InvalidRequestError({
-                message: "Invalid session ID",
-                field: "sessionID",
-              }),
-          ),
-        )
-        const row = yield* db
-          .select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
-          .from(SessionTable)
-          .where(eq(SessionTable.id, sessionID))
-          .get()
-          .pipe(Effect.orDie)
-        if (!row) {
-          return yield* new SessionNotFoundError({
-            sessionID,
-            message: `Session not found: ${sessionID}`,
-          })
-        }
-
-        return yield* effect.pipe(
-          Effect.provide(
-            locations.get(
-              Location.Ref.make({
-                directory: AbsolutePath.make(row.directory),
-                workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
-              }),
-            ),
-          ),
-        )
-      }),
-    )
-  }),
-)

+ 0 - 2
packages/server/src/routes.ts

@@ -24,7 +24,6 @@ import { authorizationLayer } from "./middleware/authorization"
 import { schemaErrorLayer } from "./middleware/schema-error"
 import { PtyEnvironment } from "./pty-environment"
 import { layer as locationLayer } from "./location"
-import { formLocationLayer } from "./middleware/form-location"
 import { sessionLocationLayer } from "./middleware/session-location"
 
 const applicationServices = LayerNode.group([
@@ -80,7 +79,6 @@ function makeRoutes<AuthError, AuthServices>(
 
   return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
     Layer.provide(handlers),
-    Layer.provide(formLocationLayer),
     Layer.provide(sessionLocationLayer),
     Layer.provide(locationLayer),
     Layer.provide(authorizationLayer),

+ 28 - 78
packages/tui/src/context/data.tsx

@@ -8,6 +8,7 @@ import type {
   PermissionSavedInfo,
   PermissionV2Request,
   ProviderV2Info,
+  QuestionV2Request,
   ReferenceInfo,
   SessionMessage,
   SessionMessageAssistant,
@@ -18,16 +19,12 @@ import type {
   Shell,
   SkillV2Info,
   V2Event,
-  FormFormInfo,
-  FormUrlInfo,
 } from "@opencode-ai/sdk/v2"
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "./helper"
 import { useSDK } from "./sdk"
 import { createSignal, onCleanup } from "solid-js"
 
-type FormInfo = FormFormInfo | FormUrlInfo
-
 export type DataSessionStatus = "idle" | "running"
 
 type LocationData = {
@@ -38,7 +35,6 @@ type LocationData = {
   model?: ModelV2Info[]
   provider?: ProviderV2Info[]
   reference?: ReferenceInfo[]
-  form?: FormInfo[]
   // Currently running shell commands for this location, keyed by shell id. Entries are removed
   // once the command exits or is deleted, so this only ever holds in-flight shells.
   shell?: Record<string, Shell>
@@ -55,7 +51,7 @@ type Data = {
     status: Record<string, DataSessionStatus>
     message: Record<string, SessionMessage[]>
     permission: Record<string, PermissionV2Request[]>
-    form: Record<string, FormInfo[]>
+    question: Record<string, QuestionV2Request[]>
   }
   project: {
     permission: Record<string, PermissionSavedInfo[]>
@@ -89,7 +85,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         status: {},
         message: {},
         permission: {},
-        form: {},
+        question: {},
       },
       project: {
         permission: {},
@@ -185,21 +181,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
       const info = store.session.info[sessionID]
       if (!info) return
       const rootID = resolveRoot(sessionID)
-      setStore(
-        "session",
-        "family",
-        produce((draft) => {
-          if (sessionID !== rootID && draft[sessionID]) {
-            const members = (draft[rootID] ??= [])
-            for (const id of draft[sessionID]) {
-              if (!members.includes(id)) members.push(id)
-            }
-            delete draft[sessionID]
+      setStore("session", "family", produce((draft) => {
+        if (sessionID !== rootID && draft[sessionID]) {
+          const members = draft[rootID] ??= []
+          for (const id of draft[sessionID]) {
+            if (!members.includes(id)) members.push(id)
           }
-          const family = (draft[rootID] ??= [])
-          if (!family.includes(sessionID)) family.push(sessionID)
-        }),
-      )
+          delete draft[sessionID]
+        }
+        const family = draft[rootID] ??= []
+        if (!family.includes(sessionID)) family.push(sessionID)
+      }))
     }
 
     function handleEvent(event: V2Event) {
@@ -575,40 +567,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             ),
           )
           break
-        case "form.created":
-          if (event.data.form.sessionID === "global") {
-            const key = locationKey(event.location ?? defaultLocation())
-            if (store.location[key]?.form?.some((request) => request.id === event.data.form.id)) break
-            setStore("location", key, (data) => ({ ...data, form: [...(data?.form ?? []), event.data.form] }))
-            break
-          }
-          if (store.session.form[event.data.form.sessionID]?.some((request) => request.id === event.data.form.id)) break
-          setStore("session", "form", event.data.form.sessionID, [
-            ...(store.session.form[event.data.form.sessionID] ?? []),
-            event.data.form,
+        case "question.v2.asked":
+          if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
+          setStore("session", "question", event.data.sessionID, [
+            ...(store.session.question[event.data.sessionID] ?? []),
+            event.data,
           ])
           break
-        case "form.replied":
-        case "form.cancelled":
+        case "question.v2.replied":
+        case "question.v2.rejected":
           setStore(
             "session",
-            "form",
+            "question",
             event.data.sessionID,
-            (store.session.form[event.data.sessionID] ?? []).filter((request) => request.id !== event.data.id),
-          )
-          if (event.location) {
-            setStore("location", locationKey(event.location), (data) => ({
-              ...data,
-              form: (data?.form ?? []).filter((request) => request.id !== event.data.id),
-            }))
-            break
-          }
-          setStore(
-            "location",
-            produce((draft) => {
-              for (const data of Object.values(draft))
-                data.form = data.form?.filter((request) => request.id !== event.data.id)
-            }),
+            (store.session.question[event.data.sessionID] ?? []).filter(
+              (request) => request.id !== event.data.requestID,
+            ),
           )
           break
         case "shell.created":
@@ -716,7 +690,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
                 return liveByID.get(message.id) ?? message
               }),
               ...live.filter((message) => !loadedIDs.has(message.id)),
-            ].toSorted((a, b) => a.time.created - b.time.created)
+            ]
+              .toSorted((a, b) => a.time.created - b.time.created)
             messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
             setStore("session", "message", sessionID, messages)
           },
@@ -729,20 +704,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             setStore("session", "permission", sessionID, mutable(await sdk.api.permission.list({ sessionID })))
           },
         },
-        form: {
-          list(sessionID: string) {
-            return store.session.form[sessionID]
-          },
-          async refresh(sessionID: string) {
-            setStore("session", "form", sessionID, mutable((await sdk.api.form.list({ sessionID })).data))
-          },
-        },
         question: {
           list(sessionID: string) {
-            return store.session.form[sessionID]
+            return store.session.question[sessionID]
           },
           async refresh(sessionID: string) {
-            setStore("session", "form", sessionID, mutable((await sdk.api.form.list({ sessionID })).data))
+            setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID })))
           },
         },
       },
@@ -833,22 +800,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             setStore("location", key, { ...store.location[key], mcp: result.data.data })
           },
         },
-        form: {
-          list(location?: LocationRef) {
-            return store.location[locationKey(location ?? defaultLocation())]?.form
-          },
-          async refresh(ref?: LocationRef) {
-            const result = await sdk.client.v2.form.request.list(
-              { location: locationQuery(ref) },
-              { throwOnError: true },
-            )
-            const key = locationKey(result.data.location)
-            setStore("location", key, {
-              ...store.location[key],
-              form: result.data.data.filter((form) => form.sessionID === "global"),
-            })
-          },
-        },
         model: {
           list(location?: LocationRef) {
             return store.location[locationKey(location ?? defaultLocation())]?.model
@@ -925,7 +876,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         result.location.agent.refresh(),
         result.location.integration.refresh(),
         result.location.mcp.refresh(),
-        result.location.form.refresh(),
         result.location.model.refresh(),
         result.location.provider.refresh(),
         result.location.reference.refresh(),

+ 0 - 19
packages/tui/src/feature-plugins/system/notifications.ts

@@ -46,25 +46,6 @@ const tui: TuiPlugin = async (api) => {
     questions.delete(event.data.requestID)
   })
 
-  api.event.on("form.created", (event) => {
-    if (questions.has(event.data.form.id)) return
-    questions.add(event.data.form.id)
-    notify(
-      api,
-      event.data.form.sessionID === "global" ? undefined : event.data.form.sessionID,
-      "Form needs input",
-      "question",
-    )
-  })
-
-  api.event.on("form.replied", (event) => {
-    questions.delete(event.data.id)
-  })
-
-  api.event.on("form.cancelled", (event) => {
-    questions.delete(event.data.id)
-  })
-
   api.event.on("permission.asked", (event) => {
     if (permissions.has(event.data.id)) return
     permissions.add(event.data.id)

+ 0 - 710
packages/tui/src/routes/session/form.tsx

@@ -1,710 +0,0 @@
-import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
-import { createStore } from "solid-js/store"
-import type { FormAnswer, FormFormInfo, FormUrlInfo, LocationRef } from "@opencode-ai/sdk/v2"
-import type { TextareaRenderable } from "@opentui/core"
-import { useRenderer } from "@opentui/solid"
-import { selectedForeground, tint, useTheme } from "../../context/theme"
-import { useSDK } from "../../context/sdk"
-import { SplitBorder } from "../../ui/border"
-import { useBindings, useOpencodeModeStack } from "../../keymap"
-import { useTuiConfig } from "../../config"
-import { errorMessage } from "../../util/error"
-import { Locale } from "../../util/locale"
-
-const FORM_MODE = "form"
-
-type FormField = FormFormInfo["fields"][number]
-type PromptForm = FormFormInfo | FormUrlInfo
-type FieldOption = { value: string; label: string; description?: string; custom?: boolean }
-type FieldTab = { type: "field"; index: number } | { type: "ellipsis"; key: string }
-
-export function FormPrompt(props: { request: PromptForm; location?: LocationRef }) {
-  const sdk = useSDK()
-  const { theme } = useTheme()
-  const renderer = useRenderer()
-  const tuiConfig = useTuiConfig()
-  const modeStack = useOpencodeModeStack()
-  const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
-  const [settling, setSettling] = createSignal<"reply" | "cancel">()
-  const [settleError, setSettleError] = createSignal<string>()
-  const [store, setStore] = createStore({
-    tab: 0,
-    selected: 0,
-    editing: false,
-    text: {} as Record<string, string>,
-    boolean: {} as Record<string, boolean>,
-    multiselect: {} as Record<string, string[]>,
-    custom: {} as Record<string, string>,
-  })
-  let textarea: TextareaRenderable | undefined
-
-  const fields = createMemo(() => (props.request.mode === "form" ? props.request.fields : []))
-  const single = createMemo(() => fields().length === 1 && fields()[0]?.type !== "multiselect")
-  const compactTabs = createMemo(() => {
-    const items = fields()
-    return items.length > 5 || items.some((item) => fieldLabel(item).length > 18)
-  })
-  const fieldTabItems = createMemo(() => fieldTabs(fields(), store.tab, compactTabs()))
-  const answeredCount = createMemo(() => fields().filter((item) => fieldText(item).length > 0).length)
-  const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
-  const confirm = createMemo(() => !single() && store.tab === fields().length)
-  const field = createMemo(() => fields()[store.tab])
-  const options = createMemo(() => fieldOptions(field()))
-  const currentText = createMemo(() => fieldText(field()))
-  const footerAction = createMemo(() => {
-    if (confirm() || props.request.mode === "url") return "submit"
-    if (field()?.type === "multiselect") return "toggle"
-    if (options().length > 0) return single() ? "submit" : "confirm"
-    return "type"
-  })
-
-  function submit() {
-    const result = props.request.mode === "url" ? { answer: {} } : buildAnswer(fields())
-    if ("error" in result) {
-      setSettleError(result.error)
-      return
-    }
-    settle("reply", () =>
-      sdk.client.v2.session.form.reply(
-        {
-          sessionID: props.request.sessionID,
-          formID: props.request.id,
-          location: locationQuery(props.location),
-          formReply: { answer: result.answer },
-        },
-        { throwOnError: true },
-      ),
-    )
-  }
-
-  function cancel() {
-    settle("cancel", () =>
-      sdk.client.v2.session.form.cancel(
-        {
-          sessionID: props.request.sessionID,
-          formID: props.request.id,
-          location: locationQuery(props.location),
-        },
-        { throwOnError: true },
-      ),
-    )
-  }
-
-  function settle(kind: "reply" | "cancel", run: () => Promise<unknown>) {
-    if (settling()) return
-    setSettling(kind)
-    setSettleError(undefined)
-    void run()
-      .catch((error) => setSettleError(errorMessage(error)))
-      .finally(() => setSettling(undefined))
-  }
-
-  function selectTab(index: number) {
-    setStore("tab", index)
-    setStore("selected", selectedOptionIndex(fields()[index]))
-    setStore("editing", false)
-  }
-
-  function moveTo(index: number) {
-    setStore("selected", index)
-  }
-
-  function moveOption(count: number, index: number) {
-    if (count === 0) return
-    moveTo((index + count) % count)
-  }
-
-  function choose() {
-    const item = field()
-    if (!item) {
-      submit()
-      return
-    }
-    if (item.type === "boolean") {
-      setStore("boolean", item.key, store.selected === 1)
-      if (single()) {
-        submit()
-        return
-      }
-      nextTab()
-      return
-    }
-    if (item.type === "string" && fieldOptions(item).length) {
-      const option = fieldOptions(item)[store.selected]
-      if (!option) return
-      if (option.custom) {
-        setStore("editing", true)
-        return
-      }
-      setStore("text", item.key, option.value)
-      if (single()) {
-        submit()
-        return
-      }
-      nextTab()
-      return
-    }
-    if (item.type === "multiselect") {
-      const option = fieldOptions(item)[store.selected]
-      if (!option) return
-      if (option.custom) {
-        const value = store.custom[item.key]
-        if (value && (store.multiselect[item.key] ?? item.default ?? []).includes(value)) {
-          setStore("multiselect", item.key, toggle(store.multiselect[item.key] ?? item.default ?? [], value))
-          return
-        }
-        setStore("editing", true)
-        return
-      }
-      const existing = store.multiselect[item.key] ?? item.default ?? []
-      setStore("multiselect", item.key, toggle(existing, option.value))
-      return
-    }
-    setStore("editing", true)
-  }
-
-  function nextTab() {
-    selectTab(Math.min(store.tab + 1, fields().length))
-  }
-
-  function fieldText(item: FormField | undefined) {
-    if (!item) return ""
-    if (item.type === "boolean") return String(store.boolean[item.key] ?? item.default ?? false)
-    if (item.type === "multiselect") return (store.multiselect[item.key] ?? item.default ?? []).join(", ")
-    return store.text[item.key] ?? (item.default === undefined ? "" : String(item.default))
-  }
-
-  function selectedOptionIndex(item: FormField | undefined) {
-    const choices = fieldOptions(item)
-    if (!item || choices.length === 0 || item.type === "multiselect") return 0
-    const index = choices.findIndex((option) => option.value === fieldText(item))
-    if (index >= 0) return index
-    const customIndex = choices.findIndex((option) => option.custom === true)
-    return customIndex >= 0 && (store.custom[item.key] || fieldText(item)) ? customIndex : 0
-  }
-
-  function buildAnswer(items: FormField[]): { answer: FormAnswer } | { error: string } {
-    const result = items.reduce<{ answer: FormAnswer; error?: string }>(
-      (state, item) => {
-        if (state.error) return state
-        if (!isActive(item, state.answer)) return state
-        if (item.type === "boolean") {
-          if (store.boolean[item.key] !== undefined || item.default !== undefined || item.required) {
-            return {
-              ...state,
-              answer: { ...state.answer, [item.key]: store.boolean[item.key] ?? item.default ?? false },
-            }
-          }
-          return state
-        }
-        if (item.type === "multiselect") {
-          const value = store.multiselect[item.key] ?? item.default ?? []
-          if (value.length > 0 || item.required) {
-            return { ...state, answer: { ...state.answer, [item.key]: value } }
-          }
-          return state
-        }
-        const text = fieldText(item).trim()
-        if (!text) {
-          if (item.required) return { ...state, answer: { ...state.answer, [item.key]: "" } }
-          return state
-        }
-        if (item.type === "number" || item.type === "integer") {
-          const value = Number(text)
-          if (!Number.isFinite(value)) return { ...state, error: `Expected number for ${fieldLabel(item)}` }
-          if (item.type === "integer" && !Number.isInteger(value))
-            return { ...state, error: `Expected integer for ${fieldLabel(item)}` }
-          return { ...state, answer: { ...state.answer, [item.key]: value } }
-        }
-        return { ...state, answer: { ...state.answer, [item.key]: text } }
-      },
-      { answer: {} },
-    )
-    if (result.error) return { error: result.error }
-    return { answer: result.answer }
-  }
-
-  onMount(() => {
-    setStore("selected", selectedOptionIndex(field()))
-    const popMode = modeStack.push(FORM_MODE)
-    onCleanup(popMode)
-  })
-
-  useBindings(() => ({
-    mode: FORM_MODE,
-    enabled: store.editing,
-    commands: [
-      {
-        name: "form.clear",
-        title: "Clear form input",
-        category: "Form",
-        run() {
-          const text = textarea?.plainText ?? ""
-          if (!text) {
-            setStore("editing", false)
-            return
-          }
-          textarea?.setText("")
-        },
-      },
-    ],
-    bindings: [
-      {
-        key: "escape",
-        desc: "Cancel edit",
-        group: "Form",
-        cmd: () => setStore("editing", false),
-      },
-      ...tuiConfig.keybinds.get("prompt.clear"),
-      {
-        key: "return",
-        desc: "Save input",
-        group: "Form",
-        cmd: () => {
-          const item = field()
-          if (!item) return
-          const text = textarea?.plainText?.trim() ?? ""
-          if (item.type === "multiselect") {
-            const previous = store.custom[item.key]
-            const existing = store.multiselect[item.key] ?? item.default ?? []
-            const withoutPrevious = previous ? existing.filter((value) => value !== previous) : existing
-            setStore(
-              "multiselect",
-              item.key,
-              text && !withoutPrevious.includes(text) ? [...withoutPrevious, text] : withoutPrevious,
-            )
-            setStore("custom", item.key, text)
-            setStore("editing", false)
-            return
-          }
-          setStore("text", item.key, text)
-          setStore("custom", item.key, text)
-          setStore("editing", false)
-          if (single()) {
-            submit()
-            return
-          }
-          nextTab()
-        },
-      },
-    ],
-  }))
-
-  useBindings(() => {
-    const item = field()
-    const count = optionCount(item)
-    return {
-      mode: FORM_MODE,
-      enabled: !store.editing,
-      commands: [
-        {
-          name: "app.exit",
-          title: "Cancel form",
-          category: "Form",
-          run: cancel,
-        },
-      ],
-      bindings: [
-        { key: "left", desc: "Previous field", group: "Form", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
-        { key: "h", desc: "Previous field", group: "Form", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
-        { key: "right", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
-        { key: "l", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
-        {
-          key: "tab",
-          desc: "Next field",
-          group: "Form",
-          cmd: ({ event }: { event: { shift: boolean } }) => {
-            selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
-          },
-        },
-        ...Array.from({ length: Math.min(count, 9) }, (_, index) => ({
-          key: String(index + 1),
-          desc: `Select option ${index + 1}`,
-          group: "Form",
-          cmd: () => {
-            moveTo(index)
-            choose()
-          },
-        })),
-        ...(count > 0
-          ? [
-              { key: "up", desc: "Previous option", group: "Form", cmd: () => moveOption(count, store.selected - 1) },
-              { key: "k", desc: "Previous option", group: "Form", cmd: () => moveOption(count, store.selected - 1) },
-              { key: "down", desc: "Next option", group: "Form", cmd: () => moveOption(count, store.selected + 1) },
-              { key: "j", desc: "Next option", group: "Form", cmd: () => moveOption(count, store.selected + 1) },
-            ]
-          : []),
-        {
-          key: "return",
-          desc: confirm() || props.request.mode === "url" ? "Submit form" : count > 0 ? "Select" : "Edit",
-          group: "Form",
-          cmd: choose,
-        },
-        { key: "escape", desc: "Cancel form", group: "Form", cmd: cancel },
-        ...tuiConfig.keybinds.get("app.exit"),
-      ],
-    }
-  })
-
-  return (
-    <box
-      backgroundColor={theme.backgroundPanel}
-      border={["left"]}
-      borderColor={theme.accent}
-      customBorderChars={SplitBorder.customBorderChars}
-    >
-      <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
-        <Switch>
-          <Match when={props.request.mode === "url"}>
-            <box paddingLeft={1} gap={1}>
-              <text fg={theme.text}>{props.request.title ?? "Open URL request"}</text>
-              <Show when={formMessage(props.request)}>
-                <text fg={theme.textMuted} wrapMode="word">
-                  {formMessage(props.request)}
-                </text>
-              </Show>
-              <text fg={theme.textMuted}>Open this URL, complete the request, then press enter:</text>
-              <text fg={theme.secondary}>{props.request.mode === "url" ? props.request.url : ""}</text>
-            </box>
-          </Match>
-          <Match when={props.request.mode === "form"}>
-            <Show when={props.request.mode === "form" && props.request.title}>
-              <box paddingLeft={1}>
-                <text fg={theme.text} wrapMode="word">
-                  {props.request.mode === "form" ? props.request.title : ""}
-                </text>
-              </box>
-            </Show>
-            <Show when={formMessage(props.request)}>
-              <box paddingLeft={1}>
-                <text fg={theme.textMuted} wrapMode="word">
-                  {formMessage(props.request)}
-                </text>
-              </box>
-            </Show>
-            <Show when={!single()}>
-              <box flexDirection="row" gap={1} paddingLeft={1}>
-                <For each={fieldTabItems()}>
-                  {(tab) => {
-                    if (tab.type === "ellipsis")
-                      return (
-                        <box paddingLeft={1} paddingRight={1}>
-                          <text fg={theme.textMuted} wrapMode="none">
-                            ...
-                          </text>
-                        </box>
-                      )
-                    const item = () => fields()[tab.index]
-                    const active = () => tab.index === store.tab
-                    const answered = () => fieldText(item()).length > 0
-                    const title = () => (compactTabs() ? String(tab.index + 1) : Locale.truncate(fieldLabel(item()), 18))
-                    return (
-                      <box
-                        paddingLeft={1}
-                        paddingRight={1}
-                        backgroundColor={
-                          active()
-                            ? theme.accent
-                            : tabHover() === tab.index
-                              ? theme.backgroundElement
-                              : theme.backgroundPanel
-                        }
-                        onMouseOver={() => setTabHover(tab.index)}
-                        onMouseOut={() => setTabHover(null)}
-                        onMouseUp={() => {
-                          if (renderer.getSelection()?.getSelectedText()) return
-                          selectTab(tab.index)
-                        }}
-                      >
-                        <text
-                          fg={
-                            active() ? selectedForeground(theme, theme.accent) : answered() ? theme.text : theme.textMuted
-                          }
-                          wrapMode="none"
-                        >
-                          {title()}
-                        </text>
-                      </box>
-                    )
-                  }}
-                </For>
-                <box
-                  paddingLeft={1}
-                  paddingRight={1}
-                  backgroundColor={
-                    confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
-                  }
-                  onMouseOver={() => setTabHover("confirm")}
-                  onMouseOut={() => setTabHover(null)}
-                  onMouseUp={() => {
-                    if (renderer.getSelection()?.getSelectedText()) return
-                    selectTab(fields().length)
-                  }}
-                >
-                  <text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted} wrapMode="none">
-                    {compactTabs() ? "Review" : "Confirm"}
-                  </text>
-                </box>
-              </box>
-            </Show>
-            <Show when={compactTabs() && !confirm()}>
-              <box paddingLeft={1}>
-                <text fg={theme.textMuted} wrapMode="none">
-                  {`Field ${store.tab + 1} of ${fields().length} - ${answeredCount()} answered`}
-                </text>
-              </box>
-            </Show>
-            <Show when={!confirm()}>
-              <FieldEditor
-                field={field()}
-                selected={store.selected}
-                value={currentText()}
-                customValue={field() ? store.custom[field()!.key] : ""}
-                editing={store.editing}
-                options={options()}
-                picked={(value) => picked(field(), value)}
-                moveTo={moveTo}
-                choose={choose}
-                textarea={(value) => {
-                  textarea = value
-                }}
-              />
-            </Show>
-            <Show when={confirm()}>
-              <box paddingLeft={1}>
-                <text fg={theme.text}>Review</text>
-              </box>
-              <box>
-                <For each={fields()}>
-                  {(item) => {
-                    const value = () => fieldText(item)
-                    return (
-                      <box paddingLeft={1}>
-                        <text>
-                          <span style={{ fg: theme.textMuted }}>{fieldLabel(item)}:</span>{" "}
-                          <span style={{ fg: value() ? theme.text : theme.error }}>{value() || "(not answered)"}</span>
-                        </text>
-                      </box>
-                    )
-                  }}
-                </For>
-              </box>
-            </Show>
-          </Match>
-        </Switch>
-      </box>
-      <box
-        flexDirection="row"
-        flexShrink={0}
-        gap={1}
-        paddingLeft={2}
-        paddingRight={3}
-        paddingBottom={1}
-        justifyContent="space-between"
-      >
-        <box flexDirection="row" gap={2}>
-          <Show when={props.request.mode === "form" && !single()}>
-            <text fg={theme.text}>
-              {"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
-            </text>
-          </Show>
-          <Show when={props.request.mode === "form" && !confirm() && optionCount(field()) > 0}>
-            <text fg={theme.text}>
-              {"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
-            </text>
-          </Show>
-          <text fg={theme.text}>
-            enter <span style={{ fg: theme.textMuted }}>{footerAction()}</span>
-          </text>
-          <text fg={theme.text}>
-            esc <span style={{ fg: theme.textMuted }}>cancel</span>
-          </text>
-          <Show when={settling()}>
-            <text fg={theme.textMuted}>{settling() === "cancel" ? "Cancelling..." : "Submitting..."}</text>
-          </Show>
-          <Show when={settleError()}>
-            <text fg={theme.error}>{settleError()}</text>
-          </Show>
-        </box>
-      </box>
-    </box>
-  )
-
-  function picked(item: FormField | undefined, value: string) {
-    if (!item) return false
-    if (value === CUSTOM_OPTION_VALUE) return Boolean(store.custom[item.key])
-    if (item.type === "multiselect") return (store.multiselect[item.key] ?? item.default ?? []).includes(value)
-    return fieldText(item) === value
-  }
-}
-
-function FieldEditor(props: {
-  field?: FormField
-  selected: number
-  value: string
-  customValue: string
-  editing: boolean
-  options: FieldOption[]
-  picked: (value: string) => boolean
-  moveTo: (index: number) => void
-  choose: () => void
-  textarea: (value: TextareaRenderable) => void
-}) {
-  const { theme } = useTheme()
-  return (
-    <box paddingLeft={1} gap={1}>
-      <text fg={theme.text}>{fieldLabel(props.field)}</text>
-      <Show when={fieldDescription(props.field)}>
-        <text fg={theme.textMuted}>{props.field?.description}</text>
-      </Show>
-      <Switch>
-        <Match when={props.options.length > 0}>
-          <For each={props.options}>
-            {(option, index) => {
-              const active = () => index() === props.selected
-              const picked = () => props.picked(option.value)
-              const multi = () => props.field?.type === "multiselect"
-              return (
-                <box
-                  onMouseOver={() => props.moveTo(index())}
-                  onMouseDown={() => props.moveTo(index())}
-                  onMouseUp={() => props.choose()}
-                >
-                  <box flexDirection="row">
-                    <box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
-                      <text
-                        fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}
-                      >{`${index() + 1}.`}</text>
-                    </box>
-                    <box backgroundColor={active() ? theme.backgroundElement : undefined}>
-                      <text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
-                        {multi() ? `[${picked() ? "✓" : " "}] ${option.label}` : option.label}
-                      </text>
-                    </box>
-                    <Show when={!multi()}>
-                      <text fg={theme.success}>{picked() ? " ✓" : ""}</text>
-                    </Show>
-                  </box>
-                  <Show when={option.description}>
-                    <box paddingLeft={3}>
-                      <text fg={theme.textMuted}>{option.description}</text>
-                    </box>
-                  </Show>
-                </box>
-              )
-            }}
-          </For>
-          <Show when={props.editing}>
-            <box paddingLeft={3}>
-              <FormTextarea value={props.customValue} placeholder="Type custom value" textarea={props.textarea} />
-            </box>
-          </Show>
-          <Show when={!props.editing && props.customValue}>
-            <box paddingLeft={3}>
-              <text fg={theme.textMuted}>{props.customValue}</text>
-            </box>
-          </Show>
-        </Match>
-        <Match when={props.editing}>
-          <FormTextarea value={props.value} placeholder="Type value" textarea={props.textarea} />
-        </Match>
-        <Match when={true}>
-          <text fg={props.value ? theme.text : theme.textMuted}>{props.value || "Press enter to type"}</text>
-        </Match>
-      </Switch>
-    </box>
-  )
-}
-
-function FormTextarea(props: { value: string; placeholder: string; textarea: (value: TextareaRenderable) => void }) {
-  const { theme } = useTheme()
-  return (
-    <textarea
-      ref={(value: TextareaRenderable) => {
-        props.textarea(value)
-        value.traits = { status: "FORM" }
-        queueMicrotask(() => {
-          value.focus()
-          value.gotoLineEnd()
-        })
-      }}
-      initialValue={props.value}
-      placeholder={props.placeholder}
-      placeholderColor={theme.textMuted}
-      minHeight={1}
-      maxHeight={6}
-      textColor={theme.text}
-      focusedTextColor={theme.text}
-      cursorColor={theme.primary}
-    />
-  )
-}
-
-function fieldTabs(fields: FormField[], active: number, compact: boolean): FieldTab[] {
-  if (!compact) return fields.map((_, index) => ({ type: "field", index }))
-  if (fields.length <= 7) return fields.map((_, index) => ({ type: "field", index }))
-
-  const last = fields.length - 1
-  const start = Math.max(0, Math.min(active - 2, last - 4))
-  const window = Array.from({ length: 5 }, (_, offset) => start + offset).filter((index) => index <= last)
-  const indexes = [...new Set([0, ...window, last])].toSorted((a, b) => a - b)
-
-  return indexes.flatMap((index, position): FieldTab[] => {
-    const previous = indexes[position - 1]
-    const tab: FieldTab = { type: "field", index }
-    if (previous !== undefined && index - previous > 1) return [{ type: "ellipsis", key: `${previous}-${index}` }, tab]
-    return [tab]
-  })
-}
-
-function fieldOptions(field: FormField | undefined) {
-  if (!field) return []
-  if (field.type === "boolean")
-    return [
-      { value: "false", label: "No" },
-      { value: "true", label: "Yes" },
-    ]
-  if (field.type === "string") return withCustomOption(field.options ?? [], field.custom)
-  if (field.type === "multiselect") return withCustomOption(field.options, field.custom)
-  return []
-}
-
-const CUSTOM_OPTION_VALUE = "__custom__"
-
-function withCustomOption(options: FieldOption[], custom: boolean | undefined): FieldOption[] {
-  if (options.length > 0 && custom !== true) return options
-  return [...options, { value: CUSTOM_OPTION_VALUE, label: "Type custom value", custom: true }]
-}
-
-function formMessage(form: PromptForm) {
-  const message = form.metadata?.message
-  return typeof message === "string" && message !== form.title ? message : undefined
-}
-
-function optionCount(field: FormField | undefined) {
-  return fieldOptions(field).length
-}
-
-function fieldLabel(field: FormField | undefined) {
-  return field?.title ?? field?.description ?? field?.key ?? "Form"
-}
-
-function fieldDescription(field: FormField | undefined) {
-  if (!field?.description || field.description === fieldLabel(field)) return undefined
-  return field.description
-}
-
-function isActive(field: FormField, answer: FormAnswer) {
-  if (!field.when) return true
-  const value = answer[field.when.key]
-  if (field.when.op === "eq") return value === field.when.value
-  return value !== field.when.value
-}
-
-function toggle(values: readonly string[], value: string) {
-  return values.includes(value) ? values.filter((item) => item !== value) : [...values, value]
-}
-
-function locationQuery(ref?: LocationRef) {
-  return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
-}

+ 15 - 16
packages/tui/src/routes/session/index.tsx

@@ -58,7 +58,7 @@ import { usePromptRef } from "../../context/prompt"
 import { useEpilogue } from "../../context/epilogue"
 import { normalizePath } from "../../util/path"
 import { PermissionPrompt } from "./permission"
-import { FormPrompt } from "./form"
+import { QuestionPrompt } from "./question"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 import { sessionEpilogue } from "../../util/presentation"
 import { useTuiConfig } from "../../config"
@@ -181,20 +181,15 @@ export function Session() {
       (sessionID) => data.session.permission.list(sessionID) ?? [],
     )
   })
-  const sessionForms = createMemo(() => {
+  const questions = createMemo(() => {
     if (session()?.parentID) return []
-    return data.session.form.list(route.sessionID) ?? []
+    return data.session.question.list(route.sessionID) ?? []
   })
-  const locationForms = createMemo(() => {
-    if (session()?.parentID) return []
-    return data.location.form.list(location()) ?? []
-  })
-  const forms = createMemo(() => [...sessionForms(), ...locationForms()])
   const [composer, setComposer] = createStore({
     open: false,
     tab: undefined as string | undefined,
   })
-  const disabled = createMemo(() => permissions().length > 0 || forms().length > 0)
+  const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
 
   const pending = createMemo(() => {
     const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
@@ -251,7 +246,7 @@ export function Session() {
       await Promise.all([
         data.session.refresh(sessionID),
         data.session.permission.refresh(sessionID),
-        data.session.form.refresh(sessionID),
+        data.session.question.refresh(sessionID),
       ])
       const info = data.session.get(sessionID)
       if (!info) {
@@ -263,7 +258,6 @@ export function Session() {
         navigate({ type: "home" })
         return
       }
-      await data.location.form.refresh(info.location)
 
       project.workspace.set(info.location.workspaceID)
       editor.reconnect(info.location.directory)
@@ -945,8 +939,8 @@ export function Session() {
                   <Match when={permissions().length > 0}>
                     <PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
                   </Match>
-                  <Match when={forms().length > 0}>
-                    <FormPrompt request={forms()[0]} location={location()} />
+                  <Match when={questions().length > 0}>
+                    <QuestionPrompt request={questions()[0]} directory={session()?.location.directory} />
                   </Match>
                   <Match when={!disabled()}>
                     <pluginRuntime.Slot
@@ -1393,7 +1387,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
         >
           <text fg={theme.text}>{props.message.text}</text>
           <Show when={files().length}>
-            <box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
+            <box
+              flexDirection="row"
+              paddingBottom={metadataVisible() ? 1 : 0}
+              paddingTop={1}
+              gap={1}
+              flexWrap="wrap"
+            >
               <For each={files()}>
                 {(file) => {
                   const directory = file.mime === "application/x-directory"
@@ -1723,8 +1723,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
       return Boolean(shellID && data.shell.get(shellID))
     }
     if (display() === "subagent") {
-      const sessionID =
-        stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId)
+      const sessionID = stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId)
       return Boolean(sessionID && data.session.status(sessionID) === "running")
     }
     return false

+ 514 - 0
packages/tui/src/routes/session/question.tsx

@@ -0,0 +1,514 @@
+import { createStore } from "solid-js/store"
+import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { useRenderer } from "@opentui/solid"
+import type { TextareaRenderable } from "@opentui/core"
+import { selectedForeground, tint, useTheme } from "../../context/theme"
+import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
+import { useSDK } from "../../context/sdk"
+import { SplitBorder } from "../../ui/border"
+import { useTuiConfig } from "../../config"
+import { useBindings, useOpencodeModeStack } from "../../keymap"
+
+const QUESTION_MODE = "question"
+
+export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) {
+  const sdk = useSDK()
+  const { theme } = useTheme()
+  const renderer = useRenderer()
+  const tuiConfig = useTuiConfig()
+  const modeStack = useOpencodeModeStack()
+
+  const questions = createMemo(() => props.request.questions)
+  const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
+  const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
+  const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
+  const [store, setStore] = createStore({
+    tab: 0,
+    answers: [] as QuestionV2Answer[],
+    custom: [] as string[],
+    selected: 0,
+    editing: false,
+  })
+
+  let textarea: TextareaRenderable | undefined
+
+  const question = createMemo(() => questions()[store.tab])
+  const confirm = createMemo(() => !single() && store.tab === questions().length)
+  const options = createMemo(() => question()?.options ?? [])
+  const custom = createMemo(() => question()?.custom !== false)
+  const other = createMemo(() => custom() && store.selected === options().length)
+  const input = createMemo(() => store.custom[store.tab] ?? "")
+  const multi = createMemo(() => question()?.multiple === true)
+  const customPicked = createMemo(() => {
+    const value = input()
+    if (!value) return false
+    return store.answers[store.tab]?.includes(value) ?? false
+  })
+
+  function submit() {
+    const answers = questions().map((_, i) => store.answers[i] ?? [])
+    void sdk.api.question.reply({
+      sessionID: props.request.sessionID,
+      requestID: props.request.id,
+      answers,
+    })
+  }
+
+  function reject() {
+    void sdk.api.question.reject({
+      sessionID: props.request.sessionID,
+      requestID: props.request.id,
+    })
+  }
+
+  function pick(answer: string, custom: boolean = false) {
+    const answers = [...store.answers]
+    answers[store.tab] = [answer]
+    setStore("answers", answers)
+    if (custom) {
+      const inputs = [...store.custom]
+      inputs[store.tab] = answer
+      setStore("custom", inputs)
+    }
+    if (single()) {
+      void sdk.api.question.reply({
+        sessionID: props.request.sessionID,
+        requestID: props.request.id,
+        answers: [[answer]],
+      })
+      return
+    }
+    setStore("tab", store.tab + 1)
+    setStore("selected", 0)
+  }
+
+  function toggle(answer: string) {
+    const existing = store.answers[store.tab] ?? []
+    const next = [...existing]
+    const index = next.indexOf(answer)
+    if (index === -1) next.push(answer)
+    if (index !== -1) next.splice(index, 1)
+    const answers = [...store.answers]
+    answers[store.tab] = next
+    setStore("answers", answers)
+  }
+
+  function moveTo(index: number) {
+    setStore("selected", index)
+  }
+
+  function selectTab(index: number) {
+    setStore("tab", index)
+    setStore("selected", 0)
+  }
+
+  function selectOption() {
+    if (other()) {
+      if (!multi()) {
+        setStore("editing", true)
+        return
+      }
+      const value = input()
+      if (value && customPicked()) {
+        toggle(value)
+        return
+      }
+      setStore("editing", true)
+      return
+    }
+    const opt = options()[store.selected]
+    if (!opt) return
+    if (multi()) {
+      toggle(opt.label)
+      return
+    }
+    pick(opt.label)
+  }
+
+  onMount(() => {
+    const popMode = modeStack.push(QUESTION_MODE)
+    onCleanup(popMode)
+  })
+
+  useBindings(() => ({
+    mode: QUESTION_MODE,
+    enabled: store.editing && !confirm(),
+    commands: [
+      {
+        name: "prompt.clear",
+        title: "Clear answer edit",
+        category: "Question",
+        run() {
+          const text = textarea?.plainText ?? ""
+          if (!text) {
+            setStore("editing", false)
+            return
+          }
+          textarea?.setText("")
+        },
+      },
+    ],
+    bindings: [
+      {
+        key: "escape",
+        desc: "Cancel answer edit",
+        group: "Question",
+        cmd: () => {
+          setStore("editing", false)
+        },
+      },
+      ...tuiConfig.keybinds.get("prompt.clear"),
+      {
+        key: "return",
+        desc: "Submit answer edit",
+        group: "Question",
+        cmd: () => {
+          const text = textarea?.plainText?.trim() ?? ""
+          const prev = store.custom[store.tab]
+
+          if (!text) {
+            if (prev) {
+              const inputs = [...store.custom]
+              inputs[store.tab] = ""
+              setStore("custom", inputs)
+
+              const answers = [...store.answers]
+              answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
+              setStore("answers", answers)
+            }
+            setStore("editing", false)
+            return
+          }
+
+          if (multi()) {
+            const inputs = [...store.custom]
+            inputs[store.tab] = text
+            setStore("custom", inputs)
+
+            const existing = store.answers[store.tab] ?? []
+            const next = [...existing]
+            if (prev) {
+              const index = next.indexOf(prev)
+              if (index !== -1) next.splice(index, 1)
+            }
+            if (!next.includes(text)) next.push(text)
+            const answers = [...store.answers]
+            answers[store.tab] = next
+            setStore("answers", answers)
+            setStore("editing", false)
+            return
+          }
+
+          pick(text, true)
+          setStore("editing", false)
+        },
+      },
+    ],
+  }))
+
+  useBindings(() => {
+    const opts = options()
+    const total = opts.length + (custom() ? 1 : 0)
+    const max = Math.min(total, 9)
+
+    return {
+      mode: QUESTION_MODE,
+      enabled: !store.editing,
+      commands: [
+        {
+          name: "app.exit",
+          title: "Reject question",
+          category: "Question",
+          run() {
+            reject()
+          },
+        },
+      ],
+      bindings: [
+        {
+          key: "left",
+          desc: "Previous question",
+          group: "Question",
+          cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
+        },
+        {
+          key: "h",
+          desc: "Previous question",
+          group: "Question",
+          cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
+        },
+        { key: "right", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
+        { key: "l", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
+        {
+          key: "tab",
+          desc: "Next question",
+          group: "Question",
+          cmd: ({ event }: { event: { shift: boolean } }) => {
+            selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
+          },
+        },
+        ...(confirm()
+          ? [
+              { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
+              { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
+              ...tuiConfig.keybinds.get("app.exit"),
+            ]
+          : [
+              ...Array.from({ length: max }, (_, index) => ({
+                key: String(index + 1),
+                desc: `Select answer ${index + 1}`,
+                group: "Question",
+                cmd: () => {
+                  moveTo(index)
+                  selectOption()
+                },
+              })),
+              {
+                key: "up",
+                desc: "Previous answer",
+                group: "Question",
+                cmd: () => moveTo((store.selected - 1 + total) % total),
+              },
+              {
+                key: "k",
+                desc: "Previous answer",
+                group: "Question",
+                cmd: () => moveTo((store.selected - 1 + total) % total),
+              },
+              { key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
+              { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
+              { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
+              { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
+              ...tuiConfig.keybinds.get("app.exit"),
+            ]),
+      ],
+    }
+  })
+
+  return (
+    <box
+      backgroundColor={theme.backgroundPanel}
+      border={["left"]}
+      borderColor={theme.accent}
+      customBorderChars={SplitBorder.customBorderChars}
+    >
+      <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
+        <Show when={!single()}>
+          <box flexDirection="row" gap={1} paddingLeft={1}>
+            <For each={questions()}>
+              {(q, index) => {
+                const isActive = () => index() === store.tab
+                const isAnswered = () => {
+                  return (store.answers[index()]?.length ?? 0) > 0
+                }
+                return (
+                  <box
+                    paddingLeft={1}
+                    paddingRight={1}
+                    backgroundColor={
+                      isActive()
+                        ? theme.accent
+                        : tabHover() === index()
+                          ? theme.backgroundElement
+                          : theme.backgroundPanel
+                    }
+                    onMouseOver={() => setTabHover(index())}
+                    onMouseOut={() => setTabHover(null)}
+                    onMouseUp={() => {
+                      if (renderer.getSelection()?.getSelectedText()) return
+                      selectTab(index())
+                    }}
+                  >
+                    <text
+                      fg={
+                        isActive()
+                          ? selectedForeground(theme, theme.accent)
+                          : isAnswered()
+                            ? theme.text
+                            : theme.textMuted
+                      }
+                    >
+                      {q.header}
+                    </text>
+                  </box>
+                )
+              }}
+            </For>
+            <box
+              paddingLeft={1}
+              paddingRight={1}
+              backgroundColor={
+                confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
+              }
+              onMouseOver={() => setTabHover("confirm")}
+              onMouseOut={() => setTabHover(null)}
+              onMouseUp={() => {
+                if (renderer.getSelection()?.getSelectedText()) return
+                selectTab(questions().length)
+              }}
+            >
+              <text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
+            </box>
+          </box>
+        </Show>
+
+        <Show when={!confirm()}>
+          <box paddingLeft={1} gap={1}>
+            <box>
+              <text fg={theme.text}>
+                {question()?.question}
+                {multi() ? " (select all that apply)" : ""}
+              </text>
+            </box>
+            <box>
+              <For each={options()}>
+                {(opt, i) => {
+                  const active = () => i() === store.selected
+                  const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
+                  return (
+                    <box
+                      onMouseOver={() => moveTo(i())}
+                      onMouseDown={() => moveTo(i())}
+                      onMouseUp={() => {
+                        if (renderer.getSelection()?.getSelectedText()) return
+                        selectOption()
+                      }}
+                    >
+                      <box flexDirection="row">
+                        <box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
+                          <text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
+                            {`${i() + 1}.`}
+                          </text>
+                        </box>
+                        <box backgroundColor={active() ? theme.backgroundElement : undefined}>
+                          <text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
+                            {multi() ? `[${picked() ? "✓" : " "}] ${opt.label}` : opt.label}
+                          </text>
+                        </box>
+                        <Show when={!multi()}>
+                          <text fg={theme.success}>{picked() ? " ✓" : ""}</text>
+                        </Show>
+                      </box>
+
+                      <box paddingLeft={3}>
+                        <text fg={theme.textMuted}>{opt.description}</text>
+                      </box>
+                    </box>
+                  )
+                }}
+              </For>
+              <Show when={custom()}>
+                <box
+                  onMouseOver={() => moveTo(options().length)}
+                  onMouseDown={() => moveTo(options().length)}
+                  onMouseUp={() => {
+                    if (renderer.getSelection()?.getSelectedText()) return
+                    selectOption()
+                  }}
+                >
+                  <box flexDirection="row">
+                    <box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
+                      <text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
+                        {`${options().length + 1}.`}
+                      </text>
+                    </box>
+                    <box backgroundColor={other() ? theme.backgroundElement : undefined}>
+                      <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
+                        {multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
+                      </text>
+                    </box>
+
+                    <Show when={!multi()}>
+                      <text fg={theme.success}>{customPicked() ? " ✓" : ""}</text>
+                    </Show>
+                  </box>
+                  <Show when={store.editing}>
+                    <box paddingLeft={3}>
+                      <textarea
+                        ref={(val: TextareaRenderable) => {
+                          textarea = val
+                          val.traits = { status: "ANSWER" }
+                          queueMicrotask(() => {
+                            val.focus()
+                            val.gotoLineEnd()
+                          })
+                        }}
+                        initialValue={input()}
+                        placeholder="Type your own answer"
+                        placeholderColor={theme.textMuted}
+                        minHeight={1}
+                        maxHeight={6}
+                        textColor={theme.text}
+                        focusedTextColor={theme.text}
+                        cursorColor={theme.primary}
+                      />
+                    </box>
+                  </Show>
+                  <Show when={!store.editing && input()}>
+                    <box paddingLeft={3}>
+                      <text fg={theme.textMuted}>{input()}</text>
+                    </box>
+                  </Show>
+                </box>
+              </Show>
+            </box>
+          </box>
+        </Show>
+
+        <Show when={confirm() && !single()}>
+          <box paddingLeft={1}>
+            <text fg={theme.text}>Review</text>
+          </box>
+          <For each={questions()}>
+            {(q, index) => {
+              const value = () => store.answers[index()]?.join(", ") ?? ""
+              const answered = () => Boolean(value())
+              return (
+                <box paddingLeft={1}>
+                  <text>
+                    <span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
+                    <span style={{ fg: answered() ? theme.text : theme.error }}>
+                      {answered() ? value() : "(not answered)"}
+                    </span>
+                  </text>
+                </box>
+              )
+            }}
+          </For>
+        </Show>
+      </box>
+      <box
+        flexDirection="row"
+        flexShrink={0}
+        gap={1}
+        paddingLeft={2}
+        paddingRight={3}
+        paddingBottom={1}
+        justifyContent="space-between"
+      >
+        <box flexDirection="row" gap={2}>
+          <Show when={!single()}>
+            <text fg={theme.text}>
+              {"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
+            </text>
+          </Show>
+          <Show when={!confirm()}>
+            <text fg={theme.text}>
+              {"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
+            </text>
+          </Show>
+          <text fg={theme.text}>
+            enter{" "}
+            <span style={{ fg: theme.textMuted }}>
+              {confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
+            </span>
+          </text>
+
+          <text fg={theme.text}>
+            esc <span style={{ fg: theme.textMuted }}>dismiss</span>
+          </text>
+        </box>
+      </box>
+    </box>
+  )
+}

+ 17 - 25
packages/tui/test/cli/tui/data.test.tsx

@@ -672,45 +672,37 @@ test("adds and dismisses question requests from live events", async () => {
   try {
     await wait(() => data.connection.status() === "connected")
     emitEvent(events, {
-      id: "evt_form_created_1",
-      type: "form.created",
+      id: "evt_question_asked_1",
+      type: "question.v2.asked",
       data: {
-        form: {
-          id: "frm_1",
-          sessionID: "ses_1",
-          mode: "form",
-          metadata: { kind: "question" },
-          fields: [{ key: "question_0", title: "Which option?", description: "Option", type: "string", options: [] }],
-        },
+        id: "que_1",
+        sessionID: "ses_1",
+        questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
       },
     })
     emitEvent(events, {
-      id: "evt_form_created_2",
-      type: "form.created",
+      id: "evt_question_asked_2",
+      type: "question.v2.asked",
       data: {
-        form: {
-          id: "frm_2",
-          sessionID: "ses_1",
-          mode: "form",
-          metadata: { kind: "question" },
-          fields: [{ key: "question_0", title: "Which environment?", description: "Environment", type: "string", options: [] }],
-        },
+        id: "que_2",
+        sessionID: "ses_1",
+        questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
       },
     })
     await wait(() => data.session.question.list("ses_1")?.length === 2)
 
     emitEvent(events, {
-      id: "evt_form_replied_1",
-      type: "form.replied",
-      data: { id: "frm_1", sessionID: "ses_1", answer: { question_0: "First" } },
+      id: "evt_question_replied_1",
+      type: "question.v2.replied",
+      data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
     })
     await wait(() => data.session.question.list("ses_1")?.length === 1)
-    expect(data.session.question.list("ses_1")?.[0]?.id).toBe("frm_2")
+    expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
 
     emitEvent(events, {
-      id: "evt_form_cancelled_2",
-      type: "form.cancelled",
-      data: { id: "frm_2", sessionID: "ses_1" },
+      id: "evt_question_rejected_2",
+      type: "question.v2.rejected",
+      data: { sessionID: "ses_1", requestID: "que_2" },
     })
     await wait(() => data.session.question.list("ses_1")?.length === 0)
   } finally {

Some files were not shown because too many files changed in this diff