瀏覽代碼

Merge branch 'dev' into new-toolbar-layout

Aaron Iker 3 月之前
父節點
當前提交
0732a2b6ce
共有 34 個文件被更改,包括 4936 次插入2911 次删除
  1. 2 1
      STYLE_GUIDE.md
  2. 77 0
      packages/desktop/src-tauri/src/lib.rs
  3. 17 2
      packages/opencode/src/agent/agent.ts
  4. 9 1
      packages/opencode/src/cli/cmd/github.ts
  5. 22 1
      packages/opencode/src/cli/cmd/run.ts
  6. 43 0
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  7. 67 35
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  8. 291 0
      packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
  9. 1 0
      packages/opencode/src/config/config.ts
  10. 10 0
      packages/opencode/src/id/id.ts
  11. 4 0
      packages/opencode/src/provider/transform.ts
  12. 162 0
      packages/opencode/src/question/index.ts
  13. 95 0
      packages/opencode/src/server/question.ts
  14. 2697 2686
      packages/opencode/src/server/server.ts
  15. 4 8
      packages/opencode/src/session/llm.ts
  16. 5 1
      packages/opencode/src/session/processor.ts
  17. 0 60
      packages/opencode/src/session/truncation.ts
  18. 17 21
      packages/opencode/src/tool/bash.ts
  19. 2 3
      packages/opencode/src/tool/bash.txt
  20. 28 0
      packages/opencode/src/tool/question.ts
  21. 9 0
      packages/opencode/src/tool/question.txt
  22. 23 5
      packages/opencode/src/tool/read.ts
  23. 6 4
      packages/opencode/src/tool/registry.ts
  24. 9 4
      packages/opencode/src/tool/tool.ts
  25. 98 0
      packages/opencode/src/tool/truncation.ts
  26. 300 0
      packages/opencode/test/question/question.test.ts
  27. 0 79
      packages/opencode/test/session/truncation.test.ts
  28. 88 0
      packages/opencode/test/tool/bash.test.ts
  29. 0 0
      packages/opencode/test/tool/fixtures/models-api.json
  30. 122 0
      packages/opencode/test/tool/read.test.ts
  31. 159 0
      packages/opencode/test/tool/truncation.test.ts
  32. 95 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  33. 151 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  34. 323 0
      packages/sdk/openapi.json

+ 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

+ 77 - 0
packages/desktop/src-tauri/src/lib.rs

@@ -15,6 +15,7 @@ use tauri::{
 };
 use tauri_plugin_shell::process::{CommandChild, CommandEvent};
 use tauri_plugin_shell::ShellExt;
+use tauri_plugin_store::StoreExt;
 use tokio::net::TcpSocket;
 
 use crate::window_customizer::PinchZoomDisablePlugin;
@@ -45,6 +46,65 @@ impl ServerState {
 struct LogState(Arc<Mutex<VecDeque<String>>>);
 
 const MAX_LOG_ENTRIES: usize = 200;
+const GLOBAL_STORAGE: &str = "opencode.global.dat";
+
+/// Check if a URL's origin matches any configured server in the store.
+/// Returns true if the URL should be allowed for internal navigation.
+fn is_allowed_server(app: &AppHandle, url: &tauri::Url) -> bool {
+    // Always allow localhost and 127.0.0.1
+    if let Some(host) = url.host_str() {
+        if host == "localhost" || host == "127.0.0.1" {
+            return true;
+        }
+    }
+
+    // Try to read the server list from the store
+    let Ok(store) = app.store(GLOBAL_STORAGE) else {
+        return false;
+    };
+
+    let Some(server_data) = store.get("server") else {
+        return false;
+    };
+
+    // Parse the server list from the stored JSON
+    let Some(list) = server_data.get("list").and_then(|v| v.as_array()) else {
+        return false;
+    };
+
+    // Get the origin of the navigation URL (scheme + host + port)
+    let url_origin = format!(
+        "{}://{}{}",
+        url.scheme(),
+        url.host_str().unwrap_or(""),
+        url.port().map(|p| format!(":{}", p)).unwrap_or_default()
+    );
+
+    // Check if any configured server matches the URL's origin
+    for server in list {
+        let Some(server_url) = server.as_str() else {
+            continue;
+        };
+
+        // Parse the server URL to extract its origin
+        let Ok(parsed) = tauri::Url::parse(server_url) else {
+            continue;
+        };
+
+        let server_origin = format!(
+            "{}://{}{}",
+            parsed.scheme(),
+            parsed.host_str().unwrap_or(""),
+            parsed.port().map(|p| format!(":{}", p)).unwrap_or_default()
+        );
+
+        if url_origin == server_origin {
+            return true;
+        }
+    }
+
+    false
+}
 
 #[tauri::command]
 fn kill_sidecar(app: AppHandle) {
@@ -236,6 +296,7 @@ pub fn run() {
                 .unwrap_or(LogicalSize::new(1920, 1080));
 
             // Create window immediately with serverReady = false
+            let app_for_nav = app.clone();
             let mut window_builder =
                 WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
                     .title("OpenCode")
@@ -243,6 +304,22 @@ pub fn run() {
                     .decorations(true)
                     .zoom_hotkeys_enabled(true)
                     .disable_drag_drop_handler()
+                    .on_navigation(move |url| {
+                        // Allow internal navigation (tauri:// scheme)
+                        if url.scheme() == "tauri" {
+                            return true;
+                        }
+                        // Allow navigation to configured servers (localhost, 127.0.0.1, or remote)
+                        if is_allowed_server(&app_for_nav, url) {
+                            return true;
+                        }
+                        // Open external http/https URLs in default browser
+                        if url.scheme() == "http" || url.scheme() == "https" {
+                            let _ = app_for_nav.shell().open(url.as_str(), None);
+                            return false; // Cancel internal navigation
+                        }
+                        true
+                    })
                     .initialization_script(format!(
                         r#"
                       window.__OPENCODE__ ??= {{}};

+ 17 - 2
packages/opencode/src/agent/agent.ts

@@ -4,6 +4,7 @@ import { Provider } from "../provider/provider"
 import { generateObject, type ModelMessage } from "ai"
 import { SystemPrompt } from "../session/system"
 import { Instance } from "../project/instance"
+import { Truncate } from "../tool/truncation"
 
 import PROMPT_GENERATE from "./generate.txt"
 import PROMPT_COMPACTION from "./prompt/compaction.txt"
@@ -46,7 +47,11 @@ export namespace Agent {
     const defaults = PermissionNext.fromConfig({
       "*": "allow",
       doom_loop: "ask",
-      external_directory: "ask",
+      external_directory: {
+        "*": "ask",
+        [Truncate.DIR]: "allow",
+      },
+      question: "deny",
       // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
       read: {
         "*": "allow",
@@ -61,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,
       },
@@ -71,6 +82,7 @@ export namespace Agent {
         permission: PermissionNext.merge(
           defaults,
           PermissionNext.fromConfig({
+            question: "allow",
             edit: {
               "*": "deny",
               ".opencode/plan/*.md": "allow",
@@ -110,6 +122,9 @@ export namespace Agent {
             websearch: "allow",
             codesearch: "allow",
             read: "allow",
+            external_directory: {
+              [Truncate.DIR]: "allow",
+            },
           }),
           user,
         ),

+ 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

+ 67 - 35
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"
 
@@ -90,7 +92,6 @@ const context = createContext<{
   conceal: () => boolean
   showThinking: () => boolean
   showTimestamps: () => boolean
-  usernameVisible: () => boolean
   showDetails: () => boolean
   diffWrapMode: () => "word" | "none"
   sync: ReturnType<typeof useSync>
@@ -118,9 +119,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
@@ -135,7 +140,6 @@ export function Session() {
   const [conceal, setConceal] = createSignal(true)
   const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true))
   const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
-  const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true))
   const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true))
   const [showAssistantMetadata, setShowAssistantMetadata] = createSignal(kv.get("assistant_metadata_visibility", true))
   const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
@@ -459,20 +463,6 @@ export function Session() {
         dialog.clear()
       },
     },
-    {
-      title: usernameVisible() ? "Hide username" : "Show username",
-      value: "session.username_visible.toggle",
-      keybind: "username_toggle",
-      category: "Session",
-      onSelect: (dialog) => {
-        setUsernameVisible((prev) => {
-          const next = !prev
-          kv.set("username_visible", next)
-          return next
-        })
-        dialog.clear()
-      },
-    },
     {
       title: "Toggle code concealment",
       value: "session.toggle.conceal",
@@ -907,7 +897,6 @@ export function Session() {
         conceal,
         showThinking,
         showTimestamps,
-        usernameVisible,
         showDetails,
         diffWrapMode,
         sync,
@@ -1037,13 +1026,20 @@ 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)
+                  // Apply initial prompt when prompt component mounts (e.g., from fork)
+                  if (route.initialPrompt) {
+                    r.set(route.initialPrompt)
+                  }
                 }}
-                disabled={permissions().length > 0}
+                disabled={permissions().length > 0 || questions().length > 0}
                 onSubmit={() => {
                   toBottom()
                 }}
@@ -1090,6 +1086,7 @@ function UserMessage(props: {
   const [hover, setHover] = createSignal(false)
   const queued = createMemo(() => props.pending && props.message.id > props.pending)
   const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent)))
+  const metadataVisible = createMemo(() => queued() || ctx.showTimestamps())
 
   const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
 
@@ -1119,7 +1116,7 @@ function UserMessage(props: {
           >
             <text fg={theme.text}>{text()?.text}</text>
             <Show when={files().length}>
-              <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
+              <box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
                 <For each={files()}>
                   {(file) => {
                     const bg = createMemo(() => {
@@ -1137,23 +1134,22 @@ function UserMessage(props: {
                 </For>
               </box>
             </Show>
-            <text fg={theme.textMuted}>
-              {ctx.usernameVisible() ? `${sync.data.config.username ?? "You "}` : "You "}
-              <Show
-                when={queued()}
-                fallback={
-                  <Show when={ctx.showTimestamps()}>
+            <Show
+              when={queued()}
+              fallback={
+                <Show when={ctx.showTimestamps()}>
+                  <text fg={theme.textMuted}>
                     <span style={{ fg: theme.textMuted }}>
-                      {ctx.usernameVisible() ? " · " : " "}
                       {Locale.todayTimeOrDateTime(props.message.time.created)}
                     </span>
-                  </Show>
-                }
-              >
-                <span> </span>
+                  </text>
+                </Show>
+              }
+            >
+              <text fg={theme.textMuted}>
                 <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
-              </Show>
-            </text>
+              </text>
+            </Show>
           </box>
         </box>
       </Show>
@@ -1377,6 +1373,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>
@@ -1438,7 +1437,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
@@ -1812,6 +1816,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)) {

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

@@ -0,0 +1,291 @@
+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}. Type your own answer
+                    </text>
+                  </box>
+                  <text fg={theme.success}>{input() ? "✓" : ""}</text>
+                </box>
+                <Show when={store.editing}>
+                  <box paddingLeft={3}>
+                    <textarea
+                      ref={(val: TextareaRenderable) => (textarea = val)}
+                      focused
+                      placeholder="Type your own answer"
+                      textColor={theme.text}
+                      focusedTextColor={theme.text}
+                      cursorColor={theme.primary}
+                      keyBindings={bindings()}
+                    />
+                  </box>
+                </Show>
+                <Show when={!store.editing && input()}>
+                  <box paddingLeft={3}>
+                    <text fg={theme.textMuted}>{input()}</text>
+                  </box>
+                </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(),

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

@@ -6,9 +6,11 @@ export namespace Identifier {
     session: "ses",
     message: "msg",
     permission: "per",
+    question: "que",
     user: "usr",
     part: "prt",
     pty: "pty",
+    tool: "tool",
   } as const
 
   export function schema(prefix: keyof typeof prefixes) {
@@ -70,4 +72,12 @@ export namespace Identifier {
 
     return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
   }
+
+  /** Extract timestamp from an ascending ID. Does not work with descending IDs. */
+  export function timestamp(id: string): number {
+    const prefix = id.split("_")[0]
+    const hex = id.slice(prefix.length + 1, prefix.length + 13)
+    const encoded = BigInt("0x" + hex)
+    return Number(encoded / BigInt(0x1000))
+  }
 }

+ 4 - 0
packages/opencode/src/provider/transform.ts

@@ -497,6 +497,10 @@ export namespace ProviderTransform {
       return { reasoningEffort: "minimal" }
     }
     if (model.providerID === "google") {
+      // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
+      if (model.api.id.includes("gemini-3")) {
+        return { thinkingConfig: { thinkingLevel: "minimal" } }
+      }
       return { thinkingConfig: { thinkingBudget: 0 } }
     }
     if (model.providerID === "openrouter") {

+ 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",

+ 4 - 8
packages/opencode/src/session/llm.ts

@@ -82,16 +82,12 @@ export namespace LLM {
     }
 
     const provider = await Provider.getProvider(input.model.providerID)
-    const small = input.small ? ProviderTransform.smallOptions(input.model) : {}
     const variant =
       !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
-    const options = pipe(
-      ProviderTransform.options(input.model, input.sessionID, provider.options),
-      mergeDeep(small),
-      mergeDeep(input.model.options),
-      mergeDeep(input.agent.options),
-      mergeDeep(variant),
-    )
+    const base = input.small
+      ? ProviderTransform.smallOptions(input.model)
+      : ProviderTransform.options(input.model, input.sessionID, provider.options)
+    const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant))
 
     const params = await Plugin.trigger(
       "chat.params",

+ 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]

+ 0 - 60
packages/opencode/src/session/truncation.ts

@@ -1,60 +0,0 @@
-export namespace Truncate {
-  export const MAX_LINES = 2000
-  export const MAX_BYTES = 50 * 1024
-
-  export interface Result {
-    content: string
-    truncated: boolean
-  }
-
-  export interface Options {
-    maxLines?: number
-    maxBytes?: number
-    direction?: "head" | "tail"
-  }
-
-  export function output(text: string, options: Options = {}): Result {
-    const maxLines = options.maxLines ?? MAX_LINES
-    const maxBytes = options.maxBytes ?? MAX_BYTES
-    const direction = options.direction ?? "head"
-    const lines = text.split("\n")
-    const totalBytes = Buffer.byteLength(text, "utf-8")
-
-    if (lines.length <= maxLines && totalBytes <= maxBytes) {
-      return { content: text, truncated: false }
-    }
-
-    const out: string[] = []
-    var i = 0
-    var bytes = 0
-    var hitBytes = false
-
-    if (direction === "head") {
-      for (i = 0; i < lines.length && i < maxLines; i++) {
-        const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
-        if (bytes + size > maxBytes) {
-          hitBytes = true
-          break
-        }
-        out.push(lines[i])
-        bytes += size
-      }
-      const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
-      const unit = hitBytes ? "chars" : "lines"
-      return { content: `${out.join("\n")}\n\n...${removed} ${unit} truncated...`, truncated: true }
-    }
-
-    for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
-      const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
-      if (bytes + size > maxBytes) {
-        hitBytes = true
-        break
-      }
-      out.unshift(lines[i])
-      bytes += size
-    }
-    const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
-    const unit = hitBytes ? "chars" : "lines"
-    return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true }
-  }
-}

+ 17 - 21
packages/opencode/src/tool/bash.ts

@@ -15,8 +15,9 @@ import { Flag } from "@/flag/flag.ts"
 import { Shell } from "@/shell/shell"
 
 import { BashArity } from "@/permission/arity"
+import { Truncate } from "./truncation"
 
-const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
+const MAX_METADATA_LENGTH = 30_000
 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
 
 export const log = Log.create({ service: "bash-tool" })
@@ -55,7 +56,9 @@ export const BashTool = Tool.define("bash", async () => {
   log.info("bash tool using shell", { shell })
 
   return {
-    description: DESCRIPTION.replaceAll("${directory}", Instance.directory),
+    description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
+      .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
+      .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
     parameters: z.object({
       command: z.string().describe("The command to execute"),
       timeout: z.number().describe("Optional timeout in milliseconds").optional(),
@@ -172,15 +175,14 @@ export const BashTool = Tool.define("bash", async () => {
       })
 
       const append = (chunk: Buffer) => {
-        if (output.length <= MAX_OUTPUT_LENGTH) {
-          output += chunk.toString()
-          ctx.metadata({
-            metadata: {
-              output,
-              description: params.description,
-            },
-          })
-        }
+        output += chunk.toString()
+        ctx.metadata({
+          metadata: {
+            // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
+            output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
+            description: params.description,
+          },
+        })
       }
 
       proc.stdout?.on("data", append)
@@ -228,12 +230,7 @@ export const BashTool = Tool.define("bash", async () => {
         })
       })
 
-      let resultMetadata: String[] = ["<bash_metadata>"]
-
-      if (output.length > MAX_OUTPUT_LENGTH) {
-        output = output.slice(0, MAX_OUTPUT_LENGTH)
-        resultMetadata.push(`bash tool truncated output as it exceeded ${MAX_OUTPUT_LENGTH} char limit`)
-      }
+      const resultMetadata: string[] = []
 
       if (timedOut) {
         resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
@@ -243,15 +240,14 @@ export const BashTool = Tool.define("bash", async () => {
         resultMetadata.push("User aborted the command")
       }
 
-      if (resultMetadata.length > 1) {
-        resultMetadata.push("</bash_metadata>")
-        output += "\n\n" + resultMetadata.join("\n")
+      if (resultMetadata.length > 0) {
+        output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
       }
 
       return {
         title: params.description,
         metadata: {
-          output,
+          output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
           exit: proc.exitCode,
           description: params.description,
         },

+ 2 - 3
packages/opencode/src/tool/bash.txt

@@ -22,10 +22,9 @@ Before executing the command, please follow these steps:
 
 Usage notes:
   - The command argument is required.
-  - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will time out after 120000ms (2 minutes).
+  - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
   - It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
-  - If the output exceeds 30000 characters, output will be truncated before being returned to you.
-  - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter.
+  - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly.
 
   - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
     - File search: Use Glob (NOT find or ls)

+ 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

+ 23 - 5
packages/opencode/src/tool/read.ts

@@ -11,6 +11,7 @@ import { Identifier } from "../id/id"
 
 const DEFAULT_READ_LIMIT = 2000
 const MAX_LINE_LENGTH = 2000
+const MAX_BYTES = 50 * 1024
 
 export const ReadTool = Tool.define("read", {
   description: DESCRIPTION,
@@ -77,6 +78,7 @@ export const ReadTool = Tool.define("read", {
         output: msg,
         metadata: {
           preview: msg,
+          truncated: false,
         },
         attachments: [
           {
@@ -97,9 +99,21 @@ export const ReadTool = Tool.define("read", {
     const limit = params.limit ?? DEFAULT_READ_LIMIT
     const offset = params.offset || 0
     const lines = await file.text().then((text) => text.split("\n"))
-    const raw = lines.slice(offset, offset + limit).map((line) => {
-      return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
-    })
+
+    const raw: string[] = []
+    let bytes = 0
+    let truncatedByBytes = false
+    for (let i = offset; i < Math.min(lines.length, offset + limit); i++) {
+      const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i]
+      const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0)
+      if (bytes + size > MAX_BYTES) {
+        truncatedByBytes = true
+        break
+      }
+      raw.push(line)
+      bytes += size
+    }
+
     const content = raw.map((line, index) => {
       return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
     })
@@ -109,10 +123,13 @@ export const ReadTool = Tool.define("read", {
     output += content.join("\n")
 
     const totalLines = lines.length
-    const lastReadLine = offset + content.length
+    const lastReadLine = offset + raw.length
     const hasMoreLines = totalLines > lastReadLine
+    const truncated = hasMoreLines || truncatedByBytes
 
-    if (hasMoreLines) {
+    if (truncatedByBytes) {
+      output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})`
+    } else if (hasMoreLines) {
       output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
     } else {
       output += `\n\n(End of file - total ${totalLines} lines)`
@@ -128,6 +145,7 @@ export const ReadTool = Tool.define("read", {
       output,
       metadata: {
         preview,
+        truncated,
       },
     }
   },

+ 6 - 4
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"
@@ -23,7 +24,7 @@ import { CodeSearchTool } from "./codesearch"
 import { Flag } from "@/flag/flag"
 import { Log } from "@/util/log"
 import { LspTool } from "./lsp"
-import { Truncate } from "../session/truncation"
+import { Truncate } from "./truncation"
 
 export namespace ToolRegistry {
   const log = Log.create({ service: "tool.registry" })
@@ -60,16 +61,16 @@ export namespace ToolRegistry {
   function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
     return {
       id,
-      init: async () => ({
+      init: async (initCtx) => ({
         parameters: z.object(def.args),
         description: def.description,
         execute: async (args, ctx) => {
           const result = await def.execute(args as any, ctx)
-          const out = Truncate.output(result)
+          const out = await Truncate.output(result, {}, initCtx?.agent)
           return {
             title: "",
             output: out.truncated ? out.content : result,
-            metadata: { truncated: out.truncated },
+            metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
           }
         },
       }),
@@ -92,6 +93,7 @@ export namespace ToolRegistry {
 
     return [
       InvalidTool,
+      ...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
       BashTool,
       ReadTool,
       GlobTool,

+ 9 - 4
packages/opencode/src/tool/tool.ts

@@ -2,7 +2,7 @@ import z from "zod"
 import type { MessageV2 } from "../session/message-v2"
 import type { Agent } from "../agent/agent"
 import type { PermissionNext } from "../permission/next"
-import { Truncate } from "../session/truncation"
+import { Truncate } from "./truncation"
 
 export namespace Tool {
   interface Metadata {
@@ -50,8 +50,8 @@ export namespace Tool {
   ): Info<Parameters, Result> {
     return {
       id,
-      init: async (ctx) => {
-        const toolInfo = init instanceof Function ? await init(ctx) : init
+      init: async (initCtx) => {
+        const toolInfo = init instanceof Function ? await init(initCtx) : init
         const execute = toolInfo.execute
         toolInfo.execute = async (args, ctx) => {
           try {
@@ -66,13 +66,18 @@ export namespace Tool {
             )
           }
           const result = await execute(args, ctx)
-          const truncated = Truncate.output(result.output)
+          // skip truncation for tools that handle it themselves
+          if (result.metadata.truncated !== undefined) {
+            return result
+          }
+          const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
           return {
             ...result,
             output: truncated.content,
             metadata: {
               ...result.metadata,
               truncated: truncated.truncated,
+              ...(truncated.truncated && { outputPath: truncated.outputPath }),
             },
           }
         }

+ 98 - 0
packages/opencode/src/tool/truncation.ts

@@ -0,0 +1,98 @@
+import fs from "fs/promises"
+import path from "path"
+import { Global } from "../global"
+import { Identifier } from "../id/id"
+import { lazy } from "../util/lazy"
+import { PermissionNext } from "../permission/next"
+import type { Agent } from "../agent/agent"
+
+export namespace Truncate {
+  export const MAX_LINES = 2000
+  export const MAX_BYTES = 50 * 1024
+  export const DIR = path.join(Global.Path.data, "tool-output")
+  const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
+
+  export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
+
+  export interface Options {
+    maxLines?: number
+    maxBytes?: number
+    direction?: "head" | "tail"
+  }
+
+  export async function cleanup() {
+    const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
+    const glob = new Bun.Glob("tool_*")
+    const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
+    for (const entry of entries) {
+      if (Identifier.timestamp(entry) >= cutoff) continue
+      await fs.unlink(path.join(DIR, entry)).catch(() => {})
+    }
+  }
+
+  const init = lazy(cleanup)
+
+  function hasTaskTool(agent?: Agent.Info): boolean {
+    if (!agent?.permission) return false
+    const rule = PermissionNext.evaluate("task", "*", agent.permission)
+    return rule.action !== "deny"
+  }
+
+  export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
+    const maxLines = options.maxLines ?? MAX_LINES
+    const maxBytes = options.maxBytes ?? MAX_BYTES
+    const direction = options.direction ?? "head"
+    const lines = text.split("\n")
+    const totalBytes = Buffer.byteLength(text, "utf-8")
+
+    if (lines.length <= maxLines && totalBytes <= maxBytes) {
+      return { content: text, truncated: false }
+    }
+
+    const out: string[] = []
+    let i = 0
+    let bytes = 0
+    let hitBytes = false
+
+    if (direction === "head") {
+      for (i = 0; i < lines.length && i < maxLines; i++) {
+        const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
+        if (bytes + size > maxBytes) {
+          hitBytes = true
+          break
+        }
+        out.push(lines[i])
+        bytes += size
+      }
+    } else {
+      for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
+        const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
+        if (bytes + size > maxBytes) {
+          hitBytes = true
+          break
+        }
+        out.unshift(lines[i])
+        bytes += size
+      }
+    }
+
+    const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
+    const unit = hitBytes ? "bytes" : "lines"
+    const preview = out.join("\n")
+
+    await init()
+    const id = Identifier.ascending("tool")
+    const filepath = path.join(DIR, id)
+    await Bun.write(Bun.file(filepath), text)
+
+    const hint = hasTaskTool(agent)
+      ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
+      : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
+    const message =
+      direction === "head"
+        ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
+        : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`
+
+    return { content: message, truncated: true, outputPath: filepath }
+  }
+}

+ 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)
+    },
+  })
+})

+ 0 - 79
packages/opencode/test/session/truncation.test.ts

@@ -1,79 +0,0 @@
-import { describe, test, expect } from "bun:test"
-import { Truncate } from "../../src/session/truncation"
-import path from "path"
-
-const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
-
-describe("Truncate", () => {
-  describe("output", () => {
-    test("truncates large json file by bytes", async () => {
-      const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
-      const result = Truncate.output(content)
-
-      expect(result.truncated).toBe(true)
-      expect(Buffer.byteLength(result.content, "utf-8")).toBeLessThanOrEqual(Truncate.MAX_BYTES + 100)
-      expect(result.content).toContain("truncated...")
-    })
-
-    test("returns content unchanged when under limits", () => {
-      const content = "line1\nline2\nline3"
-      const result = Truncate.output(content)
-
-      expect(result.truncated).toBe(false)
-      expect(result.content).toBe(content)
-    })
-
-    test("truncates by line count", () => {
-      const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
-      const result = Truncate.output(lines, { maxLines: 10 })
-
-      expect(result.truncated).toBe(true)
-      expect(result.content.split("\n").length).toBeLessThanOrEqual(12)
-      expect(result.content).toContain("...90 lines truncated...")
-    })
-
-    test("truncates by byte count", () => {
-      const content = "a".repeat(1000)
-      const result = Truncate.output(content, { maxBytes: 100 })
-
-      expect(result.truncated).toBe(true)
-      expect(result.content).toContain("truncated...")
-    })
-
-    test("truncates from head by default", () => {
-      const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
-      const result = Truncate.output(lines, { maxLines: 3 })
-
-      expect(result.truncated).toBe(true)
-      expect(result.content).toContain("line0")
-      expect(result.content).toContain("line1")
-      expect(result.content).toContain("line2")
-      expect(result.content).not.toContain("line9")
-    })
-
-    test("truncates from tail when direction is tail", () => {
-      const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
-      const result = Truncate.output(lines, { maxLines: 3, direction: "tail" })
-
-      expect(result.truncated).toBe(true)
-      expect(result.content).toContain("line7")
-      expect(result.content).toContain("line8")
-      expect(result.content).toContain("line9")
-      expect(result.content).not.toContain("line0")
-    })
-
-    test("uses default MAX_LINES and MAX_BYTES", () => {
-      expect(Truncate.MAX_LINES).toBe(2000)
-      expect(Truncate.MAX_BYTES).toBe(50 * 1024)
-    })
-
-    test("large single-line file truncates with byte message", async () => {
-      const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
-      const result = Truncate.output(content)
-
-      expect(result.truncated).toBe(true)
-      expect(result.content).toContain("chars truncated...")
-      expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
-    })
-  })
-})

+ 88 - 0
packages/opencode/test/tool/bash.test.ts

@@ -4,6 +4,7 @@ import { BashTool } from "../../src/tool/bash"
 import { Instance } from "../../src/project/instance"
 import { tmpdir } from "../fixture/fixture"
 import type { PermissionNext } from "../../src/permission/next"
+import { Truncate } from "../../src/tool/truncation"
 
 const ctx = {
   sessionID: "test",
@@ -230,3 +231,90 @@ describe("tool.bash permissions", () => {
     })
   })
 })
+
+describe("tool.bash truncation", () => {
+  test("truncates output exceeding line limit", async () => {
+    await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        const bash = await BashTool.init()
+        const lineCount = Truncate.MAX_LINES + 500
+        const result = await bash.execute(
+          {
+            command: `seq 1 ${lineCount}`,
+            description: "Generate lines exceeding limit",
+          },
+          ctx,
+        )
+        expect((result.metadata as any).truncated).toBe(true)
+        expect(result.output).toContain("truncated")
+        expect(result.output).toContain("The tool call succeeded but the output was truncated")
+      },
+    })
+  })
+
+  test("truncates output exceeding byte limit", async () => {
+    await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        const bash = await BashTool.init()
+        const byteCount = Truncate.MAX_BYTES + 10000
+        const result = await bash.execute(
+          {
+            command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`,
+            description: "Generate bytes exceeding limit",
+          },
+          ctx,
+        )
+        expect((result.metadata as any).truncated).toBe(true)
+        expect(result.output).toContain("truncated")
+        expect(result.output).toContain("The tool call succeeded but the output was truncated")
+      },
+    })
+  })
+
+  test("does not truncate small output", async () => {
+    await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        const bash = await BashTool.init()
+        const result = await bash.execute(
+          {
+            command: "echo hello",
+            description: "Echo hello",
+          },
+          ctx,
+        )
+        expect((result.metadata as any).truncated).toBe(false)
+        expect(result.output).toBe("hello\n")
+      },
+    })
+  })
+
+  test("full output is saved to file when truncated", async () => {
+    await Instance.provide({
+      directory: projectRoot,
+      fn: async () => {
+        const bash = await BashTool.init()
+        const lineCount = Truncate.MAX_LINES + 100
+        const result = await bash.execute(
+          {
+            command: `seq 1 ${lineCount}`,
+            description: "Generate lines for file check",
+          },
+          ctx,
+        )
+        expect((result.metadata as any).truncated).toBe(true)
+
+        const filepath = (result.metadata as any).outputPath
+        expect(filepath).toBeTruthy()
+
+        const saved = await Bun.file(filepath).text()
+        const lines = saved.trim().split("\n")
+        expect(lines.length).toBe(lineCount)
+        expect(lines[0]).toBe("1")
+        expect(lines[lineCount - 1]).toBe(String(lineCount))
+      },
+    })
+  })
+})

+ 0 - 0
packages/opencode/test/session/fixtures/models-api.json → packages/opencode/test/tool/fixtures/models-api.json


+ 122 - 0
packages/opencode/test/tool/read.test.ts

@@ -6,6 +6,8 @@ import { tmpdir } from "../fixture/fixture"
 import { PermissionNext } from "../../src/permission/next"
 import { Agent } from "../../src/agent/agent"
 
+const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
+
 const ctx = {
   sessionID: "test",
   messageID: "",
@@ -165,3 +167,123 @@ describe("tool.read env file blocking", () => {
     })
   })
 })
+
+describe("tool.read truncation", () => {
+  test("truncates large file by bytes and sets truncated metadata", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
+        await Bun.write(path.join(dir, "large.json"), content)
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const read = await ReadTool.init()
+        const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx)
+        expect(result.metadata.truncated).toBe(true)
+        expect(result.output).toContain("Output truncated at")
+        expect(result.output).toContain("bytes")
+      },
+    })
+  })
+
+  test("truncates by line count when limit is specified", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+        await Bun.write(path.join(dir, "many-lines.txt"), lines)
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const read = await ReadTool.init()
+        const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx)
+        expect(result.metadata.truncated).toBe(true)
+        expect(result.output).toContain("File has more lines")
+        expect(result.output).toContain("line0")
+        expect(result.output).toContain("line9")
+        expect(result.output).not.toContain("line10")
+      },
+    })
+  })
+
+  test("does not truncate small file", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "small.txt"), "hello world")
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const read = await ReadTool.init()
+        const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx)
+        expect(result.metadata.truncated).toBe(false)
+        expect(result.output).toContain("End of file")
+      },
+    })
+  })
+
+  test("respects offset parameter", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n")
+        await Bun.write(path.join(dir, "offset.txt"), lines)
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const read = await ReadTool.init()
+        const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
+        expect(result.output).toContain("line10")
+        expect(result.output).toContain("line14")
+        expect(result.output).not.toContain("line0")
+        expect(result.output).not.toContain("line15")
+      },
+    })
+  })
+
+  test("truncates long lines", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const longLine = "x".repeat(3000)
+        await Bun.write(path.join(dir, "long-line.txt"), longLine)
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const read = await ReadTool.init()
+        const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx)
+        expect(result.output).toContain("...")
+        expect(result.output.length).toBeLessThan(3000)
+      },
+    })
+  })
+
+  test("image files set truncated to false", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        // 1x1 red PNG
+        const png = Buffer.from(
+          "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
+          "base64",
+        )
+        await Bun.write(path.join(dir, "image.png"), png)
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const read = await ReadTool.init()
+        const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx)
+        expect(result.metadata.truncated).toBe(false)
+        expect(result.attachments).toBeDefined()
+        expect(result.attachments?.length).toBe(1)
+      },
+    })
+  })
+})

+ 159 - 0
packages/opencode/test/tool/truncation.test.ts

@@ -0,0 +1,159 @@
+import { describe, test, expect, afterAll } from "bun:test"
+import { Truncate } from "../../src/tool/truncation"
+import { Identifier } from "../../src/id/id"
+import fs from "fs/promises"
+import path from "path"
+
+const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
+
+describe("Truncate", () => {
+  describe("output", () => {
+    test("truncates large json file by bytes", async () => {
+      const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
+      const result = await Truncate.output(content)
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("truncated...")
+      if (result.truncated) expect(result.outputPath).toBeDefined()
+    })
+
+    test("returns content unchanged when under limits", async () => {
+      const content = "line1\nline2\nline3"
+      const result = await Truncate.output(content)
+
+      expect(result.truncated).toBe(false)
+      expect(result.content).toBe(content)
+    })
+
+    test("truncates by line count", async () => {
+      const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+      const result = await Truncate.output(lines, { maxLines: 10 })
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("...90 lines truncated...")
+    })
+
+    test("truncates by byte count", async () => {
+      const content = "a".repeat(1000)
+      const result = await Truncate.output(content, { maxBytes: 100 })
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("truncated...")
+    })
+
+    test("truncates from head by default", async () => {
+      const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
+      const result = await Truncate.output(lines, { maxLines: 3 })
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("line0")
+      expect(result.content).toContain("line1")
+      expect(result.content).toContain("line2")
+      expect(result.content).not.toContain("line9")
+    })
+
+    test("truncates from tail when direction is tail", async () => {
+      const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
+      const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("line7")
+      expect(result.content).toContain("line8")
+      expect(result.content).toContain("line9")
+      expect(result.content).not.toContain("line0")
+    })
+
+    test("uses default MAX_LINES and MAX_BYTES", () => {
+      expect(Truncate.MAX_LINES).toBe(2000)
+      expect(Truncate.MAX_BYTES).toBe(50 * 1024)
+    })
+
+    test("large single-line file truncates with byte message", async () => {
+      const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
+      const result = await Truncate.output(content)
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("bytes truncated...")
+      expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
+    })
+
+    test("writes full output to file when truncated", async () => {
+      const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+      const result = await Truncate.output(lines, { maxLines: 10 })
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("The tool call succeeded but the output was truncated")
+      expect(result.content).toContain("Grep")
+      if (!result.truncated) throw new Error("expected truncated")
+      expect(result.outputPath).toBeDefined()
+      expect(result.outputPath).toContain("tool_")
+
+      const written = await Bun.file(result.outputPath).text()
+      expect(written).toBe(lines)
+    })
+
+    test("suggests Task tool when agent has task permission", async () => {
+      const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+      const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
+      const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("Grep")
+      expect(result.content).toContain("Task tool")
+    })
+
+    test("omits Task tool hint when agent lacks task permission", async () => {
+      const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
+      const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
+      const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
+
+      expect(result.truncated).toBe(true)
+      expect(result.content).toContain("Grep")
+      expect(result.content).not.toContain("Task tool")
+    })
+
+    test("does not write file when not truncated", async () => {
+      const content = "short content"
+      const result = await Truncate.output(content)
+
+      expect(result.truncated).toBe(false)
+      if (result.truncated) throw new Error("expected not truncated")
+      expect("outputPath" in result).toBe(false)
+    })
+  })
+
+  describe("cleanup", () => {
+    const DAY_MS = 24 * 60 * 60 * 1000
+    let oldFile: string
+    let recentFile: string
+
+    afterAll(async () => {
+      await fs.unlink(oldFile).catch(() => {})
+      await fs.unlink(recentFile).catch(() => {})
+    })
+
+    test("deletes files older than 7 days and preserves recent files", async () => {
+      await fs.mkdir(Truncate.DIR, { recursive: true })
+
+      // Create an old file (10 days ago)
+      const oldTimestamp = Date.now() - 10 * DAY_MS
+      const oldId = Identifier.create("tool", false, oldTimestamp)
+      oldFile = path.join(Truncate.DIR, oldId)
+      await Bun.write(Bun.file(oldFile), "old content")
+
+      // Create a recent file (3 days ago)
+      const recentTimestamp = Date.now() - 3 * DAY_MS
+      const recentId = Identifier.create("tool", false, recentTimestamp)
+      recentFile = path.join(Truncate.DIR, recentId)
+      await Bun.write(Bun.file(recentFile), "recent content")
+
+      await Truncate.cleanup()
+
+      // Old file should be deleted
+      expect(await Bun.file(oldFile).exists()).toBe(false)
+
+      // Recent file should still exist
+      expect(await Bun.file(recentFile).exists()).toBe(true)
+    })
+  })
+})

+ 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 })

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

@@ -517,6 +517,67 @@ export type EventSessionIdle = {
   }
 }
 
+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 EventSessionCompacted = {
   type: "session.compacted"
   properties: {
@@ -788,6 +849,9 @@ export type Event =
   | EventPermissionReplied
   | EventSessionStatus
   | EventSessionIdle
+  | EventQuestionAsked
+  | EventQuestionReplied
+  | EventQuestionRejected
   | EventSessionCompacted
   | EventFileEdited
   | EventTodoUpdated
@@ -1233,6 +1297,7 @@ export type PermissionConfig =
       external_directory?: PermissionRuleConfig
       todowrite?: PermissionActionConfig
       todoread?: PermissionActionConfig
+      question?: PermissionActionConfig
       webfetch?: PermissionActionConfig
       websearch?: PermissionActionConfig
       codesearch?: PermissionActionConfig
@@ -3545,6 +3610,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

+ 323 - 0
packages/sdk/openapi.json

@@ -3156,6 +3156,185 @@
         ]
       }
     },
+    "/question": {
+      "get": {
+        "operationId": "question.list",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "summary": "List pending questions",
+        "description": "Get all pending question requests across all sessions.",
+        "responses": {
+          "200": {
+            "description": "List of pending questions",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/components/schemas/QuestionRequest"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n  ...\n})"
+          }
+        ]
+      }
+    },
+    "/question/{requestID}/reply": {
+      "post": {
+        "operationId": "question.reply",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "in": "path",
+            "name": "requestID",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          }
+        ],
+        "summary": "Reply to question request",
+        "description": "Provide answers to a question request from the AI assistant.",
+        "responses": {
+          "200": {
+            "description": "Question answered successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Bad request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BadRequestError"
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Not found",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NotFoundError"
+                }
+              }
+            }
+          }
+        },
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "answers": {
+                    "type": "array",
+                    "items": {
+                      "type": "string"
+                    }
+                  }
+                },
+                "required": ["answers"]
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n  ...\n})"
+          }
+        ]
+      }
+    },
+    "/question/{requestID}/reject": {
+      "post": {
+        "operationId": "question.reject",
+        "parameters": [
+          {
+            "in": "query",
+            "name": "directory",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "in": "path",
+            "name": "requestID",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          }
+        ],
+        "summary": "Reject question request",
+        "description": "Reject a question request from the AI assistant.",
+        "responses": {
+          "200": {
+            "description": "Question rejected successfully",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Bad request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BadRequestError"
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Not found",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/NotFoundError"
+                }
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/command": {
       "get": {
         "operationId": "command.list",
@@ -6906,6 +7085,138 @@
         },
         "required": ["type", "properties"]
       },
+      "QuestionOption": {
+        "type": "object",
+        "properties": {
+          "label": {
+            "description": "Display text (1-5 words, concise)",
+            "type": "string"
+          },
+          "description": {
+            "description": "Explanation of choice",
+            "type": "string"
+          }
+        },
+        "required": ["label", "description"]
+      },
+      "QuestionInfo": {
+        "type": "object",
+        "properties": {
+          "question": {
+            "description": "Complete question",
+            "type": "string"
+          },
+          "header": {
+            "description": "Very short label (max 12 chars)",
+            "type": "string",
+            "maxLength": 12
+          },
+          "options": {
+            "description": "Available choices",
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/QuestionOption"
+            }
+          }
+        },
+        "required": ["question", "header", "options"]
+      },
+      "QuestionRequest": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string",
+            "pattern": "^que.*"
+          },
+          "sessionID": {
+            "type": "string",
+            "pattern": "^ses.*"
+          },
+          "questions": {
+            "description": "Questions to ask",
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/QuestionInfo"
+            }
+          },
+          "tool": {
+            "type": "object",
+            "properties": {
+              "messageID": {
+                "type": "string"
+              },
+              "callID": {
+                "type": "string"
+              }
+            },
+            "required": ["messageID", "callID"]
+          }
+        },
+        "required": ["id", "sessionID", "questions"]
+      },
+      "Event.question.asked": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "type": "string",
+            "const": "question.asked"
+          },
+          "properties": {
+            "$ref": "#/components/schemas/QuestionRequest"
+          }
+        },
+        "required": ["type", "properties"]
+      },
+      "Event.question.replied": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "type": "string",
+            "const": "question.replied"
+          },
+          "properties": {
+            "type": "object",
+            "properties": {
+              "sessionID": {
+                "type": "string"
+              },
+              "requestID": {
+                "type": "string"
+              },
+              "answers": {
+                "type": "array",
+                "items": {
+                  "type": "string"
+                }
+              }
+            },
+            "required": ["sessionID", "requestID", "answers"]
+          }
+        },
+        "required": ["type", "properties"]
+      },
+      "Event.question.rejected": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "type": "string",
+            "const": "question.rejected"
+          },
+          "properties": {
+            "type": "object",
+            "properties": {
+              "sessionID": {
+                "type": "string"
+              },
+              "requestID": {
+                "type": "string"
+              }
+            },
+            "required": ["sessionID", "requestID"]
+          }
+        },
+        "required": ["type", "properties"]
+      },
       "Event.session.compacted": {
         "type": "object",
         "properties": {
@@ -7630,6 +7941,15 @@
           {
             "$ref": "#/components/schemas/Event.session.idle"
           },
+          {
+            "$ref": "#/components/schemas/Event.question.asked"
+          },
+          {
+            "$ref": "#/components/schemas/Event.question.replied"
+          },
+          {
+            "$ref": "#/components/schemas/Event.question.rejected"
+          },
           {
             "$ref": "#/components/schemas/Event.session.compacted"
           },
@@ -8289,6 +8609,9 @@
               "todoread": {
                 "$ref": "#/components/schemas/PermissionActionConfig"
               },
+              "question": {
+                "$ref": "#/components/schemas/PermissionActionConfig"
+              },
               "webfetch": {
                 "$ref": "#/components/schemas/PermissionActionConfig"
               },