- Last modified
+ {t().session.lastModified}
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
diff --git a/packages/app/src/i18n/context.tsx b/packages/app/src/i18n/context.tsx
new file mode 100644
index 00000000000..37cde79acbf
--- /dev/null
+++ b/packages/app/src/i18n/context.tsx
@@ -0,0 +1,70 @@
+import { createContext, useContext, Accessor, Setter, createSignal, createRoot } from "solid-js"
+import { locales, type Locale, type Translation, defaultLocale, supportedLocales } from "./locales"
+
+type I18nContextValue = {
+ locale: Accessor
+ setLocale: Setter
+ t: Accessor
+ locales: typeof supportedLocales
+}
+
+const I18nContext = createContext()
+
+export function I18nProvider(props: { children: any }) {
+ const [locale, setLocale] = createSignal(defaultLocale)
+
+ // Initialize from localStorage or browser language
+ const initializeLocale = () => {
+ const stored = localStorage.getItem("opencode-locale") as Locale | null
+ if (stored && supportedLocales.includes(stored)) {
+ setLocale(stored)
+ return
+ }
+
+ // Detect browser language
+ const browserLang = navigator.language
+ if (browserLang.startsWith("zh")) {
+ setLocale("zh-CN")
+ } else if (browserLang.startsWith("ja")) {
+ setLocale("ja")
+ } else if (browserLang.startsWith("fr")) {
+ setLocale("fr")
+ } else if (browserLang.startsWith("es")) {
+ setLocale("es")
+ }
+ }
+ initializeLocale()
+
+ // Persist locale changes
+ const persistLocale: Setter = (...args) => {
+ const result = setLocale(...args)
+ const newLocale = locale()
+ localStorage.setItem("opencode-locale", newLocale)
+ return result
+ }
+
+ const t = () => locales[locale()]
+
+ const value: I18nContextValue = {
+ locale,
+ setLocale: persistLocale,
+ t,
+ locales: supportedLocales,
+ }
+
+ return {props.children}
+}
+
+export function useI18n() {
+ const context = useContext(I18nContext)
+ if (!context) {
+ throw new Error("useI18n must be used within I18nProvider")
+ }
+ return context
+}
+
+// Helper to get nested translation values
+export function useT() {
+ const { t } = useI18n()
+ return t
+}
diff --git a/packages/app/src/i18n/index.ts b/packages/app/src/i18n/index.ts
new file mode 100644
index 00000000000..580f87100eb
--- /dev/null
+++ b/packages/app/src/i18n/index.ts
@@ -0,0 +1,2 @@
+export { I18nProvider, useI18n, useT } from "./context"
+export { locales, defaultLocale, supportedLocales, type Locale, type Translation } from "./locales"
diff --git a/packages/app/src/i18n/locales/en.ts b/packages/app/src/i18n/locales/en.ts
new file mode 100644
index 00000000000..8557f8c3903
--- /dev/null
+++ b/packages/app/src/i18n/locales/en.ts
@@ -0,0 +1,108 @@
+export default {
+ common: {
+ loading: "Loading...",
+ save: "Save",
+ cancel: "Cancel",
+ confirm: "Confirm",
+ delete: "Delete",
+ edit: "Edit",
+ search: "Search",
+ back: "Back",
+ next: "Next",
+ close: "Close",
+ },
+ home: {
+ title: "OpenCode AI",
+ subtitle: "AI-powered development tool",
+ start: "Start Coding",
+ recentProjects: "Recent projects",
+ noRecentProjects: "No recent projects",
+ getStarted: "Get started by opening a local project",
+ openProject: "Open project",
+ },
+ session: {
+ new: "New Session",
+ newSession: "New session",
+ mainBranch: "Main branch",
+ mainBranchWithName: "Main branch ({branch})",
+ createWorktree: "Create new worktree",
+ lastModified: "Last modified",
+ backToParent: "Back to parent session",
+ share: "Share session",
+ terminate: "Terminate",
+ archive: "Archive session",
+ filesChanged: "{count} file{plural} changed",
+ },
+ dialog: {
+ selectProvider: {
+ title: "Select Provider",
+ description: "Choose an AI provider to use",
+ },
+ selectModel: {
+ title: "Select Model",
+ description: "Choose a model to use",
+ unpaid: {
+ title: "Model Payment Required",
+ description: "This model requires payment to use",
+ },
+ },
+ selectServer: {
+ title: "Select Server",
+ description: "Choose a server to connect to",
+ },
+ selectDirectory: {
+ title: "Select Directory",
+ description: "Choose a directory to work with",
+ openProject: "Open project",
+ },
+ selectFile: {
+ title: "Select File",
+ description: "Choose a file to work with",
+ },
+ selectMcp: {
+ title: "Select MCP",
+ description: "Choose a Model Context Protocol server",
+ },
+ connectProvider: {
+ title: "Connect Provider",
+ description: "Configure your AI provider credentials",
+ },
+ editProject: {
+ title: "Edit Project",
+ description: "Edit project settings",
+ editProject: "Edit project",
+ closeProject: "Close project",
+ },
+ manageModels: {
+ title: "Manage Models",
+ description: "Manage available models",
+ },
+ },
+ terminal: {
+ tabs: {
+ session: "Session",
+ context: "Context",
+ lsp: "LSP",
+ mcp: "MCP",
+ },
+ },
+ fileTree: {
+ empty: "No files found",
+ refresh: "Refresh",
+ },
+ sidebar: {
+ toggle: "Toggle sidebar",
+ newSession: "New session",
+ loadMore: "Load more",
+ gettingStarted: "Getting started",
+ gettingStartedDesc1: "OpenCode includes free models so you can start immediately.",
+ gettingStartedDesc2: "Connect any provider to use models, inc. Claude, GPT, Gemini etc.",
+ connectProvider: "Connect provider",
+ shareFeedback: "Share feedback",
+ changeLanguage: "Change language",
+ },
+ layout: {
+ editProject: "Edit project",
+ closeProject: "Close project",
+ },
+} as const
diff --git a/packages/app/src/i18n/locales/es.ts b/packages/app/src/i18n/locales/es.ts
new file mode 100644
index 00000000000..207da6fd659
--- /dev/null
+++ b/packages/app/src/i18n/locales/es.ts
@@ -0,0 +1,108 @@
+export default {
+ common: {
+ loading: "Cargando...",
+ save: "Guardar",
+ cancel: "Cancelar",
+ confirm: "Confirmar",
+ delete: "Eliminar",
+ edit: "Editar",
+ search: "Buscar",
+ back: "Atrás",
+ next: "Siguiente",
+ close: "Cerrar",
+ },
+ home: {
+ title: "OpenCode AI",
+ subtitle: "Herramienta de desarrollo impulsada por IA",
+ start: "Comenzar a codificar",
+ recentProjects: "Proyectos recientes",
+ noRecentProjects: "Sin proyectos recientes",
+ getStarted: "Abre un proyecto local para comenzar",
+ openProject: "Abrir proyecto",
+ },
+ session: {
+ new: "Nueva sesión",
+ newSession: "Nueva sesión",
+ mainBranch: "Rama principal",
+ mainBranchWithName: "Rama principal ({branch})",
+ createWorktree: "Crear nuevo árbol de trabajo",
+ lastModified: "Última modificación",
+ backToParent: "Volver a la sesión principal",
+ share: "Compartir sesión",
+ terminate: "Terminar",
+ archive: "Archivar sesión",
+ filesChanged: "{count} archivo{plural} modificado{plural}",
+ },
+ dialog: {
+ selectProvider: {
+ title: "Seleccionar proveedor",
+ description: "Elige un proveedor de IA para usar",
+ },
+ selectModel: {
+ title: "Seleccionar modelo",
+ description: "Elige un modelo para usar",
+ unpaid: {
+ title: "Pago del modelo requerido",
+ description: "Este modelo requiere pago para usarse",
+ },
+ },
+ selectServer: {
+ title: "Seleccionar servidor",
+ description: "Elige un servidor al cual conectarse",
+ },
+ selectDirectory: {
+ title: "Seleccionar directorio",
+ description: "Elige un directorio con el cual trabajar",
+ openProject: "Abrir proyecto",
+ },
+ selectFile: {
+ title: "Seleccionar archivo",
+ description: "Elige un archivo con el cual trabajar",
+ },
+ selectMcp: {
+ title: "Seleccionar MCP",
+ description: "Elige un servidor Model Context Protocol",
+ },
+ connectProvider: {
+ title: "Conectar proveedor",
+ description: "Configura tus credenciales de proveedor de IA",
+ },
+ editProject: {
+ title: "Editar proyecto",
+ description: "Edita la configuración del proyecto",
+ editProject: "Editar proyecto",
+ closeProject: "Cerrar proyecto",
+ },
+ manageModels: {
+ title: "Administrar modelos",
+ description: "Administra los modelos disponibles",
+ },
+ },
+ terminal: {
+ tabs: {
+ session: "Sesión",
+ context: "Contexto",
+ lsp: "LSP",
+ mcp: "MCP",
+ },
+ },
+ fileTree: {
+ empty: "No se encontraron archivos",
+ refresh: "Actualizar",
+ },
+ sidebar: {
+ toggle: "Alternar barra lateral",
+ newSession: "Nueva sesión",
+ loadMore: "Cargar más",
+ gettingStarted: "Comenzando",
+ gettingStartedDesc1: "OpenCode incluye modelos gratuitos para que puedas comenzar de inmediato.",
+ gettingStartedDesc2: "Conecta cualquier proveedor para usar modelos, incl. Claude, GPT, Gemini, etc.",
+ connectProvider: "Conectar proveedor",
+ shareFeedback: "Comentar comentarios",
+ changeLanguage: "Cambiar idioma",
+ },
+ layout: {
+ editProject: "Editar proyecto",
+ closeProject: "Cerrar proyecto",
+ },
+} as const
diff --git a/packages/app/src/i18n/locales/fr.ts b/packages/app/src/i18n/locales/fr.ts
new file mode 100644
index 00000000000..eb52d3e531a
--- /dev/null
+++ b/packages/app/src/i18n/locales/fr.ts
@@ -0,0 +1,108 @@
+export default {
+ common: {
+ loading: "Chargement...",
+ save: "Enregistrer",
+ cancel: "Annuler",
+ confirm: "Confirmer",
+ delete: "Supprimer",
+ edit: "Modifier",
+ search: "Rechercher",
+ back: "Retour",
+ next: "Suivant",
+ close: "Fermer",
+ },
+ home: {
+ title: "OpenCode AI",
+ subtitle: "Outil de développement alimenté par l'IA",
+ start: "Commencer à coder",
+ recentProjects: "Projets récents",
+ noRecentProjects: "Aucun projet récent",
+ getStarted: "Ouvrez un projet local pour commencer",
+ openProject: "Ouvrir un projet",
+ },
+ session: {
+ new: "Nouvelle session",
+ newSession: "Nouvelle session",
+ mainBranch: "Branche principale",
+ mainBranchWithName: "Branche principale ({branch})",
+ createWorktree: "Créer un nouvel arbre de travail",
+ lastModified: "Dernière modification",
+ backToParent: "Retour à la session parente",
+ share: "Partager la session",
+ terminate: "Terminer",
+ archive: "Archiver la session",
+ filesChanged: "{count} fichier{plural} modifié{plural}",
+ },
+ dialog: {
+ selectProvider: {
+ title: "Sélectionner un fournisseur",
+ description: "Choisir un fournisseur d'IA à utiliser",
+ },
+ selectModel: {
+ title: "Sélectionner un modèle",
+ description: "Choisir un modèle à utiliser",
+ unpaid: {
+ title: "Paiement du modèle requis",
+ description: "Ce modèle nécessite un paiement pour être utilisé",
+ },
+ },
+ selectServer: {
+ title: "Sélectionner un serveur",
+ description: "Choisir un serveur auquel se connecter",
+ },
+ selectDirectory: {
+ title: "Sélectionner un répertoire",
+ description: "Choisir un répertoire avec lequel travailler",
+ openProject: "Ouvrir un projet",
+ },
+ selectFile: {
+ title: "Sélectionner un fichier",
+ description: "Choisir un fichier avec lequel travailler",
+ },
+ selectMcp: {
+ title: "Sélectionner un MCP",
+ description: "Choisir un serveur Model Context Protocol",
+ },
+ connectProvider: {
+ title: "Connecter un fournisseur",
+ description: "Configurer vos identifiants de fournisseur d'IA",
+ },
+ editProject: {
+ title: "Modifier le projet",
+ description: "Modifier les paramètres du projet",
+ editProject: "Modifier le projet",
+ closeProject: "Fermer le projet",
+ },
+ manageModels: {
+ title: "Gérer les modèles",
+ description: "Gérer les modèles disponibles",
+ },
+ },
+ terminal: {
+ tabs: {
+ session: "Session",
+ context: "Contexte",
+ lsp: "LSP",
+ mcp: "MCP",
+ },
+ },
+ fileTree: {
+ empty: "Aucun fichier trouvé",
+ refresh: "Actualiser",
+ },
+ sidebar: {
+ toggle: "Afficher/Masquer la barre latérale",
+ newSession: "Nouvelle session",
+ loadMore: "Charger plus",
+ gettingStarted: "Premiers pas",
+ gettingStartedDesc1: "OpenCode inclut des modèles gratuits pour que vous puissiez commencer immédiatement.",
+ gettingStartedDesc2: "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini, etc.",
+ connectProvider: "Connecter un fournisseur",
+ shareFeedback: "Partager des commentaires",
+ changeLanguage: "Changer de langue",
+ },
+ layout: {
+ editProject: "Modifier le projet",
+ closeProject: "Fermer le projet",
+ },
+} as const
diff --git a/packages/app/src/i18n/locales/index.ts b/packages/app/src/i18n/locales/index.ts
new file mode 100644
index 00000000000..ddf15d5609c
--- /dev/null
+++ b/packages/app/src/i18n/locales/index.ts
@@ -0,0 +1,19 @@
+import en from "./en"
+import zhCN from "./zh-CN"
+import ja from "./ja"
+import fr from "./fr"
+import es from "./es"
+
+export const locales = {
+ en,
+ "zh-CN": zhCN,
+ ja,
+ fr,
+ es,
+} as const
+
+export type Locale = keyof typeof locales
+export const defaultLocale: Locale = "en"
+export const supportedLocales: Locale[] = Object.keys(locales) as Locale[]
+
+export type Translation = typeof locales[Locale]
diff --git a/packages/app/src/i18n/locales/ja.ts b/packages/app/src/i18n/locales/ja.ts
new file mode 100644
index 00000000000..cf59d9878ae
--- /dev/null
+++ b/packages/app/src/i18n/locales/ja.ts
@@ -0,0 +1,108 @@
+export default {
+ common: {
+ loading: "読み込み中...",
+ save: "保存",
+ cancel: "キャンセル",
+ confirm: "確認",
+ delete: "削除",
+ edit: "編集",
+ search: "検索",
+ back: "戻る",
+ next: "次へ",
+ close: "閉じる",
+ },
+ home: {
+ title: "OpenCode AI",
+ subtitle: "AI による開発ツール",
+ start: "コーディングを開始",
+ recentProjects: "最近のプロジェクト",
+ noRecentProjects: "最近のプロジェクトがありません",
+ getStarted: "ローカルプロジェクトを開いて始めましょう",
+ openProject: "プロジェクトを開く",
+ },
+ session: {
+ new: "新しいセッション",
+ newSession: "新しいセッション",
+ mainBranch: "メインブランチ",
+ mainBranchWithName: "メインブランチ ({branch})",
+ createWorktree: "新しいワークツリーを作成",
+ lastModified: "最終更新",
+ backToParent: "親セッションに戻る",
+ share: "セッションを共有",
+ terminate: "終了",
+ archive: "セッションをアーカイブ",
+ filesChanged: "{count} ファイルが変更されました",
+ },
+ dialog: {
+ selectProvider: {
+ title: "プロバイダーを選択",
+ description: "使用する AI プロバイダーを選択してください",
+ },
+ selectModel: {
+ title: "モデルを選択",
+ description: "使用するモデルを選択してください",
+ unpaid: {
+ title: "モデルの支払いが必要",
+ description: "このモデルを使用するには支払いが必要です",
+ },
+ },
+ selectServer: {
+ title: "サーバーを選択",
+ description: "接続するサーバーを選択してください",
+ },
+ selectDirectory: {
+ title: "ディレクトリを選択",
+ description: "使用するディレクトリを選択してください",
+ openProject: "プロジェクトを開く",
+ },
+ selectFile: {
+ title: "ファイルを選択",
+ description: "使用するファイルを選択してください",
+ },
+ selectMcp: {
+ title: "MCP を選択",
+ description: "モデル コンテキスト プロトコル サーバーを選択してください",
+ },
+ connectProvider: {
+ title: "プロバイダーに接続",
+ description: "AI プロバイダーの認証情報を設定してください",
+ },
+ editProject: {
+ title: "プロジェクトを編集",
+ description: "プロジェクト設定を編集してください",
+ editProject: "プロジェクトを編集",
+ closeProject: "プロジェクトを閉じる",
+ },
+ manageModels: {
+ title: "モデルを管理",
+ description: "利用可能なモデルを管理してください",
+ },
+ },
+ terminal: {
+ tabs: {
+ session: "セッション",
+ context: "コンテキスト",
+ lsp: "LSP",
+ mcp: "MCP",
+ },
+ },
+ fileTree: {
+ empty: "ファイルが見つかりません",
+ refresh: "更新",
+ },
+ sidebar: {
+ toggle: "サイドバーを切り替え",
+ newSession: "新しいセッション",
+ loadMore: "もっと見る",
+ gettingStarted: "はじめに",
+ gettingStartedDesc1: "OpenCode には無料モデルが含まれており、すぐに始められます。",
+ gettingStartedDesc2: "プロバイダーを接続してモデルを使用できます(Claude、GPT、Gemini など)。",
+ connectProvider: "プロバイダーに接続",
+ shareFeedback: "フィードバックを共有",
+ changeLanguage: "言語を変更",
+ },
+ layout: {
+ editProject: "プロジェクトを編集",
+ closeProject: "プロジェクトを閉じる",
+ },
+} as const
diff --git a/packages/app/src/i18n/locales/zh-CN.ts b/packages/app/src/i18n/locales/zh-CN.ts
new file mode 100644
index 00000000000..ec32202d07f
--- /dev/null
+++ b/packages/app/src/i18n/locales/zh-CN.ts
@@ -0,0 +1,108 @@
+export default {
+ common: {
+ loading: "加载中...",
+ save: "保存",
+ cancel: "取消",
+ confirm: "确认",
+ delete: "删除",
+ edit: "编辑",
+ search: "搜索",
+ back: "返回",
+ next: "下一步",
+ close: "关闭",
+ },
+ home: {
+ title: "OpenCode AI",
+ subtitle: "AI 驱动的开发工具",
+ start: "开始编码",
+ recentProjects: "最近的项目",
+ noRecentProjects: "没有最近的项目",
+ getStarted: "打开本地项目开始使用",
+ openProject: "打开项目",
+ },
+ session: {
+ new: "新会话",
+ newSession: "新会话",
+ mainBranch: "主分支",
+ mainBranchWithName: "主分支 ({branch})",
+ createWorktree: "创建新工作树",
+ lastModified: "最后修改",
+ backToParent: "返回父会话",
+ share: "分享会话",
+ terminate: "终止",
+ archive: "归档会话",
+ filesChanged: "{count} 个文件已更改",
+ },
+ dialog: {
+ selectProvider: {
+ title: "选择提供商",
+ description: "选择要使用的 AI 提供商",
+ },
+ selectModel: {
+ title: "选择模型",
+ description: "选择要使用的模型",
+ unpaid: {
+ title: "模型需要付费",
+ description: "此模型需要付费才能使用",
+ },
+ },
+ selectServer: {
+ title: "选择服务器",
+ description: "选择要连接的服务器",
+ },
+ selectDirectory: {
+ title: "选择目录",
+ description: "选择要使用的目录",
+ openProject: "打开项目",
+ },
+ selectFile: {
+ title: "选择文件",
+ description: "选择要使用的文件",
+ },
+ selectMcp: {
+ title: "选择 MCP",
+ description: "选择模型上下文协议服务器",
+ },
+ connectProvider: {
+ title: "连接提供商",
+ description: "配置您的 AI 提供商凭据",
+ },
+ editProject: {
+ title: "编辑项目",
+ description: "编辑项目设置",
+ editProject: "编辑项目",
+ closeProject: "关闭项目",
+ },
+ manageModels: {
+ title: "管理模型",
+ description: "管理可用模型",
+ },
+ },
+ terminal: {
+ tabs: {
+ session: "会话",
+ context: "上下文",
+ lsp: "LSP",
+ mcp: "MCP",
+ },
+ },
+ fileTree: {
+ empty: "未找到文件",
+ refresh: "刷新",
+ },
+ sidebar: {
+ toggle: "切换侧边栏",
+ newSession: "新会话",
+ loadMore: "加载更多",
+ gettingStarted: "入门指南",
+ gettingStartedDesc1: "OpenCode 包含免费模型,您可以立即开始使用。",
+ gettingStartedDesc2: "连接任何提供商以使用模型,包括 Claude、GPT、Gemini 等。",
+ connectProvider: "连接提供商",
+ shareFeedback: "分享反馈",
+ changeLanguage: "切换语言",
+ },
+ layout: {
+ editProject: "编辑项目",
+ closeProject: "关闭项目",
+ },
+} as const
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index 275113566ad..8e3358d0238 100644
--- a/packages/app/src/pages/home.tsx
+++ b/packages/app/src/pages/home.tsx
@@ -12,6 +12,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
+import { useI18n } from "@/i18n"
export default function Home() {
const sync = useGlobalSync()
@@ -20,6 +21,7 @@ export default function Home() {
const dialog = useDialog()
const navigate = useNavigate()
const server = useServer()
+ const { t } = useI18n()
const homedir = createMemo(() => sync.data.path.home)
function openProject(directory: string) {
@@ -40,7 +42,7 @@ export default function Home() {
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
- title: "Open project",
+ title: t().home.openProject,
multiple: true,
})
resolve(result)
@@ -75,9 +77,9 @@ export default function Home() {
0}>
-
Recent projects
+
{t().home.recentProjects}
@@ -107,12 +109,12 @@ export default function Home() {
-
No recent projects
-
Get started by opening a local project
+
{t().home.noRecentProjects}
+
{t().home.getStarted}
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 85d61d57beb..c7dd5517d02 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -53,10 +53,12 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DialogSelectServer } from "@/components/dialog-select-server"
+import { DialogSelectLanguage } from "@/components/dialog-select-language"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useServer } from "@/context/server"
+import { useI18n } from "@/i18n"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -91,6 +93,7 @@ export default function Layout(props: ParentProps) {
const dialog = useDialog()
const command = useCommand()
const theme = useTheme()
+ const { t } = useI18n()
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeLabel: Record = {
@@ -176,7 +179,7 @@ export default function Layout(props: ParentProps) {
const session = store.session.find((s) => s.id === perm.sessionID)
const sessionKey = `${directory}:${perm.sessionID}`
- const sessionTitle = session?.title ?? "New session"
+ const sessionTitle = session?.title ?? t().session.newSession
const projectName = getFilename(directory)
const description = `${sessionTitle} in ${projectName} needs permission`
const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
@@ -359,21 +362,21 @@ export default function Layout(props: ParentProps) {
const commands: CommandOption[] = [
{
id: "sidebar.toggle",
- title: "Toggle sidebar",
+ title: t().sidebar.toggle,
category: "View",
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
{
id: "project.open",
- title: "Open project",
+ title: t().home.openProject,
category: "Project",
keybind: "mod+o",
onSelect: () => chooseProject(),
},
{
id: "provider.connect",
- title: "Connect provider",
+ title: t().sidebar.connectProvider,
category: "Provider",
onSelect: () => connectProvider(),
},
@@ -397,9 +400,19 @@ export default function Layout(props: ParentProps) {
keybind: "alt+arrowdown",
onSelect: () => navigateSessionByOffset(1),
},
+ {
+ id: "session.new",
+ title: t().session.newSession,
+ category: "Session",
+ keybind: "mod+n",
+ onSelect: () => {
+ const current = params.dir ? base64Decode(params.dir) : undefined
+ if (current) navigate(`/${params.dir}/session`)
+ },
+ },
{
id: "session.archive",
- title: "Archive session",
+ title: t().session.archive,
category: "Session",
keybind: "mod+shift+backspace",
disabled: !params.dir || !params.id,
@@ -732,7 +745,7 @@ export default function Layout(props: ParentProps) {
archiveSession(props.session)} />
@@ -810,15 +823,15 @@ export default function Layout(props: ParentProps) {
dialog.show(() => )}
>
- Edit project
+ {t().layout.editProject}
closeProject(props.project.worktree)}>
- Close project
+ {t().layout.closeProject}
-
+
@@ -842,14 +855,14 @@ export default function Layout(props: ParentProps) {
>
@@ -918,8 +931,7 @@ export default function Layout(props: ParentProps) {
@@ -948,7 +960,7 @@ export default function Layout(props: ParentProps) {
@@ -985,24 +997,24 @@ export default function Layout(props: ParentProps) {
0 && !providers.paid().length && expanded()}>
-
Getting started
-
OpenCode includes free models so you can start immediately.
-
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
+
{t().sidebar.gettingStarted}
+
{t().sidebar.gettingStartedDesc1}
+
{t().sidebar.gettingStartedDesc2}
-
+
0}>
-
+
@@ -1019,7 +1031,7 @@ export default function Layout(props: ParentProps) {
placement="right"
value={
- Open project
+ {t().home.openProject}
{command.keybind("project.open")}
@@ -1034,10 +1046,10 @@ export default function Layout(props: ParentProps) {
icon="folder-add-left"
onClick={chooseProject}
>
- Open project
+ {t().home.openProject}
-
+
+
+
+
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 5d558ea14ca..ab17dd2235c 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -51,6 +51,7 @@ export namespace Agent {
"*": "ask",
[Truncate.DIR]: "allow",
},
+ question: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
@@ -65,7 +66,13 @@ export namespace Agent {
build: {
name: "build",
options: {},
- permission: PermissionNext.merge(defaults, user),
+ permission: PermissionNext.merge(
+ defaults,
+ PermissionNext.fromConfig({
+ question: "allow",
+ }),
+ user,
+ ),
mode: "primary",
native: true,
},
@@ -75,6 +82,7 @@ export namespace Agent {
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
+ question: "allow",
edit: {
"*": "deny",
".opencode/plan/*.md": "allow",
diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts
index f6c6b688a35..e6203d66574 100644
--- a/packages/opencode/src/cli/cmd/github.ts
+++ b/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
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index bd9d29b4deb..a86b435ec3e 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/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
})()
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 8a14d8b2e77..0edc911344c 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/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
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index e1423e22c22..78f2ff7aa83 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -41,6 +41,7 @@ import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
+import type { QuestionTool } from "@/tool/question"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -69,6 +70,7 @@ import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { PermissionPrompt } from "./permission"
+import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
@@ -118,9 +120,13 @@ export function Session() {
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const permissions = createMemo(() => {
- if (session()?.parentID) return sync.data.permission[route.sessionID] ?? []
+ if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
})
+ const questions = createMemo(() => {
+ if (session()?.parentID) return []
+ return children().flatMap((x) => sync.data.question[x.id] ?? [])
+ })
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -1037,8 +1043,11 @@ export function Session() {
0}>
+ 0}>
+
+
{
prompt = r
promptRef.set(r)
@@ -1047,7 +1056,7 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
- disabled={permissions().length > 0}
+ disabled={permissions().length > 0 || questions().length > 0}
onSubmit={() => {
toBottom()
}}
@@ -1381,6 +1390,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
+
+
+
@@ -1442,7 +1454,12 @@ function InlineTool(props: {
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
- const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule"))
+ const denied = createMemo(
+ () =>
+ error()?.includes("rejected permission") ||
+ error()?.includes("specified a rule") ||
+ error()?.includes("user dismissed"),
+ )
return (
) {
)
}
+function Question(props: ToolProps) {
+ const { theme } = useTheme()
+ const count = createMemo(() => props.input.questions?.length ?? 0)
+ return (
+
+
+
+
+
+ {(q, i) => (
+
+ {q.question}
+ {props.metadata.answers?.[i()] || "(no answer)"}
+
+ )}
+
+
+
+
+
+
+ Asked {count()} question{count() !== 1 ? "s" : ""}
+
+
+
+ )
+}
+
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
new file mode 100644
index 00000000000..82a6a021cf6
--- /dev/null
+++ b/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 (
+
+
+
+
+
+ {(q, index) => {
+ const isActive = () => index() === store.tab
+ const isAnswered = () => store.answers[index()] !== undefined
+ return (
+
+
+ {q.header}
+
+
+ )
+ }}
+
+
+ Confirm
+
+
+
+
+
+
+
+ {question()?.question}
+
+
+
+ {(opt, i) => {
+ const active = () => i() === store.selected
+ const picked = () => store.answers[store.tab] === opt.label
+ return (
+
+
+
+
+ {i() + 1}. {opt.label}
+
+
+ {picked() ? "✓" : ""}
+
+
+ {opt.description}
+
+
+ )
+ }}
+
+
+
+
+
+ {options().length + 1}. Type your own answer
+
+
+ {input() ? "✓" : ""}
+
+
+
+
+
+
+
+ {input()}
+
+
+
+
+
+
+
+
+
+ Review
+
+
+ {(q, index) => {
+ const answer = () => store.answers[index()]
+ return (
+
+ {q.header}:
+ {answer() ?? "(not answered)"}
+
+ )
+ }}
+
+
+
+
+
+
+
+ {"⇆"} tab
+
+
+
+
+ {"↑↓"} select
+
+
+
+ enter {confirm() ? "submit" : single() ? "submit" : "confirm"}
+
+
+ esc dismiss
+
+
+
+
+ )
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 6f04ecc6854..be234948424 100644
--- a/packages/opencode/src/config/config.ts
+++ b/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(),
diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts
index 7c81c5ed62d..db2920b0a45 100644
--- a/packages/opencode/src/id/id.ts
+++ b/packages/opencode/src/id/id.ts
@@ -6,6 +6,7 @@ export namespace Identifier {
session: "ses",
message: "msg",
permission: "per",
+ question: "que",
user: "usr",
part: "prt",
pty: "pty",
diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts
new file mode 100644
index 00000000000..0fc90b40cee
--- /dev/null
+++ b/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
+
+ 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
+
+ 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
+
+ export const Reply = z.object({
+ answers: z.array(z.string()).describe("User answers in order of questions"),
+ })
+ export type Reply = z.infer
+
+ 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 {
+ const s = await state()
+ const id = Identifier.ascending("question")
+
+ log.info("asking", { id, questions: input.questions.length })
+
+ return new Promise((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 {
+ 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 {
+ 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))
+ }
+}
diff --git a/packages/opencode/src/server/question.ts b/packages/opencode/src/server/question.ts
new file mode 100644
index 00000000000..e4f9e443a0f
--- /dev/null
+++ b/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)
+ },
+ )
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 615d9272866..c7baec778c6 100644
--- a/packages/opencode/src/server/server.ts
+++ b/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((resolve) => {
- stream.onAbort(() => {
- clearInterval(heartbeat)
- GlobalBus.off("event", handler)
- resolve()
- log.info("global event disconnected")
+ await new Promise((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
- 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
+ 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 = {}
- 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 = {}
+ 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((resolve) => {
- stream.onAbort(() => {
- clearInterval(heartbeat)
- unsub()
- resolve()
- log.info("event disconnected")
+ await new Promise((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",
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 227ca64bb9b..71db7f13677 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/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]
diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts
new file mode 100644
index 00000000000..5b34875c15f
--- /dev/null
+++ b/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,
+ },
+ }
+ },
+})
diff --git a/packages/opencode/src/tool/question.txt b/packages/opencode/src/tool/question.txt
new file mode 100644
index 00000000000..bb5af82756d
--- /dev/null
+++ b/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
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 608edc65eb4..eb76681ded4 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -1,3 +1,4 @@
+import { QuestionTool } from "./question"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -92,6 +93,7 @@ export namespace ToolRegistry {
return [
InvalidTool,
+ ...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
BashTool,
ReadTool,
GlobTool,
diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts
new file mode 100644
index 00000000000..2e4b2d7ab53
--- /dev/null
+++ b/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)
+ },
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index a26cefb176f..dae865a7cfc 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/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(
+ parameters?: {
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ return (options?.client ?? this.client).get({
+ url: "/question",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Reply to question request
+ *
+ * Provide answers to a question request from the AI assistant.
+ */
+ public reply(
+ parameters: {
+ requestID: string
+ directory?: string
+ answers?: Array
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "requestID" },
+ { in: "query", key: "directory" },
+ { in: "body", key: "answers" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ 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(
+ parameters: {
+ requestID: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "requestID" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ 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 })
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 97a695162ed..ea86b022daf 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/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
+}
+
+export type QuestionRequest = {
+ id: string
+ sessionID: string
+ /**
+ * Questions to ask
+ */
+ questions: Array
+ 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
+ }
+}
+
+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
+}
+
+export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]
+
+export type QuestionReplyData = {
+ body?: {
+ answers: Array
+ }
+ 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
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index f697f6e3f3e..13346a625d9 100644
--- a/packages/sdk/openapi.json
+++ b/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"
},