mini: add reconnect, forms, and shared targets (#37811)

Centralize session target resolution for mini and noninteractive
run paths. Recover from transport drops, replace questions with
forms, and keep tool/catalog state location-scoped with live
progress and theme discovery.
This commit is contained in:
Simon Klee 2026-07-19 22:45:10 +02:00 committed by GitHub
commit 925c2423de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 5631 additions and 4292 deletions

View file

@ -1,7 +1,9 @@
import { Effect, Option } from "effect"
import { Context, Effect, FileSystem, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { Config } from "../../config"
import { resolve } from "@opencode-ai/tui/config"
export default Runtime.handler(Commands.commands.mini, (input) =>
Effect.gen(function* () {
@ -9,9 +11,17 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
yield* Effect.promise(async () => validateMiniTerminal())
const serverURL = Option.getOrUndefined(input.server)
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
const config = yield* Config.Service
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
const fileSystem = yield* FileSystem.FileSystem
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
const service = server.service
yield* Effect.promise(() =>
runMini({
server,
server: {
endpoint: server.endpoint,
reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined,
},
continue: input.continue,
session: Option.getOrUndefined(input.session),
fork: input.fork,
@ -21,6 +31,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
replay: input.replay,
replayLimit: Option.getOrUndefined(input.replayLimit),
demo: input.demo,
tuiConfig: resolved,
}),
)
}),

View file

@ -47,7 +47,8 @@ function preferences(statePath: string): MiniHost["preferences"] {
return {
async resolveVariant(model) {
if (!model) return
return (await read()).variant?.[variantKey(model)]
const variant = (await read()).variant?.[variantKey(model)]
return variant === "default" ? undefined : variant
},
async saveVariant(model, variant) {
if (!model) return
@ -78,7 +79,7 @@ function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
function createTrace(
logPath: string,
diagnostics: Pick<MiniHost["diagnostics"], "pid" | "cwd" | "argv">,
diagnostics: { pid: number; cwd: string; argv: string[] },
): MiniHost["diagnostics"]["trace"] {
if (!process.env.OPENCODE_DIRECT_TRACE) return
const stamp = new Date()
@ -162,7 +163,7 @@ export async function usingInteractiveStdin<T>(
export function createMiniHost(input: {
terminal: InteractiveStdin
directory: string
paths?: MiniHost["paths"]
paths?: { home: string; state: string; log: string }
}): MiniHost {
const paths = input.paths ?? {
home: Global.Path.home,
@ -175,7 +176,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
terminal: input.terminal,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
write(value) {
@ -191,7 +192,7 @@ export function createMiniHost(input: {
return openEditor(options)
},
},
paths,
paths: { home: paths.home },
signals: {
sigint: signal("SIGINT"),
sigusr2: signal("SIGUSR2"),
@ -201,7 +202,6 @@ export function createMiniHost(input: {
now: () => performance.now(),
},
diagnostics: {
...diagnostics,
trace: createTrace(paths.log, diagnostics),
},
preferences: preferences(paths.state),

View file

@ -1,14 +1,17 @@
import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { setTimeout } from "node:timers/promises"
import { ServerConnection } from "./services/server-connection"
import { waitForCatalogReady } from "./services/catalog"
import { readStdin } from "./util/io"
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
export type MiniCommandInput = {
server: ServerConnection.Resolved
server: {
endpoint: Endpoint
reconnect?: (signal: AbortSignal) => Promise<Endpoint>
}
continue?: boolean
session?: string
fork?: boolean
@ -21,7 +24,6 @@ export type MiniCommandInput = {
tuiConfig?: MiniFrontendInput["tuiConfig"]
}
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
type Model = MiniFrontendInput["model"]
class MiniInputError extends Error {}
@ -33,42 +35,86 @@ export async function runMini(input: MiniCommandInput) {
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
const frontendTask = import("@opencode-ai/tui/mini")
const directory = localDirectory()
const sdk = OpenCode.make({
baseUrl: input.server.endpoint.url,
headers: Service.headers(input.server.endpoint),
})
const model = parseModel(input.model)
let agentTask: Promise<string | undefined> | undefined
const resolveAgent = () => {
agentTask ??= validateAgent(sdk, directory, input.agent)
return agentTask
}
const resolveSession = async () => {
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
const readyModel =
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
const session = selected ?? (await createSession(sdk, directory, agent, model))
return { id: session.id, title: session.title, resume: selected !== undefined }
const connection = createMiniConnection(input.server)
const sdk = connection.sdk
const requested = parseModel(input.model)
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
const prepare = prepareTarget(input.agent)
const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => {
const resolved = await resolveMiniTarget({
sdk: initial,
reconnect: connection.reconnect,
signal,
resolve: (client) =>
resolveSessionTarget({
client,
location: { directory },
continue: input.continue,
session: input.session,
fork: input.fork,
model: requested,
agent: input.agent,
prepare,
signal,
}).catch((error) => {
if (error instanceof Error && error.message === "Session not found")
throw new MiniInputError(error.message)
throw error
}),
})
const target = resolved.value
return {
sdk: resolved.sdk,
sessionID: target.session.id,
sessionTitle: target.session.title,
location: target.location,
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
variant: target.model?.variant,
agent: target.agent,
resume: target.resume,
}
}
const create = (
_sdk: OpenCodeClient,
next: { agent: string | undefined; model: Model; variant: string | undefined },
) => createSession(sdk, directory, next.agent, next.model, next.variant)
client: OpenCodeClient,
next: {
location: { directory: string; workspaceID?: string }
agent: string | undefined
model: Model
variant: string | undefined
},
signal?: AbortSignal,
) =>
resolveSessionTarget({
client,
location: { directory: next.location.directory, workspace: next.location.workspaceID },
agent: next.agent,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
: undefined,
prepare,
signal,
}).then((target) => ({
sessionID: target.session.id,
sessionTitle: target.session.title,
location: target.location,
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
variant: target.model?.variant,
agent: target.agent,
resume: false,
}))
const frontend = await frontendTask
return frontend.runMiniFrontend({
host: createMiniHost({ terminal, directory }),
sdk,
directory,
resolveAgent,
session: resolveSession,
target: resolveTarget,
reconnect: connection.reconnect,
createSession: create,
agent: input.agent,
model,
variant: undefined,
variant: requested?.variant,
files: [],
initialInput,
thinking: true,
replay: input.replay ?? true,
replayLimit: input.replayLimit,
demo: input.demo,
@ -83,6 +129,51 @@ export async function runMini(input: MiniCommandInput) {
}
}
/** @internal Exported for CLI boundary tests. */
export function createMiniConnection(input: MiniCommandInput["server"]) {
const make = (endpoint: Endpoint) =>
OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const reconnect = input.reconnect
return {
sdk: make(input.endpoint),
reconnect: reconnect
? async (signal: AbortSignal) => {
const endpoint = await reconnect(signal)
return make(endpoint)
}
: undefined,
}
}
/** @internal Exported for reconnect lifecycle tests. */
export async function resolveMiniTarget<A>(input: {
sdk: OpenCodeClient
reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
signal: AbortSignal
resolve: (sdk: OpenCodeClient) => Promise<A>
}) {
let sdk = input.sdk
while (true) {
try {
return { sdk, value: await input.resolve(sdk) }
} catch (error) {
if (!input.reconnect || !(error instanceof ClientError) || error.reason !== "Transport") throw error
while (true) {
try {
sdk = await input.reconnect(input.signal)
break
} catch (resolveError) {
if (input.signal.aborted) throw resolveError
await setTimeout(250, undefined, { signal: input.signal })
}
}
}
}
}
export function validateMiniTerminal() {
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
}
@ -112,28 +203,63 @@ function localDirectory(): string {
}
}
function parseModel(value?: string): Model {
if (!value) return
const [providerID, ...rest] = value.split("/")
const modelID = rest.join("/")
if (!providerID || !modelID) throw new MiniInputError("--model must use the format provider/model")
return { providerID, modelID }
function parseModel(value?: string) {
try {
return parseSessionTargetModel(value)
} catch {
throw new MiniInputError("--model must use the format provider/model[#variant]")
}
}
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
return async (input) => {
if (input.model)
await waitForCatalogReady({
sdk: input.client,
directory: input.location.directory,
workspace: input.location.workspaceID,
model: { providerID: input.model.providerID, modelID: input.model.id },
signal: input.signal,
})
return {
model: input.model,
agent: requestedAgent
? await validateAgent(
input.client,
input.location.directory,
input.location.workspaceID,
requestedAgent,
input.signal,
)
: input.agent,
}
}
}
async function validateAgent(
sdk: OpenCodeClient,
directory: string,
workspace: string | undefined,
name?: string,
signal?: AbortSignal,
) {
if (!name) return
const deadline = Date.now() + 5_000
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
while (Date.now() < deadline) {
agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
while (Date.now() < deadline && !signal?.aborted) {
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
const agent = agents?.data.find((item) => item.id === name)
if (agent?.mode === "subagent") {
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
return
}
if (agent) return name
await setTimeout(25)
await setTimeout(25, undefined, { signal }).catch(() => {})
}
if (signal?.aborted) return
if (!agents) {
warning("failed to list agents. Falling back to default agent")
return
@ -141,37 +267,6 @@ async function validateAgent(sdk: OpenCodeClient, directory: string, name?: stri
warning(`agent "${name}" not found. Falling back to default agent`)
}
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
const selected =
preselected ??
(input.session
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await sdk.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined)
if (input.session && !selected) throw new MiniInputError("Session not found")
if (!selected) return
if (!input.fork) return selected
return sdk.session.fork({ sessionID: selected.id })
}
async function createSession(
sdk: OpenCodeClient,
directory: string,
agent: string | undefined,
model: Model,
variant?: string,
): Promise<Session> {
if (model) await waitForCatalogReady({ sdk, directory, model })
return sdk.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory },
})
}
function warning(message: string) {
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
}

View file

@ -1,4 +1,11 @@
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type {
EventSubscribeOutput,
JsonValue,
LLMToolContent,
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
} from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
import { readFile } from "node:fs/promises"
@ -19,6 +26,7 @@ type File = {
type Input = {
client: OpenCodeClient
sessionID: string
location: LocationRef
message: string
files: File[]
agent?: string
@ -30,8 +38,8 @@ type Input = {
/** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean
compatibility?: "v1"
renderTool: (part: MiniToolPart) => Promise<void>
renderToolError: (part: MiniToolPart) => Promise<void>
renderTool: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError: (part: SessionMessageAssistantTool) => Promise<void>
}
type StartedPart = {
@ -42,9 +50,12 @@ type StartedPart = {
type ToolState = StartedPart & {
assistantMessageID: string
tool: string
input: Record<string, unknown>
input: Record<string, JsonValue>
raw?: string
provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
structured: Record<string, JsonValue>
content: LLMToolContent[]
}
type V2Event = EventSubscribeOutput
@ -67,7 +78,6 @@ export async function runNonInteractivePrompt(input: Input) {
let submitted = false
let promoted = false
let emittedError = false
let questionRejected = false
let permissionRejected = false
let formCancelled = false
let interrupted = false
@ -126,14 +136,16 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
const rejectQuestion = async (request: { id: string }) => {
questionRejected = true
await input.client.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
}
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
try {
await input.client.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
formCancelled = true
await input.client.form.cancel({ sessionID: request.sessionID, formID: request.id }).catch(() => {})
}
const consume = async () => {
@ -152,15 +164,13 @@ export async function runNonInteractivePrompt(input: Input) {
await replyPermission(event.data)
continue
}
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await rejectQuestion(event.data)
continue
}
if (
event.type === "form.created" &&
submitted &&
(event.data.form.sessionID === input.sessionID ||
(!input.attached && event.data.form.sessionID === GLOBAL_FORM_SESSION_ID))
(!input.attached &&
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
sameLocation(event.location, input.location)))
) {
await cancelForm(event.data.form)
continue
@ -177,7 +187,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (
event.type === "session.execution.interrupted" &&
event.data.reason === "user" &&
(interrupted || permissionRejected || questionRejected || formCancelled)
(interrupted || permissionRejected || formCancelled)
) {
return
}
@ -260,24 +270,32 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.tool.input.started") {
flushStep()
tools.set(event.data.callID, {
tools.set(toolKey(event.data.assistantMessageID, event.data.callID), {
id: partID(event.id),
timestamp: time,
assistantMessageID: event.data.assistantMessageID,
tool: event.data.name,
input: {},
structured: {},
content: [],
})
continue
}
if (event.type === "session.tool.input.ended") {
const current = tools.get(event.data.callID)
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = event.data.text
continue
}
if (event.type === "session.tool.input.delta") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) current.raw = (current.raw ?? "") + event.data.delta
continue
}
if (event.type === "session.tool.called") {
flushStep()
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key)
tools.set(key, {
id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID,
@ -285,11 +303,39 @@ export async function runNonInteractivePrompt(input: Input) {
input: event.data.input,
raw: current?.raw,
provider: { executed: event.data.executed, state: event.data.state },
providerState: event.data.state,
structured: {},
content: [],
})
continue
}
if (event.type === "session.tool.progress") {
const current = tools.get(toolKey(event.data.assistantMessageID, event.data.callID))
if (current) {
current.structured = event.data.structured
current.content = event.data.content
}
continue
}
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
providerResultState: event.data.resultState,
state: {
status: "completed",
input: current.input,
structured: event.data.structured,
content: event.data.content,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
@ -313,13 +359,31 @@ export async function runNonInteractivePrompt(input: Input) {
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (!emit("tool_use", time, { part })) await input.renderTool(part)
tools.delete(key)
if (!emit("tool_use", time, { part })) await input.renderTool(tool)
continue
}
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const key = toolKey(event.data.assistantMessageID, event.data.callID)
const current = tools.get(key) ?? fallbackTool(event)
const error = event.data.error.message
const tool: SessionMessageAssistantTool = {
type: "tool",
id: event.data.callID,
name: current.tool,
executed: event.data.executed,
providerState: current.providerState,
providerResultState: event.data.resultState,
state: {
status: "error",
input: current.input,
structured: current.structured,
content: current.content,
error: event.data.error,
result: event.data.result,
},
time: { created: current.timestamp, ran: current.timestamp, completed: time },
}
const part: MiniToolPart = {
id: current.id,
sessionID: input.sessionID,
@ -340,10 +404,21 @@ export async function runNonInteractivePrompt(input: Input) {
time: { start: current.timestamp, end: time },
},
}
tools.delete(event.data.callID)
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) continue
tools.delete(key)
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) continue
if (!emit("tool_use", time, { part })) {
await input.renderToolError(part)
if (toolOutputText(current.tool, current.content).trim())
await input.renderTool({
...tool,
state: {
status: "completed",
input: current.input,
structured: current.structured,
content: current.content,
result: event.data.result,
},
})
await input.renderToolError(tool)
UI.error(error)
}
continue
@ -373,7 +448,7 @@ export async function runNonInteractivePrompt(input: Input) {
v1InvalidOutput = true
continue
}
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
if (interrupted || permissionRejected || formCancelled) continue
flushStep()
emittedError = true
process.exitCode = 1
@ -381,13 +456,9 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "session.execution.failed") {
if (
input.compatibility === "v1" &&
(v1InvalidOutput || permissionRejected || questionRejected || formCancelled)
)
return
if (input.compatibility === "v1" && (v1InvalidOutput || permissionRejected || formCancelled)) return
flushStep()
if (!emittedError && !questionRejected && !formCancelled) {
if (!emittedError && !formCancelled) {
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
@ -395,7 +466,7 @@ export async function runNonInteractivePrompt(input: Input) {
return
}
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || questionRejected || formCancelled)) return
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
@ -470,19 +541,23 @@ export async function runNonInteractivePrompt(input: Input) {
if (!response) return
if (interrupted) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
const [permissions, questions, forms] = await Promise.all([
const [permissions, forms, globals] = await Promise.all([
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.question.list({ sessionID: input.sessionID }).catch(() => undefined),
Promise.all(
(input.attached ? [input.sessionID] : [input.sessionID, GLOBAL_FORM_SESSION_ID]).map((sessionID) =>
input.client.form.list({ sessionID }).catch(() => undefined),
),
),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached
? Promise.resolve(undefined)
: input.client.form.request
.list({
location: { directory: input.location.directory, workspace: input.location.workspaceID },
})
.catch(() => undefined),
])
await Promise.all([
...(permissions ?? []).map(replyPermission),
...(questions ?? []).map(rejectQuestion),
...forms.flatMap((response) => response ?? []).map(cancelForm),
...(forms ?? []).map(cancelForm),
...(globals && sameLocation(globals.location, input.location)
? globals.data.filter((form) => form.sessionID === GLOBAL_FORM_SESSION_ID).map(cancelForm)
: []),
])
await completed
} finally {
@ -492,10 +567,34 @@ export async function runNonInteractivePrompt(input: Input) {
}
}
function sameLocation(left: LocationRef | undefined, right: LocationRef) {
return !!left && left.directory === right.directory && left.workspaceID === right.workspaceID
}
function formRequestOptions(location: LocationRef | undefined): [] | [{ headers: Record<string, string> }] {
if (!location) return []
return [
{
headers: {
"x-opencode-directory": encodeURIComponent(location.directory),
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
},
},
]
}
function formAlreadySettled(error: unknown) {
return !!error && typeof error === "object" && Reflect.get(error, "_tag") === "FormAlreadySettledError"
}
function partID(eventID: string) {
return `prt_${eventID.replace(/^evt_/, "")}`
}
function toolKey(messageID: string, callID: string) {
return `${messageID}\u0000${callID}`
}
function fallbackTool(event: {
id: string
created: number
@ -507,6 +606,8 @@ function fallbackTool(event: {
assistantMessageID: event.data.assistantMessageID,
tool: "tool",
input: {},
structured: {},
content: [],
}
}

View file

@ -1,13 +1,13 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import { open } from "node:fs/promises"
import path from "node:path"
import { readStdin } from "../util/io"
import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "../services/catalog"
import { toolInlineInfo, type MiniToolPart } from "@opencode-ai/tui/mini/tool"
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
import { UI } from "./ui"
@ -47,6 +47,15 @@ type ExecutionOptions = {
compatibility?: "v1"
}
class RunTargetError extends Error {
constructor(
message: string,
readonly sessionID?: string,
) {
super(message)
}
}
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
export function runNonInteractive(input: RunCommandInput) {
@ -72,50 +81,74 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory")
const session = await selectSession(client, requestedDirectory, input)
const cwd = session?.location.directory ?? requestedDirectory
const workspace = session?.location.workspaceID
const explicit = parseRunModel(input.model)
const explicitModel = explicit?.model
const variant = options.variant ?? explicit?.variant
const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
const defaultModel =
!explicitModel && !sessionModel
? await client.model
.default({ location: { directory: cwd, workspace } })
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
if (variant && !model) return reportRunError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.modelID))
return reportRunError(input, `Model unavailable: ${model.providerID}/${model.modelID}`, session?.id)
}
const agent = await validateAgent(client, cwd, input.agent)
const selected =
session ??
(await client.session.create({
agent,
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
location: { directory: cwd },
}))
if (!session && input.title !== undefined) {
const target = await resolveSessionTarget({
client,
location: prepared.directory ? { directory: prepared.directory } : undefined,
continue: input.continue,
session: input.session,
fork: input.fork,
model: explicit
? { providerID: explicit.model.providerID, id: explicit.model.modelID, variant: explicit.variant }
: undefined,
agent: input.agent,
prepare: async (next) => {
const selected =
next.model ??
(await client.model
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
.then((result) => result.data))
const model = selected
? {
providerID: selected.providerID,
id: selected.id,
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
}
: undefined
if ((options.variant ?? explicit?.variant) && !model)
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
if (model) {
await waitForCatalogReady({
sdk: client,
directory: next.location.directory,
workspace: next.location.workspaceID,
model: { providerID: model.providerID, modelID: model.id },
})
const available = await client.model.list({
location: { directory: next.location.directory, workspace: next.location.workspaceID },
})
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
}
return {
model,
agent: input.agent
? await validateAgent(client, next.location.directory, next.location.workspaceID, input.agent)
: next.agent,
}
},
}).catch((error) => {
if (!(error instanceof RunTargetError)) throw error
reportRunError(input, error.message, error.sessionID)
return undefined
})
if (!target) return
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
const variant = target.model?.variant
if (!target.resume && input.title !== undefined) {
await client.session.rename({
sessionID: selected.id,
sessionID: target.session.id,
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
}
await runNonInteractivePrompt({
client,
sessionID: selected.id,
sessionID: target.session.id,
location: target.location,
message: prepared.message,
files: prepared.files,
agent,
agent: target.agent,
model,
variant,
thinking: input.thinking ?? false,
@ -123,9 +156,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
auto: input.auto ?? false,
attached: options.attached ?? true,
compatibility: options.compatibility,
renderTool,
renderToolError,
}).catch((error) => reportRunError(input, errorMessage(error), selected.id))
renderTool: (part) => renderTool(part, target.location.directory),
renderToolError: (part) => renderToolError(part, target.location.directory),
}).catch((error) => reportRunError(input, errorMessage(error), target.session.id))
}
export function mergeInput(message: string | undefined, piped: string | undefined) {
@ -134,17 +167,6 @@ export function mergeInput(message: string | undefined, piped: string | undefine
return message + "\n" + piped
}
export function pickRunModel(
explicit: { providerID: string; modelID: string } | undefined,
variant: string | undefined,
session: { providerID: string; modelID: string } | undefined,
fallback: { providerID: string; modelID: string } | undefined,
) {
if (explicit) return explicit
if (!variant) return
return session ?? fallback
}
function formatMessage(message: string[]) {
const value = message.map((part) => (part.includes(" ") ? `"${part.replace(/"/g, '\\"')}"` : part)).join(" ")
return value || undefined
@ -160,18 +182,18 @@ function localDirectory(root: string) {
}
export function parseRunModel(value?: string) {
if (!value) return
const ref = Model.Ref.parse(value)
const ref = parseSessionTargetModel(value)
if (!ref) return
return {
model: { providerID: ref.providerID, modelID: ref.id },
variant: ref.variant,
}
}
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {
async function validateAgent(client: OpenCodeClient, directory: string, workspace: string | undefined, name?: string) {
if (!name) return
const agents = await client.agent
.list({ location: { directory } })
.list({ location: { directory, workspace } })
.then((result) => result.data)
.catch(() => undefined)
if (!agents) {
@ -190,19 +212,6 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s
return name
}
async function selectSession(client: OpenCodeClient, directory: string, input: RunCommandInput) {
const selected = input.session
? await client.session.get({ sessionID: input.session }).catch(() => undefined)
: input.continue
? await client.session
.list({ directory, parentID: null, limit: 1, order: "desc" })
.then((result) => result.data[0])
: undefined
if (input.session && !selected) fail("Session not found")
if (!selected || !input.fork) return selected
return client.session.fork({ sessionID: selected.id })
}
async function prepareFile(input: string, directory: string, options: ExecutionOptions): Promise<FilePart> {
const file = path.resolve(directory, input)
const handle = await open(file, "r").catch(() => fail(`File not found: ${input}`))
@ -244,8 +253,8 @@ function isBinaryContent(bytes: Uint8Array) {
return bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
}
async function renderTool(part: MiniToolPart) {
const info = toolInlineInfo(part)
async function renderTool(part: SessionMessageAssistantTool, directory: string) {
const info = toolInlineInfo(part, directory)
if (info.mode === "block") {
UI.empty()
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title)
@ -260,8 +269,8 @@ async function renderTool(part: MiniToolPart) {
)
}
async function renderToolError(part: MiniToolPart) {
const info = toolInlineInfo(part)
async function renderToolError(part: SessionMessageAssistantTool, directory: string) {
const info = toolInlineInfo(part, directory)
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}

View file

@ -1,4 +1,4 @@
import type { OpenCodeClient } from "@opencode-ai/client/promise"
import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
// Location plugins initialize asynchronously, so explicit model selection must
// wait for that exact model before prompt admission. The execution path owns
@ -9,14 +9,35 @@ export async function waitForCatalogReady(input: {
workspace?: string
model: { providerID: string; modelID: string }
timeoutMs?: number
signal?: AbortSignal
}) {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline) {
while (Date.now() < deadline && !input.signal?.aborted) {
const models = await input.sdk.model
.list({ location: { directory: input.directory, workspace: input.workspace } })
.list(
{ location: { directory: input.directory, workspace: input.workspace } },
{ signal: input.signal },
)
.then((result) => result.data)
.catch(() => undefined)
.catch((error) => {
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
return undefined
})
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
await new Promise((resolve) => setTimeout(resolve, 25))
await wait(25, input.signal)
}
}
function wait(delay: number, signal?: AbortSignal) {
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
if (signal.aborted) return Promise.resolve()
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal?.removeEventListener("abort", done)
resolve()
}
})
}

View file

@ -0,0 +1,166 @@
import type { LocationGetOutput, ModelRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client/promise"
import { Model } from "@opencode-ai/schema/model"
const SESSION_PAGE_LIMIT = 50
export type SessionTarget = {
session: SessionInfo
location: LocationGetOutput
model: ModelRef | undefined
agent: string | undefined
resume: boolean
}
export type SessionTargetPreparation = (input: {
client: OpenCodeClient
location: LocationGetOutput
session: SessionInfo | undefined
model: ModelRef | undefined
agent: string | undefined
signal?: AbortSignal
}) => Promise<{ model: ModelRef | undefined; agent: string | undefined }>
export class SessionTargetMutationError extends Error {
override readonly name = "SessionTargetMutationError"
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : "Session target mutation failed", { cause })
}
}
export async function resolveSessionTarget(input: {
client: OpenCodeClient
location?: { directory?: string; workspace?: string }
continue?: boolean
session?: string
fork?: boolean
model?: ModelRef
agent?: string
prepare: SessionTargetPreparation
signal?: AbortSignal
}): Promise<SessionTarget> {
const selection = await selectSession(input)
const selected = selection.session
const location =
selection.location ??
(await resolveLocation(
input.client,
selected ? { directory: selected.location.directory, workspace: selected.location.workspaceID } : input.location,
input.signal,
))
const prepared = await input.prepare({
client: input.client,
location,
session: selected,
model: input.model ?? selected?.model,
agent: input.agent ?? selected?.agent,
signal: input.signal,
})
const session =
selected ??
(await input.client.session
.create(
{
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory, workspaceID: location.workspaceID },
},
...requestOptions(input.signal),
)
.catch((error) => {
throw new SessionTargetMutationError(error)
}))
return {
session,
location,
model: prepared.model,
agent: prepared.agent,
resume: selected !== undefined,
}
}
export function parseSessionTargetModel(value?: string): ModelRef | undefined {
if (!value) return
const model = Model.Ref.parse(value)
return { providerID: model.providerID, id: model.id, variant: model.variant }
}
async function selectSession(input: {
client: OpenCodeClient
location?: { directory?: string; workspace?: string }
continue?: boolean
session?: string
fork?: boolean
signal?: AbortSignal
}) {
const explicit = input.session
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
if (error && typeof error === "object" && Reflect.get(error, "_tag") === "SessionNotFoundError")
return undefined
throw error
})
: undefined
if (input.session && !explicit) throw new Error("Session not found")
if (explicit)
return {
session: input.fork
? await input.client.session
.fork({ sessionID: explicit.id }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
: explicit,
}
if (!input.continue) return { session: undefined }
const location = await resolveLocation(input.client, input.location, input.signal)
const selected = await latestSession(input.client, location, undefined, input.signal)
if (!selected) return { session: undefined, location }
return {
session: input.fork
? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => {
throw new SessionTargetMutationError(error)
})
: selected,
}
}
async function latestSession(
client: OpenCodeClient,
location: LocationGetOutput,
cursor?: string,
signal?: AbortSignal,
): Promise<SessionInfo | undefined> {
const page = await client.session.list(
{
directory: location.directory,
workspace: location.workspaceID,
parentID: null,
limit: SESSION_PAGE_LIMIT,
order: "desc",
...(cursor ? { cursor } : {}),
},
...requestOptions(signal),
)
const selected = page.data.find(
(session) =>
session.location.directory === location.directory && session.location.workspaceID === location.workspaceID,
)
if (selected) return selected
if (!page.cursor.next || page.data.length === 0) return
return latestSession(client, location, page.cursor.next, signal)
}
function resolveLocation(
client: OpenCodeClient,
location?: { directory?: string; workspace?: string },
signal?: AbortSignal,
) {
if (!location && !signal) return client.location.get()
if (!location) return client.location.get(undefined, { signal })
return client.location.get({ location }, ...requestOptions(signal))
}
function requestOptions(signal?: AbortSignal): [] | [{ signal: AbortSignal }] {
return signal ? [{ signal }] : []
}