diff options
| author | Dax <[email protected]> | 2026-01-07 22:29:42 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-07 22:29:42 -0500 |
| commit | e37fd9c1056ad461577948d4e968daed1ece8398 (patch) | |
| tree | 85999d77608fc44dd07fbc6e74c3b5af2333cc3a | |
| parent | 2e4fe973c966af45ea67c1d9ff8fd3b6251e960f (diff) | |
| download | opencode-e37fd9c1056ad461577948d4e968daed1ece8398.tar.gz opencode-e37fd9c1056ad461577948d4e968daed1ece8398.zip | |
core: add interactive question tool for gathering user preferences and clarifying instructions (#7268)
19 files changed, 3954 insertions, 2683 deletions
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 47d008fb4..8dd3be589 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1,7 +1,8 @@ ## Style Guide - Try to keep things in one function unless composable or reusable -- AVOID unnecessary destructuring of variables +- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } += obj` just reference it as obj.a and obj.b. this preserves context - AVOID `try`/`catch` where possible - AVOID `else` statements - AVOID using `any` type diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5d558ea14..ab17dd223 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 f6c6b688a..e6203d665 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 bd9d29b4d..a86b435ec 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 8a14d8b2e..0edc91134 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 e1423e22c..78f2ff7aa 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() { <Show when={permissions().length > 0}> <PermissionPrompt request={permissions()[0]} /> </Show> + <Show when={permissions().length === 0 && questions().length > 0}> + <QuestionPrompt request={questions()[0]} /> + </Show> <Prompt - visible={!session()?.parentID && permissions().length === 0} + visible={!session()?.parentID && permissions().length === 0 && questions().length === 0} ref={(r) => { prompt = r promptRef.set(r) @@ -1047,7 +1056,7 @@ export function Session() { r.set(route.initialPrompt) } }} - disabled={permissions().length > 0} + disabled={permissions().length > 0 || questions().length > 0} onSubmit={() => { toBottom() }} @@ -1381,6 +1390,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess <Match when={props.part.tool === "todowrite"}> <TodoWrite {...toolprops} /> </Match> + <Match when={props.part.tool === "question"}> + <Question {...toolprops} /> + </Match> <Match when={true}> <GenericTool {...toolprops} /> </Match> @@ -1442,7 +1454,12 @@ function InlineTool(props: { const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined)) - const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule")) + const denied = createMemo( + () => + error()?.includes("rejected permission") || + error()?.includes("specified a rule") || + error()?.includes("user dismissed"), + ) return ( <box @@ -1816,6 +1833,34 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) { ) } +function Question(props: ToolProps<typeof QuestionTool>) { + const { theme } = useTheme() + const count = createMemo(() => props.input.questions?.length ?? 0) + return ( + <Switch> + <Match when={props.metadata.answers}> + <BlockTool title="# Questions" part={props.part}> + <box> + <For each={props.input.questions ?? []}> + {(q, i) => ( + <box flexDirection="row" gap={1}> + <text fg={theme.textMuted}>{q.question}</text> + <text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text> + </box> + )} + </For> + </box> + </BlockTool> + </Match> + <Match when={true}> + <InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}> + Asked {count()} question{count() !== 1 ? "s" : ""} + </InlineTool> + </Match> + </Switch> + ) +} + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { 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 000000000..96883415b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -0,0 +1,287 @@ +import { createStore } from "solid-js/store" +import { createMemo, For, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" +import { useKeybind } from "../../context/keybind" +import { useTheme } from "../../context/theme" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import { useSDK } from "../../context/sdk" +import { SplitBorder } from "../../component/border" +import { useTextareaKeybindings } from "../../component/textarea-keybindings" +import { useDialog } from "../../ui/dialog" + +export function QuestionPrompt(props: { request: QuestionRequest }) { + const sdk = useSDK() + const { theme } = useTheme() + const keybind = useKeybind() + const bindings = useTextareaKeybindings() + + const questions = createMemo(() => props.request.questions) + const single = createMemo(() => questions().length === 1) + const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single) + const [store, setStore] = createStore({ + tab: 0, + answers: [] as string[], + custom: [] as string[], + selected: 0, + editing: false, + }) + + let textarea: TextareaRenderable | undefined + + const question = createMemo(() => questions()[store.tab]) + const confirm = createMemo(() => !single() && store.tab === questions().length) + const options = createMemo(() => question()?.options ?? []) + const other = createMemo(() => store.selected === options().length) + const input = createMemo(() => store.custom[store.tab] ?? "") + + function submit() { + // Fill in empty answers with empty strings + const answers = questions().map((_, i) => store.answers[i] ?? "") + sdk.client.question.reply({ + requestID: props.request.id, + answers, + }) + } + + function reject() { + sdk.client.question.reject({ + requestID: props.request.id, + }) + } + + function pick(answer: string, custom: boolean = false) { + const answers = [...store.answers] + answers[store.tab] = answer + setStore("answers", answers) + if (custom) { + const inputs = [...store.custom] + inputs[store.tab] = answer + setStore("custom", inputs) + } + if (single()) { + sdk.client.question.reply({ + requestID: props.request.id, + answers: [answer], + }) + return + } + setStore("tab", store.tab + 1) + setStore("selected", 0) + } + + const dialog = useDialog() + + useKeyboard((evt) => { + // When editing "Other" textarea + if (store.editing && !confirm()) { + if (evt.name === "escape") { + evt.preventDefault() + setStore("editing", false) + return + } + if (evt.name === "return") { + evt.preventDefault() + const text = textarea?.plainText?.trim() + if (text) { + pick(text, true) + setStore("editing", false) + } + return + } + // Let textarea handle all other keys + return + } + + if (evt.name === "left" || evt.name === "h") { + evt.preventDefault() + const next = (store.tab - 1 + tabs()) % tabs() + setStore("tab", next) + setStore("selected", 0) + } + + if (evt.name === "right" || evt.name === "l") { + evt.preventDefault() + const next = (store.tab + 1) % tabs() + setStore("tab", next) + setStore("selected", 0) + } + + if (confirm()) { + if (evt.name === "return") { + evt.preventDefault() + submit() + } + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + } + } else { + const opts = options() + const total = opts.length + 1 // options + "Other" + + if (evt.name === "up" || evt.name === "k") { + evt.preventDefault() + setStore("selected", (store.selected - 1 + total) % total) + } + + if (evt.name === "down" || evt.name === "j") { + evt.preventDefault() + setStore("selected", (store.selected + 1) % total) + } + + if (evt.name === "return") { + evt.preventDefault() + if (other()) { + setStore("editing", true) + } else { + const opt = opts[store.selected] + if (opt) { + pick(opt.label) + } + } + } + + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + reject() + } + } + }) + + return ( + <box + backgroundColor={theme.backgroundPanel} + border={["left"]} + borderColor={theme.accent} + customBorderChars={SplitBorder.customBorderChars} + > + <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}> + <Show when={!single()}> + <box flexDirection="row" gap={1} paddingLeft={1}> + <For each={questions()}> + {(q, index) => { + const isActive = () => index() === store.tab + const isAnswered = () => store.answers[index()] !== undefined + return ( + <box + paddingLeft={1} + paddingRight={1} + backgroundColor={isActive() ? theme.accent : theme.backgroundElement} + > + <text fg={isActive() ? theme.selectedListItemText : isAnswered() ? theme.text : theme.textMuted}> + {q.header} + </text> + </box> + ) + }} + </For> + <box paddingLeft={1} paddingRight={1} backgroundColor={confirm() ? theme.accent : theme.backgroundElement}> + <text fg={confirm() ? theme.selectedListItemText : theme.textMuted}>Confirm</text> + </box> + </box> + </Show> + + <Show when={!confirm()}> + <box paddingLeft={1} gap={1}> + <box> + <text fg={theme.text}>{question()?.question}</text> + </box> + <box> + <For each={options()}> + {(opt, i) => { + const active = () => i() === store.selected + const picked = () => store.answers[store.tab] === opt.label + return ( + <box> + <box flexDirection="row" gap={1}> + <box backgroundColor={active() ? theme.backgroundElement : undefined}> + <text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}> + {i() + 1}. {opt.label} + </text> + </box> + <text fg={theme.success}>{picked() ? "✓" : ""}</text> + </box> + <box paddingLeft={3}> + <text fg={theme.textMuted}>{opt.description}</text> + </box> + </box> + ) + }} + </For> + <box> + <box flexDirection="row" gap={1}> + <box backgroundColor={other() ? theme.backgroundElement : undefined}> + <text fg={other() ? theme.secondary : input() ? theme.success : theme.text}> + {options().length + 1}. Other + </text> + </box> + <text fg={theme.success}>{input() ? "✓" : ""}</text> + </box> + <Show when={store.editing}> + <textarea + ref={(val: TextareaRenderable) => (textarea = val)} + focused + placeholder="Type your own answer" + textColor={theme.text} + focusedTextColor={theme.text} + cursorColor={theme.primary} + keyBindings={bindings()} + /> + </Show> + <Show when={!store.editing && input()}> + <text fg={theme.textMuted}>{input()}</text> + </Show> + </box> + </box> + </box> + </Show> + + <Show when={confirm() && !single()}> + <box paddingLeft={1}> + <text fg={theme.text}>Review</text> + </box> + <For each={questions()}> + {(q, index) => { + const answer = () => store.answers[index()] + return ( + <box flexDirection="row" gap={1} paddingLeft={1}> + <text fg={theme.textMuted}>{q.header}:</text> + <text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</text> + </box> + ) + }} + </For> + </Show> + </box> + <box + flexDirection="row" + flexShrink={0} + gap={1} + paddingLeft={2} + paddingRight={3} + paddingBottom={1} + justifyContent="space-between" + > + <box flexDirection="row" gap={2}> + <Show when={!single()}> + <text fg={theme.text}> + {"⇆"} <span style={{ fg: theme.textMuted }}>tab</span> + </text> + </Show> + <Show when={!confirm()}> + <text fg={theme.text}> + {"↑↓"} <span style={{ fg: theme.textMuted }}>select</span> + </text> + </Show> + <text fg={theme.text}> + enter <span style={{ fg: theme.textMuted }}>{confirm() ? "submit" : single() ? "submit" : "confirm"}</span> + </text> + <text fg={theme.text}> + esc <span style={{ fg: theme.textMuted }}>dismiss</span> + </text> + </box> + </box> + </box> + ) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6f04ecc68..be2349484 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 7c81c5ed6..db2920b0a 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 000000000..0fc90b40c --- /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<typeof Option> + + export const Info = z + .object({ + question: z.string().describe("Complete question"), + header: z.string().max(12).describe("Very short label (max 12 chars)"), + options: z.array(Option).describe("Available choices"), + }) + .meta({ + ref: "QuestionInfo", + }) + export type Info = z.infer<typeof Info> + + export const Request = z + .object({ + id: Identifier.schema("question"), + sessionID: Identifier.schema("session"), + questions: z.array(Info).describe("Questions to ask"), + tool: z + .object({ + messageID: z.string(), + callID: z.string(), + }) + .optional(), + }) + .meta({ + ref: "QuestionRequest", + }) + export type Request = z.infer<typeof Request> + + export const Reply = z.object({ + answers: z.array(z.string()).describe("User answers in order of questions"), + }) + export type Reply = z.infer<typeof Reply> + + export const Event = { + Asked: BusEvent.define("question.asked", Request), + Replied: BusEvent.define( + "question.replied", + z.object({ + sessionID: z.string(), + requestID: z.string(), + answers: z.array(z.string()), + }), + ), + Rejected: BusEvent.define( + "question.rejected", + z.object({ + sessionID: z.string(), + requestID: z.string(), + }), + ), + } + + const state = Instance.state(async () => { + const pending: Record< + string, + { + info: Request + resolve: (answers: string[]) => void + reject: (e: any) => void + } + > = {} + + return { + pending, + } + }) + + export async function ask(input: { + sessionID: string + questions: Info[] + tool?: { messageID: string; callID: string } + }): Promise<string[]> { + const s = await state() + const id = Identifier.ascending("question") + + log.info("asking", { id, questions: input.questions.length }) + + return new Promise<string[]>((resolve, reject) => { + const info: Request = { + id, + sessionID: input.sessionID, + questions: input.questions, + tool: input.tool, + } + s.pending[id] = { + info, + resolve, + reject, + } + Bus.publish(Event.Asked, info) + }) + } + + export async function reply(input: { requestID: string; answers: string[] }): Promise<void> { + const s = await state() + const existing = s.pending[input.requestID] + if (!existing) { + log.warn("reply for unknown request", { requestID: input.requestID }) + return + } + delete s.pending[input.requestID] + + log.info("replied", { requestID: input.requestID, answers: input.answers }) + + Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + answers: input.answers, + }) + + existing.resolve(input.answers) + } + + export async function reject(requestID: string): Promise<void> { + const s = await state() + const existing = s.pending[requestID] + if (!existing) { + log.warn("reject for unknown request", { requestID }) + return + } + delete s.pending[requestID] + + log.info("rejected", { requestID }) + + Bus.publish(Event.Rejected, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + }) + + existing.reject(new RejectedError()) + } + + export class RejectedError extends Error { + constructor() { + super("The user dismissed this question") + } + } + + export async function list() { + return state().then((x) => Object.values(x.pending).map((x) => x.info)) + } +} diff --git a/packages/opencode/src/server/question.ts b/packages/opencode/src/server/question.ts new file mode 100644 index 000000000..e4f9e443a --- /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 615d92728..c7baec778 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) - await new Promise<void>((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - GlobalBus.off("event", handler) - resolve() - log.info("global event disconnected") + // Send heartbeat every 30s to prevent WKWebView timeout (60s default) + const heartbeat = setInterval(() => { + stream.writeSSE({ + data: JSON.stringify({ + payload: { + type: "server.heartbeat", + properties: {}, + }, + }), + }) + }, 30000) + + await new Promise<void>((resolve) => { + stream.onAbort(() => { + clearInterval(heartbeat) + GlobalBus.off("event", handler) + resolve() + log.info("global event disconnected") + }) }) }) - }) - }, - ) - .post( - "/global/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose all OpenCode instances, releasing all resources.", - operationId: "global.dispose", - responses: { - 200: { - description: "Global disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Instance.disposeAll() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, + }, + ) + .post( + "/global/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose all OpenCode instances, releasing all resources.", + operationId: "global.dispose", + responses: { + 200: { + description: "Global disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Instance.disposeAll() + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }) + return c.json(true) + }, + ) + .use(async (c, next) => { + let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() + try { + directory = decodeURIComponent(directory) + } catch { + // fallback to original value + } + return Instance.provide({ + directory, + init: InstanceBootstrap, + async fn() { + return next() }, }) - return c.json(true) - }, - ) - .use(async (c, next) => { - let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - try { - directory = decodeURIComponent(directory) - } catch { - // fallback to original value - } - return Instance.provide({ - directory, - init: InstanceBootstrap, - async fn() { - return next() - }, }) - }) - .get( - "/doc", - openAPIRouteHandler(app, { - documentation: { - info: { - title: "opencode", - version: "0.0.3", - description: "opencode api", - }, - openapi: "3.1.1", - }, - }), - ) - .use(validator("query", z.object({ directory: z.string().optional() }))) + .get( + "/doc", + openAPIRouteHandler(app, { + documentation: { + info: { + title: "opencode", + version: "0.0.3", + description: "opencode api", + }, + openapi: "3.1.1", + }, + }), + ) + .use(validator("query", z.object({ directory: z.string().optional() }))) - .route("/project", ProjectRoute) + .route("/project", ProjectRoute) - .get( - "/pty", - describeRoute({ - summary: "List PTY sessions", - description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - operationId: "pty.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Pty.Info.array()), + .get( + "/pty", + describeRoute({ + summary: "List PTY sessions", + description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + operationId: "pty.list", + responses: { + 200: { + description: "List of sessions", + content: { + "application/json": { + schema: resolver(Pty.Info.array()), + }, }, }, }, - }, - }), - async (c) => { - return c.json(Pty.list()) - }, - ) - .post( - "/pty", - describeRoute({ - summary: "Create PTY session", - description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - operationId: "pty.create", - responses: { - 200: { - description: "Created session", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Pty.CreateInput), - async (c) => { - const info = await Pty.create(c.req.valid("json")) - return c.json(info) - }, - ) - .get( - "/pty/:ptyID", - describeRoute({ - summary: "Get PTY session", - description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - operationId: "pty.get", - responses: { - 200: { - description: "Session info", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: z.string() })), - async (c) => { - const info = Pty.get(c.req.valid("param").ptyID) - if (!info) { - throw new Storage.NotFoundError({ message: "Session not found" }) - } - return c.json(info) - }, - ) - .put( - "/pty/:ptyID", - describeRoute({ - summary: "Update PTY session", - description: "Update properties of an existing pseudo-terminal (PTY) session.", - operationId: "pty.update", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Pty.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("param", z.object({ ptyID: z.string() })), - validator("json", Pty.UpdateInput), - async (c) => { - const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) - return c.json(info) - }, - ) - .delete( - "/pty/:ptyID", - describeRoute({ - summary: "Remove PTY session", - description: "Remove and terminate a specific pseudo-terminal (PTY) session.", - operationId: "pty.remove", - responses: { - 200: { - description: "Session removed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: z.string() })), - async (c) => { - await Pty.remove(c.req.valid("param").ptyID) - return c.json(true) - }, - ) - .get( - "/pty/:ptyID/connect", - describeRoute({ - summary: "Connect to PTY session", - description: - "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - operationId: "pty.connect", - responses: { - 200: { - description: "Connected session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: z.string() })), - upgradeWebSocket((c) => { - const id = c.req.param("ptyID") - let handler: ReturnType<typeof Pty.connect> - if (!Pty.get(id)) throw new Error("Session not found") - return { - onOpen(_event, ws) { - handler = Pty.connect(id, ws) - }, - onMessage(event) { - handler?.onMessage(String(event.data)) - }, - onClose() { - handler?.onClose() + }), + async (c) => { + return c.json(Pty.list()) + }, + ) + .post( + "/pty", + describeRoute({ + summary: "Create PTY session", + description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + operationId: "pty.create", + responses: { + 200: { + description: "Created session", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(400), }, - } - }), - ) + }), + validator("json", Pty.CreateInput), + async (c) => { + const info = await Pty.create(c.req.valid("json")) + return c.json(info) + }, + ) + .get( + "/pty/:ptyID", + describeRoute({ + summary: "Get PTY session", + description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + operationId: "pty.get", + responses: { + 200: { + description: "Session info", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ ptyID: z.string() })), + async (c) => { + const info = Pty.get(c.req.valid("param").ptyID) + if (!info) { + throw new Storage.NotFoundError({ message: "Session not found" }) + } + return c.json(info) + }, + ) + .put( + "/pty/:ptyID", + describeRoute({ + summary: "Update PTY session", + description: "Update properties of an existing pseudo-terminal (PTY) session.", + operationId: "pty.update", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Pty.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("param", z.object({ ptyID: z.string() })), + validator("json", Pty.UpdateInput), + async (c) => { + const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json")) + return c.json(info) + }, + ) + .delete( + "/pty/:ptyID", + describeRoute({ + summary: "Remove PTY session", + description: "Remove and terminate a specific pseudo-terminal (PTY) session.", + operationId: "pty.remove", + responses: { + 200: { + description: "Session removed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ ptyID: z.string() })), + async (c) => { + await Pty.remove(c.req.valid("param").ptyID) + return c.json(true) + }, + ) + .get( + "/pty/:ptyID/connect", + describeRoute({ + summary: "Connect to PTY session", + description: + "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + operationId: "pty.connect", + responses: { + 200: { + description: "Connected session", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(404), + }, + }), + validator("param", z.object({ ptyID: z.string() })), + upgradeWebSocket((c) => { + const id = c.req.param("ptyID") + let handler: ReturnType<typeof Pty.connect> + if (!Pty.get(id)) throw new Error("Session not found") + return { + onOpen(_event, ws) { + handler = Pty.connect(id, ws) + }, + onMessage(event) { + handler?.onMessage(String(event.data)) + }, + onClose() { + handler?.onClose() + }, + } + }), + ) - .get( - "/config", - describeRoute({ - summary: "Get configuration", - description: "Retrieve the current OpenCode configuration settings and preferences.", - operationId: "config.get", - responses: { - 200: { - description: "Get config info", - content: { - "application/json": { - schema: resolver(Config.Info), + .get( + "/config", + describeRoute({ + summary: "Get configuration", + description: "Retrieve the current OpenCode configuration settings and preferences.", + operationId: "config.get", + responses: { + 200: { + description: "Get config info", + content: { + "application/json": { + schema: resolver(Config.Info), + }, }, }, }, + }), + async (c) => { + return c.json(await Config.get()) }, - }), - async (c) => { - return c.json(await Config.get()) - }, - ) + ) - .patch( - "/config", - describeRoute({ - summary: "Update configuration", - description: "Update OpenCode configuration settings and preferences.", - operationId: "config.update", - responses: { - 200: { - description: "Successfully updated config", - content: { - "application/json": { - schema: resolver(Config.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Config.Info), - async (c) => { - const config = c.req.valid("json") - await Config.update(config) - return c.json(config) - }, - ) - .get( - "/experimental/tool/ids", - describeRoute({ - summary: "List tool IDs", - description: - "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", - operationId: "tool.ids", - responses: { - 200: { - description: "Tool IDs", - content: { - "application/json": { - schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => { - return c.json(await ToolRegistry.ids()) - }, - ) - .get( - "/experimental/tool", - describeRoute({ - summary: "List tools", - description: - "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", - operationId: "tool.list", - responses: { - 200: { - description: "Tools", - content: { - "application/json": { - schema: resolver( - z - .array( - z - .object({ - id: z.string(), - description: z.string(), - parameters: z.any(), - }) - .meta({ ref: "ToolListItem" }), - ) - .meta({ ref: "ToolList" }), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "query", - z.object({ - provider: z.string(), - model: z.string(), - }), - ), - async (c) => { - const { provider } = c.req.valid("query") - const tools = await ToolRegistry.tools(provider) - return c.json( - tools.map((t) => ({ - id: t.id, - description: t.description, - // Handle both Zod schemas and plain JSON schemas - parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, - })), - ) - }, - ) - .post( - "/instance/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose the current OpenCode instance, releasing all resources.", - operationId: "instance.dispose", - responses: { - 200: { - description: "Instance disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Instance.dispose() - return c.json(true) - }, - ) - .get( - "/path", - describeRoute({ - summary: "Get paths", - description: "Retrieve the current working directory and related path information for the OpenCode instance.", - operationId: "path.get", - responses: { - 200: { - description: "Path", - content: { - "application/json": { - schema: resolver( - z - .object({ - home: z.string(), - state: z.string(), - config: z.string(), - worktree: z.string(), - directory: z.string(), - }) - .meta({ - ref: "Path", - }), - ), + .patch( + "/config", + describeRoute({ + summary: "Update configuration", + description: "Update OpenCode configuration settings and preferences.", + operationId: "config.update", + responses: { + 200: { + description: "Successfully updated config", + content: { + "application/json": { + schema: resolver(Config.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", Config.Info), + async (c) => { + const config = c.req.valid("json") + await Config.update(config) + return c.json(config) + }, + ) + .get( + "/experimental/tool/ids", + describeRoute({ + summary: "List tool IDs", + description: + "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + operationId: "tool.ids", + responses: { + 200: { + description: "Tool IDs", + content: { + "application/json": { + schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), + }, }, }, + ...errors(400), }, + }), + async (c) => { + return c.json(await ToolRegistry.ids()) + }, + ) + .get( + "/experimental/tool", + describeRoute({ + summary: "List tools", + description: + "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + operationId: "tool.list", + responses: { + 200: { + description: "Tools", + content: { + "application/json": { + schema: resolver( + z + .array( + z + .object({ + id: z.string(), + description: z.string(), + parameters: z.any(), + }) + .meta({ ref: "ToolListItem" }), + ) + .meta({ ref: "ToolList" }), + ), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "query", + z.object({ + provider: z.string(), + model: z.string(), + }), + ), + async (c) => { + const { provider } = c.req.valid("query") + const tools = await ToolRegistry.tools(provider) + return c.json( + tools.map((t) => ({ + id: t.id, + description: t.description, + // Handle both Zod schemas and plain JSON schemas + parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters, + })), + ) }, - }), - async (c) => { - return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - ) - .post( - "/experimental/worktree", - describeRoute({ - summary: "Create worktree", - description: "Create a new git worktree for the current project.", - operationId: "worktree.create", - responses: { - 200: { - description: "Worktree created", - content: { - "application/json": { - schema: resolver(Worktree.Info), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.create.schema), - async (c) => { - const body = c.req.valid("json") - const worktree = await Worktree.create(body) - return c.json(worktree) - }, - ) - .get( - "/experimental/worktree", - describeRoute({ - summary: "List worktrees", - description: "List all sandbox worktrees for the current project.", - operationId: "worktree.list", - responses: { - 200: { - description: "List of worktree directories", - content: { - "application/json": { - schema: resolver(z.array(z.string())), - }, - }, - }, - }, - }), - async (c) => { - const sandboxes = await Project.sandboxes(Instance.project.id) - return c.json(sandboxes) - }, - ) - .get( - "/vcs", - describeRoute({ - summary: "Get VCS info", - description: "Retrieve version control system (VCS) information for the current project, such as git branch.", - operationId: "vcs.get", - responses: { - 200: { - description: "VCS info", - content: { - "application/json": { - schema: resolver(Vcs.Info), - }, - }, - }, - }, - }), - async (c) => { - const branch = await Vcs.branch() - return c.json({ - branch, - }) - }, - ) - .get( - "/session", - describeRoute({ - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - operationId: "session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.Info.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - start: z.coerce - .number() - .optional() - .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), - search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), - limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - const term = query.search?.toLowerCase() - const sessions: Session.Info[] = [] - for await (const session of Session.list()) { - if (query.start !== undefined && session.time.updated < query.start) continue - if (term !== undefined && !session.title.toLowerCase().includes(term)) continue - sessions.push(session) - if (query.limit !== undefined && sessions.length >= query.limit) break - } - return c.json(sessions) - }, - ) - .get( - "/session/status", - describeRoute({ - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - operationId: "session.status", - responses: { - 200: { - description: "Get session status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), SessionStatus.Info)), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => { - const result = SessionStatus.list() - return c.json(result) - }, - ) - .get( - "/session/:sessionID", - describeRoute({ - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - tags: ["Session"], - operationId: "session.get", - responses: { - 200: { - description: "Get session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.get.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - log.info("SEARCH", { url: c.req.url }) - const session = await Session.get(sessionID) - return c.json(session) - }, - ) - .get( - "/session/:sessionID/children", - describeRoute({ - summary: "Get session children", - tags: ["Session"], - description: "Retrieve all child sessions that were forked from the specified parent session.", - operationId: "session.children", - responses: { - 200: { - description: "List of children", - content: { - "application/json": { - schema: resolver(Session.Info.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.children.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const session = await Session.children(sessionID) - return c.json(session) - }, - ) - .get( - "/session/:sessionID/todo", - describeRoute({ - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - operationId: "session.todo", - responses: { - 200: { - description: "Todo list", - content: { - "application/json": { - schema: resolver(Todo.Info.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string().meta({ description: "Session ID" }), - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const todos = await Todo.get(sessionID) - return c.json(todos) - }, - ) - .post( - "/session", - describeRoute({ - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - operationId: "session.create", - responses: { - ...errors(400), - 200: { - description: "Successfully created session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - }, - }), - validator("json", Session.create.schema.optional()), - async (c) => { - const body = c.req.valid("json") ?? {} - const session = await Session.create(body) - return c.json(session) - }, - ) - .delete( - "/session/:sessionID", - describeRoute({ - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - operationId: "session.delete", - responses: { - 200: { - description: "Successfully deleted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.remove.schema, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - await Session.remove(sessionID) - return c.json(true) - }, - ) - .patch( - "/session/:sessionID", - describeRoute({ - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - operationId: "session.update", - responses: { - 200: { - description: "Successfully updated session", - content: { - "application/json": { - schema: resolver(Session.Info), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: z.string(), - }), - ), - validator( - "json", - z.object({ - title: z.string().optional(), - time: z - .object({ - archived: z.number().optional(), - }) - .optional(), + ) + .post( + "/instance/dispose", + describeRoute({ + summary: "Dispose instance", + description: "Clean up and dispose the current OpenCode instance, releasing all resources.", + operationId: "instance.dispose", + responses: { + 200: { + description: "Instance disposed", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - - const updatedSession = await Session.update(sessionID, (session) => { - if (updates.title !== undefined) { - session.title = updates.title + 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 } - if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived - }) + 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(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const updates = c.req.valid("json") - 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) - }, - ) + 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 + }) - .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 + 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) => { + 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.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 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") - 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 msg = await SessionPrompt.shell({ ...body, sessionID }) + return c.json(msg) + }, + ) + .post( + "/session/:sessionID/revert", + describeRoute({ + summary: "Revert message", + description: + "Revert a specific message in a session, undoing its effects and restoring the previous state.", + operationId: "session.revert", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + }), + ), + validator("json", SessionRevert.RevertInput.omit({ sessionID: true })), + async (c) => { + const sessionID = c.req.valid("param").sessionID + log.info("revert", c.req.valid("json")) + const session = await SessionRevert.revert({ + sessionID, + ...c.req.valid("json"), + }) + return c.json(session) + }, + ) + .post( + "/session/:sessionID/unrevert", + describeRoute({ + summary: "Restore reverted messages", + description: "Restore all previously reverted messages in a session.", + operationId: "session.unrevert", + responses: { + 200: { + description: "Updated session", + content: { + "application/json": { + schema: resolver(Session.Info), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const session = await SessionRevert.unrevert({ sessionID }) + return c.json(session) + }, + ) + .post( + "/session/:sessionID/permissions/:permissionID", + describeRoute({ + summary: "Respond to permission", + deprecated: true, + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.respond", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + permissionID: z.string(), + }), + ), + validator("json", z.object({ response: PermissionNext.Reply })), + async (c) => { + const params = c.req.valid("param") + PermissionNext.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }) + return c.json(true) + }, + ) + .post( + "/permission/:requestID/reply", + describeRoute({ + summary: "Respond to permission request", + description: "Approve or deny a permission request from the AI assistant.", + operationId: "permission.reply", + responses: { + 200: { + description: "Permission processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + requestID: z.string(), + }), + ), + validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })), + async (c) => { + const params = c.req.valid("param") + const json = c.req.valid("json") + await PermissionNext.reply({ + requestID: params.requestID, + reply: json.reply, + message: json.message, + }) + return c.json(true) + }, + ) + .get( + "/permission", + describeRoute({ + summary: "List pending permissions", + description: "Get all pending permission requests across all sessions.", + operationId: "permission.list", + responses: { + 200: { + description: "List of pending permissions", + content: { + "application/json": { + schema: resolver(PermissionNext.Request.array()), + }, + }, + }, + }, + }), + async (c) => { + const permissions = await PermissionNext.list() + return c.json(permissions) + }, + ) + .route("/question", QuestionRoute) + .get( + "/command", + describeRoute({ + summary: "List commands", + description: "Get a list of all available commands in the OpenCode system.", + operationId: "command.list", + responses: { + 200: { + description: "List of commands", + content: { + "application/json": { + schema: resolver(Command.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const commands = await Command.list() + return c.json(commands) + }, + ) + .get( + "/config/providers", + describeRoute({ + summary: "List config providers", + description: "Get a list of all configured AI providers and their default models.", + operationId: "config.providers", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + providers: Provider.Info.array(), + default: z.record(z.string(), z.string()), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + using _ = log.time("providers") + const providers = await Provider.list().then((x) => mapValues(x, (item) => item)) + return c.json({ + providers: Object.values(providers), + default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), + }) + }, + ) + .get( + "/provider", + describeRoute({ + summary: "List providers", + description: "Get a list of all available AI providers, including both available and connected ones.", + operationId: "provider.list", + responses: { + 200: { + description: "List of providers", + content: { + "application/json": { + schema: resolver( + z.object({ + all: ModelsDev.Provider.array(), + default: z.record(z.string(), z.string()), + connected: z.array(z.string()), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const config = await Config.get() + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const allProviders = await ModelsDev.get() - const filteredProviders: Record<string, (typeof allProviders)[string]> = {} - for (const [key, value] of Object.entries(allProviders)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filteredProviders[key] = value + const allProviders = await ModelsDev.get() + const filteredProviders: Record<string, (typeof allProviders)[string]> = {} + for (const [key, value] of Object.entries(allProviders)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filteredProviders[key] = value + } } - } - const connected = await Provider.list() - const providers = Object.assign( - mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)), - connected, - ) - return c.json({ - all: Object.values(providers), - default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id), - connected: Object.keys(connected), - }) - }, - ) - .get( - "/provider/auth", - describeRoute({ - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - operationId: "provider.auth", - responses: { - 200: { - description: "Provider auth methods", - content: { - "application/json": { - schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await ProviderAuth.methods()) - }, - ) - .post( - "/provider/:providerID/oauth/authorize", - describeRoute({ - summary: "OAuth authorize", - description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", - operationId: "provider.oauth.authorize", - responses: { - 200: { - description: "Authorization URL and method", - content: { - "application/json": { - schema: resolver(ProviderAuth.Authorization.optional()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: z.string().meta({ description: "Provider ID" }), - }), - ), - validator( - "json", - z.object({ - method: z.number().meta({ description: "Auth method index" }), - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method } = c.req.valid("json") - const result = await ProviderAuth.authorize({ - providerID, - method, - }) - return c.json(result) - }, - ) - .post( - "/provider/:providerID/oauth/callback", - describeRoute({ - summary: "OAuth callback", - description: "Handle the OAuth callback from a provider after user authorization.", - operationId: "provider.oauth.callback", - responses: { - 200: { - description: "OAuth callback processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: z.string().meta({ description: "Provider ID" }), - }), - ), - validator( - "json", - z.object({ - method: z.number().meta({ description: "Auth method index" }), - code: z.string().optional().meta({ description: "OAuth authorization code" }), - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") - await ProviderAuth.callback({ - providerID, - method, - code, - }) - return c.json(true) - }, - ) - .get( - "/find", - describeRoute({ - summary: "Find text", - description: "Search for text patterns across files in the project using ripgrep.", - operationId: "find.text", - responses: { - 200: { - description: "Matches", - content: { - "application/json": { - schema: resolver(Ripgrep.Match.shape.data.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - pattern: z.string(), - }), - ), - async (c) => { - const pattern = c.req.valid("query").pattern - const result = await Ripgrep.search({ - cwd: Instance.directory, - pattern, - limit: 10, - }) - return c.json(result) - }, - ) - .get( - "/find/file", - describeRoute({ - summary: "Find files", - description: "Search for files or directories by name or pattern in the project directory.", - operationId: "find.files", - responses: { - 200: { - description: "File paths", - content: { - "application/json": { - schema: resolver(z.string().array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - dirs: z.enum(["true", "false"]).optional(), - type: z.enum(["file", "directory"]).optional(), - limit: z.coerce.number().int().min(1).max(200).optional(), - }), - ), - async (c) => { - const query = c.req.valid("query").query - const dirs = c.req.valid("query").dirs - const type = c.req.valid("query").type - const limit = c.req.valid("query").limit - const results = await File.search({ - query, - limit: limit ?? 10, - dirs: dirs !== "false", - type, - }) - return c.json(results) - }, - ) - .get( - "/find/symbol", - describeRoute({ - summary: "Find symbols", - description: "Search for workspace symbols like functions, classes, and variables using LSP.", - operationId: "find.symbols", - responses: { - 200: { - description: "Symbols", - content: { - "application/json": { - schema: resolver(LSP.Symbol.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - }), - ), - async (c) => { - /* + const 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 }) + return c.json([]) + }, + ) + .get( + "/file", + describeRoute({ + summary: "List files", + description: "List files and directories in a specified path.", + operationId: "file.list", + responses: { + 200: { + description: "Files and directories", + content: { + "application/json": { + schema: resolver(File.Node.array()), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.list(path) + return c.json(content) + }, + ) + .get( + "/file/content", + describeRoute({ + summary: "Read file", + description: "Read the content of a specified file.", + operationId: "file.read", + responses: { + 200: { + description: "File content", + content: { + "application/json": { + schema: resolver(File.Content), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + path: z.string(), + }), + ), + async (c) => { + const path = c.req.valid("query").path + const content = await File.read(path) + return c.json(content) + }, + ) + .get( + "/file/status", + describeRoute({ + summary: "Get file status", + description: "Get the git status of all files in the project.", + operationId: "file.status", + responses: { + 200: { + description: "File status", + content: { + "application/json": { + schema: resolver(File.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const content = await File.status() + return c.json(content) + }, + ) + .post( + "/log", + describeRoute({ + summary: "Write log", + description: "Write a log entry to the server logs with specified level and metadata.", + operationId: "app.log", + responses: { + 200: { + description: "Log entry written successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + service: z.string().meta({ description: "Service name for the log entry" }), + level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), + message: z.string().meta({ description: "Log message" }), + extra: z + .record(z.string(), z.any()) + .optional() + .meta({ description: "Additional metadata for the log entry" }), + }), + ), + async (c) => { + const { service, level, message, extra } = c.req.valid("json") + const logger = Log.create({ service }) - switch (level) { - case "debug": - logger.debug(message, extra) - break - case "info": - logger.info(message, extra) - break - case "error": - logger.error(message, extra) - break - case "warn": - logger.warn(message, extra) - break - } + switch (level) { + case "debug": + logger.debug(message, extra) + break + case "info": + logger.info(message, extra) + break + case "error": + logger.error(message, extra) + break + case "warn": + logger.warn(message, extra) + break + } - return c.json(true) - }, - ) - .get( - "/agent", - describeRoute({ - summary: "List agents", - description: "Get a list of all available AI agents in the OpenCode system.", - operationId: "app.agents", - responses: { - 200: { - description: "List of agents", - content: { - "application/json": { - schema: resolver(Agent.Info.array()), - }, - }, - }, - }, - }), - async (c) => { - const modes = await Agent.list() - return c.json(modes) - }, - ) - .get( - "/mcp", - describeRoute({ - summary: "Get MCP status", - description: "Get the status of all Model Context Protocol (MCP) servers.", - operationId: "mcp.status", - responses: { - 200: { - description: "MCP server status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await MCP.status()) - }, - ) - .post( - "/mcp", - describeRoute({ - summary: "Add MCP server", - description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", - operationId: "mcp.add", - responses: { - 200: { - description: "MCP server added successfully", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status)), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - name: z.string(), - config: Config.Mcp, - }), - ), - async (c) => { - const { name, config } = c.req.valid("json") - const result = await MCP.add(name, config) - return c.json(result.status) - }, - ) - .post( - "/mcp/:name/auth", - describeRoute({ - summary: "Start MCP OAuth", - description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", - operationId: "mcp.auth.start", - responses: { - 200: { - description: "OAuth flow started", - content: { - "application/json": { - schema: resolver( - z.object({ - authorizationUrl: z.string().describe("URL to open in browser for authorization"), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - async (c) => { - const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - const result = await MCP.startAuth(name) - return c.json(result) - }, - ) - .post( - "/mcp/:name/auth/callback", - describeRoute({ - summary: "Complete MCP OAuth", - description: - "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", - operationId: "mcp.auth.callback", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "json", - z.object({ - code: z.string().describe("Authorization code from OAuth callback"), - }), - ), - async (c) => { - const name = c.req.param("name") - const { code } = c.req.valid("json") - const status = await MCP.finishAuth(name, code) - return c.json(status) - }, - ) - .post( - "/mcp/:name/auth/authenticate", - describeRoute({ - summary: "Authenticate MCP OAuth", - description: "Start OAuth flow and wait for callback (opens browser)", - operationId: "mcp.auth.authenticate", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status), - }, - }, - }, - ...errors(400, 404), - }, - }), - async (c) => { - const name = c.req.param("name") - const supportsOAuth = await MCP.supportsOAuth(name) - if (!supportsOAuth) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - const status = await MCP.authenticate(name) - return c.json(status) - }, - ) - .delete( - "/mcp/:name/auth", - describeRoute({ - summary: "Remove MCP OAuth", - description: "Remove OAuth credentials for an MCP server", - operationId: "mcp.auth.remove", - responses: { - 200: { - description: "OAuth credentials removed", - content: { - "application/json": { - schema: resolver(z.object({ success: z.literal(true) })), - }, - }, - }, - ...errors(404), - }, - }), - async (c) => { - const name = c.req.param("name") - await MCP.removeAuth(name) - return c.json({ success: true as const }) - }, - ) - .post( - "/mcp/:name/connect", - describeRoute({ - description: "Connect an MCP server", - operationId: "mcp.connect", - responses: { - 200: { - description: "MCP server connected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await MCP.connect(name) - return c.json(true) - }, - ) - .post( - "/mcp/:name/disconnect", - describeRoute({ - description: "Disconnect an MCP server", - operationId: "mcp.disconnect", - responses: { - 200: { - description: "MCP server disconnected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => { - const { name } = c.req.valid("param") - await MCP.disconnect(name) - return c.json(true) - }, - ) - .get( - "/experimental/resource", - describeRoute({ - summary: "Get MCP resources", - description: "Get all available MCP resources from connected servers. Optionally filter by name.", - operationId: "experimental.resource.list", - responses: { - 200: { - description: "MCP resources", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Resource)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await MCP.resources()) - }, - ) - .get( - "/lsp", - describeRoute({ - summary: "Get LSP status", - description: "Get LSP server status", - operationId: "lsp.status", - responses: { - 200: { - description: "LSP server status", - content: { - "application/json": { - schema: resolver(LSP.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await LSP.status()) - }, - ) - .get( - "/formatter", - describeRoute({ - summary: "Get formatter status", - description: "Get formatter status", - operationId: "formatter.status", - responses: { - 200: { - description: "Formatter status", - content: { - "application/json": { - schema: resolver(Format.Status.array()), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Format.status()) - }, - ) - .post( - "/tui/append-prompt", - describeRoute({ - summary: "Append TUI prompt", - description: "Append prompt to the TUI", - operationId: "tui.appendPrompt", - responses: { - 200: { - description: "Prompt processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", TuiEvent.PromptAppend.properties), - async (c) => { - await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json")) - return c.json(true) - }, - ) - .post( - "/tui/open-help", - describeRoute({ - summary: "Open help dialog", - description: "Open the help dialog in the TUI to display user assistance information.", - operationId: "tui.openHelp", - responses: { - 200: { - description: "Help dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - // TODO: open dialog - return c.json(true) - }, - ) - .post( - "/tui/open-sessions", - describeRoute({ - summary: "Open sessions dialog", - description: "Open the session dialog", - operationId: "tui.openSessions", - responses: { - 200: { - description: "Session dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "session.list", - }) - return c.json(true) - }, - ) - .post( - "/tui/open-themes", - describeRoute({ - summary: "Open themes dialog", - description: "Open the theme dialog", - operationId: "tui.openThemes", - responses: { - 200: { - description: "Theme dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "session.list", - }) - return c.json(true) - }, - ) - .post( - "/tui/open-models", - describeRoute({ - summary: "Open models dialog", - description: "Open the model dialog", - operationId: "tui.openModels", - responses: { - 200: { - description: "Model dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "model.list", - }) - return c.json(true) - }, - ) - .post( - "/tui/submit-prompt", - describeRoute({ - summary: "Submit TUI prompt", - description: "Submit the prompt", - operationId: "tui.submitPrompt", - responses: { - 200: { - description: "Prompt submitted successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "prompt.submit", - }) - return c.json(true) - }, - ) - .post( - "/tui/clear-prompt", - describeRoute({ - summary: "Clear TUI prompt", - description: "Clear the prompt", - operationId: "tui.clearPrompt", - responses: { - 200: { - description: "Prompt cleared successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "prompt.clear", - }) - return c.json(true) - }, - ) - .post( - "/tui/execute-command", - describeRoute({ - summary: "Execute TUI command", - description: "Execute a TUI command (e.g. agent_cycle)", - operationId: "tui.executeCommand", - responses: { - 200: { - description: "Command executed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", z.object({ command: z.string() })), - async (c) => { - const command = c.req.valid("json").command - await Bus.publish(TuiEvent.CommandExecute, { - // @ts-expect-error - command: { - session_new: "session.new", - session_share: "session.share", - session_interrupt: "session.interrupt", - session_compact: "session.compact", - messages_page_up: "session.page.up", - messages_page_down: "session.page.down", - messages_half_page_up: "session.half.page.up", - messages_half_page_down: "session.half.page.down", - messages_first: "session.first", - messages_last: "session.last", - agent_cycle: "agent.cycle", - }[command], - }) - return c.json(true) - }, - ) - .post( - "/tui/show-toast", - describeRoute({ - summary: "Show TUI toast", - description: "Show a toast notification in the TUI", - operationId: "tui.showToast", - responses: { - 200: { - description: "Toast notification shown successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", TuiEvent.ToastShow.properties), - async (c) => { - await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) - return c.json(true) - }, - ) - .post( - "/tui/publish", - describeRoute({ - summary: "Publish TUI event", - description: "Publish a TUI event", - operationId: "tui.publish", - responses: { - 200: { - description: "Event published successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.union( - Object.values(TuiEvent).map((def) => { - return z - .object({ - type: z.literal(def.type), - properties: def.properties, - }) - .meta({ - ref: "Event" + "." + def.type, - }) + return c.json(true) + }, + ) + .get( + "/agent", + describeRoute({ + summary: "List agents", + description: "Get a list of all available AI agents in the OpenCode system.", + operationId: "app.agents", + responses: { + 200: { + description: "List of agents", + content: { + "application/json": { + schema: resolver(Agent.Info.array()), + }, + }, + }, + }, + }), + async (c) => { + const modes = await Agent.list() + return c.json(modes) + }, + ) + .get( + "/mcp", + describeRoute({ + summary: "Get MCP status", + description: "Get the status of all Model Context Protocol (MCP) servers.", + operationId: "mcp.status", + responses: { + 200: { + description: "MCP server status", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await MCP.status()) + }, + ) + .post( + "/mcp", + describeRoute({ + summary: "Add MCP server", + description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", + operationId: "mcp.add", + responses: { + 200: { + description: "MCP server added successfully", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Status)), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + name: z.string(), + config: Config.Mcp, }), ), - ), - async (c) => { - const evt = c.req.valid("json") - await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties) - return c.json(true) - }, - ) - .post( - "/tui/select-session", - describeRoute({ - summary: "Select session", - description: "Navigate the TUI to display the specified session.", - operationId: "tui.selectSession", - responses: { - 200: { - description: "Session selected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator("json", TuiEvent.SessionSelect.properties), - async (c) => { - const { sessionID } = c.req.valid("json") - await Session.get(sessionID) - await Bus.publish(TuiEvent.SessionSelect, { sessionID }) - return c.json(true) - }, - ) - .route("/tui/control", TuiRoute) - .put( - "/auth/:providerID", - describeRoute({ - summary: "Set auth credentials", - description: "Set authentication credentials", - operationId: "auth.set", - responses: { - 200: { - description: "Successfully set authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: z.string(), - }), - ), - validator("json", Auth.Info), - async (c) => { - const providerID = c.req.valid("param").providerID - const info = c.req.valid("json") - await Auth.set(providerID, info) - return c.json(true) - }, - ) - .get( - "/event", - describeRoute({ - summary: "Subscribe to events", - description: "Get events", - operationId: "event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver(BusEvent.payloads()), - }, - }, - }, - }, - }), - async (c) => { - log.info("event connected") - return streamSSE(c, async (stream) => { - stream.writeSSE({ - data: JSON.stringify({ - type: "server.connected", - properties: {}, - }), + async (c) => { + const { name, config } = c.req.valid("json") + const result = await MCP.add(name, config) + return c.json(result.status) + }, + ) + .post( + "/mcp/:name/auth", + describeRoute({ + summary: "Start MCP OAuth", + description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + operationId: "mcp.auth.start", + responses: { + 200: { + description: "OAuth flow started", + content: { + "application/json": { + schema: resolver( + z.object({ + authorizationUrl: z.string().describe("URL to open in browser for authorization"), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + const result = await MCP.startAuth(name) + return c.json(result) + }, + ) + .post( + "/mcp/:name/auth/callback", + describeRoute({ + summary: "Complete MCP OAuth", + description: + "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + operationId: "mcp.auth.callback", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "json", + z.object({ + code: z.string().describe("Authorization code from OAuth callback"), + }), + ), + async (c) => { + const name = c.req.param("name") + const { code } = c.req.valid("json") + const status = await MCP.finishAuth(name, code) + return c.json(status) + }, + ) + .post( + "/mcp/:name/auth/authenticate", + describeRoute({ + summary: "Authenticate MCP OAuth", + description: "Start OAuth flow and wait for callback (opens browser)", + operationId: "mcp.auth.authenticate", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) + } + const status = await MCP.authenticate(name) + return c.json(status) + }, + ) + .delete( + "/mcp/:name/auth", + describeRoute({ + summary: "Remove MCP OAuth", + description: "Remove OAuth credentials for an MCP server", + operationId: "mcp.auth.remove", + responses: { + 200: { + description: "OAuth credentials removed", + content: { + "application/json": { + schema: resolver(z.object({ success: z.literal(true) })), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => { + const name = c.req.param("name") + await MCP.removeAuth(name) + return c.json({ success: true as const }) + }, + ) + .post( + "/mcp/:name/connect", + describeRoute({ + description: "Connect an MCP server", + operationId: "mcp.connect", + responses: { + 200: { + description: "MCP server connected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + await MCP.connect(name) + return c.json(true) + }, + ) + .post( + "/mcp/:name/disconnect", + describeRoute({ + description: "Disconnect an MCP server", + operationId: "mcp.disconnect", + responses: { + 200: { + description: "MCP server disconnected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("param", z.object({ name: z.string() })), + async (c) => { + const { name } = c.req.valid("param") + await MCP.disconnect(name) + return c.json(true) + }, + ) + .get( + "/experimental/resource", + describeRoute({ + summary: "Get MCP resources", + description: "Get all available MCP resources from connected servers. Optionally filter by name.", + operationId: "experimental.resource.list", + responses: { + 200: { + description: "MCP resources", + content: { + "application/json": { + schema: resolver(z.record(z.string(), MCP.Resource)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await MCP.resources()) + }, + ) + .get( + "/lsp", + describeRoute({ + summary: "Get LSP status", + description: "Get LSP server status", + operationId: "lsp.status", + responses: { + 200: { + description: "LSP server status", + content: { + "application/json": { + schema: resolver(LSP.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await LSP.status()) + }, + ) + .get( + "/formatter", + describeRoute({ + summary: "Get formatter status", + description: "Get formatter status", + operationId: "formatter.status", + responses: { + 200: { + description: "Formatter status", + content: { + "application/json": { + schema: resolver(Format.Status.array()), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await Format.status()) + }, + ) + .post( + "/tui/append-prompt", + describeRoute({ + summary: "Append TUI prompt", + description: "Append prompt to the TUI", + operationId: "tui.appendPrompt", + responses: { + 200: { + description: "Prompt processed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", TuiEvent.PromptAppend.properties), + async (c) => { + await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json")) + return c.json(true) + }, + ) + .post( + "/tui/open-help", + describeRoute({ + summary: "Open help dialog", + description: "Open the help dialog in the TUI to display user assistance information.", + operationId: "tui.openHelp", + responses: { + 200: { + description: "Help dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + // TODO: open dialog + return c.json(true) + }, + ) + .post( + "/tui/open-sessions", + describeRoute({ + summary: "Open sessions dialog", + description: "Open the session dialog", + operationId: "tui.openSessions", + responses: { + 200: { + description: "Session dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", }) - const unsub = Bus.subscribeAll(async (event) => { - await stream.writeSSE({ - data: JSON.stringify(event), - }) - if (event.type === Bus.InstanceDisposed.type) { - stream.close() - } + return c.json(true) + }, + ) + .post( + "/tui/open-themes", + describeRoute({ + summary: "Open themes dialog", + description: "Open the theme dialog", + operationId: "tui.openThemes", + responses: { + 200: { + description: "Theme dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "session.list", }) - - // Send heartbeat every 30s to prevent WKWebView timeout (60s default) - const heartbeat = setInterval(() => { + return c.json(true) + }, + ) + .post( + "/tui/open-models", + describeRoute({ + summary: "Open models dialog", + description: "Open the model dialog", + operationId: "tui.openModels", + responses: { + 200: { + description: "Model dialog opened successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "model.list", + }) + return c.json(true) + }, + ) + .post( + "/tui/submit-prompt", + describeRoute({ + summary: "Submit TUI prompt", + description: "Submit the prompt", + operationId: "tui.submitPrompt", + responses: { + 200: { + description: "Prompt submitted successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.submit", + }) + return c.json(true) + }, + ) + .post( + "/tui/clear-prompt", + describeRoute({ + summary: "Clear TUI prompt", + description: "Clear the prompt", + operationId: "tui.clearPrompt", + responses: { + 200: { + description: "Prompt cleared successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + async (c) => { + await Bus.publish(TuiEvent.CommandExecute, { + command: "prompt.clear", + }) + return c.json(true) + }, + ) + .post( + "/tui/execute-command", + describeRoute({ + summary: "Execute TUI command", + description: "Execute a TUI command (e.g. agent_cycle)", + operationId: "tui.executeCommand", + responses: { + 200: { + description: "Command executed successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", z.object({ command: z.string() })), + async (c) => { + const command = c.req.valid("json").command + await Bus.publish(TuiEvent.CommandExecute, { + // @ts-expect-error + command: { + session_new: "session.new", + session_share: "session.share", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + agent_cycle: "agent.cycle", + }[command], + }) + return c.json(true) + }, + ) + .post( + "/tui/show-toast", + describeRoute({ + summary: "Show TUI toast", + description: "Show a toast notification in the TUI", + operationId: "tui.showToast", + responses: { + 200: { + description: "Toast notification shown successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + }, + }), + validator("json", TuiEvent.ToastShow.properties), + async (c) => { + await Bus.publish(TuiEvent.ToastShow, c.req.valid("json")) + return c.json(true) + }, + ) + .post( + "/tui/publish", + describeRoute({ + summary: "Publish TUI event", + description: "Publish a TUI event", + operationId: "tui.publish", + responses: { + 200: { + description: "Event published successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.union( + Object.values(TuiEvent).map((def) => { + return z + .object({ + type: z.literal(def.type), + properties: def.properties, + }) + .meta({ + ref: "Event" + "." + def.type, + }) + }), + ), + ), + async (c) => { + const evt = c.req.valid("json") + await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties) + return c.json(true) + }, + ) + .post( + "/tui/select-session", + describeRoute({ + summary: "Select session", + description: "Navigate the TUI to display the specified session.", + operationId: "tui.selectSession", + responses: { + 200: { + description: "Session selected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator("json", TuiEvent.SessionSelect.properties), + async (c) => { + const { sessionID } = c.req.valid("json") + await Session.get(sessionID) + await Bus.publish(TuiEvent.SessionSelect, { sessionID }) + return c.json(true) + }, + ) + .route("/tui/control", TuiRoute) + .put( + "/auth/:providerID", + describeRoute({ + summary: "Set auth credentials", + description: "Set authentication credentials", + operationId: "auth.set", + responses: { + 200: { + description: "Successfully set authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + validator("json", Auth.Info), + async (c) => { + const providerID = c.req.valid("param").providerID + const info = c.req.valid("json") + await Auth.set(providerID, info) + return c.json(true) + }, + ) + .get( + "/event", + describeRoute({ + summary: "Subscribe to events", + description: "Get events", + operationId: "event.subscribe", + responses: { + 200: { + description: "Event stream", + content: { + "text/event-stream": { + schema: resolver(BusEvent.payloads()), + }, + }, + }, + }, + }), + async (c) => { + log.info("event connected") + return streamSSE(c, async (stream) => { stream.writeSSE({ data: JSON.stringify({ - type: "server.heartbeat", + type: "server.connected", properties: {}, }), }) - }, 30000) + const unsub = Bus.subscribeAll(async (event) => { + await stream.writeSSE({ + data: JSON.stringify(event), + }) + if (event.type === Bus.InstanceDisposed.type) { + stream.close() + } + }) + + // Send heartbeat every 30s to prevent WKWebView timeout (60s default) + const heartbeat = setInterval(() => { + stream.writeSSE({ + data: JSON.stringify({ + type: "server.heartbeat", + properties: {}, + }), + }) + }, 30000) - await new Promise<void>((resolve) => { - stream.onAbort(() => { - clearInterval(heartbeat) - unsub() - resolve() - log.info("event disconnected") + await new Promise<void>((resolve) => { + stream.onAbort(() => { + clearInterval(heartbeat) + unsub() + resolve() + log.info("event disconnected") + }) }) }) - }) - }, - ) - .all("/*", async (c) => { - const path = c.req.path - const response = await proxy(`https://app.opencode.ai${path}`, { - ...c.req, - headers: { - ...c.req.raw.headers, - host: "app.opencode.ai", }, - }) - return response - }), + ) + .all("/*", async (c) => { + const path = c.req.path + const response = await proxy(`https://app.opencode.ai${path}`, { + ...c.req, + headers: { + ...c.req.raw.headers, + host: "app.opencode.ai", + }, + }) + return response + }) as unknown as Hono, ) export async function openapi() { - const result = await generateSpecs(App(), { + // Cast to break excessive type recursion from long route chains + const result = await generateSpecs(App() as Hono, { documentation: { info: { title: "opencode", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 227ca64bb..71db7f136 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 000000000..5b34875c1 --- /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 000000000..bb5af8275 --- /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 608edc65e..eb76681de 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 000000000..2e4b2d7ab --- /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 a26cefb17..dae865a7c 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<ThrowOnError extends boolean = false>( + parameters?: { + directory?: string + }, + options?: Options<never, ThrowOnError>, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get<QuestionListResponses, unknown, ThrowOnError>({ + url: "/question", + ...options, + ...params, + }) + } + + /** + * Reply to question request + * + * Provide answers to a question request from the AI assistant. + */ + public reply<ThrowOnError extends boolean = false>( + parameters: { + requestID: string + directory?: string + answers?: Array<string> + }, + options?: Options<never, ThrowOnError>, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "body", key: "answers" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post<QuestionReplyResponses, QuestionReplyErrors, ThrowOnError>({ + url: "/question/{requestID}/reply", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reject question request + * + * Reject a question request from the AI assistant. + */ + public reject<ThrowOnError extends boolean = false>( + parameters: { + requestID: string + directory?: string + }, + options?: Options<never, ThrowOnError>, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post<QuestionRejectResponses, QuestionRejectErrors, ThrowOnError>({ + url: "/question/{requestID}/reject", + ...options, + ...params, + }) + } +} + export class Command extends HeyApiClient { /** * List commands @@ -2912,6 +3005,8 @@ export class OpencodeClient extends HeyApiClient { permission = new Permission({ client: this.client }) + question = new Question({ client: this.client }) + command = new Command({ client: this.client }) provider = new Provider({ client: this.client }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 97a695162..d42e7d176 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -524,6 +524,67 @@ export type EventSessionCompacted = { } } +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string +} + +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 12 chars) + */ + header: string + /** + * Available choices + */ + options: Array<QuestionOption> +} + +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array<QuestionInfo> + tool?: { + messageID: string + callID: string + } +} + +export type EventQuestionAsked = { + type: "question.asked" + properties: QuestionRequest +} + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array<string> + } +} + +export type EventQuestionRejected = { + type: "question.rejected" + properties: { + sessionID: string + requestID: string + } +} + export type EventFileEdited = { type: "file.edited" properties: { @@ -789,6 +850,9 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected | EventFileEdited | EventTodoUpdated | EventTuiPromptAppend @@ -3545,6 +3609,92 @@ export type PermissionListResponses = { export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] +export type QuestionListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/question" +} + +export type QuestionListResponses = { + /** + * List of pending questions + */ + 200: Array<QuestionRequest> +} + +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] + +export type QuestionReplyData = { + body?: { + answers: Array<string> + } + path: { + requestID: string + } + query?: { + directory?: string + } + url: "/question/{requestID}/reply" +} + +export type QuestionReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] + +export type QuestionReplyResponses = { + /** + * Question answered successfully + */ + 200: boolean +} + +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] + +export type QuestionRejectData = { + body?: never + path: { + requestID: string + } + query?: { + directory?: string + } + url: "/question/{requestID}/reject" +} + +export type QuestionRejectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] + +export type QuestionRejectResponses = { + /** + * Question rejected successfully + */ + 200: boolean +} + +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] + export type CommandListData = { body?: never path?: never |
