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:
parent
71cb419570
commit
925c2423de
65 changed files with 5631 additions and 4292 deletions
|
|
@ -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,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
166
packages/cli/src/session-target.ts
Normal file
166
packages/cli/src/session-target.ts
Normal 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 }] : []
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue