Przeglądaj źródła

core: add interactive question tool for gathering user preferences and clarifying instructions (#7268)

Dax 3 miesięcy temu
rodzic
commit
e37fd9c105

+ 2 - 1
STYLE_GUIDE.md

@@ -1,7 +1,8 @@
 ## Style Guide
 
 - Try to keep things in one function unless composable or reusable
-- AVOID unnecessary destructuring of variables
+- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
+= obj` just reference it as obj.a and obj.b. this preserves context
 - AVOID `try`/`catch` where possible
 - AVOID `else` statements
 - AVOID using `any` type

+ 9 - 1
packages/opencode/src/agent/agent.ts

@@ -51,6 +51,7 @@ export namespace Agent {
         "*": "ask",
         [Truncate.DIR]: "allow",
       },
+      question: "deny",
       // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
       read: {
         "*": "allow",
@@ -65,7 +66,13 @@ export namespace Agent {
       build: {
         name: "build",
         options: {},
-        permission: PermissionNext.merge(defaults, user),
+        permission: PermissionNext.merge(
+          defaults,
+          PermissionNext.fromConfig({
+            question: "allow",
+          }),
+          user,
+        ),
         mode: "primary",
         native: true,
       },
@@ -75,6 +82,7 @@ export namespace Agent {
         permission: PermissionNext.merge(
           defaults,
           PermissionNext.fromConfig({
+            question: "allow",
             edit: {
               "*": "deny",
               ".opencode/plan/*.md": "allow",

+ 9 - 1
packages/opencode/src/cli/cmd/github.ts

@@ -515,7 +515,15 @@ export const GithubRunCommand = cmd({
 
         // Setup opencode session
         const repoData = await fetchRepo()
-        session = await Session.create({})
+        session = await Session.create({
+          permission: [
+            {
+              permission: "question",
+              action: "deny",
+              pattern: "*",
+            },
+          ],
+        })
         subscribeSessionEvents()
         shareId = await (async () => {
           if (share === false) return

+ 22 - 1
packages/opencode/src/cli/cmd/run.ts

@@ -292,7 +292,28 @@ export const RunCommand = cmd({
               : args.title
             : undefined
 
-        const result = await sdk.session.create(title ? { title } : {})
+        const result = await sdk.session.create(
+          title
+            ? {
+                title,
+                permission: [
+                  {
+                    permission: "question",
+                    action: "deny",
+                    pattern: "*",
+                  },
+                ],
+              }
+            : {
+                permission: [
+                  {
+                    permission: "question",
+                    action: "deny",
+                    pattern: "*",
+                  },
+                ],
+              },
+        )
         return result.data?.id
       })()
 

+ 43 - 0
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -8,6 +8,7 @@ import type {
   Todo,
   Command,
   PermissionRequest,
+  QuestionRequest,
   LspStatus,
   McpStatus,
   McpResource,
@@ -42,6 +43,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       permission: {
         [sessionID: string]: PermissionRequest[]
       }
+      question: {
+        [sessionID: string]: QuestionRequest[]
+      }
       config: Config
       session: Session[]
       session_status: {
@@ -80,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       status: "loading",
       agent: [],
       permission: {},
+      question: {},
       command: [],
       provider: [],
       provider_default: {},
@@ -142,6 +147,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           break
         }
 
+        case "question.replied":
+        case "question.rejected": {
+          const requests = store.question[event.properties.sessionID]
+          if (!requests) break
+          const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
+          if (!match.found) break
+          setStore(
+            "question",
+            event.properties.sessionID,
+            produce((draft) => {
+              draft.splice(match.index, 1)
+            }),
+          )
+          break
+        }
+
+        case "question.asked": {
+          const request = event.properties
+          const requests = store.question[request.sessionID]
+          if (!requests) {
+            setStore("question", request.sessionID, [request])
+            break
+          }
+          const match = Binary.search(requests, request.id, (r) => r.id)
+          if (match.found) {
+            setStore("question", request.sessionID, match.index, reconcile(request))
+            break
+          }
+          setStore(
+            "question",
+            request.sessionID,
+            produce((draft) => {
+              draft.splice(match.index, 0, request)
+            }),
+          )
+          break
+        }
+
         case "todo.updated":
           setStore("todo", event.properties.sessionID, event.properties.todos)
           break

+ 49 - 4
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -41,6 +41,7 @@ import type { EditTool } from "@/tool/edit"
 import type { PatchTool } from "@/tool/patch"
 import type { WebFetchTool } from "@/tool/webfetch"
 import type { TaskTool } from "@/tool/task"
+import type { QuestionTool } from "@/tool/question"
 import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { useSDK } from "@tui/context/sdk"
 import { useCommandDialog } from "@tui/component/dialog-command"
@@ -69,6 +70,7 @@ import { usePromptRef } from "../../context/prompt"
 import { useExit } from "../../context/exit"
 import { Filesystem } from "@/util/filesystem"
 import { PermissionPrompt } from "./permission"
+import { QuestionPrompt } from "./question"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 import { formatTranscript } from "../../util/transcript"
 
@@ -118,9 +120,13 @@ export function Session() {
   })
   const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
   const permissions = createMemo(() => {
-    if (session()?.parentID) return sync.data.permission[route.sessionID] ?? []
+    if (session()?.parentID) return []
     return children().flatMap((x) => sync.data.permission[x.id] ?? [])
   })
+  const questions = createMemo(() => {
+    if (session()?.parentID) return []
+    return children().flatMap((x) => sync.data.question[x.id] ?? [])
+  })
 
   const pending = createMemo(() => {
     return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -1037,8 +1043,11 @@ export function Session() {
               <Show when={permissions().length > 0}>
                 <PermissionPrompt request={permissions()[0]} />
               </Show>
+              <Show when={permissions().length === 0 && questions().length > 0}>
+                <QuestionPrompt request={questions()[0]} />
+              </Show>
               <Prompt
-                visible={!session()?.parentID && permissions().length === 0}
+                visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
                 ref={(r) => {
                   prompt = r
                   promptRef.set(r)
@@ -1047,7 +1056,7 @@ export function Session() {
                     r.set(route.initialPrompt)
                   }
                 }}
-                disabled={permissions().length > 0}
+                disabled={permissions().length > 0 || questions().length > 0}
                 onSubmit={() => {
                   toBottom()
                 }}
@@ -1381,6 +1390,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
       <Match when={props.part.tool === "todowrite"}>
         <TodoWrite {...toolprops} />
       </Match>
+      <Match when={props.part.tool === "question"}>
+        <Question {...toolprops} />
+      </Match>
       <Match when={true}>
         <GenericTool {...toolprops} />
       </Match>
@@ -1442,7 +1454,12 @@ function InlineTool(props: {
 
   const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
 
-  const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule"))
+  const denied = createMemo(
+    () =>
+      error()?.includes("rejected permission") ||
+      error()?.includes("specified a rule") ||
+      error()?.includes("user dismissed"),
+  )
 
   return (
     <box
@@ -1816,6 +1833,34 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
   )
 }
 
+function Question(props: ToolProps<typeof QuestionTool>) {
+  const { theme } = useTheme()
+  const count = createMemo(() => props.input.questions?.length ?? 0)
+  return (
+    <Switch>
+      <Match when={props.metadata.answers}>
+        <BlockTool title="# Questions" part={props.part}>
+          <box>
+            <For each={props.input.questions ?? []}>
+              {(q, i) => (
+                <box flexDirection="row" gap={1}>
+                  <text fg={theme.textMuted}>{q.question}</text>
+                  <text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text>
+                </box>
+              )}
+            </For>
+          </box>
+        </BlockTool>
+      </Match>
+      <Match when={true}>
+        <InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
+          Asked {count()} question{count() !== 1 ? "s" : ""}
+        </InlineTool>
+      </Match>
+    </Switch>
+  )
+}
+
 function normalizePath(input?: string) {
   if (!input) return ""
   if (path.isAbsolute(input)) {

+ 287 - 0
packages/opencode/src/cli/cmd/tui/routes/session/question.tsx

@@ -0,0 +1,287 @@
+import { createStore } from "solid-js/store"
+import { createMemo, For, Show } from "solid-js"
+import { useKeyboard } from "@opentui/solid"
+import type { TextareaRenderable } from "@opentui/core"
+import { useKeybind } from "../../context/keybind"
+import { useTheme } from "../../context/theme"
+import type { QuestionRequest } from "@opencode-ai/sdk/v2"
+import { useSDK } from "../../context/sdk"
+import { SplitBorder } from "../../component/border"
+import { useTextareaKeybindings } from "../../component/textarea-keybindings"
+import { useDialog } from "../../ui/dialog"
+
+export function QuestionPrompt(props: { request: QuestionRequest }) {
+  const sdk = useSDK()
+  const { theme } = useTheme()
+  const keybind = useKeybind()
+  const bindings = useTextareaKeybindings()
+
+  const questions = createMemo(() => props.request.questions)
+  const single = createMemo(() => questions().length === 1)
+  const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
+  const [store, setStore] = createStore({
+    tab: 0,
+    answers: [] as string[],
+    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 other = createMemo(() => store.selected === options().length)
+  const input = createMemo(() => store.custom[store.tab] ?? "")
+
+  function submit() {
+    // Fill in empty answers with empty strings
+    const answers = questions().map((_, i) => store.answers[i] ?? "")
+    sdk.client.question.reply({
+      requestID: props.request.id,
+      answers,
+    })
+  }
+
+  function reject() {
+    sdk.client.question.reject({
+      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()) {
+      sdk.client.question.reply({
+        requestID: props.request.id,
+        answers: [answer],
+      })
+      return
+    }
+    setStore("tab", store.tab + 1)
+    setStore("selected", 0)
+  }
+
+  const dialog = useDialog()
+
+  useKeyboard((evt) => {
+    // When editing "Other" textarea
+    if (store.editing && !confirm()) {
+      if (evt.name === "escape") {
+        evt.preventDefault()
+        setStore("editing", false)
+        return
+      }
+      if (evt.name === "return") {
+        evt.preventDefault()
+        const text = textarea?.plainText?.trim()
+        if (text) {
+          pick(text, true)
+          setStore("editing", false)
+        }
+        return
+      }
+      // Let textarea handle all other keys
+      return
+    }
+
+    if (evt.name === "left" || evt.name === "h") {
+      evt.preventDefault()
+      const next = (store.tab - 1 + tabs()) % tabs()
+      setStore("tab", next)
+      setStore("selected", 0)
+    }
+
+    if (evt.name === "right" || evt.name === "l") {
+      evt.preventDefault()
+      const next = (store.tab + 1) % tabs()
+      setStore("tab", next)
+      setStore("selected", 0)
+    }
+
+    if (confirm()) {
+      if (evt.name === "return") {
+        evt.preventDefault()
+        submit()
+      }
+      if (evt.name === "escape" || keybind.match("app_exit", evt)) {
+        evt.preventDefault()
+        reject()
+      }
+    } else {
+      const opts = options()
+      const total = opts.length + 1 // options + "Other"
+
+      if (evt.name === "up" || evt.name === "k") {
+        evt.preventDefault()
+        setStore("selected", (store.selected - 1 + total) % total)
+      }
+
+      if (evt.name === "down" || evt.name === "j") {
+        evt.preventDefault()
+        setStore("selected", (store.selected + 1) % total)
+      }
+
+      if (evt.name === "return") {
+        evt.preventDefault()
+        if (other()) {
+          setStore("editing", true)
+        } else {
+          const opt = opts[store.selected]
+          if (opt) {
+            pick(opt.label)
+          }
+        }
+      }
+
+      if (evt.name === "escape" || keybind.match("app_exit", evt)) {
+        evt.preventDefault()
+        reject()
+      }
+    }
+  })
+
+  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 = () => store.answers[index()] !== undefined
+                return (
+                  <box
+                    paddingLeft={1}
+                    paddingRight={1}
+                    backgroundColor={isActive() ? theme.accent : theme.backgroundElement}
+                  >
+                    <text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}>
+                      {q.header}
+                    </text>
+                  </box>
+                )
+              }}
+            </For>
+            <box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}>
+              <text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text>
+            </box>
+          </box>
+        </Show>
+
+        <Show when={!confirm()}>
+          <box paddingLeft={1} gap={1}>
+            <box>
+              <text fg={theme.text}>{question()?.question}</text>
+            </box>
+            <box>
+              <For each={options()}>
+                {(opt, i) => {
+                  const active = () => i() === store.selected
+                  const picked = () => store.answers[store.tab] === opt.label
+                  return (
+                    <box>
+                      <box flexDirection="row" gap={1}>
+                        <box backgroundColor={active() ? theme.backgroundElement : undefined}>
+                          <text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
+                            {i() + 1}. {opt.label}
+                          </text>
+                        </box>
+                        <text fg={theme.success}>{picked() ? "✓" : ""}</text>
+                      </box>
+                      <box paddingLeft={3}>
+                        <text fg={theme.textMuted}>{opt.description}</text>
+                      </box>
+                    </box>
+                  )
+                }}
+              </For>
+              <box>
+                <box flexDirection="row" gap={1}>
+                  <box backgroundColor={other() ? theme.backgroundElement : undefined}>
+                    <text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
+                      {options().length + 1}. Other
+                    </text>
+                  </box>
+                  <text fg={theme.success}>{input() ? "✓" : ""}</text>
+                </box>
+                <Show when={store.editing}>
+                  <textarea
+                    ref={(val: TextareaRenderable) => (textarea = val)}
+                    focused
+                    placeholder="Type your own answer"
+                    textColor={theme.text}
+                    focusedTextColor={theme.text}
+                    cursorColor={theme.primary}
+                    keyBindings={bindings()}
+                  />
+                </Show>
+                <Show when={!store.editing && input()}>
+                  <text fg={theme.textMuted}>{input()}</text>
+                </Show>
+              </box>
+            </box>
+          </box>
+        </Show>
+
+        <Show when={confirm() && !single()}>
+          <box paddingLeft={1}>
+            <text fg={theme.text}>Review</text>
+          </box>
+          <For each={questions()}>
+            {(q, index) => {
+              const answer = () => store.answers[index()]
+              return (
+                <box flexDirection="row" gap={1} paddingLeft={1}>
+                  <text fg={theme.textMuted}>{q.header}:</text>
+                  <text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</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" : single() ? "submit" : "confirm"}</span>
+          </text>
+          <text fg={theme.text}>
+            esc <span style={{ fg: theme.textMuted }}>dismiss</span>
+          </text>
+        </box>
+      </box>
+    </box>
+  )
+}

+ 1 - 0
packages/opencode/src/config/config.ts

@@ -450,6 +450,7 @@ export namespace Config {
           external_directory: PermissionRule.optional(),
           todowrite: PermissionAction.optional(),
           todoread: PermissionAction.optional(),
+          question: PermissionAction.optional(),
           webfetch: PermissionAction.optional(),
           websearch: PermissionAction.optional(),
           codesearch: PermissionAction.optional(),

+ 1 - 0
packages/opencode/src/id/id.ts

@@ -6,6 +6,7 @@ export namespace Identifier {
     session: "ses",
     message: "msg",
     permission: "per",
+    question: "que",
     user: "usr",
     part: "prt",
     pty: "pty",

+ 162 - 0
packages/opencode/src/question/index.ts

@@ -0,0 +1,162 @@
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Identifier } from "@/id/id"
+import { Instance } from "@/project/instance"
+import { Log } from "@/util/log"
+import z from "zod"
+
+export namespace Question {
+  const log = Log.create({ service: "question" })
+
+  export const Option = z
+    .object({
+      label: z.string().describe("Display text (1-5 words, concise)"),
+      description: z.string().describe("Explanation of choice"),
+    })
+    .meta({
+      ref: "QuestionOption",
+    })
+  export type Option = z.infer<typeof Option>
+
+  export const Info = z
+    .object({
+      question: z.string().describe("Complete question"),
+      header: z.string().max(12).describe("Very short label (max 12 chars)"),
+      options: z.array(Option).describe("Available choices"),
+    })
+    .meta({
+      ref: "QuestionInfo",
+    })
+  export type Info = z.infer<typeof Info>
+
+  export const Request = z
+    .object({
+      id: Identifier.schema("question"),
+      sessionID: Identifier.schema("session"),
+      questions: z.array(Info).describe("Questions to ask"),
+      tool: z
+        .object({
+          messageID: z.string(),
+          callID: z.string(),
+        })
+        .optional(),
+    })
+    .meta({
+      ref: "QuestionRequest",
+    })
+  export type Request = z.infer<typeof Request>
+
+  export const Reply = z.object({
+    answers: z.array(z.string()).describe("User answers in order of questions"),
+  })
+  export type Reply = z.infer<typeof Reply>
+
+  export const Event = {
+    Asked: BusEvent.define("question.asked", Request),
+    Replied: BusEvent.define(
+      "question.replied",
+      z.object({
+        sessionID: z.string(),
+        requestID: z.string(),
+        answers: z.array(z.string()),
+      }),
+    ),
+    Rejected: BusEvent.define(
+      "question.rejected",
+      z.object({
+        sessionID: z.string(),
+        requestID: z.string(),
+      }),
+    ),
+  }
+
+  const state = Instance.state(async () => {
+    const pending: Record<
+      string,
+      {
+        info: Request
+        resolve: (answers: string[]) => void
+        reject: (e: any) => void
+      }
+    > = {}
+
+    return {
+      pending,
+    }
+  })
+
+  export async function ask(input: {
+    sessionID: string
+    questions: Info[]
+    tool?: { messageID: string; callID: string }
+  }): Promise<string[]> {
+    const s = await state()
+    const id = Identifier.ascending("question")
+
+    log.info("asking", { id, questions: input.questions.length })
+
+    return new Promise<string[]>((resolve, reject) => {
+      const info: Request = {
+        id,
+        sessionID: input.sessionID,
+        questions: input.questions,
+        tool: input.tool,
+      }
+      s.pending[id] = {
+        info,
+        resolve,
+        reject,
+      }
+      Bus.publish(Event.Asked, info)
+    })
+  }
+
+  export async function reply(input: { requestID: string; answers: string[] }): Promise<void> {
+    const s = await state()
+    const existing = s.pending[input.requestID]
+    if (!existing) {
+      log.warn("reply for unknown request", { requestID: input.requestID })
+      return
+    }
+    delete s.pending[input.requestID]
+
+    log.info("replied", { requestID: input.requestID, answers: input.answers })
+
+    Bus.publish(Event.Replied, {
+      sessionID: existing.info.sessionID,
+      requestID: existing.info.id,
+      answers: input.answers,
+    })
+
+    existing.resolve(input.answers)
+  }
+
+  export async function reject(requestID: string): Promise<void> {
+    const s = await state()
+    const existing = s.pending[requestID]
+    if (!existing) {
+      log.warn("reject for unknown request", { requestID })
+      return
+    }
+    delete s.pending[requestID]
+
+    log.info("rejected", { requestID })
+
+    Bus.publish(Event.Rejected, {
+      sessionID: existing.info.sessionID,
+      requestID: existing.info.id,
+    })
+
+    existing.reject(new RejectedError())
+  }
+
+  export class RejectedError extends Error {
+    constructor() {
+      super("The user dismissed this question")
+    }
+  }
+
+  export async function list() {
+    return state().then((x) => Object.values(x.pending).map((x) => x.info))
+  }
+}

+ 95 - 0
packages/opencode/src/server/question.ts

@@ -0,0 +1,95 @@
+import { Hono } from "hono"
+import { describeRoute, validator } from "hono-openapi"
+import { resolver } from "hono-openapi"
+import { Question } from "../question"
+import z from "zod"
+import { errors } from "./error"
+
+export const QuestionRoute = new Hono()
+  .get(
+    "/",
+    describeRoute({
+      summary: "List pending questions",
+      description: "Get all pending question requests across all sessions.",
+      operationId: "question.list",
+      responses: {
+        200: {
+          description: "List of pending questions",
+          content: {
+            "application/json": {
+              schema: resolver(Question.Request.array()),
+            },
+          },
+        },
+      },
+    }),
+    async (c) => {
+      const questions = await Question.list()
+      return c.json(questions)
+    },
+  )
+  .post(
+    "/:requestID/reply",
+    describeRoute({
+      summary: "Reply to question request",
+      description: "Provide answers to a question request from the AI assistant.",
+      operationId: "question.reply",
+      responses: {
+        200: {
+          description: "Question answered successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+        ...errors(400, 404),
+      },
+    }),
+    validator(
+      "param",
+      z.object({
+        requestID: z.string(),
+      }),
+    ),
+    validator("json", z.object({ answers: z.array(z.string()) })),
+    async (c) => {
+      const params = c.req.valid("param")
+      const json = c.req.valid("json")
+      await Question.reply({
+        requestID: params.requestID,
+        answers: json.answers,
+      })
+      return c.json(true)
+    },
+  )
+  .post(
+    "/:requestID/reject",
+    describeRoute({
+      summary: "Reject question request",
+      description: "Reject a question request from the AI assistant.",
+      operationId: "question.reject",
+      responses: {
+        200: {
+          description: "Question rejected successfully",
+          content: {
+            "application/json": {
+              schema: resolver(z.boolean()),
+            },
+          },
+        },
+        ...errors(400, 404),
+      },
+    }),
+    validator(
+      "param",
+      z.object({
+        requestID: z.string(),
+      }),
+    ),
+    async (c) => {
+      const params = c.req.valid("param")
+      await Question.reject(params.requestID)
+      return c.json(true)
+    },
+  )

+ 2697 - 2686
packages/opencode/src/server/server.ts

@@ -48,6 +48,7 @@ import { upgradeWebSocket, websocket } from "hono/bun"
 import { errors } from "./error"
 import { Pty } from "@/pty"
 import { PermissionNext } from "@/permission/next"
+import { QuestionRoute } from "./question"
 import { Installation } from "@/installation"
 import { MDNS } from "./mdns"
 import { Worktree } from "../worktree"
@@ -71,2757 +72,2767 @@ export namespace Server {
   }
 
   const app = new Hono()
-  export const App = lazy(() =>
-    app
-      .onError((err, c) => {
-        log.error("failed", {
-          error: err,
-        })
-        if (err instanceof NamedError) {
-          let status: ContentfulStatusCode
-          if (err instanceof Storage.NotFoundError) status = 404
-          else if (err instanceof Provider.ModelNotFoundError) status = 400
-          else if (err.name.startsWith("Worktree")) status = 400
-          else status = 500
-          return c.json(err.toObject(), { status })
-        }
-        const message = err instanceof Error && err.stack ? err.stack : err.toString()
-        return c.json(new NamedError.Unknown({ message }).toObject(), {
-          status: 500,
+  export const App: () => Hono = lazy(
+    () =>
+      app
+        .onError((err, c) => {
+          log.error("failed", {
+            error: err,
+          })
+          if (err instanceof NamedError) {
+            let status: ContentfulStatusCode
+            if (err instanceof Storage.NotFoundError) status = 404
+            else if (err instanceof Provider.ModelNotFoundError) status = 400
+            else if (err.name.startsWith("Worktree")) status = 400
+            else status = 500
+            return c.json(err.toObject(), { status })
+          }
+          const message = err instanceof Error && err.stack ? err.stack : err.toString()
+          return c.json(new NamedError.Unknown({ message }).toObject(), {
+            status: 500,
+          })
         })
-      })
-      .use(async (c, next) => {
-        const skipLogging = c.req.path === "/log"
-        if (!skipLogging) {
-          log.info("request", {
+        .use(async (c, next) => {
+          const skipLogging = c.req.path === "/log"
+          if (!skipLogging) {
+            log.info("request", {
+              method: c.req.method,
+              path: c.req.path,
+            })
+          }
+          const timer = log.time("request", {
             method: c.req.method,
             path: c.req.path,
           })
-        }
-        const timer = log.time("request", {
-          method: c.req.method,
-          path: c.req.path,
+          await next()
+          if (!skipLogging) {
+            timer.stop()
+          }
         })
-        await next()
-        if (!skipLogging) {
-          timer.stop()
-        }
-      })
-      .use(
-        cors({
-          origin(input) {
-            if (!input) return
+        .use(
+          cors({
+            origin(input) {
+              if (!input) return
 
-            if (input.startsWith("http://localhost:")) return input
-            if (input.startsWith("http://127.0.0.1:")) return input
-            if (input === "tauri://localhost" || input === "http://tauri.localhost") return input
+              if (input.startsWith("http://localhost:")) return input
+              if (input.startsWith("http://127.0.0.1:")) return input
+              if (input === "tauri://localhost" || input === "http://tauri.localhost") return input
 
-            // *.opencode.ai (https only, adjust if needed)
-            if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
-              return input
-            }
-            if (_corsWhitelist.includes(input)) {
-              return input
-            }
+              // *.opencode.ai (https only, adjust if needed)
+              if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
+                return input
+              }
+              if (_corsWhitelist.includes(input)) {
+                return input
+              }
 
-            return
-          },
-        }),
-      )
-      .get(
-        "/global/health",
-        describeRoute({
-          summary: "Get health",
-          description: "Get health information about the OpenCode server.",
-          operationId: "global.health",
-          responses: {
-            200: {
-              description: "Health information",
-              content: {
-                "application/json": {
-                  schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
+              return
+            },
+          }),
+        )
+        .get(
+          "/global/health",
+          describeRoute({
+            summary: "Get health",
+            description: "Get health information about the OpenCode server.",
+            operationId: "global.health",
+            responses: {
+              200: {
+                description: "Health information",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
+                  },
                 },
               },
             },
-          },
-        }),
-        async (c) => {
-          return c.json({ healthy: true, version: Installation.VERSION })
-        },
-      )
-      .get(
-        "/global/event",
-        describeRoute({
-          summary: "Get global events",
-          description: "Subscribe to global events from the OpenCode system using server-sent events.",
-          operationId: "global.event",
-          responses: {
-            200: {
-              description: "Event stream",
-              content: {
-                "text/event-stream": {
-                  schema: resolver(
-                    z
-                      .object({
-                        directory: z.string(),
-                        payload: BusEvent.payloads(),
-                      })
-                      .meta({
-                        ref: "GlobalEvent",
-                      }),
-                  ),
+          }),
+          async (c) => {
+            return c.json({ healthy: true, version: Installation.VERSION })
+          },
+        )
+        .get(
+          "/global/event",
+          describeRoute({
+            summary: "Get global events",
+            description: "Subscribe to global events from the OpenCode system using server-sent events.",
+            operationId: "global.event",
+            responses: {
+              200: {
+                description: "Event stream",
+                content: {
+                  "text/event-stream": {
+                    schema: resolver(
+                      z
+                        .object({
+                          directory: z.string(),
+                          payload: BusEvent.payloads(),
+                        })
+                        .meta({
+                          ref: "GlobalEvent",
+                        }),
+                    ),
+                  },
                 },
               },
             },
-          },
-        }),
-        async (c) => {
-          log.info("global event connected")
-          return streamSSE(c, async (stream) => {
-            stream.writeSSE({
-              data: JSON.stringify({
-                payload: {
-                  type: "server.connected",
-                  properties: {},
-                },
-              }),
-            })
-            async function handler(event: any) {
-              await stream.writeSSE({
-                data: JSON.stringify(event),
-              })
-            }
-            GlobalBus.on("event", handler)
-
-            // Send heartbeat every 30s to prevent WKWebView timeout (60s default)
-            const heartbeat = setInterval(() => {
+          }),
+          async (c) => {
+            log.info("global event connected")
+            return streamSSE(c, async (stream) => {
               stream.writeSSE({
                 data: JSON.stringify({
                   payload: {
-                    type: "server.heartbeat",
+                    type: "server.connected",
                     properties: {},
                   },
                 }),
               })
-            }, 30000)
+              async function handler(event: any) {
+                await stream.writeSSE({
+                  data: JSON.stringify(event),
+                })
+              }
+              GlobalBus.on("event", handler)
+
+              // Send heartbeat every 30s to prevent WKWebView timeout (60s default)
+              const heartbeat = setInterval(() => {
+                stream.writeSSE({
+                  data: JSON.stringify({
+                    payload: {
+                      type: "server.heartbeat",
+                      properties: {},
+                    },
+                  }),
+                })
+              }, 30000)
 
-            await new Promise<void>((resolve) => {
-              stream.onAbort(() => {
-                clearInterval(heartbeat)
-                GlobalBus.off("event", handler)
-                resolve()
-                log.info("global event disconnected")
+              await new Promise<void>((resolve) => {
+                stream.onAbort(() => {
+                  clearInterval(heartbeat)
+                  GlobalBus.off("event", handler)
+                  resolve()
+                  log.info("global event disconnected")
+                })
               })
             })
-          })
-        },
-      )
-      .post(
-        "/global/dispose",
-        describeRoute({
-          summary: "Dispose instance",
-          description: "Clean up and dispose all OpenCode instances, releasing all resources.",
-          operationId: "global.dispose",
-          responses: {
-            200: {
-              description: "Global disposed",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          await Instance.disposeAll()
-          GlobalBus.emit("event", {
-            directory: "global",
-            payload: {
-              type: Event.Disposed.type,
-              properties: {},
+          },
+        )
+        .post(
+          "/global/dispose",
+          describeRoute({
+            summary: "Dispose instance",
+            description: "Clean up and dispose all OpenCode instances, releasing all resources.",
+            operationId: "global.dispose",
+            responses: {
+              200: {
+                description: "Global disposed",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            await Instance.disposeAll()
+            GlobalBus.emit("event", {
+              directory: "global",
+              payload: {
+                type: Event.Disposed.type,
+                properties: {},
+              },
+            })
+            return c.json(true)
+          },
+        )
+        .use(async (c, next) => {
+          let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
+          try {
+            directory = decodeURIComponent(directory)
+          } catch {
+            // fallback to original value
+          }
+          return Instance.provide({
+            directory,
+            init: InstanceBootstrap,
+            async fn() {
+              return next()
             },
           })
-          return c.json(true)
-        },
-      )
-      .use(async (c, next) => {
-        let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
-        try {
-          directory = decodeURIComponent(directory)
-        } catch {
-          // fallback to original value
-        }
-        return Instance.provide({
-          directory,
-          init: InstanceBootstrap,
-          async fn() {
-            return next()
-          },
         })
-      })
-      .get(
-        "/doc",
-        openAPIRouteHandler(app, {
-          documentation: {
-            info: {
-              title: "opencode",
-              version: "0.0.3",
-              description: "opencode api",
-            },
-            openapi: "3.1.1",
-          },
-        }),
-      )
-      .use(validator("query", z.object({ directory: z.string().optional() })))
+        .get(
+          "/doc",
+          openAPIRouteHandler(app, {
+            documentation: {
+              info: {
+                title: "opencode",
+                version: "0.0.3",
+                description: "opencode api",
+              },
+              openapi: "3.1.1",
+            },
+          }),
+        )
+        .use(validator("query", z.object({ directory: z.string().optional() })))
 
-      .route("/project", ProjectRoute)
+        .route("/project", ProjectRoute)
 
-      .get(
-        "/pty",
-        describeRoute({
-          summary: "List PTY sessions",
-          description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
-          operationId: "pty.list",
-          responses: {
-            200: {
-              description: "List of sessions",
-              content: {
-                "application/json": {
-                  schema: resolver(Pty.Info.array()),
+        .get(
+          "/pty",
+          describeRoute({
+            summary: "List PTY sessions",
+            description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
+            operationId: "pty.list",
+            responses: {
+              200: {
+                description: "List of sessions",
+                content: {
+                  "application/json": {
+                    schema: resolver(Pty.Info.array()),
+                  },
                 },
               },
             },
-          },
-        }),
-        async (c) => {
-          return c.json(Pty.list())
-        },
-      )
-      .post(
-        "/pty",
-        describeRoute({
-          summary: "Create PTY session",
-          description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
-          operationId: "pty.create",
-          responses: {
-            200: {
-              description: "Created session",
-              content: {
-                "application/json": {
-                  schema: resolver(Pty.Info),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator("json", Pty.CreateInput),
-        async (c) => {
-          const info = await Pty.create(c.req.valid("json"))
-          return c.json(info)
-        },
-      )
-      .get(
-        "/pty/:ptyID",
-        describeRoute({
-          summary: "Get PTY session",
-          description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
-          operationId: "pty.get",
-          responses: {
-            200: {
-              description: "Session info",
-              content: {
-                "application/json": {
-                  schema: resolver(Pty.Info),
-                },
-              },
-            },
-            ...errors(404),
-          },
-        }),
-        validator("param", z.object({ ptyID: z.string() })),
-        async (c) => {
-          const info = Pty.get(c.req.valid("param").ptyID)
-          if (!info) {
-            throw new Storage.NotFoundError({ message: "Session not found" })
-          }
-          return c.json(info)
-        },
-      )
-      .put(
-        "/pty/:ptyID",
-        describeRoute({
-          summary: "Update PTY session",
-          description: "Update properties of an existing pseudo-terminal (PTY) session.",
-          operationId: "pty.update",
-          responses: {
-            200: {
-              description: "Updated session",
-              content: {
-                "application/json": {
-                  schema: resolver(Pty.Info),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator("param", z.object({ ptyID: z.string() })),
-        validator("json", Pty.UpdateInput),
-        async (c) => {
-          const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
-          return c.json(info)
-        },
-      )
-      .delete(
-        "/pty/:ptyID",
-        describeRoute({
-          summary: "Remove PTY session",
-          description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
-          operationId: "pty.remove",
-          responses: {
-            200: {
-              description: "Session removed",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(404),
-          },
-        }),
-        validator("param", z.object({ ptyID: z.string() })),
-        async (c) => {
-          await Pty.remove(c.req.valid("param").ptyID)
-          return c.json(true)
-        },
-      )
-      .get(
-        "/pty/:ptyID/connect",
-        describeRoute({
-          summary: "Connect to PTY session",
-          description:
-            "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
-          operationId: "pty.connect",
-          responses: {
-            200: {
-              description: "Connected session",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(404),
-          },
-        }),
-        validator("param", z.object({ ptyID: z.string() })),
-        upgradeWebSocket((c) => {
-          const id = c.req.param("ptyID")
-          let handler: ReturnType<typeof Pty.connect>
-          if (!Pty.get(id)) throw new Error("Session not found")
-          return {
-            onOpen(_event, ws) {
-              handler = Pty.connect(id, ws)
-            },
-            onMessage(event) {
-              handler?.onMessage(String(event.data))
-            },
-            onClose() {
-              handler?.onClose()
+          }),
+          async (c) => {
+            return c.json(Pty.list())
+          },
+        )
+        .post(
+          "/pty",
+          describeRoute({
+            summary: "Create PTY session",
+            description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
+            operationId: "pty.create",
+            responses: {
+              200: {
+                description: "Created session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Pty.Info),
+                  },
+                },
+              },
+              ...errors(400),
             },
-          }
-        }),
-      )
+          }),
+          validator("json", Pty.CreateInput),
+          async (c) => {
+            const info = await Pty.create(c.req.valid("json"))
+            return c.json(info)
+          },
+        )
+        .get(
+          "/pty/:ptyID",
+          describeRoute({
+            summary: "Get PTY session",
+            description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
+            operationId: "pty.get",
+            responses: {
+              200: {
+                description: "Session info",
+                content: {
+                  "application/json": {
+                    schema: resolver(Pty.Info),
+                  },
+                },
+              },
+              ...errors(404),
+            },
+          }),
+          validator("param", z.object({ ptyID: z.string() })),
+          async (c) => {
+            const info = Pty.get(c.req.valid("param").ptyID)
+            if (!info) {
+              throw new Storage.NotFoundError({ message: "Session not found" })
+            }
+            return c.json(info)
+          },
+        )
+        .put(
+          "/pty/:ptyID",
+          describeRoute({
+            summary: "Update PTY session",
+            description: "Update properties of an existing pseudo-terminal (PTY) session.",
+            operationId: "pty.update",
+            responses: {
+              200: {
+                description: "Updated session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Pty.Info),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator("param", z.object({ ptyID: z.string() })),
+          validator("json", Pty.UpdateInput),
+          async (c) => {
+            const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
+            return c.json(info)
+          },
+        )
+        .delete(
+          "/pty/:ptyID",
+          describeRoute({
+            summary: "Remove PTY session",
+            description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
+            operationId: "pty.remove",
+            responses: {
+              200: {
+                description: "Session removed",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(404),
+            },
+          }),
+          validator("param", z.object({ ptyID: z.string() })),
+          async (c) => {
+            await Pty.remove(c.req.valid("param").ptyID)
+            return c.json(true)
+          },
+        )
+        .get(
+          "/pty/:ptyID/connect",
+          describeRoute({
+            summary: "Connect to PTY session",
+            description:
+              "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
+            operationId: "pty.connect",
+            responses: {
+              200: {
+                description: "Connected session",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(404),
+            },
+          }),
+          validator("param", z.object({ ptyID: z.string() })),
+          upgradeWebSocket((c) => {
+            const id = c.req.param("ptyID")
+            let handler: ReturnType<typeof Pty.connect>
+            if (!Pty.get(id)) throw new Error("Session not found")
+            return {
+              onOpen(_event, ws) {
+                handler = Pty.connect(id, ws)
+              },
+              onMessage(event) {
+                handler?.onMessage(String(event.data))
+              },
+              onClose() {
+                handler?.onClose()
+              },
+            }
+          }),
+        )
 
-      .get(
-        "/config",
-        describeRoute({
-          summary: "Get configuration",
-          description: "Retrieve the current OpenCode configuration settings and preferences.",
-          operationId: "config.get",
-          responses: {
-            200: {
-              description: "Get config info",
-              content: {
-                "application/json": {
-                  schema: resolver(Config.Info),
+        .get(
+          "/config",
+          describeRoute({
+            summary: "Get configuration",
+            description: "Retrieve the current OpenCode configuration settings and preferences.",
+            operationId: "config.get",
+            responses: {
+              200: {
+                description: "Get config info",
+                content: {
+                  "application/json": {
+                    schema: resolver(Config.Info),
+                  },
                 },
               },
             },
+          }),
+          async (c) => {
+            return c.json(await Config.get())
           },
-        }),
-        async (c) => {
-          return c.json(await Config.get())
-        },
-      )
+        )
 
-      .patch(
-        "/config",
-        describeRoute({
-          summary: "Update configuration",
-          description: "Update OpenCode configuration settings and preferences.",
-          operationId: "config.update",
-          responses: {
-            200: {
-              description: "Successfully updated config",
-              content: {
-                "application/json": {
-                  schema: resolver(Config.Info),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator("json", Config.Info),
-        async (c) => {
-          const config = c.req.valid("json")
-          await Config.update(config)
-          return c.json(config)
-        },
-      )
-      .get(
-        "/experimental/tool/ids",
-        describeRoute({
-          summary: "List tool IDs",
-          description:
-            "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
-          operationId: "tool.ids",
-          responses: {
-            200: {
-              description: "Tool IDs",
-              content: {
-                "application/json": {
-                  schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        async (c) => {
-          return c.json(await ToolRegistry.ids())
-        },
-      )
-      .get(
-        "/experimental/tool",
-        describeRoute({
-          summary: "List tools",
-          description:
-            "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
-          operationId: "tool.list",
-          responses: {
-            200: {
-              description: "Tools",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z
-                      .array(
-                        z
-                          .object({
-                            id: z.string(),
-                            description: z.string(),
-                            parameters: z.any(),
-                          })
-                          .meta({ ref: "ToolListItem" }),
-                      )
-                      .meta({ ref: "ToolList" }),
-                  ),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "query",
-          z.object({
-            provider: z.string(),
-            model: z.string(),
-          }),
-        ),
-        async (c) => {
-          const { provider } = c.req.valid("query")
-          const tools = await ToolRegistry.tools(provider)
-          return c.json(
-            tools.map((t) => ({
-              id: t.id,
-              description: t.description,
-              // Handle both Zod schemas and plain JSON schemas
-              parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
-            })),
-          )
-        },
-      )
-      .post(
-        "/instance/dispose",
-        describeRoute({
-          summary: "Dispose instance",
-          description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
-          operationId: "instance.dispose",
-          responses: {
-            200: {
-              description: "Instance disposed",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          await Instance.dispose()
-          return c.json(true)
-        },
-      )
-      .get(
-        "/path",
-        describeRoute({
-          summary: "Get paths",
-          description: "Retrieve the current working directory and related path information for the OpenCode instance.",
-          operationId: "path.get",
-          responses: {
-            200: {
-              description: "Path",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z
-                      .object({
-                        home: z.string(),
-                        state: z.string(),
-                        config: z.string(),
-                        worktree: z.string(),
-                        directory: z.string(),
-                      })
-                      .meta({
-                        ref: "Path",
-                      }),
-                  ),
+        .patch(
+          "/config",
+          describeRoute({
+            summary: "Update configuration",
+            description: "Update OpenCode configuration settings and preferences.",
+            operationId: "config.update",
+            responses: {
+              200: {
+                description: "Successfully updated config",
+                content: {
+                  "application/json": {
+                    schema: resolver(Config.Info),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator("json", Config.Info),
+          async (c) => {
+            const config = c.req.valid("json")
+            await Config.update(config)
+            return c.json(config)
+          },
+        )
+        .get(
+          "/experimental/tool/ids",
+          describeRoute({
+            summary: "List tool IDs",
+            description:
+              "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
+            operationId: "tool.ids",
+            responses: {
+              200: {
+                description: "Tool IDs",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          async (c) => {
+            return c.json(await ToolRegistry.ids())
+          },
+        )
+        .get(
+          "/experimental/tool",
+          describeRoute({
+            summary: "List tools",
+            description:
+              "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
+            operationId: "tool.list",
+            responses: {
+              200: {
+                description: "Tools",
+                content: {
+                  "application/json": {
+                    schema: resolver(
+                      z
+                        .array(
+                          z
+                            .object({
+                              id: z.string(),
+                              description: z.string(),
+                              parameters: z.any(),
+                            })
+                            .meta({ ref: "ToolListItem" }),
+                        )
+                        .meta({ ref: "ToolList" }),
+                    ),
+                  },
                 },
               },
+              ...errors(400),
             },
+          }),
+          validator(
+            "query",
+            z.object({
+              provider: z.string(),
+              model: z.string(),
+            }),
+          ),
+          async (c) => {
+            const { provider } = c.req.valid("query")
+            const tools = await ToolRegistry.tools(provider)
+            return c.json(
+              tools.map((t) => ({
+                id: t.id,
+                description: t.description,
+                // Handle both Zod schemas and plain JSON schemas
+                parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
+              })),
+            )
           },
-        }),
-        async (c) => {
-          return c.json({
-            home: Global.Path.home,
-            state: Global.Path.state,
-            config: Global.Path.config,
-            worktree: Instance.worktree,
-            directory: Instance.directory,
-          })
-        },
-      )
-      .post(
-        "/experimental/worktree",
-        describeRoute({
-          summary: "Create worktree",
-          description: "Create a new git worktree for the current project.",
-          operationId: "worktree.create",
-          responses: {
-            200: {
-              description: "Worktree created",
-              content: {
-                "application/json": {
-                  schema: resolver(Worktree.Info),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator("json", Worktree.create.schema),
-        async (c) => {
-          const body = c.req.valid("json")
-          const worktree = await Worktree.create(body)
-          return c.json(worktree)
-        },
-      )
-      .get(
-        "/experimental/worktree",
-        describeRoute({
-          summary: "List worktrees",
-          description: "List all sandbox worktrees for the current project.",
-          operationId: "worktree.list",
-          responses: {
-            200: {
-              description: "List of worktree directories",
-              content: {
-                "application/json": {
-                  schema: resolver(z.array(z.string())),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const sandboxes = await Project.sandboxes(Instance.project.id)
-          return c.json(sandboxes)
-        },
-      )
-      .get(
-        "/vcs",
-        describeRoute({
-          summary: "Get VCS info",
-          description: "Retrieve version control system (VCS) information for the current project, such as git branch.",
-          operationId: "vcs.get",
-          responses: {
-            200: {
-              description: "VCS info",
-              content: {
-                "application/json": {
-                  schema: resolver(Vcs.Info),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const branch = await Vcs.branch()
-          return c.json({
-            branch,
-          })
-        },
-      )
-      .get(
-        "/session",
-        describeRoute({
-          summary: "List sessions",
-          description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
-          operationId: "session.list",
-          responses: {
-            200: {
-              description: "List of sessions",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info.array()),
-                },
-              },
-            },
-          },
-        }),
-        validator(
-          "query",
-          z.object({
-            start: z.coerce
-              .number()
-              .optional()
-              .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
-            search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
-            limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
-          }),
-        ),
-        async (c) => {
-          const query = c.req.valid("query")
-          const term = query.search?.toLowerCase()
-          const sessions: Session.Info[] = []
-          for await (const session of Session.list()) {
-            if (query.start !== undefined && session.time.updated < query.start) continue
-            if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
-            sessions.push(session)
-            if (query.limit !== undefined && sessions.length >= query.limit) break
-          }
-          return c.json(sessions)
-        },
-      )
-      .get(
-        "/session/status",
-        describeRoute({
-          summary: "Get session status",
-          description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
-          operationId: "session.status",
-          responses: {
-            200: {
-              description: "Get session status",
-              content: {
-                "application/json": {
-                  schema: resolver(z.record(z.string(), SessionStatus.Info)),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        async (c) => {
-          const result = SessionStatus.list()
-          return c.json(result)
-        },
-      )
-      .get(
-        "/session/:sessionID",
-        describeRoute({
-          summary: "Get session",
-          description: "Retrieve detailed information about a specific OpenCode session.",
-          tags: ["Session"],
-          operationId: "session.get",
-          responses: {
-            200: {
-              description: "Get session",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: Session.get.schema,
-          }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          log.info("SEARCH", { url: c.req.url })
-          const session = await Session.get(sessionID)
-          return c.json(session)
-        },
-      )
-      .get(
-        "/session/:sessionID/children",
-        describeRoute({
-          summary: "Get session children",
-          tags: ["Session"],
-          description: "Retrieve all child sessions that were forked from the specified parent session.",
-          operationId: "session.children",
-          responses: {
-            200: {
-              description: "List of children",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info.array()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: Session.children.schema,
-          }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const session = await Session.children(sessionID)
-          return c.json(session)
-        },
-      )
-      .get(
-        "/session/:sessionID/todo",
-        describeRoute({
-          summary: "Get session todos",
-          description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
-          operationId: "session.todo",
-          responses: {
-            200: {
-              description: "Todo list",
-              content: {
-                "application/json": {
-                  schema: resolver(Todo.Info.array()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const todos = await Todo.get(sessionID)
-          return c.json(todos)
-        },
-      )
-      .post(
-        "/session",
-        describeRoute({
-          summary: "Create session",
-          description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
-          operationId: "session.create",
-          responses: {
-            ...errors(400),
-            200: {
-              description: "Successfully created session",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info),
-                },
-              },
-            },
-          },
-        }),
-        validator("json", Session.create.schema.optional()),
-        async (c) => {
-          const body = c.req.valid("json") ?? {}
-          const session = await Session.create(body)
-          return c.json(session)
-        },
-      )
-      .delete(
-        "/session/:sessionID",
-        describeRoute({
-          summary: "Delete session",
-          description: "Delete a session and permanently remove all associated data, including messages and history.",
-          operationId: "session.delete",
-          responses: {
-            200: {
-              description: "Successfully deleted session",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: Session.remove.schema,
-          }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          await Session.remove(sessionID)
-          return c.json(true)
-        },
-      )
-      .patch(
-        "/session/:sessionID",
-        describeRoute({
-          summary: "Update session",
-          description: "Update properties of an existing session, such as title or other metadata.",
-          operationId: "session.update",
-          responses: {
-            200: {
-              description: "Successfully updated session",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string(),
-          }),
-        ),
-        validator(
-          "json",
-          z.object({
-            title: z.string().optional(),
-            time: z
-              .object({
-                archived: z.number().optional(),
-              })
-              .optional(),
+        )
+        .post(
+          "/instance/dispose",
+          describeRoute({
+            summary: "Dispose instance",
+            description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
+            operationId: "instance.dispose",
+            responses: {
+              200: {
+                description: "Instance disposed",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
           }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const updates = c.req.valid("json")
-
-          const updatedSession = await Session.update(sessionID, (session) => {
-            if (updates.title !== undefined) {
-              session.title = updates.title
-            }
-            if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
-          })
-
-          return c.json(updatedSession)
-        },
-      )
-      .post(
-        "/session/:sessionID/init",
-        describeRoute({
-          summary: "Initialize session",
-          description:
-            "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
-          operationId: "session.init",
-          responses: {
-            200: {
-              description: "200",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        validator("json", Session.initialize.schema.omit({ sessionID: true })),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const body = c.req.valid("json")
-          await Session.initialize({ ...body, sessionID })
-          return c.json(true)
-        },
-      )
-      .post(
-        "/session/:sessionID/fork",
-        describeRoute({
-          summary: "Fork session",
-          description: "Create a new session by forking an existing session at a specific message point.",
-          operationId: "session.fork",
-          responses: {
-            200: {
-              description: "200",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info),
-                },
-              },
-            },
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: Session.fork.schema.shape.sessionID,
-          }),
-        ),
-        validator("json", Session.fork.schema.omit({ sessionID: true })),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const body = c.req.valid("json")
-          const result = await Session.fork({ ...body, sessionID })
-          return c.json(result)
-        },
-      )
-      .post(
-        "/session/:sessionID/abort",
-        describeRoute({
-          summary: "Abort session",
-          description: "Abort an active session and stop any ongoing AI processing or command execution.",
-          operationId: "session.abort",
-          responses: {
-            200: {
-              description: "Aborted session",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string(),
-          }),
-        ),
-        async (c) => {
-          SessionPrompt.cancel(c.req.valid("param").sessionID)
-          return c.json(true)
-        },
-      )
-
-      .post(
-        "/session/:sessionID/share",
-        describeRoute({
-          summary: "Share session",
-          description: "Create a shareable link for a session, allowing others to view the conversation.",
-          operationId: "session.share",
-          responses: {
-            200: {
-              description: "Successfully shared session",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string(),
-          }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          await Session.share(sessionID)
-          const session = await Session.get(sessionID)
-          return c.json(session)
-        },
-      )
-      .get(
-        "/session/:sessionID/diff",
-        describeRoute({
-          summary: "Get message diff",
-          description: "Get the file changes (diff) that resulted from a specific user message in the session.",
-          operationId: "session.diff",
-          responses: {
-            200: {
-              description: "Successfully retrieved diff",
-              content: {
-                "application/json": {
-                  schema: resolver(Snapshot.FileDiff.array()),
-                },
-              },
-            },
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: SessionSummary.diff.schema.shape.sessionID,
-          }),
-        ),
-        validator(
-          "query",
-          z.object({
-            messageID: SessionSummary.diff.schema.shape.messageID,
-          }),
-        ),
-        async (c) => {
-          const query = c.req.valid("query")
-          const params = c.req.valid("param")
-          const result = await SessionSummary.diff({
-            sessionID: params.sessionID,
-            messageID: query.messageID,
-          })
-          return c.json(result)
-        },
-      )
-      .delete(
-        "/session/:sessionID/share",
-        describeRoute({
-          summary: "Unshare session",
-          description: "Remove the shareable link for a session, making it private again.",
-          operationId: "session.unshare",
-          responses: {
-            200: {
-              description: "Successfully unshared session",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: Session.unshare.schema,
-          }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          await Session.unshare(sessionID)
-          const session = await Session.get(sessionID)
-          return c.json(session)
-        },
-      )
-      .post(
-        "/session/:sessionID/summarize",
-        describeRoute({
-          summary: "Summarize session",
-          description: "Generate a concise summary of the session using AI compaction to preserve key information.",
-          operationId: "session.summarize",
-          responses: {
-            200: {
-              description: "Summarized session",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        validator(
-          "json",
-          z.object({
-            providerID: z.string(),
-            modelID: z.string(),
-            auto: z.boolean().optional().default(false),
-          }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const body = c.req.valid("json")
-          const session = await Session.get(sessionID)
-          await SessionRevert.cleanup(session)
-          const msgs = await Session.messages({ sessionID })
-          let currentAgent = await Agent.defaultAgent()
-          for (let i = msgs.length - 1; i >= 0; i--) {
-            const info = msgs[i].info
-            if (info.role === "user") {
-              currentAgent = info.agent || (await Agent.defaultAgent())
-              break
+          async (c) => {
+            await Instance.dispose()
+            return c.json(true)
+          },
+        )
+        .get(
+          "/path",
+          describeRoute({
+            summary: "Get paths",
+            description:
+              "Retrieve the current working directory and related path information for the OpenCode instance.",
+            operationId: "path.get",
+            responses: {
+              200: {
+                description: "Path",
+                content: {
+                  "application/json": {
+                    schema: resolver(
+                      z
+                        .object({
+                          home: z.string(),
+                          state: z.string(),
+                          config: z.string(),
+                          worktree: z.string(),
+                          directory: z.string(),
+                        })
+                        .meta({
+                          ref: "Path",
+                        }),
+                    ),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            return c.json({
+              home: Global.Path.home,
+              state: Global.Path.state,
+              config: Global.Path.config,
+              worktree: Instance.worktree,
+              directory: Instance.directory,
+            })
+          },
+        )
+        .post(
+          "/experimental/worktree",
+          describeRoute({
+            summary: "Create worktree",
+            description: "Create a new git worktree for the current project.",
+            operationId: "worktree.create",
+            responses: {
+              200: {
+                description: "Worktree created",
+                content: {
+                  "application/json": {
+                    schema: resolver(Worktree.Info),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator("json", Worktree.create.schema),
+          async (c) => {
+            const body = c.req.valid("json")
+            const worktree = await Worktree.create(body)
+            return c.json(worktree)
+          },
+        )
+        .get(
+          "/experimental/worktree",
+          describeRoute({
+            summary: "List worktrees",
+            description: "List all sandbox worktrees for the current project.",
+            operationId: "worktree.list",
+            responses: {
+              200: {
+                description: "List of worktree directories",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.array(z.string())),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            const sandboxes = await Project.sandboxes(Instance.project.id)
+            return c.json(sandboxes)
+          },
+        )
+        .get(
+          "/vcs",
+          describeRoute({
+            summary: "Get VCS info",
+            description:
+              "Retrieve version control system (VCS) information for the current project, such as git branch.",
+            operationId: "vcs.get",
+            responses: {
+              200: {
+                description: "VCS info",
+                content: {
+                  "application/json": {
+                    schema: resolver(Vcs.Info),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            const branch = await Vcs.branch()
+            return c.json({
+              branch,
+            })
+          },
+        )
+        .get(
+          "/session",
+          describeRoute({
+            summary: "List sessions",
+            description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
+            operationId: "session.list",
+            responses: {
+              200: {
+                description: "List of sessions",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info.array()),
+                  },
+                },
+              },
+            },
+          }),
+          validator(
+            "query",
+            z.object({
+              start: z.coerce
+                .number()
+                .optional()
+                .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
+              search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
+              limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
+            }),
+          ),
+          async (c) => {
+            const query = c.req.valid("query")
+            const term = query.search?.toLowerCase()
+            const sessions: Session.Info[] = []
+            for await (const session of Session.list()) {
+              if (query.start !== undefined && session.time.updated < query.start) continue
+              if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
+              sessions.push(session)
+              if (query.limit !== undefined && sessions.length >= query.limit) break
             }
-          }
-          await SessionCompaction.create({
-            sessionID,
-            agent: currentAgent,
-            model: {
-              providerID: body.providerID,
-              modelID: body.modelID,
-            },
-            auto: body.auto,
-          })
-          await SessionPrompt.loop(sessionID)
-          return c.json(true)
-        },
-      )
-      .get(
-        "/session/:sessionID/message",
-        describeRoute({
-          summary: "Get session messages",
-          description: "Retrieve all messages in a session, including user prompts and AI responses.",
-          operationId: "session.messages",
-          responses: {
-            200: {
-              description: "List of messages",
-              content: {
-                "application/json": {
-                  schema: resolver(MessageV2.WithParts.array()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        validator(
-          "query",
-          z.object({
-            limit: z.coerce.number().optional(),
-          }),
-        ),
-        async (c) => {
-          const query = c.req.valid("query")
-          const messages = await Session.messages({
-            sessionID: c.req.valid("param").sessionID,
-            limit: query.limit,
-          })
-          return c.json(messages)
-        },
-      )
-      .get(
-        "/session/:sessionID/diff",
-        describeRoute({
-          summary: "Get session diff",
-          description: "Get all file changes (diffs) made during this session.",
-          operationId: "session.diff",
-          responses: {
-            200: {
-              description: "List of diffs",
-              content: {
-                "application/json": {
-                  schema: resolver(Snapshot.FileDiff.array()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        async (c) => {
-          const diff = await Session.diff(c.req.valid("param").sessionID)
-          return c.json(diff)
-        },
-      )
-      .get(
-        "/session/:sessionID/message/:messageID",
-        describeRoute({
-          summary: "Get message",
-          description: "Retrieve a specific message from a session by its message ID.",
-          operationId: "session.message",
-          responses: {
-            200: {
-              description: "Message",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z.object({
-                      info: MessageV2.Info,
-                      parts: MessageV2.Part.array(),
-                    }),
-                  ),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-            messageID: z.string().meta({ description: "Message ID" }),
-          }),
-        ),
-        async (c) => {
-          const params = c.req.valid("param")
-          const message = await MessageV2.get({
-            sessionID: params.sessionID,
-            messageID: params.messageID,
-          })
-          return c.json(message)
-        },
-      )
-      .delete(
-        "/session/:sessionID/message/:messageID/part/:partID",
-        describeRoute({
-          description: "Delete a part from a message",
-          operationId: "part.delete",
-          responses: {
-            200: {
-              description: "Successfully deleted part",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-            messageID: z.string().meta({ description: "Message ID" }),
-            partID: z.string().meta({ description: "Part ID" }),
-          }),
-        ),
-        async (c) => {
-          const params = c.req.valid("param")
-          await Session.removePart({
-            sessionID: params.sessionID,
-            messageID: params.messageID,
-            partID: params.partID,
-          })
-          return c.json(true)
-        },
-      )
-      .patch(
-        "/session/:sessionID/message/:messageID/part/:partID",
-        describeRoute({
-          description: "Update a part in a message",
-          operationId: "part.update",
-          responses: {
-            200: {
-              description: "Successfully updated part",
-              content: {
-                "application/json": {
-                  schema: resolver(MessageV2.Part),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-            messageID: z.string().meta({ description: "Message ID" }),
-            partID: z.string().meta({ description: "Part ID" }),
-          }),
-        ),
-        validator("json", MessageV2.Part),
-        async (c) => {
-          const params = c.req.valid("param")
-          const body = c.req.valid("json")
-          if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) {
-            throw new Error(
-              `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
-            )
-          }
-          const part = await Session.updatePart(body)
-          return c.json(part)
-        },
-      )
-      .post(
-        "/session/:sessionID/message",
-        describeRoute({
-          summary: "Send message",
-          description: "Create and send a new message to a session, streaming the AI response.",
-          operationId: "session.prompt",
-          responses: {
-            200: {
-              description: "Created message",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z.object({
-                      info: MessageV2.Assistant,
-                      parts: MessageV2.Part.array(),
-                    }),
-                  ),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
-        async (c) => {
-          c.status(200)
-          c.header("Content-Type", "application/json")
-          return stream(c, async (stream) => {
+            return c.json(sessions)
+          },
+        )
+        .get(
+          "/session/status",
+          describeRoute({
+            summary: "Get session status",
+            description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
+            operationId: "session.status",
+            responses: {
+              200: {
+                description: "Get session status",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.record(z.string(), SessionStatus.Info)),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          async (c) => {
+            const result = SessionStatus.list()
+            return c.json(result)
+          },
+        )
+        .get(
+          "/session/:sessionID",
+          describeRoute({
+            summary: "Get session",
+            description: "Retrieve detailed information about a specific OpenCode session.",
+            tags: ["Session"],
+            operationId: "session.get",
+            responses: {
+              200: {
+                description: "Get session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: Session.get.schema,
+            }),
+          ),
+          async (c) => {
             const sessionID = c.req.valid("param").sessionID
-            const body = c.req.valid("json")
-            const msg = await SessionPrompt.prompt({ ...body, sessionID })
-            stream.write(JSON.stringify(msg))
-          })
-        },
-      )
-      .post(
-        "/session/:sessionID/prompt_async",
-        describeRoute({
-          summary: "Send async message",
-          description:
-            "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
-          operationId: "session.prompt_async",
-          responses: {
-            204: {
-              description: "Prompt accepted",
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
-        async (c) => {
-          c.status(204)
-          c.header("Content-Type", "application/json")
-          return stream(c, async () => {
+            log.info("SEARCH", { url: c.req.url })
+            const session = await Session.get(sessionID)
+            return c.json(session)
+          },
+        )
+        .get(
+          "/session/:sessionID/children",
+          describeRoute({
+            summary: "Get session children",
+            tags: ["Session"],
+            description: "Retrieve all child sessions that were forked from the specified parent session.",
+            operationId: "session.children",
+            responses: {
+              200: {
+                description: "List of children",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info.array()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: Session.children.schema,
+            }),
+          ),
+          async (c) => {
             const sessionID = c.req.valid("param").sessionID
-            const body = c.req.valid("json")
-            SessionPrompt.prompt({ ...body, sessionID })
-          })
-        },
-      )
-      .post(
-        "/session/:sessionID/command",
-        describeRoute({
-          summary: "Send command",
-          description: "Send a new command to a session for execution by the AI assistant.",
-          operationId: "session.command",
-          responses: {
-            200: {
-              description: "Created message",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z.object({
-                      info: MessageV2.Assistant,
-                      parts: MessageV2.Part.array(),
-                    }),
-                  ),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const body = c.req.valid("json")
-          const msg = await SessionPrompt.command({ ...body, sessionID })
-          return c.json(msg)
-        },
-      )
-      .post(
-        "/session/:sessionID/shell",
-        describeRoute({
-          summary: "Run shell command",
-          description: "Execute a shell command within the session context and return the AI's response.",
-          operationId: "session.shell",
-          responses: {
-            200: {
-              description: "Created message",
-              content: {
-                "application/json": {
-                  schema: resolver(MessageV2.Assistant),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string().meta({ description: "Session ID" }),
-          }),
-        ),
-        validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const body = c.req.valid("json")
-          const msg = await SessionPrompt.shell({ ...body, sessionID })
-          return c.json(msg)
-        },
-      )
-      .post(
-        "/session/:sessionID/revert",
-        describeRoute({
-          summary: "Revert message",
-          description: "Revert a specific message in a session, undoing its effects and restoring the previous state.",
-          operationId: "session.revert",
-          responses: {
-            200: {
-              description: "Updated session",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string(),
-          }),
-        ),
-        validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          log.info("revert", c.req.valid("json"))
-          const session = await SessionRevert.revert({
-            sessionID,
-            ...c.req.valid("json"),
-          })
-          return c.json(session)
-        },
-      )
-      .post(
-        "/session/:sessionID/unrevert",
-        describeRoute({
-          summary: "Restore reverted messages",
-          description: "Restore all previously reverted messages in a session.",
-          operationId: "session.unrevert",
-          responses: {
-            200: {
-              description: "Updated session",
-              content: {
-                "application/json": {
-                  schema: resolver(Session.Info),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string(),
-          }),
-        ),
-        async (c) => {
-          const sessionID = c.req.valid("param").sessionID
-          const session = await SessionRevert.unrevert({ sessionID })
-          return c.json(session)
-        },
-      )
-      .post(
-        "/session/:sessionID/permissions/:permissionID",
-        describeRoute({
-          summary: "Respond to permission",
-          deprecated: true,
-          description: "Approve or deny a permission request from the AI assistant.",
-          operationId: "permission.respond",
-          responses: {
-            200: {
-              description: "Permission processed successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            sessionID: z.string(),
-            permissionID: z.string(),
-          }),
-        ),
-        validator("json", z.object({ response: PermissionNext.Reply })),
-        async (c) => {
-          const params = c.req.valid("param")
-          PermissionNext.reply({
-            requestID: params.permissionID,
-            reply: c.req.valid("json").response,
-          })
-          return c.json(true)
-        },
-      )
-      .post(
-        "/permission/:requestID/reply",
-        describeRoute({
-          summary: "Respond to permission request",
-          description: "Approve or deny a permission request from the AI assistant.",
-          operationId: "permission.reply",
-          responses: {
-            200: {
-              description: "Permission processed successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            requestID: z.string(),
-          }),
-        ),
-        validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
-        async (c) => {
-          const params = c.req.valid("param")
-          const json = c.req.valid("json")
-          await PermissionNext.reply({
-            requestID: params.requestID,
-            reply: json.reply,
-            message: json.message,
-          })
-          return c.json(true)
-        },
-      )
-      .get(
-        "/permission",
-        describeRoute({
-          summary: "List pending permissions",
-          description: "Get all pending permission requests across all sessions.",
-          operationId: "permission.list",
-          responses: {
-            200: {
-              description: "List of pending permissions",
-              content: {
-                "application/json": {
-                  schema: resolver(PermissionNext.Request.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const permissions = await PermissionNext.list()
-          return c.json(permissions)
-        },
-      )
-      .get(
-        "/command",
-        describeRoute({
-          summary: "List commands",
-          description: "Get a list of all available commands in the OpenCode system.",
-          operationId: "command.list",
-          responses: {
-            200: {
-              description: "List of commands",
-              content: {
-                "application/json": {
-                  schema: resolver(Command.Info.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const commands = await Command.list()
-          return c.json(commands)
-        },
-      )
-      .get(
-        "/config/providers",
-        describeRoute({
-          summary: "List config providers",
-          description: "Get a list of all configured AI providers and their default models.",
-          operationId: "config.providers",
-          responses: {
-            200: {
-              description: "List of providers",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z.object({
-                      providers: Provider.Info.array(),
-                      default: z.record(z.string(), z.string()),
-                    }),
-                  ),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          using _ = log.time("providers")
-          const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
-          return c.json({
-            providers: Object.values(providers),
-            default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
-          })
-        },
-      )
-      .get(
-        "/provider",
-        describeRoute({
-          summary: "List providers",
-          description: "Get a list of all available AI providers, including both available and connected ones.",
-          operationId: "provider.list",
-          responses: {
-            200: {
-              description: "List of providers",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z.object({
-                      all: ModelsDev.Provider.array(),
-                      default: z.record(z.string(), z.string()),
-                      connected: z.array(z.string()),
-                    }),
-                  ),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const config = await Config.get()
-          const disabled = new Set(config.disabled_providers ?? [])
-          const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
-
-          const allProviders = await ModelsDev.get()
-          const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
-          for (const [key, value] of Object.entries(allProviders)) {
-            if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
-              filteredProviders[key] = value
-            }
-          }
-
-          const connected = await Provider.list()
-          const providers = Object.assign(
-            mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
-            connected,
-          )
-          return c.json({
-            all: Object.values(providers),
-            default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
-            connected: Object.keys(connected),
-          })
-        },
-      )
-      .get(
-        "/provider/auth",
-        describeRoute({
-          summary: "Get provider auth methods",
-          description: "Retrieve available authentication methods for all AI providers.",
-          operationId: "provider.auth",
-          responses: {
-            200: {
-              description: "Provider auth methods",
-              content: {
-                "application/json": {
-                  schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          return c.json(await ProviderAuth.methods())
-        },
-      )
-      .post(
-        "/provider/:providerID/oauth/authorize",
-        describeRoute({
-          summary: "OAuth authorize",
-          description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.",
-          operationId: "provider.oauth.authorize",
-          responses: {
-            200: {
-              description: "Authorization URL and method",
-              content: {
-                "application/json": {
-                  schema: resolver(ProviderAuth.Authorization.optional()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            providerID: z.string().meta({ description: "Provider ID" }),
-          }),
-        ),
-        validator(
-          "json",
-          z.object({
-            method: z.number().meta({ description: "Auth method index" }),
-          }),
-        ),
-        async (c) => {
-          const providerID = c.req.valid("param").providerID
-          const { method } = c.req.valid("json")
-          const result = await ProviderAuth.authorize({
-            providerID,
-            method,
-          })
-          return c.json(result)
-        },
-      )
-      .post(
-        "/provider/:providerID/oauth/callback",
-        describeRoute({
-          summary: "OAuth callback",
-          description: "Handle the OAuth callback from a provider after user authorization.",
-          operationId: "provider.oauth.callback",
-          responses: {
-            200: {
-              description: "OAuth callback processed successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            providerID: z.string().meta({ description: "Provider ID" }),
-          }),
-        ),
-        validator(
-          "json",
-          z.object({
-            method: z.number().meta({ description: "Auth method index" }),
-            code: z.string().optional().meta({ description: "OAuth authorization code" }),
-          }),
-        ),
-        async (c) => {
-          const providerID = c.req.valid("param").providerID
-          const { method, code } = c.req.valid("json")
-          await ProviderAuth.callback({
-            providerID,
-            method,
-            code,
-          })
-          return c.json(true)
-        },
-      )
-      .get(
-        "/find",
-        describeRoute({
-          summary: "Find text",
-          description: "Search for text patterns across files in the project using ripgrep.",
-          operationId: "find.text",
-          responses: {
-            200: {
-              description: "Matches",
-              content: {
-                "application/json": {
-                  schema: resolver(Ripgrep.Match.shape.data.array()),
-                },
-              },
-            },
-          },
-        }),
-        validator(
-          "query",
-          z.object({
-            pattern: z.string(),
-          }),
-        ),
-        async (c) => {
-          const pattern = c.req.valid("query").pattern
-          const result = await Ripgrep.search({
-            cwd: Instance.directory,
-            pattern,
-            limit: 10,
-          })
-          return c.json(result)
-        },
-      )
-      .get(
-        "/find/file",
-        describeRoute({
-          summary: "Find files",
-          description: "Search for files or directories by name or pattern in the project directory.",
-          operationId: "find.files",
-          responses: {
-            200: {
-              description: "File paths",
-              content: {
-                "application/json": {
-                  schema: resolver(z.string().array()),
-                },
-              },
-            },
-          },
-        }),
-        validator(
-          "query",
-          z.object({
-            query: z.string(),
-            dirs: z.enum(["true", "false"]).optional(),
-            type: z.enum(["file", "directory"]).optional(),
-            limit: z.coerce.number().int().min(1).max(200).optional(),
-          }),
-        ),
-        async (c) => {
-          const query = c.req.valid("query").query
-          const dirs = c.req.valid("query").dirs
-          const type = c.req.valid("query").type
-          const limit = c.req.valid("query").limit
-          const results = await File.search({
-            query,
-            limit: limit ?? 10,
-            dirs: dirs !== "false",
-            type,
-          })
-          return c.json(results)
-        },
-      )
-      .get(
-        "/find/symbol",
-        describeRoute({
-          summary: "Find symbols",
-          description: "Search for workspace symbols like functions, classes, and variables using LSP.",
-          operationId: "find.symbols",
-          responses: {
-            200: {
-              description: "Symbols",
-              content: {
-                "application/json": {
-                  schema: resolver(LSP.Symbol.array()),
-                },
-              },
-            },
-          },
-        }),
-        validator(
-          "query",
-          z.object({
-            query: z.string(),
-          }),
-        ),
-        async (c) => {
-          /*
-          const query = c.req.valid("query").query
-          const result = await LSP.workspaceSymbol(query)
-          return c.json(result)
-          */
-          return c.json([])
-        },
-      )
-      .get(
-        "/file",
-        describeRoute({
-          summary: "List files",
-          description: "List files and directories in a specified path.",
-          operationId: "file.list",
-          responses: {
-            200: {
-              description: "Files and directories",
-              content: {
-                "application/json": {
-                  schema: resolver(File.Node.array()),
-                },
-              },
-            },
-          },
-        }),
-        validator(
-          "query",
-          z.object({
-            path: z.string(),
-          }),
-        ),
-        async (c) => {
-          const path = c.req.valid("query").path
-          const content = await File.list(path)
-          return c.json(content)
-        },
-      )
-      .get(
-        "/file/content",
-        describeRoute({
-          summary: "Read file",
-          description: "Read the content of a specified file.",
-          operationId: "file.read",
-          responses: {
-            200: {
-              description: "File content",
-              content: {
-                "application/json": {
-                  schema: resolver(File.Content),
-                },
-              },
-            },
-          },
-        }),
-        validator(
-          "query",
-          z.object({
-            path: z.string(),
-          }),
-        ),
-        async (c) => {
-          const path = c.req.valid("query").path
-          const content = await File.read(path)
-          return c.json(content)
-        },
-      )
-      .get(
-        "/file/status",
-        describeRoute({
-          summary: "Get file status",
-          description: "Get the git status of all files in the project.",
-          operationId: "file.status",
-          responses: {
-            200: {
-              description: "File status",
-              content: {
-                "application/json": {
-                  schema: resolver(File.Info.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const content = await File.status()
-          return c.json(content)
-        },
-      )
-      .post(
-        "/log",
-        describeRoute({
-          summary: "Write log",
-          description: "Write a log entry to the server logs with specified level and metadata.",
-          operationId: "app.log",
-          responses: {
-            200: {
-              description: "Log entry written successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "json",
-          z.object({
-            service: z.string().meta({ description: "Service name for the log entry" }),
-            level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
-            message: z.string().meta({ description: "Log message" }),
-            extra: z
-              .record(z.string(), z.any())
-              .optional()
-              .meta({ description: "Additional metadata for the log entry" }),
-          }),
-        ),
-        async (c) => {
-          const { service, level, message, extra } = c.req.valid("json")
-          const logger = Log.create({ service })
+            const session = await Session.children(sessionID)
+            return c.json(session)
+          },
+        )
+        .get(
+          "/session/:sessionID/todo",
+          describeRoute({
+            summary: "Get session todos",
+            description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
+            operationId: "session.todo",
+            responses: {
+              200: {
+                description: "Todo list",
+                content: {
+                  "application/json": {
+                    schema: resolver(Todo.Info.array()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            const todos = await Todo.get(sessionID)
+            return c.json(todos)
+          },
+        )
+        .post(
+          "/session",
+          describeRoute({
+            summary: "Create session",
+            description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
+            operationId: "session.create",
+            responses: {
+              ...errors(400),
+              200: {
+                description: "Successfully created session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info),
+                  },
+                },
+              },
+            },
+          }),
+          validator("json", Session.create.schema.optional()),
+          async (c) => {
+            const body = c.req.valid("json") ?? {}
+            const session = await Session.create(body)
+            return c.json(session)
+          },
+        )
+        .delete(
+          "/session/:sessionID",
+          describeRoute({
+            summary: "Delete session",
+            description: "Delete a session and permanently remove all associated data, including messages and history.",
+            operationId: "session.delete",
+            responses: {
+              200: {
+                description: "Successfully deleted session",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: Session.remove.schema,
+            }),
+          ),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            await Session.remove(sessionID)
+            return c.json(true)
+          },
+        )
+        .patch(
+          "/session/:sessionID",
+          describeRoute({
+            summary: "Update session",
+            description: "Update properties of an existing session, such as title or other metadata.",
+            operationId: "session.update",
+            responses: {
+              200: {
+                description: "Successfully updated session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string(),
+            }),
+          ),
+          validator(
+            "json",
+            z.object({
+              title: z.string().optional(),
+              time: z
+                .object({
+                  archived: z.number().optional(),
+                })
+                .optional(),
+            }),
+          ),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            const updates = c.req.valid("json")
+
+            const updatedSession = await Session.update(sessionID, (session) => {
+              if (updates.title !== undefined) {
+                session.title = updates.title
+              }
+              if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
+            })
+
+            return c.json(updatedSession)
+          },
+        )
+        .post(
+          "/session/:sessionID/init",
+          describeRoute({
+            summary: "Initialize session",
+            description:
+              "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
+            operationId: "session.init",
+            responses: {
+              200: {
+                description: "200",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          validator("json", Session.initialize.schema.omit({ sessionID: true })),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            const body = c.req.valid("json")
+            await Session.initialize({ ...body, sessionID })
+            return c.json(true)
+          },
+        )
+        .post(
+          "/session/:sessionID/fork",
+          describeRoute({
+            summary: "Fork session",
+            description: "Create a new session by forking an existing session at a specific message point.",
+            operationId: "session.fork",
+            responses: {
+              200: {
+                description: "200",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info),
+                  },
+                },
+              },
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: Session.fork.schema.shape.sessionID,
+            }),
+          ),
+          validator("json", Session.fork.schema.omit({ sessionID: true })),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            const body = c.req.valid("json")
+            const result = await Session.fork({ ...body, sessionID })
+            return c.json(result)
+          },
+        )
+        .post(
+          "/session/:sessionID/abort",
+          describeRoute({
+            summary: "Abort session",
+            description: "Abort an active session and stop any ongoing AI processing or command execution.",
+            operationId: "session.abort",
+            responses: {
+              200: {
+                description: "Aborted session",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string(),
+            }),
+          ),
+          async (c) => {
+            SessionPrompt.cancel(c.req.valid("param").sessionID)
+            return c.json(true)
+          },
+        )
+
+        .post(
+          "/session/:sessionID/share",
+          describeRoute({
+            summary: "Share session",
+            description: "Create a shareable link for a session, allowing others to view the conversation.",
+            operationId: "session.share",
+            responses: {
+              200: {
+                description: "Successfully shared session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string(),
+            }),
+          ),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            await Session.share(sessionID)
+            const session = await Session.get(sessionID)
+            return c.json(session)
+          },
+        )
+        .get(
+          "/session/:sessionID/diff",
+          describeRoute({
+            summary: "Get message diff",
+            description: "Get the file changes (diff) that resulted from a specific user message in the session.",
+            operationId: "session.diff",
+            responses: {
+              200: {
+                description: "Successfully retrieved diff",
+                content: {
+                  "application/json": {
+                    schema: resolver(Snapshot.FileDiff.array()),
+                  },
+                },
+              },
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: SessionSummary.diff.schema.shape.sessionID,
+            }),
+          ),
+          validator(
+            "query",
+            z.object({
+              messageID: SessionSummary.diff.schema.shape.messageID,
+            }),
+          ),
+          async (c) => {
+            const query = c.req.valid("query")
+            const params = c.req.valid("param")
+            const result = await SessionSummary.diff({
+              sessionID: params.sessionID,
+              messageID: query.messageID,
+            })
+            return c.json(result)
+          },
+        )
+        .delete(
+          "/session/:sessionID/share",
+          describeRoute({
+            summary: "Unshare session",
+            description: "Remove the shareable link for a session, making it private again.",
+            operationId: "session.unshare",
+            responses: {
+              200: {
+                description: "Successfully unshared session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: Session.unshare.schema,
+            }),
+          ),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            await Session.unshare(sessionID)
+            const session = await Session.get(sessionID)
+            return c.json(session)
+          },
+        )
+        .post(
+          "/session/:sessionID/summarize",
+          describeRoute({
+            summary: "Summarize session",
+            description: "Generate a concise summary of the session using AI compaction to preserve key information.",
+            operationId: "session.summarize",
+            responses: {
+              200: {
+                description: "Summarized session",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          validator(
+            "json",
+            z.object({
+              providerID: z.string(),
+              modelID: z.string(),
+              auto: z.boolean().optional().default(false),
+            }),
+          ),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            const body = c.req.valid("json")
+            const session = await Session.get(sessionID)
+            await SessionRevert.cleanup(session)
+            const msgs = await Session.messages({ sessionID })
+            let currentAgent = await Agent.defaultAgent()
+            for (let i = msgs.length - 1; i >= 0; i--) {
+              const info = msgs[i].info
+              if (info.role === "user") {
+                currentAgent = info.agent || (await Agent.defaultAgent())
+                break
+              }
+            }
+            await SessionCompaction.create({
+              sessionID,
+              agent: currentAgent,
+              model: {
+                providerID: body.providerID,
+                modelID: body.modelID,
+              },
+              auto: body.auto,
+            })
+            await SessionPrompt.loop(sessionID)
+            return c.json(true)
+          },
+        )
+        .get(
+          "/session/:sessionID/message",
+          describeRoute({
+            summary: "Get session messages",
+            description: "Retrieve all messages in a session, including user prompts and AI responses.",
+            operationId: "session.messages",
+            responses: {
+              200: {
+                description: "List of messages",
+                content: {
+                  "application/json": {
+                    schema: resolver(MessageV2.WithParts.array()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          validator(
+            "query",
+            z.object({
+              limit: z.coerce.number().optional(),
+            }),
+          ),
+          async (c) => {
+            const query = c.req.valid("query")
+            const messages = await Session.messages({
+              sessionID: c.req.valid("param").sessionID,
+              limit: query.limit,
+            })
+            return c.json(messages)
+          },
+        )
+        .get(
+          "/session/:sessionID/diff",
+          describeRoute({
+            summary: "Get session diff",
+            description: "Get all file changes (diffs) made during this session.",
+            operationId: "session.diff",
+            responses: {
+              200: {
+                description: "List of diffs",
+                content: {
+                  "application/json": {
+                    schema: resolver(Snapshot.FileDiff.array()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          async (c) => {
+            const diff = await Session.diff(c.req.valid("param").sessionID)
+            return c.json(diff)
+          },
+        )
+        .get(
+          "/session/:sessionID/message/:messageID",
+          describeRoute({
+            summary: "Get message",
+            description: "Retrieve a specific message from a session by its message ID.",
+            operationId: "session.message",
+            responses: {
+              200: {
+                description: "Message",
+                content: {
+                  "application/json": {
+                    schema: resolver(
+                      z.object({
+                        info: MessageV2.Info,
+                        parts: MessageV2.Part.array(),
+                      }),
+                    ),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+              messageID: z.string().meta({ description: "Message ID" }),
+            }),
+          ),
+          async (c) => {
+            const params = c.req.valid("param")
+            const message = await MessageV2.get({
+              sessionID: params.sessionID,
+              messageID: params.messageID,
+            })
+            return c.json(message)
+          },
+        )
+        .delete(
+          "/session/:sessionID/message/:messageID/part/:partID",
+          describeRoute({
+            description: "Delete a part from a message",
+            operationId: "part.delete",
+            responses: {
+              200: {
+                description: "Successfully deleted part",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+              messageID: z.string().meta({ description: "Message ID" }),
+              partID: z.string().meta({ description: "Part ID" }),
+            }),
+          ),
+          async (c) => {
+            const params = c.req.valid("param")
+            await Session.removePart({
+              sessionID: params.sessionID,
+              messageID: params.messageID,
+              partID: params.partID,
+            })
+            return c.json(true)
+          },
+        )
+        .patch(
+          "/session/:sessionID/message/:messageID/part/:partID",
+          describeRoute({
+            description: "Update a part in a message",
+            operationId: "part.update",
+            responses: {
+              200: {
+                description: "Successfully updated part",
+                content: {
+                  "application/json": {
+                    schema: resolver(MessageV2.Part),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+              messageID: z.string().meta({ description: "Message ID" }),
+              partID: z.string().meta({ description: "Part ID" }),
+            }),
+          ),
+          validator("json", MessageV2.Part),
+          async (c) => {
+            const params = c.req.valid("param")
+            const body = c.req.valid("json")
+            if (
+              body.id !== params.partID ||
+              body.messageID !== params.messageID ||
+              body.sessionID !== params.sessionID
+            ) {
+              throw new Error(
+                `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
+              )
+            }
+            const part = await Session.updatePart(body)
+            return c.json(part)
+          },
+        )
+        .post(
+          "/session/:sessionID/message",
+          describeRoute({
+            summary: "Send message",
+            description: "Create and send a new message to a session, streaming the AI response.",
+            operationId: "session.prompt",
+            responses: {
+              200: {
+                description: "Created message",
+                content: {
+                  "application/json": {
+                    schema: resolver(
+                      z.object({
+                        info: MessageV2.Assistant,
+                        parts: MessageV2.Part.array(),
+                      }),
+                    ),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
+          async (c) => {
+            c.status(200)
+            c.header("Content-Type", "application/json")
+            return stream(c, async (stream) => {
+              const sessionID = c.req.valid("param").sessionID
+              const body = c.req.valid("json")
+              const msg = await SessionPrompt.prompt({ ...body, sessionID })
+              stream.write(JSON.stringify(msg))
+            })
+          },
+        )
+        .post(
+          "/session/:sessionID/prompt_async",
+          describeRoute({
+            summary: "Send async message",
+            description:
+              "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
+            operationId: "session.prompt_async",
+            responses: {
+              204: {
+                description: "Prompt accepted",
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
+          async (c) => {
+            c.status(204)
+            c.header("Content-Type", "application/json")
+            return stream(c, async () => {
+              const sessionID = c.req.valid("param").sessionID
+              const body = c.req.valid("json")
+              SessionPrompt.prompt({ ...body, sessionID })
+            })
+          },
+        )
+        .post(
+          "/session/:sessionID/command",
+          describeRoute({
+            summary: "Send command",
+            description: "Send a new command to a session for execution by the AI assistant.",
+            operationId: "session.command",
+            responses: {
+              200: {
+                description: "Created message",
+                content: {
+                  "application/json": {
+                    schema: resolver(
+                      z.object({
+                        info: MessageV2.Assistant,
+                        parts: MessageV2.Part.array(),
+                      }),
+                    ),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            const body = c.req.valid("json")
+            const msg = await SessionPrompt.command({ ...body, sessionID })
+            return c.json(msg)
+          },
+        )
+        .post(
+          "/session/:sessionID/shell",
+          describeRoute({
+            summary: "Run shell command",
+            description: "Execute a shell command within the session context and return the AI's response.",
+            operationId: "session.shell",
+            responses: {
+              200: {
+                description: "Created message",
+                content: {
+                  "application/json": {
+                    schema: resolver(MessageV2.Assistant),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string().meta({ description: "Session ID" }),
+            }),
+          ),
+          validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            const body = c.req.valid("json")
+            const msg = await SessionPrompt.shell({ ...body, sessionID })
+            return c.json(msg)
+          },
+        )
+        .post(
+          "/session/:sessionID/revert",
+          describeRoute({
+            summary: "Revert message",
+            description:
+              "Revert a specific message in a session, undoing its effects and restoring the previous state.",
+            operationId: "session.revert",
+            responses: {
+              200: {
+                description: "Updated session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string(),
+            }),
+          ),
+          validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            log.info("revert", c.req.valid("json"))
+            const session = await SessionRevert.revert({
+              sessionID,
+              ...c.req.valid("json"),
+            })
+            return c.json(session)
+          },
+        )
+        .post(
+          "/session/:sessionID/unrevert",
+          describeRoute({
+            summary: "Restore reverted messages",
+            description: "Restore all previously reverted messages in a session.",
+            operationId: "session.unrevert",
+            responses: {
+              200: {
+                description: "Updated session",
+                content: {
+                  "application/json": {
+                    schema: resolver(Session.Info),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string(),
+            }),
+          ),
+          async (c) => {
+            const sessionID = c.req.valid("param").sessionID
+            const session = await SessionRevert.unrevert({ sessionID })
+            return c.json(session)
+          },
+        )
+        .post(
+          "/session/:sessionID/permissions/:permissionID",
+          describeRoute({
+            summary: "Respond to permission",
+            deprecated: true,
+            description: "Approve or deny a permission request from the AI assistant.",
+            operationId: "permission.respond",
+            responses: {
+              200: {
+                description: "Permission processed successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              sessionID: z.string(),
+              permissionID: z.string(),
+            }),
+          ),
+          validator("json", z.object({ response: PermissionNext.Reply })),
+          async (c) => {
+            const params = c.req.valid("param")
+            PermissionNext.reply({
+              requestID: params.permissionID,
+              reply: c.req.valid("json").response,
+            })
+            return c.json(true)
+          },
+        )
+        .post(
+          "/permission/:requestID/reply",
+          describeRoute({
+            summary: "Respond to permission request",
+            description: "Approve or deny a permission request from the AI assistant.",
+            operationId: "permission.reply",
+            responses: {
+              200: {
+                description: "Permission processed successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              requestID: z.string(),
+            }),
+          ),
+          validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
+          async (c) => {
+            const params = c.req.valid("param")
+            const json = c.req.valid("json")
+            await PermissionNext.reply({
+              requestID: params.requestID,
+              reply: json.reply,
+              message: json.message,
+            })
+            return c.json(true)
+          },
+        )
+        .get(
+          "/permission",
+          describeRoute({
+            summary: "List pending permissions",
+            description: "Get all pending permission requests across all sessions.",
+            operationId: "permission.list",
+            responses: {
+              200: {
+                description: "List of pending permissions",
+                content: {
+                  "application/json": {
+                    schema: resolver(PermissionNext.Request.array()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            const permissions = await PermissionNext.list()
+            return c.json(permissions)
+          },
+        )
+        .route("/question", QuestionRoute)
+        .get(
+          "/command",
+          describeRoute({
+            summary: "List commands",
+            description: "Get a list of all available commands in the OpenCode system.",
+            operationId: "command.list",
+            responses: {
+              200: {
+                description: "List of commands",
+                content: {
+                  "application/json": {
+                    schema: resolver(Command.Info.array()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            const commands = await Command.list()
+            return c.json(commands)
+          },
+        )
+        .get(
+          "/config/providers",
+          describeRoute({
+            summary: "List config providers",
+            description: "Get a list of all configured AI providers and their default models.",
+            operationId: "config.providers",
+            responses: {
+              200: {
+                description: "List of providers",
+                content: {
+                  "application/json": {
+                    schema: resolver(
+                      z.object({
+                        providers: Provider.Info.array(),
+                        default: z.record(z.string(), z.string()),
+                      }),
+                    ),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            using _ = log.time("providers")
+            const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
+            return c.json({
+              providers: Object.values(providers),
+              default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
+            })
+          },
+        )
+        .get(
+          "/provider",
+          describeRoute({
+            summary: "List providers",
+            description: "Get a list of all available AI providers, including both available and connected ones.",
+            operationId: "provider.list",
+            responses: {
+              200: {
+                description: "List of providers",
+                content: {
+                  "application/json": {
+                    schema: resolver(
+                      z.object({
+                        all: ModelsDev.Provider.array(),
+                        default: z.record(z.string(), z.string()),
+                        connected: z.array(z.string()),
+                      }),
+                    ),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            const config = await Config.get()
+            const disabled = new Set(config.disabled_providers ?? [])
+            const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
+
+            const allProviders = await ModelsDev.get()
+            const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
+            for (const [key, value] of Object.entries(allProviders)) {
+              if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
+                filteredProviders[key] = value
+              }
+            }
+
+            const connected = await Provider.list()
+            const providers = Object.assign(
+              mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
+              connected,
+            )
+            return c.json({
+              all: Object.values(providers),
+              default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
+              connected: Object.keys(connected),
+            })
+          },
+        )
+        .get(
+          "/provider/auth",
+          describeRoute({
+            summary: "Get provider auth methods",
+            description: "Retrieve available authentication methods for all AI providers.",
+            operationId: "provider.auth",
+            responses: {
+              200: {
+                description: "Provider auth methods",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            return c.json(await ProviderAuth.methods())
+          },
+        )
+        .post(
+          "/provider/:providerID/oauth/authorize",
+          describeRoute({
+            summary: "OAuth authorize",
+            description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.",
+            operationId: "provider.oauth.authorize",
+            responses: {
+              200: {
+                description: "Authorization URL and method",
+                content: {
+                  "application/json": {
+                    schema: resolver(ProviderAuth.Authorization.optional()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              providerID: z.string().meta({ description: "Provider ID" }),
+            }),
+          ),
+          validator(
+            "json",
+            z.object({
+              method: z.number().meta({ description: "Auth method index" }),
+            }),
+          ),
+          async (c) => {
+            const providerID = c.req.valid("param").providerID
+            const { method } = c.req.valid("json")
+            const result = await ProviderAuth.authorize({
+              providerID,
+              method,
+            })
+            return c.json(result)
+          },
+        )
+        .post(
+          "/provider/:providerID/oauth/callback",
+          describeRoute({
+            summary: "OAuth callback",
+            description: "Handle the OAuth callback from a provider after user authorization.",
+            operationId: "provider.oauth.callback",
+            responses: {
+              200: {
+                description: "OAuth callback processed successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              providerID: z.string().meta({ description: "Provider ID" }),
+            }),
+          ),
+          validator(
+            "json",
+            z.object({
+              method: z.number().meta({ description: "Auth method index" }),
+              code: z.string().optional().meta({ description: "OAuth authorization code" }),
+            }),
+          ),
+          async (c) => {
+            const providerID = c.req.valid("param").providerID
+            const { method, code } = c.req.valid("json")
+            await ProviderAuth.callback({
+              providerID,
+              method,
+              code,
+            })
+            return c.json(true)
+          },
+        )
+        .get(
+          "/find",
+          describeRoute({
+            summary: "Find text",
+            description: "Search for text patterns across files in the project using ripgrep.",
+            operationId: "find.text",
+            responses: {
+              200: {
+                description: "Matches",
+                content: {
+                  "application/json": {
+                    schema: resolver(Ripgrep.Match.shape.data.array()),
+                  },
+                },
+              },
+            },
+          }),
+          validator(
+            "query",
+            z.object({
+              pattern: z.string(),
+            }),
+          ),
+          async (c) => {
+            const pattern = c.req.valid("query").pattern
+            const result = await Ripgrep.search({
+              cwd: Instance.directory,
+              pattern,
+              limit: 10,
+            })
+            return c.json(result)
+          },
+        )
+        .get(
+          "/find/file",
+          describeRoute({
+            summary: "Find files",
+            description: "Search for files or directories by name or pattern in the project directory.",
+            operationId: "find.files",
+            responses: {
+              200: {
+                description: "File paths",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.string().array()),
+                  },
+                },
+              },
+            },
+          }),
+          validator(
+            "query",
+            z.object({
+              query: z.string(),
+              dirs: z.enum(["true", "false"]).optional(),
+              type: z.enum(["file", "directory"]).optional(),
+              limit: z.coerce.number().int().min(1).max(200).optional(),
+            }),
+          ),
+          async (c) => {
+            const query = c.req.valid("query").query
+            const dirs = c.req.valid("query").dirs
+            const type = c.req.valid("query").type
+            const limit = c.req.valid("query").limit
+            const results = await File.search({
+              query,
+              limit: limit ?? 10,
+              dirs: dirs !== "false",
+              type,
+            })
+            return c.json(results)
+          },
+        )
+        .get(
+          "/find/symbol",
+          describeRoute({
+            summary: "Find symbols",
+            description: "Search for workspace symbols like functions, classes, and variables using LSP.",
+            operationId: "find.symbols",
+            responses: {
+              200: {
+                description: "Symbols",
+                content: {
+                  "application/json": {
+                    schema: resolver(LSP.Symbol.array()),
+                  },
+                },
+              },
+            },
+          }),
+          validator(
+            "query",
+            z.object({
+              query: z.string(),
+            }),
+          ),
+          async (c) => {
+            /*
+          const query = c.req.valid("query").query
+          const result = await LSP.workspaceSymbol(query)
+          return c.json(result)
+          */
+            return c.json([])
+          },
+        )
+        .get(
+          "/file",
+          describeRoute({
+            summary: "List files",
+            description: "List files and directories in a specified path.",
+            operationId: "file.list",
+            responses: {
+              200: {
+                description: "Files and directories",
+                content: {
+                  "application/json": {
+                    schema: resolver(File.Node.array()),
+                  },
+                },
+              },
+            },
+          }),
+          validator(
+            "query",
+            z.object({
+              path: z.string(),
+            }),
+          ),
+          async (c) => {
+            const path = c.req.valid("query").path
+            const content = await File.list(path)
+            return c.json(content)
+          },
+        )
+        .get(
+          "/file/content",
+          describeRoute({
+            summary: "Read file",
+            description: "Read the content of a specified file.",
+            operationId: "file.read",
+            responses: {
+              200: {
+                description: "File content",
+                content: {
+                  "application/json": {
+                    schema: resolver(File.Content),
+                  },
+                },
+              },
+            },
+          }),
+          validator(
+            "query",
+            z.object({
+              path: z.string(),
+            }),
+          ),
+          async (c) => {
+            const path = c.req.valid("query").path
+            const content = await File.read(path)
+            return c.json(content)
+          },
+        )
+        .get(
+          "/file/status",
+          describeRoute({
+            summary: "Get file status",
+            description: "Get the git status of all files in the project.",
+            operationId: "file.status",
+            responses: {
+              200: {
+                description: "File status",
+                content: {
+                  "application/json": {
+                    schema: resolver(File.Info.array()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            const content = await File.status()
+            return c.json(content)
+          },
+        )
+        .post(
+          "/log",
+          describeRoute({
+            summary: "Write log",
+            description: "Write a log entry to the server logs with specified level and metadata.",
+            operationId: "app.log",
+            responses: {
+              200: {
+                description: "Log entry written successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator(
+            "json",
+            z.object({
+              service: z.string().meta({ description: "Service name for the log entry" }),
+              level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
+              message: z.string().meta({ description: "Log message" }),
+              extra: z
+                .record(z.string(), z.any())
+                .optional()
+                .meta({ description: "Additional metadata for the log entry" }),
+            }),
+          ),
+          async (c) => {
+            const { service, level, message, extra } = c.req.valid("json")
+            const logger = Log.create({ service })
 
-          switch (level) {
-            case "debug":
-              logger.debug(message, extra)
-              break
-            case "info":
-              logger.info(message, extra)
-              break
-            case "error":
-              logger.error(message, extra)
-              break
-            case "warn":
-              logger.warn(message, extra)
-              break
-          }
+            switch (level) {
+              case "debug":
+                logger.debug(message, extra)
+                break
+              case "info":
+                logger.info(message, extra)
+                break
+              case "error":
+                logger.error(message, extra)
+                break
+              case "warn":
+                logger.warn(message, extra)
+                break
+            }
 
-          return c.json(true)
-        },
-      )
-      .get(
-        "/agent",
-        describeRoute({
-          summary: "List agents",
-          description: "Get a list of all available AI agents in the OpenCode system.",
-          operationId: "app.agents",
-          responses: {
-            200: {
-              description: "List of agents",
-              content: {
-                "application/json": {
-                  schema: resolver(Agent.Info.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          const modes = await Agent.list()
-          return c.json(modes)
-        },
-      )
-      .get(
-        "/mcp",
-        describeRoute({
-          summary: "Get MCP status",
-          description: "Get the status of all Model Context Protocol (MCP) servers.",
-          operationId: "mcp.status",
-          responses: {
-            200: {
-              description: "MCP server status",
-              content: {
-                "application/json": {
-                  schema: resolver(z.record(z.string(), MCP.Status)),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          return c.json(await MCP.status())
-        },
-      )
-      .post(
-        "/mcp",
-        describeRoute({
-          summary: "Add MCP server",
-          description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
-          operationId: "mcp.add",
-          responses: {
-            200: {
-              description: "MCP server added successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.record(z.string(), MCP.Status)),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "json",
-          z.object({
-            name: z.string(),
-            config: Config.Mcp,
-          }),
-        ),
-        async (c) => {
-          const { name, config } = c.req.valid("json")
-          const result = await MCP.add(name, config)
-          return c.json(result.status)
-        },
-      )
-      .post(
-        "/mcp/:name/auth",
-        describeRoute({
-          summary: "Start MCP OAuth",
-          description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
-          operationId: "mcp.auth.start",
-          responses: {
-            200: {
-              description: "OAuth flow started",
-              content: {
-                "application/json": {
-                  schema: resolver(
-                    z.object({
-                      authorizationUrl: z.string().describe("URL to open in browser for authorization"),
-                    }),
-                  ),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        async (c) => {
-          const name = c.req.param("name")
-          const supportsOAuth = await MCP.supportsOAuth(name)
-          if (!supportsOAuth) {
-            return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
-          }
-          const result = await MCP.startAuth(name)
-          return c.json(result)
-        },
-      )
-      .post(
-        "/mcp/:name/auth/callback",
-        describeRoute({
-          summary: "Complete MCP OAuth",
-          description:
-            "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
-          operationId: "mcp.auth.callback",
-          responses: {
-            200: {
-              description: "OAuth authentication completed",
-              content: {
-                "application/json": {
-                  schema: resolver(MCP.Status),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator(
-          "json",
-          z.object({
-            code: z.string().describe("Authorization code from OAuth callback"),
-          }),
-        ),
-        async (c) => {
-          const name = c.req.param("name")
-          const { code } = c.req.valid("json")
-          const status = await MCP.finishAuth(name, code)
-          return c.json(status)
-        },
-      )
-      .post(
-        "/mcp/:name/auth/authenticate",
-        describeRoute({
-          summary: "Authenticate MCP OAuth",
-          description: "Start OAuth flow and wait for callback (opens browser)",
-          operationId: "mcp.auth.authenticate",
-          responses: {
-            200: {
-              description: "OAuth authentication completed",
-              content: {
-                "application/json": {
-                  schema: resolver(MCP.Status),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        async (c) => {
-          const name = c.req.param("name")
-          const supportsOAuth = await MCP.supportsOAuth(name)
-          if (!supportsOAuth) {
-            return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
-          }
-          const status = await MCP.authenticate(name)
-          return c.json(status)
-        },
-      )
-      .delete(
-        "/mcp/:name/auth",
-        describeRoute({
-          summary: "Remove MCP OAuth",
-          description: "Remove OAuth credentials for an MCP server",
-          operationId: "mcp.auth.remove",
-          responses: {
-            200: {
-              description: "OAuth credentials removed",
-              content: {
-                "application/json": {
-                  schema: resolver(z.object({ success: z.literal(true) })),
-                },
-              },
-            },
-            ...errors(404),
-          },
-        }),
-        async (c) => {
-          const name = c.req.param("name")
-          await MCP.removeAuth(name)
-          return c.json({ success: true as const })
-        },
-      )
-      .post(
-        "/mcp/:name/connect",
-        describeRoute({
-          description: "Connect an MCP server",
-          operationId: "mcp.connect",
-          responses: {
-            200: {
-              description: "MCP server connected successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        validator("param", z.object({ name: z.string() })),
-        async (c) => {
-          const { name } = c.req.valid("param")
-          await MCP.connect(name)
-          return c.json(true)
-        },
-      )
-      .post(
-        "/mcp/:name/disconnect",
-        describeRoute({
-          description: "Disconnect an MCP server",
-          operationId: "mcp.disconnect",
-          responses: {
-            200: {
-              description: "MCP server disconnected successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        validator("param", z.object({ name: z.string() })),
-        async (c) => {
-          const { name } = c.req.valid("param")
-          await MCP.disconnect(name)
-          return c.json(true)
-        },
-      )
-      .get(
-        "/experimental/resource",
-        describeRoute({
-          summary: "Get MCP resources",
-          description: "Get all available MCP resources from connected servers. Optionally filter by name.",
-          operationId: "experimental.resource.list",
-          responses: {
-            200: {
-              description: "MCP resources",
-              content: {
-                "application/json": {
-                  schema: resolver(z.record(z.string(), MCP.Resource)),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          return c.json(await MCP.resources())
-        },
-      )
-      .get(
-        "/lsp",
-        describeRoute({
-          summary: "Get LSP status",
-          description: "Get LSP server status",
-          operationId: "lsp.status",
-          responses: {
-            200: {
-              description: "LSP server status",
-              content: {
-                "application/json": {
-                  schema: resolver(LSP.Status.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          return c.json(await LSP.status())
-        },
-      )
-      .get(
-        "/formatter",
-        describeRoute({
-          summary: "Get formatter status",
-          description: "Get formatter status",
-          operationId: "formatter.status",
-          responses: {
-            200: {
-              description: "Formatter status",
-              content: {
-                "application/json": {
-                  schema: resolver(Format.Status.array()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          return c.json(await Format.status())
-        },
-      )
-      .post(
-        "/tui/append-prompt",
-        describeRoute({
-          summary: "Append TUI prompt",
-          description: "Append prompt to the TUI",
-          operationId: "tui.appendPrompt",
-          responses: {
-            200: {
-              description: "Prompt processed successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator("json", TuiEvent.PromptAppend.properties),
-        async (c) => {
-          await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/open-help",
-        describeRoute({
-          summary: "Open help dialog",
-          description: "Open the help dialog in the TUI to display user assistance information.",
-          operationId: "tui.openHelp",
-          responses: {
-            200: {
-              description: "Help dialog opened successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          // TODO: open dialog
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/open-sessions",
-        describeRoute({
-          summary: "Open sessions dialog",
-          description: "Open the session dialog",
-          operationId: "tui.openSessions",
-          responses: {
-            200: {
-              description: "Session dialog opened successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          await Bus.publish(TuiEvent.CommandExecute, {
-            command: "session.list",
-          })
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/open-themes",
-        describeRoute({
-          summary: "Open themes dialog",
-          description: "Open the theme dialog",
-          operationId: "tui.openThemes",
-          responses: {
-            200: {
-              description: "Theme dialog opened successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          await Bus.publish(TuiEvent.CommandExecute, {
-            command: "session.list",
-          })
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/open-models",
-        describeRoute({
-          summary: "Open models dialog",
-          description: "Open the model dialog",
-          operationId: "tui.openModels",
-          responses: {
-            200: {
-              description: "Model dialog opened successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          await Bus.publish(TuiEvent.CommandExecute, {
-            command: "model.list",
-          })
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/submit-prompt",
-        describeRoute({
-          summary: "Submit TUI prompt",
-          description: "Submit the prompt",
-          operationId: "tui.submitPrompt",
-          responses: {
-            200: {
-              description: "Prompt submitted successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          await Bus.publish(TuiEvent.CommandExecute, {
-            command: "prompt.submit",
-          })
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/clear-prompt",
-        describeRoute({
-          summary: "Clear TUI prompt",
-          description: "Clear the prompt",
-          operationId: "tui.clearPrompt",
-          responses: {
-            200: {
-              description: "Prompt cleared successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          await Bus.publish(TuiEvent.CommandExecute, {
-            command: "prompt.clear",
-          })
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/execute-command",
-        describeRoute({
-          summary: "Execute TUI command",
-          description: "Execute a TUI command (e.g. agent_cycle)",
-          operationId: "tui.executeCommand",
-          responses: {
-            200: {
-              description: "Command executed successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator("json", z.object({ command: z.string() })),
-        async (c) => {
-          const command = c.req.valid("json").command
-          await Bus.publish(TuiEvent.CommandExecute, {
-            // @ts-expect-error
-            command: {
-              session_new: "session.new",
-              session_share: "session.share",
-              session_interrupt: "session.interrupt",
-              session_compact: "session.compact",
-              messages_page_up: "session.page.up",
-              messages_page_down: "session.page.down",
-              messages_half_page_up: "session.half.page.up",
-              messages_half_page_down: "session.half.page.down",
-              messages_first: "session.first",
-              messages_last: "session.last",
-              agent_cycle: "agent.cycle",
-            }[command],
-          })
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/show-toast",
-        describeRoute({
-          summary: "Show TUI toast",
-          description: "Show a toast notification in the TUI",
-          operationId: "tui.showToast",
-          responses: {
-            200: {
-              description: "Toast notification shown successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-          },
-        }),
-        validator("json", TuiEvent.ToastShow.properties),
-        async (c) => {
-          await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/publish",
-        describeRoute({
-          summary: "Publish TUI event",
-          description: "Publish a TUI event",
-          operationId: "tui.publish",
-          responses: {
-            200: {
-              description: "Event published successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "json",
-          z.union(
-            Object.values(TuiEvent).map((def) => {
-              return z
-                .object({
-                  type: z.literal(def.type),
-                  properties: def.properties,
-                })
-                .meta({
-                  ref: "Event" + "." + def.type,
-                })
+            return c.json(true)
+          },
+        )
+        .get(
+          "/agent",
+          describeRoute({
+            summary: "List agents",
+            description: "Get a list of all available AI agents in the OpenCode system.",
+            operationId: "app.agents",
+            responses: {
+              200: {
+                description: "List of agents",
+                content: {
+                  "application/json": {
+                    schema: resolver(Agent.Info.array()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            const modes = await Agent.list()
+            return c.json(modes)
+          },
+        )
+        .get(
+          "/mcp",
+          describeRoute({
+            summary: "Get MCP status",
+            description: "Get the status of all Model Context Protocol (MCP) servers.",
+            operationId: "mcp.status",
+            responses: {
+              200: {
+                description: "MCP server status",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.record(z.string(), MCP.Status)),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            return c.json(await MCP.status())
+          },
+        )
+        .post(
+          "/mcp",
+          describeRoute({
+            summary: "Add MCP server",
+            description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
+            operationId: "mcp.add",
+            responses: {
+              200: {
+                description: "MCP server added successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.record(z.string(), MCP.Status)),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator(
+            "json",
+            z.object({
+              name: z.string(),
+              config: Config.Mcp,
             }),
           ),
-        ),
-        async (c) => {
-          const evt = c.req.valid("json")
-          await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties)
-          return c.json(true)
-        },
-      )
-      .post(
-        "/tui/select-session",
-        describeRoute({
-          summary: "Select session",
-          description: "Navigate the TUI to display the specified session.",
-          operationId: "tui.selectSession",
-          responses: {
-            200: {
-              description: "Session selected successfully",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400, 404),
-          },
-        }),
-        validator("json", TuiEvent.SessionSelect.properties),
-        async (c) => {
-          const { sessionID } = c.req.valid("json")
-          await Session.get(sessionID)
-          await Bus.publish(TuiEvent.SessionSelect, { sessionID })
-          return c.json(true)
-        },
-      )
-      .route("/tui/control", TuiRoute)
-      .put(
-        "/auth/:providerID",
-        describeRoute({
-          summary: "Set auth credentials",
-          description: "Set authentication credentials",
-          operationId: "auth.set",
-          responses: {
-            200: {
-              description: "Successfully set authentication credentials",
-              content: {
-                "application/json": {
-                  schema: resolver(z.boolean()),
-                },
-              },
-            },
-            ...errors(400),
-          },
-        }),
-        validator(
-          "param",
-          z.object({
-            providerID: z.string(),
-          }),
-        ),
-        validator("json", Auth.Info),
-        async (c) => {
-          const providerID = c.req.valid("param").providerID
-          const info = c.req.valid("json")
-          await Auth.set(providerID, info)
-          return c.json(true)
-        },
-      )
-      .get(
-        "/event",
-        describeRoute({
-          summary: "Subscribe to events",
-          description: "Get events",
-          operationId: "event.subscribe",
-          responses: {
-            200: {
-              description: "Event stream",
-              content: {
-                "text/event-stream": {
-                  schema: resolver(BusEvent.payloads()),
-                },
-              },
-            },
-          },
-        }),
-        async (c) => {
-          log.info("event connected")
-          return streamSSE(c, async (stream) => {
-            stream.writeSSE({
-              data: JSON.stringify({
-                type: "server.connected",
-                properties: {},
-              }),
+          async (c) => {
+            const { name, config } = c.req.valid("json")
+            const result = await MCP.add(name, config)
+            return c.json(result.status)
+          },
+        )
+        .post(
+          "/mcp/:name/auth",
+          describeRoute({
+            summary: "Start MCP OAuth",
+            description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
+            operationId: "mcp.auth.start",
+            responses: {
+              200: {
+                description: "OAuth flow started",
+                content: {
+                  "application/json": {
+                    schema: resolver(
+                      z.object({
+                        authorizationUrl: z.string().describe("URL to open in browser for authorization"),
+                      }),
+                    ),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          async (c) => {
+            const name = c.req.param("name")
+            const supportsOAuth = await MCP.supportsOAuth(name)
+            if (!supportsOAuth) {
+              return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
+            }
+            const result = await MCP.startAuth(name)
+            return c.json(result)
+          },
+        )
+        .post(
+          "/mcp/:name/auth/callback",
+          describeRoute({
+            summary: "Complete MCP OAuth",
+            description:
+              "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
+            operationId: "mcp.auth.callback",
+            responses: {
+              200: {
+                description: "OAuth authentication completed",
+                content: {
+                  "application/json": {
+                    schema: resolver(MCP.Status),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator(
+            "json",
+            z.object({
+              code: z.string().describe("Authorization code from OAuth callback"),
+            }),
+          ),
+          async (c) => {
+            const name = c.req.param("name")
+            const { code } = c.req.valid("json")
+            const status = await MCP.finishAuth(name, code)
+            return c.json(status)
+          },
+        )
+        .post(
+          "/mcp/:name/auth/authenticate",
+          describeRoute({
+            summary: "Authenticate MCP OAuth",
+            description: "Start OAuth flow and wait for callback (opens browser)",
+            operationId: "mcp.auth.authenticate",
+            responses: {
+              200: {
+                description: "OAuth authentication completed",
+                content: {
+                  "application/json": {
+                    schema: resolver(MCP.Status),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          async (c) => {
+            const name = c.req.param("name")
+            const supportsOAuth = await MCP.supportsOAuth(name)
+            if (!supportsOAuth) {
+              return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
+            }
+            const status = await MCP.authenticate(name)
+            return c.json(status)
+          },
+        )
+        .delete(
+          "/mcp/:name/auth",
+          describeRoute({
+            summary: "Remove MCP OAuth",
+            description: "Remove OAuth credentials for an MCP server",
+            operationId: "mcp.auth.remove",
+            responses: {
+              200: {
+                description: "OAuth credentials removed",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.object({ success: z.literal(true) })),
+                  },
+                },
+              },
+              ...errors(404),
+            },
+          }),
+          async (c) => {
+            const name = c.req.param("name")
+            await MCP.removeAuth(name)
+            return c.json({ success: true as const })
+          },
+        )
+        .post(
+          "/mcp/:name/connect",
+          describeRoute({
+            description: "Connect an MCP server",
+            operationId: "mcp.connect",
+            responses: {
+              200: {
+                description: "MCP server connected successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          validator("param", z.object({ name: z.string() })),
+          async (c) => {
+            const { name } = c.req.valid("param")
+            await MCP.connect(name)
+            return c.json(true)
+          },
+        )
+        .post(
+          "/mcp/:name/disconnect",
+          describeRoute({
+            description: "Disconnect an MCP server",
+            operationId: "mcp.disconnect",
+            responses: {
+              200: {
+                description: "MCP server disconnected successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          validator("param", z.object({ name: z.string() })),
+          async (c) => {
+            const { name } = c.req.valid("param")
+            await MCP.disconnect(name)
+            return c.json(true)
+          },
+        )
+        .get(
+          "/experimental/resource",
+          describeRoute({
+            summary: "Get MCP resources",
+            description: "Get all available MCP resources from connected servers. Optionally filter by name.",
+            operationId: "experimental.resource.list",
+            responses: {
+              200: {
+                description: "MCP resources",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.record(z.string(), MCP.Resource)),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            return c.json(await MCP.resources())
+          },
+        )
+        .get(
+          "/lsp",
+          describeRoute({
+            summary: "Get LSP status",
+            description: "Get LSP server status",
+            operationId: "lsp.status",
+            responses: {
+              200: {
+                description: "LSP server status",
+                content: {
+                  "application/json": {
+                    schema: resolver(LSP.Status.array()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            return c.json(await LSP.status())
+          },
+        )
+        .get(
+          "/formatter",
+          describeRoute({
+            summary: "Get formatter status",
+            description: "Get formatter status",
+            operationId: "formatter.status",
+            responses: {
+              200: {
+                description: "Formatter status",
+                content: {
+                  "application/json": {
+                    schema: resolver(Format.Status.array()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            return c.json(await Format.status())
+          },
+        )
+        .post(
+          "/tui/append-prompt",
+          describeRoute({
+            summary: "Append TUI prompt",
+            description: "Append prompt to the TUI",
+            operationId: "tui.appendPrompt",
+            responses: {
+              200: {
+                description: "Prompt processed successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator("json", TuiEvent.PromptAppend.properties),
+          async (c) => {
+            await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/open-help",
+          describeRoute({
+            summary: "Open help dialog",
+            description: "Open the help dialog in the TUI to display user assistance information.",
+            operationId: "tui.openHelp",
+            responses: {
+              200: {
+                description: "Help dialog opened successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            // TODO: open dialog
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/open-sessions",
+          describeRoute({
+            summary: "Open sessions dialog",
+            description: "Open the session dialog",
+            operationId: "tui.openSessions",
+            responses: {
+              200: {
+                description: "Session dialog opened successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            await Bus.publish(TuiEvent.CommandExecute, {
+              command: "session.list",
             })
-            const unsub = Bus.subscribeAll(async (event) => {
-              await stream.writeSSE({
-                data: JSON.stringify(event),
-              })
-              if (event.type === Bus.InstanceDisposed.type) {
-                stream.close()
-              }
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/open-themes",
+          describeRoute({
+            summary: "Open themes dialog",
+            description: "Open the theme dialog",
+            operationId: "tui.openThemes",
+            responses: {
+              200: {
+                description: "Theme dialog opened successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            await Bus.publish(TuiEvent.CommandExecute, {
+              command: "session.list",
             })
-
-            // Send heartbeat every 30s to prevent WKWebView timeout (60s default)
-            const heartbeat = setInterval(() => {
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/open-models",
+          describeRoute({
+            summary: "Open models dialog",
+            description: "Open the model dialog",
+            operationId: "tui.openModels",
+            responses: {
+              200: {
+                description: "Model dialog opened successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            await Bus.publish(TuiEvent.CommandExecute, {
+              command: "model.list",
+            })
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/submit-prompt",
+          describeRoute({
+            summary: "Submit TUI prompt",
+            description: "Submit the prompt",
+            operationId: "tui.submitPrompt",
+            responses: {
+              200: {
+                description: "Prompt submitted successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            await Bus.publish(TuiEvent.CommandExecute, {
+              command: "prompt.submit",
+            })
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/clear-prompt",
+          describeRoute({
+            summary: "Clear TUI prompt",
+            description: "Clear the prompt",
+            operationId: "tui.clearPrompt",
+            responses: {
+              200: {
+                description: "Prompt cleared successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            await Bus.publish(TuiEvent.CommandExecute, {
+              command: "prompt.clear",
+            })
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/execute-command",
+          describeRoute({
+            summary: "Execute TUI command",
+            description: "Execute a TUI command (e.g. agent_cycle)",
+            operationId: "tui.executeCommand",
+            responses: {
+              200: {
+                description: "Command executed successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator("json", z.object({ command: z.string() })),
+          async (c) => {
+            const command = c.req.valid("json").command
+            await Bus.publish(TuiEvent.CommandExecute, {
+              // @ts-expect-error
+              command: {
+                session_new: "session.new",
+                session_share: "session.share",
+                session_interrupt: "session.interrupt",
+                session_compact: "session.compact",
+                messages_page_up: "session.page.up",
+                messages_page_down: "session.page.down",
+                messages_half_page_up: "session.half.page.up",
+                messages_half_page_down: "session.half.page.down",
+                messages_first: "session.first",
+                messages_last: "session.last",
+                agent_cycle: "agent.cycle",
+              }[command],
+            })
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/show-toast",
+          describeRoute({
+            summary: "Show TUI toast",
+            description: "Show a toast notification in the TUI",
+            operationId: "tui.showToast",
+            responses: {
+              200: {
+                description: "Toast notification shown successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+            },
+          }),
+          validator("json", TuiEvent.ToastShow.properties),
+          async (c) => {
+            await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/publish",
+          describeRoute({
+            summary: "Publish TUI event",
+            description: "Publish a TUI event",
+            operationId: "tui.publish",
+            responses: {
+              200: {
+                description: "Event published successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator(
+            "json",
+            z.union(
+              Object.values(TuiEvent).map((def) => {
+                return z
+                  .object({
+                    type: z.literal(def.type),
+                    properties: def.properties,
+                  })
+                  .meta({
+                    ref: "Event" + "." + def.type,
+                  })
+              }),
+            ),
+          ),
+          async (c) => {
+            const evt = c.req.valid("json")
+            await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties)
+            return c.json(true)
+          },
+        )
+        .post(
+          "/tui/select-session",
+          describeRoute({
+            summary: "Select session",
+            description: "Navigate the TUI to display the specified session.",
+            operationId: "tui.selectSession",
+            responses: {
+              200: {
+                description: "Session selected successfully",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400, 404),
+            },
+          }),
+          validator("json", TuiEvent.SessionSelect.properties),
+          async (c) => {
+            const { sessionID } = c.req.valid("json")
+            await Session.get(sessionID)
+            await Bus.publish(TuiEvent.SessionSelect, { sessionID })
+            return c.json(true)
+          },
+        )
+        .route("/tui/control", TuiRoute)
+        .put(
+          "/auth/:providerID",
+          describeRoute({
+            summary: "Set auth credentials",
+            description: "Set authentication credentials",
+            operationId: "auth.set",
+            responses: {
+              200: {
+                description: "Successfully set authentication credentials",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              providerID: z.string(),
+            }),
+          ),
+          validator("json", Auth.Info),
+          async (c) => {
+            const providerID = c.req.valid("param").providerID
+            const info = c.req.valid("json")
+            await Auth.set(providerID, info)
+            return c.json(true)
+          },
+        )
+        .get(
+          "/event",
+          describeRoute({
+            summary: "Subscribe to events",
+            description: "Get events",
+            operationId: "event.subscribe",
+            responses: {
+              200: {
+                description: "Event stream",
+                content: {
+                  "text/event-stream": {
+                    schema: resolver(BusEvent.payloads()),
+                  },
+                },
+              },
+            },
+          }),
+          async (c) => {
+            log.info("event connected")
+            return streamSSE(c, async (stream) => {
               stream.writeSSE({
                 data: JSON.stringify({
-                  type: "server.heartbeat",
+                  type: "server.connected",
                   properties: {},
                 }),
               })
-            }, 30000)
+              const unsub = Bus.subscribeAll(async (event) => {
+                await stream.writeSSE({
+                  data: JSON.stringify(event),
+                })
+                if (event.type === Bus.InstanceDisposed.type) {
+                  stream.close()
+                }
+              })
+
+              // Send heartbeat every 30s to prevent WKWebView timeout (60s default)
+              const heartbeat = setInterval(() => {
+                stream.writeSSE({
+                  data: JSON.stringify({
+                    type: "server.heartbeat",
+                    properties: {},
+                  }),
+                })
+              }, 30000)
 
-            await new Promise<void>((resolve) => {
-              stream.onAbort(() => {
-                clearInterval(heartbeat)
-                unsub()
-                resolve()
-                log.info("event disconnected")
+              await new Promise<void>((resolve) => {
+                stream.onAbort(() => {
+                  clearInterval(heartbeat)
+                  unsub()
+                  resolve()
+                  log.info("event disconnected")
+                })
               })
             })
-          })
-        },
-      )
-      .all("/*", async (c) => {
-        const path = c.req.path
-        const response = await proxy(`https://app.opencode.ai${path}`, {
-          ...c.req,
-          headers: {
-            ...c.req.raw.headers,
-            host: "app.opencode.ai",
           },
-        })
-        return response
-      }),
+        )
+        .all("/*", async (c) => {
+          const path = c.req.path
+          const response = await proxy(`https://app.opencode.ai${path}`, {
+            ...c.req,
+            headers: {
+              ...c.req.raw.headers,
+              host: "app.opencode.ai",
+            },
+          })
+          return response
+        }) as unknown as Hono,
   )
 
   export async function openapi() {
-    const result = await generateSpecs(App(), {
+    // Cast to break excessive type recursion from long route chains
+    const result = await generateSpecs(App() as Hono, {
       documentation: {
         info: {
           title: "opencode",

+ 5 - 1
packages/opencode/src/session/processor.ts

@@ -14,6 +14,7 @@ import { LLM } from "./llm"
 import { Config } from "@/config/config"
 import { SessionCompaction } from "./compaction"
 import { PermissionNext } from "@/permission/next"
+import { Question } from "@/question"
 
 export namespace SessionProcessor {
   const DOOM_LOOP_THRESHOLD = 3
@@ -208,7 +209,10 @@ export namespace SessionProcessor {
                       },
                     })
 
-                    if (value.error instanceof PermissionNext.RejectedError) {
+                    if (
+                      value.error instanceof PermissionNext.RejectedError ||
+                      value.error instanceof Question.RejectedError
+                    ) {
                       blocked = shouldBreak
                     }
                     delete toolcalls[value.toolCallId]

+ 28 - 0
packages/opencode/src/tool/question.ts

@@ -0,0 +1,28 @@
+import z from "zod"
+import { Tool } from "./tool"
+import { Question } from "../question"
+import DESCRIPTION from "./question.txt"
+
+export const QuestionTool = Tool.define("question", {
+  description: DESCRIPTION,
+  parameters: z.object({
+    questions: z.array(Question.Info).describe("Questions to ask"),
+  }),
+  async execute(params, ctx) {
+    const answers = await Question.ask({
+      sessionID: ctx.sessionID,
+      questions: params.questions,
+      tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
+    })
+
+    const formatted = params.questions.map((q, i) => `"${q.question}"="${answers[i] ?? "Unanswered"}"`).join(", ")
+
+    return {
+      title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
+      output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
+      metadata: {
+        answers,
+      },
+    }
+  },
+})

+ 9 - 0
packages/opencode/src/tool/question.txt

@@ -0,0 +1,9 @@
+Use this tool when you need to ask the user questions during execution. This allows you to:
+1. Gather user preferences or requirements
+2. Clarify ambiguous instructions
+3. Get decisions on implementation choices as you work
+4. Offer choices to the user about what direction to take.
+
+Usage notes:
+- Users will always be able to select "Other" to provide custom text input
+- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label

+ 2 - 0
packages/opencode/src/tool/registry.ts

@@ -1,3 +1,4 @@
+import { QuestionTool } from "./question"
 import { BashTool } from "./bash"
 import { EditTool } from "./edit"
 import { GlobTool } from "./glob"
@@ -92,6 +93,7 @@ export namespace ToolRegistry {
 
     return [
       InvalidTool,
+      ...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
       BashTool,
       ReadTool,
       GlobTool,

+ 300 - 0
packages/opencode/test/question/question.test.ts

@@ -0,0 +1,300 @@
+import { test, expect } from "bun:test"
+import { Question } from "../../src/question"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+test("ask - returns pending promise", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const promise = Question.ask({
+        sessionID: "ses_test",
+        questions: [
+          {
+            question: "What would you like to do?",
+            header: "Action",
+            options: [
+              { label: "Option 1", description: "First option" },
+              { label: "Option 2", description: "Second option" },
+            ],
+          },
+        ],
+      })
+      expect(promise).toBeInstanceOf(Promise)
+    },
+  })
+})
+
+test("ask - adds to pending list", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const questions = [
+        {
+          question: "What would you like to do?",
+          header: "Action",
+          options: [
+            { label: "Option 1", description: "First option" },
+            { label: "Option 2", description: "Second option" },
+          ],
+        },
+      ]
+
+      Question.ask({
+        sessionID: "ses_test",
+        questions,
+      })
+
+      const pending = await Question.list()
+      expect(pending.length).toBe(1)
+      expect(pending[0].questions).toEqual(questions)
+    },
+  })
+})
+
+// reply tests
+
+test("reply - resolves the pending ask with answers", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const questions = [
+        {
+          question: "What would you like to do?",
+          header: "Action",
+          options: [
+            { label: "Option 1", description: "First option" },
+            { label: "Option 2", description: "Second option" },
+          ],
+        },
+      ]
+
+      const askPromise = Question.ask({
+        sessionID: "ses_test",
+        questions,
+      })
+
+      const pending = await Question.list()
+      const requestID = pending[0].id
+
+      await Question.reply({
+        requestID,
+        answers: ["Option 1"],
+      })
+
+      const answers = await askPromise
+      expect(answers).toEqual(["Option 1"])
+    },
+  })
+})
+
+test("reply - removes from pending list", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      Question.ask({
+        sessionID: "ses_test",
+        questions: [
+          {
+            question: "What would you like to do?",
+            header: "Action",
+            options: [
+              { label: "Option 1", description: "First option" },
+              { label: "Option 2", description: "Second option" },
+            ],
+          },
+        ],
+      })
+
+      const pending = await Question.list()
+      expect(pending.length).toBe(1)
+
+      await Question.reply({
+        requestID: pending[0].id,
+        answers: ["Option 1"],
+      })
+
+      const pendingAfter = await Question.list()
+      expect(pendingAfter.length).toBe(0)
+    },
+  })
+})
+
+test("reply - does nothing for unknown requestID", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      await Question.reply({
+        requestID: "que_unknown",
+        answers: ["Option 1"],
+      })
+      // Should not throw
+    },
+  })
+})
+
+// reject tests
+
+test("reject - throws RejectedError", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const askPromise = Question.ask({
+        sessionID: "ses_test",
+        questions: [
+          {
+            question: "What would you like to do?",
+            header: "Action",
+            options: [
+              { label: "Option 1", description: "First option" },
+              { label: "Option 2", description: "Second option" },
+            ],
+          },
+        ],
+      })
+
+      const pending = await Question.list()
+      await Question.reject(pending[0].id)
+
+      await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
+    },
+  })
+})
+
+test("reject - removes from pending list", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const askPromise = Question.ask({
+        sessionID: "ses_test",
+        questions: [
+          {
+            question: "What would you like to do?",
+            header: "Action",
+            options: [
+              { label: "Option 1", description: "First option" },
+              { label: "Option 2", description: "Second option" },
+            ],
+          },
+        ],
+      })
+
+      const pending = await Question.list()
+      expect(pending.length).toBe(1)
+
+      await Question.reject(pending[0].id)
+      askPromise.catch(() => {}) // Ignore rejection
+
+      const pendingAfter = await Question.list()
+      expect(pendingAfter.length).toBe(0)
+    },
+  })
+})
+
+test("reject - does nothing for unknown requestID", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      await Question.reject("que_unknown")
+      // Should not throw
+    },
+  })
+})
+
+// multiple questions tests
+
+test("ask - handles multiple questions", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const questions = [
+        {
+          question: "What would you like to do?",
+          header: "Action",
+          options: [
+            { label: "Build", description: "Build the project" },
+            { label: "Test", description: "Run tests" },
+          ],
+        },
+        {
+          question: "Which environment?",
+          header: "Env",
+          options: [
+            { label: "Dev", description: "Development" },
+            { label: "Prod", description: "Production" },
+          ],
+        },
+      ]
+
+      const askPromise = Question.ask({
+        sessionID: "ses_test",
+        questions,
+      })
+
+      const pending = await Question.list()
+
+      await Question.reply({
+        requestID: pending[0].id,
+        answers: ["Build", "Dev"],
+      })
+
+      const answers = await askPromise
+      expect(answers).toEqual(["Build", "Dev"])
+    },
+  })
+})
+
+// list tests
+
+test("list - returns all pending requests", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      Question.ask({
+        sessionID: "ses_test1",
+        questions: [
+          {
+            question: "Question 1?",
+            header: "Q1",
+            options: [{ label: "A", description: "A" }],
+          },
+        ],
+      })
+
+      Question.ask({
+        sessionID: "ses_test2",
+        questions: [
+          {
+            question: "Question 2?",
+            header: "Q2",
+            options: [{ label: "B", description: "B" }],
+          },
+        ],
+      })
+
+      const pending = await Question.list()
+      expect(pending.length).toBe(2)
+    },
+  })
+})
+
+test("list - returns empty when no pending", async () => {
+  await using tmp = await tmpdir({ git: true })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const pending = await Question.list()
+      expect(pending.length).toBe(0)
+    },
+  })
+})

+ 95 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -84,6 +84,11 @@ import type {
   PtyRemoveResponses,
   PtyUpdateErrors,
   PtyUpdateResponses,
+  QuestionListResponses,
+  QuestionRejectErrors,
+  QuestionRejectResponses,
+  QuestionReplyErrors,
+  QuestionReplyResponses,
   SessionAbortErrors,
   SessionAbortResponses,
   SessionChildrenErrors,
@@ -1781,6 +1786,94 @@ export class Permission extends HeyApiClient {
   }
 }
 
+export class Question extends HeyApiClient {
+  /**
+   * List pending questions
+   *
+   * Get all pending question requests across all sessions.
+   */
+  public list<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+    return (options?.client ?? this.client).get<QuestionListResponses, unknown, ThrowOnError>({
+      url: "/question",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Reply to question request
+   *
+   * Provide answers to a question request from the AI assistant.
+   */
+  public reply<ThrowOnError extends boolean = false>(
+    parameters: {
+      requestID: string
+      directory?: string
+      answers?: Array<string>
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "requestID" },
+            { in: "query", key: "directory" },
+            { in: "body", key: "answers" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<QuestionReplyResponses, QuestionReplyErrors, ThrowOnError>({
+      url: "/question/{requestID}/reply",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+
+  /**
+   * Reject question request
+   *
+   * Reject a question request from the AI assistant.
+   */
+  public reject<ThrowOnError extends boolean = false>(
+    parameters: {
+      requestID: string
+      directory?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "requestID" },
+            { in: "query", key: "directory" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<QuestionRejectResponses, QuestionRejectErrors, ThrowOnError>({
+      url: "/question/{requestID}/reject",
+      ...options,
+      ...params,
+    })
+  }
+}
+
 export class Command extends HeyApiClient {
   /**
    * List commands
@@ -2912,6 +3005,8 @@ export class OpencodeClient extends HeyApiClient {
 
   permission = new Permission({ client: this.client })
 
+  question = new Question({ client: this.client })
+
   command = new Command({ client: this.client })
 
   provider = new Provider({ client: this.client })

+ 150 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -524,6 +524,67 @@ export type EventSessionCompacted = {
   }
 }
 
+export type QuestionOption = {
+  /**
+   * Display text (1-5 words, concise)
+   */
+  label: string
+  /**
+   * Explanation of choice
+   */
+  description: string
+}
+
+export type QuestionInfo = {
+  /**
+   * Complete question
+   */
+  question: string
+  /**
+   * Very short label (max 12 chars)
+   */
+  header: string
+  /**
+   * Available choices
+   */
+  options: Array<QuestionOption>
+}
+
+export type QuestionRequest = {
+  id: string
+  sessionID: string
+  /**
+   * Questions to ask
+   */
+  questions: Array<QuestionInfo>
+  tool?: {
+    messageID: string
+    callID: string
+  }
+}
+
+export type EventQuestionAsked = {
+  type: "question.asked"
+  properties: QuestionRequest
+}
+
+export type EventQuestionReplied = {
+  type: "question.replied"
+  properties: {
+    sessionID: string
+    requestID: string
+    answers: Array<string>
+  }
+}
+
+export type EventQuestionRejected = {
+  type: "question.rejected"
+  properties: {
+    sessionID: string
+    requestID: string
+  }
+}
+
 export type EventFileEdited = {
   type: "file.edited"
   properties: {
@@ -789,6 +850,9 @@ export type Event =
   | EventSessionStatus
   | EventSessionIdle
   | EventSessionCompacted
+  | EventQuestionAsked
+  | EventQuestionReplied
+  | EventQuestionRejected
   | EventFileEdited
   | EventTodoUpdated
   | EventTuiPromptAppend
@@ -3545,6 +3609,92 @@ export type PermissionListResponses = {
 
 export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
 
+export type QuestionListData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/question"
+}
+
+export type QuestionListResponses = {
+  /**
+   * List of pending questions
+   */
+  200: Array<QuestionRequest>
+}
+
+export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]
+
+export type QuestionReplyData = {
+  body?: {
+    answers: Array<string>
+  }
+  path: {
+    requestID: string
+  }
+  query?: {
+    directory?: string
+  }
+  url: "/question/{requestID}/reply"
+}
+
+export type QuestionReplyErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+  /**
+   * Not found
+   */
+  404: NotFoundError
+}
+
+export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors]
+
+export type QuestionReplyResponses = {
+  /**
+   * Question answered successfully
+   */
+  200: boolean
+}
+
+export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses]
+
+export type QuestionRejectData = {
+  body?: never
+  path: {
+    requestID: string
+  }
+  query?: {
+    directory?: string
+  }
+  url: "/question/{requestID}/reject"
+}
+
+export type QuestionRejectErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+  /**
+   * Not found
+   */
+  404: NotFoundError
+}
+
+export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors]
+
+export type QuestionRejectResponses = {
+  /**
+   * Question rejected successfully
+   */
+  200: boolean
+}
+
+export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]
+
 export type CommandListData = {
   body?: never
   path?: never