opencode/packages/tui/src/mini/runtime.ts

1054 lines
33 KiB
TypeScript

// Top-level orchestrator for `opencode mini`.
//
// Wires the boot sequence, lifecycle (renderer + footer), stream transport,
// and prompt queue together into a single session loop. The frontend paints
// before resolving its Session, then:
// 1. resolves TUI config, model info, and session history,
// 2. creates the split-footer lifecycle (renderer + RunFooter),
// 3. starts the stream transport (SDK event subscription), lazily for fresh
// local sessions,
// 4. runs the prompt queue until the footer closes.
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { LocationRef } from "@opencode-ai/client/promise"
import type { Config } from "../config"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
import {
resolveMiniSettings,
resolveModelInfo,
resolveModelInfoStrict,
resolveRunTuiConfig,
resolveSessionInfo,
} from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import type {
LocalReplayRow,
MiniHost,
MiniSettings,
RunInput,
RunPrompt,
RunProvider,
RunTuiConfig,
StreamCommit,
} from "./types"
type BootContext = Pick<RunInput, "sdk" | "agent" | "model" | "variant"> & {
location: LocationRef
}
type CreateSessionInput = {
location: LocationRef
agent: string | undefined
model: RunInput["model"]
variant: string | undefined
}
type CreateSession = (sdk: RunInput["sdk"], input: CreateSessionInput, signal?: AbortSignal) => Promise<ResolvedSession>
type Reconnect = (signal: AbortSignal) => Promise<RunInput["sdk"]>
type RunRuntimeInput = {
host: MiniHost
boot: () => Promise<BootContext>
resolveSession: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
createSession?: CreateSession
reconnect?: Reconnect
files: RunInput["files"]
initialInput?: string
thinking?: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
config?: Pick<Config.Interface, "update">
}
export type RunDeferredInput = {
host: MiniHost
sdk: RunInput["sdk"]
reconnect?: Reconnect
directory: string
target: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
createSession?: CreateSession
agent: RunInput["agent"]
model: RunInput["model"]
variant: RunInput["variant"]
files: RunInput["files"]
initialInput?: string
thinking?: boolean
replay?: boolean
replayLimit?: number
demo?: RunInput["demo"]
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
config?: Pick<Config.Interface, "update">
}
type StreamTransportModule = Pick<
Awaited<typeof import("./stream-v2.transport")>,
"createSessionTransport" | "formatUnknownError"
>
export type RunRuntimeDeps = {
createRuntimeLifecycle?: typeof createRuntimeLifecycle
streamTransport?: Promise<StreamTransportModule>
}
type StreamState = {
mod: StreamTransportModule
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
}
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
type ResolvedSession = {
sdk?: RunInput["sdk"]
sessionID: string
sessionTitle?: string
location: RunInput["location"]
model: RunInput["model"]
variant: string | undefined
agent?: string | undefined
resume?: boolean
}
type RuntimeState = {
sdk: RunInput["sdk"]
shown: boolean
aborting: boolean
model: RunInput["model"]
providers: RunProvider[]
variants: string[]
activeVariant: string | undefined
sessionID: string
history: RunPrompt[]
localRows: LocalReplayRow[]
sessionTitle?: string
agent: string | undefined
location: LocationRef
switching?: Promise<void>
demo?: RunDemo
selectSubagent?: (sessionID: string | undefined) => void
session?: Promise<void>
stream?: Promise<StreamState>
}
type ClientAttempt = {
sdk: RunInput["sdk"]
generation: number
signal: AbortSignal
}
function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
if (!model) {
return []
}
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
}
function formRequestOptions(location: LocationRef | undefined) {
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"
}
const RESIZE_DELAY = 250
const LOCAL_REPLAY_ROW_LIMIT = 100
function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefined> {
if (signal.aborted) return Promise.resolve(undefined)
return new Promise((resolve) => {
const abort = () => {
signal.removeEventListener("abort", abort)
resolve(undefined)
}
signal.addEventListener("abort", abort, { once: true })
void task.then(
(value) => {
signal.removeEventListener("abort", abort)
resolve(value)
},
() => {
signal.removeEventListener("abort", abort)
resolve(undefined)
},
)
})
}
// Core runtime loop. Boot resolves the SDK context, then we set up the
// lifecycle (renderer + footer), wire the stream transport for SDK events,
// and feed prompts through the queue until the user exits.
//
// Files only attach on the first prompt turn -- after that, includeFiles
// flips to false so subsequent turns don't re-send attachments.
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
const start = input.host.startup.now()
const log = input.host.diagnostics.trace
const config = input.config
const configState: { current: MiniSettings } = { current: resolveMiniSettings() }
const tuiConfigTask = resolveRunTuiConfig(input.tuiConfig, input.host.platform).then((tuiConfig) => {
configState.current = resolveMiniSettings(tuiConfig)
return tuiConfig
})
const ctx = await input.boot()
const runtimeController = new AbortController()
const session = {
first: true,
history: [] as RunPrompt[],
model: undefined as RunInput["model"],
variant: undefined as string | undefined,
}
const savedVariant = await input.host.preferences.resolveVariant(ctx.model)
const state: RuntimeState = {
sdk: ctx.sdk,
shown: !session.first,
aborting: false,
model: ctx.model ?? session.model,
providers: [],
variants: [],
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
sessionID: "",
history: [...session.history],
localRows: [],
agent: ctx.agent,
location: ctx.location,
}
const settleForm = async (sessionID: string, formID: string) => {
if (!state.stream) return
const stream = await state.stream
stream.handle.settleForm?.(sessionID, formID)
}
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
host: input.host,
getDirectory: () => state.location.directory,
findFiles: (query) =>
state.sdk.file
.find({
query,
type: "file",
location: { directory: state.location.directory, workspace: state.location.workspaceID },
})
.then((result) => result.data.map((file) => file.path))
.catch(() => []),
agents: [],
references: [],
sessionID: state.sessionID,
sessionTitle: state.sessionTitle,
getSessionID: () => state.sessionID,
first: session.first,
history: state.history,
agent: state.agent,
model: state.model,
variant: state.activeVariant,
tuiConfig: tuiConfigTask,
onMiniSettingChange: config
? async (change) => {
const info = await config.update((draft) => {
if (!draft.mini || typeof draft.mini !== "object") draft.mini = {}
draft.mini[change.key] = change.value
})
configState.current = resolveMiniSettings(info)
return configState.current
}
: undefined,
onPermissionReply: async (next) => {
if (state.demo?.permission(next)) {
return
}
log?.write("send.permission.reply", next)
await state.sdk.permission.reply(next)
},
onFormReply: async (next) => {
if (state.demo?.formReply(next)) return
try {
await state.sdk.form.reply(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
await settleForm(next.sessionID, next.formID)
},
onFormCancel: async (next) => {
if (state.demo?.formCancel(next)) return
try {
await state.sdk.form.cancel(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
await settleForm(next.sessionID, next.formID)
},
onCycleVariant: () => {
if (!state.model || state.variants.length === 0) {
return {
status: "no variants available",
}
}
state.activeVariant = cycleVariant(state.activeVariant, state.variants)
void input.host.preferences.saveVariant(state.model, state.activeVariant)
return {
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
variant: state.activeVariant,
}
},
onModelSelect: async (model) => {
if (state.model?.providerID === model.providerID && state.model.modelID === model.modelID) {
return
}
state.model = model
state.activeVariant = undefined
state.variants = variantsFor(state.providers, model)
const switching = input.host.preferences.resolveVariant(model).then((saved) => {
const current = state.model
if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) {
return
}
state.activeVariant = resolveVariant(undefined, undefined, saved, state.variants)
})
state.switching = switching
await switching
if (state.switching === switching) {
state.switching = undefined
}
const current = state.model
if (!current || current.providerID !== model.providerID || current.modelID !== model.modelID) {
return
}
return {
modelLabel: formatModelLabel(model, state.activeVariant, state.providers),
status: `model ${model.modelID}`,
variant: state.activeVariant,
variants: state.variants,
}
},
onVariantSelect: async (variant) => {
if (!state.model || state.variants.length === 0) {
return {
status: "no variants available",
}
}
if (variant && !state.variants.includes(variant)) {
return {
status: `variant ${variant} unavailable`,
}
}
state.activeVariant = variant
void input.host.preferences.saveVariant(state.model, state.activeVariant)
return {
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
variant: state.activeVariant,
variants: state.variants,
}
},
onInterrupt: () => {
if (!state.sessionID || state.aborting) {
return false
}
state.aborting = true
void (
state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: state.sdk.session.interrupt({ sessionID: state.sessionID })
)
.catch(() => {})
.finally(() => {
state.aborting = false
})
return true
},
onBackground: () => {
if (!state.sessionID) {
return
}
log?.write("send.background", { sessionID: state.sessionID })
void state.sdk.session.background({ sessionID: state.sessionID }).catch(() => {})
},
onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID })
void state.sdk.session.interrupt({ sessionID }).catch(() => {})
},
onSubagentSelect: (sessionID) => {
state.selectSubagent?.(sessionID)
log?.write("subagent.select", {
sessionID,
})
},
})
await tuiConfigTask
const thinking = () => input.thinking ?? configState.current.thinking === "show"
const footer = shell.footer
const firstPaint = footer.idle().catch(() => {})
const offRuntimeClose = footer.onClose(() => runtimeController.abort())
let clientGeneration = 0
let clientController = new AbortController()
let modelAttempt: AbortController | undefined
let modelLoad: Promise<void> | undefined
let modelLoadQueued = false
let modelLoadStarted = false
const updateClient = (sdk: RunInput["sdk"]) => {
if (state.sdk === sdk) return
state.sdk = sdk
clientGeneration++
clientController.abort()
clientController = new AbortController()
modelAttempt?.abort()
if (modelLoadStarted && !state.model) void requestModelLoad()
}
const clientAttempt = (signal?: AbortSignal): ClientAttempt => ({
sdk: state.sdk,
generation: clientGeneration,
signal: AbortSignal.any(
signal
? [runtimeController.signal, clientController.signal, signal]
: [runtimeController.signal, clientController.signal],
),
})
const currentClient = (attempt: ClientAttempt) =>
!footer.isClosed && !attempt.signal.aborted && attempt.generation === clientGeneration && attempt.sdk === state.sdk
const ensureSession = () => {
if (state.sessionID) {
return Promise.resolve()
}
if (state.session) {
return state.session
}
const task = input
.resolveSession(state.sdk, runtimeController.signal)
.then(async (next) => {
if (next.sdk) updateClient(next.sdk)
if (footer.isClosed || runtimeController.signal.aborted) return
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent
state.location = next.location
state.model = next.model
state.activeVariant = next.variant
footer.event({ type: "agent", agent: state.agent })
if (!next.resume) return
const resumed = await resolveSessionInfo(state.sdk, next.sessionID, next.model, runtimeController.signal)
if (footer.isClosed || runtimeController.signal.aborted) return
session.first = resumed.first
session.history = resumed.history
session.model = resumed.model
session.variant = resumed.variant
state.shown = !resumed.first
state.history = [...resumed.history]
state.model = next.model ?? resumed.model
const resumedSavedVariant = state.model ? await input.host.preferences.resolveVariant(state.model) : undefined
state.activeVariant = resolveVariant(next.variant, resumed.variant, resumedSavedVariant, [])
session.variant = state.activeVariant
footer.event({ type: "history", history: resumed.history })
footer.event({ type: "first", first: resumed.first })
if (footer.isClosed || runtimeController.signal.aborted) return
await shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
})
})
.catch((error) => {
if (footer.isClosed || runtimeController.signal.aborted) return
throw error
})
state.session = task
void task.catch(() => {
if (state.session === task) state.session = undefined
})
return task
}
const currentModelLoad = (generation: number, sdk: RunInput["sdk"]) =>
!footer.isClosed && !runtimeController.signal.aborted && generation === clientGeneration && sdk === state.sdk
const loadCurrentModel = async () => {
const generation = clientGeneration
const sdk = state.sdk
const selected = state.model
const controller = new AbortController()
const signal = AbortSignal.any([runtimeController.signal, controller.signal])
modelAttempt = controller
try {
const info = await abortable(resolveModelInfo(sdk, state.location, signal), signal)
if (
!info ||
!currentModelLoad(generation, sdk) ||
(selected &&
(state.model?.providerID !== selected.providerID || state.model.modelID !== selected.modelID))
)
return
applyModelInfo(
info,
selected ? session.variant : state.activeVariant,
{ sdk, generation, signal },
!!selected,
savedVariant,
)
} finally {
if (modelAttempt === controller) modelAttempt = undefined
}
}
function requestModelLoad(): Promise<void> {
modelLoadQueued = true
if (modelLoad || footer.isClosed) return modelLoad ?? Promise.resolve()
const task = (async () => {
while (modelLoadQueued && !footer.isClosed) {
modelLoadQueued = false
await loadCurrentModel()
}
})()
modelLoad = task
const cleanup = () => {
if (modelLoad === task) modelLoad = undefined
if (modelLoadQueued && !footer.isClosed) void requestModelLoad()
}
void task.then(cleanup, cleanup)
return task
}
const modelTask = firstPaint.then(async () => {
if (footer.isClosed) return
await ensureSession()
if (footer.isClosed) return
modelLoadStarted = true
return requestModelLoad()
})
const rememberLocal = (commit: StreamCommit) => {
const last = state.localRows.at(-1)
if (
last &&
(commit.kind === "assistant" || commit.kind === "reasoning") &&
last.commit.kind === commit.kind &&
last.commit.source === commit.source &&
last.commit.messageID === commit.messageID &&
last.commit.partID === commit.partID &&
last.commit.tool === commit.tool
) {
state.localRows = [...state.localRows.slice(0, -1), { commit }]
return
}
state.localRows = [...state.localRows, { commit }].slice(-LOCAL_REPLAY_ROW_LIMIT)
}
const applyCatalog = (
catalog: {
agents: Awaited<ReturnType<typeof loadRunAgents>>
references: Awaited<ReturnType<typeof loadRunReferences>>
commands: Awaited<ReturnType<typeof loadRunCommands>>
},
attempt: ClientAttempt,
) => {
if (!currentClient(attempt)) return
footer.event({
type: "catalog",
agents: catalog.agents,
references: catalog.references,
commands: catalog.commands,
})
}
const fetchCatalog = async (attempt: ClientAttempt) => {
const [agents, references, commands] = await Promise.all([
loadRunAgents(attempt.sdk, state.location, attempt.signal),
loadRunReferences(attempt.sdk, state.location, attempt.signal),
loadRunCommands(attempt.sdk, state.location, attempt.signal),
])
return { agents, references, commands }
}
const loadCatalog = async (attempt: ClientAttempt) => {
const catalog = await abortable(
Promise.all([
loadRunAgents(attempt.sdk, state.location, attempt.signal).catch(() => []),
loadRunReferences(attempt.sdk, state.location, attempt.signal).catch(() => []),
loadRunCommands(attempt.sdk, state.location, attempt.signal).catch(() => []),
]).then(([agents, references, commands]) => ({ agents, references, commands })),
attempt.signal,
)
if (catalog) applyCatalog(catalog, attempt)
}
function applyModelInfo(
info: Awaited<ReturnType<typeof resolveModelInfo>>,
current: string | undefined,
attempt: ClientAttempt,
boot = false,
saved = savedVariant,
) {
if (!currentClient(attempt)) return
state.providers = info.providers
state.variants = variantsFor(state.providers, state.model)
state.activeVariant = boot
? resolveVariant(ctx.variant, current, saved, state.variants)
: current && !state.variants.includes(current)
? undefined
: current
if (footer.isClosed) return
footer.event({ type: "models", providers: info.providers })
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
if (state.model)
footer.event({
type: "model",
model: formatModelLabel(state.model, state.activeVariant, state.providers),
selection: state.model,
})
}
let catalogRefresh:
| {
attempt: ClientAttempt
source: AbortSignal | undefined
queued: boolean
task: Promise<void>
}
| undefined
const requestCatalogRefresh = (signal?: AbortSignal): Promise<void> => {
const attempt = clientAttempt(signal)
if (!currentClient(attempt)) return Promise.resolve()
const running = catalogRefresh
if (
running &&
!running.attempt.signal.aborted &&
running.attempt.generation === attempt.generation &&
running.attempt.sdk === attempt.sdk
) {
running.queued = true
return running.task
}
const refresh = {
attempt,
source: signal,
queued: true,
task: Promise.resolve(),
}
const task = (async () => {
await Promise.all([abortable(modelTask, attempt.signal), abortable(initialCatalog, attempt.signal)])
while (refresh.queued && currentClient(attempt)) {
refresh.queued = false
const [catalog, info] = await Promise.all([
abortable(fetchCatalog(attempt), attempt.signal),
abortable(resolveModelInfoStrict(attempt.sdk, state.location, attempt.signal), attempt.signal),
])
if (!currentClient(attempt)) return
if (catalog) applyCatalog(catalog, attempt)
if (info) applyModelInfo(info, state.activeVariant, attempt)
}
})()
refresh.task = task
catalogRefresh = refresh
const cleanup = () => {
if (catalogRefresh !== refresh) return
catalogRefresh = undefined
if (refresh.queued && currentClient(attempt)) void requestCatalogRefresh(refresh.source)
}
void task.then(cleanup, cleanup)
return task
}
const initialCatalog = firstPaint
.then(() => (footer.isClosed ? undefined : ensureSession()))
.then(() => (footer.isClosed ? undefined : loadCatalog(clientAttempt())))
.catch(() => {})
void initialCatalog
if (input.host.startup.showTiming) {
void firstPaint.then(() => {
if (footer.isClosed) return
footer.append({
kind: "system",
text: `startup ${Math.max(0, Math.round(input.host.startup.now() - start))}ms`,
phase: "final",
source: "system",
})
})
}
const createDemo = async () => {
const { createRunDemo } = await import("./demo")
return createRunDemo({
footer,
sessionID: state.sessionID,
thinking: thinking(),
})
}
if (input.demo) {
await firstPaint
if (!footer.isClosed) {
await ensureSession()
state.demo = await createDemo()
}
}
let streamTask = deps.streamTransport
const loadStreamTransport = () => {
if (streamTask) return streamTask
streamTask = import("./stream-v2.transport")
return streamTask
}
const ensureStream = () => {
if (state.stream) {
return state.stream
}
// Share eager prewarm and first-turn boot through one in-flight promise,
// but clear it if transport creation fails so a later prompt can retry.
const next = (async () => {
await ensureSession()
if (footer.isClosed) {
throw new Error("runtime closed")
}
const mod = await loadStreamTransport()
if (footer.isClosed) {
throw new Error("runtime closed")
}
const handle = await mod.createSessionTransport({
sdk: state.sdk,
reconnect: input.reconnect,
onClient: updateClient,
readTextFile: input.host.files.readText,
location: state.location,
sessionID: state.sessionID,
thinking: thinking(),
replay: input.replay,
replayLimit: input.replayLimit,
footer,
onCommit: rememberLocal,
trace: log,
onCatalogRefresh: requestCatalogRefresh,
contextLimit: (model) =>
state.providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.limit?.context,
})
if (footer.isClosed) {
await handle.close()
throw new Error("runtime closed")
}
state.selectSubagent = (sessionID) => handle.selectSubagent(sessionID)
return { mod, handle }
})()
state.stream = next
void next.catch(() => {
if (state.stream === next) {
state.stream = undefined
}
})
return next
}
let resizeTimer: ReturnType<typeof setTimeout> | undefined
const offResize = shell.onResize(() => {
if (resizeTimer) {
clearTimeout(resizeTimer)
}
resizeTimer = setTimeout(() => {
resizeTimer = undefined
if (footer.isClosed) {
return
}
shell.refreshTheme()
if (!input.replay || !state.stream) {
return
}
void state.stream
.then((item) =>
item.handle.replayOnResize({
localRows: () => state.localRows,
reset: () =>
shell.resetForReplay({
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
}),
}),
)
.catch(() => {})
}, RESIZE_DELAY)
})
const renderPromptError = async (prompt: RunPrompt, error: unknown, signal?: AbortSignal) => {
if (signal?.aborted || footer.isClosed) return
const text =
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
(error instanceof Error ? error.message : String(error))
const commit = {
kind: "error",
text,
phase: "start",
source: "system",
messageID: prompt.messageID,
} as const
rememberLocal(commit)
footer.append(commit)
}
const runQueue = async () => {
await firstPaint
if (footer.isClosed) return
await ensureSession()
if (footer.isClosed) return
await modelTask
if (footer.isClosed) return
let includeFiles = true
if (state.demo) {
await state.demo.start()
}
const mod = await import("./runtime.queue")
const createSession = input.createSession
await mod.runPromptQueue({
footer,
initialInput: input.initialInput,
trace: log,
onSend: (prompt, delivery) => {
state.shown = true
state.history.push(prompt)
if (prompt.mode !== "shell" && delivery === "steer") {
rememberLocal({
kind: "user",
text: prompt.text,
phase: "start",
source: "system",
messageID: prompt.messageID,
})
}
},
admit: async (prompt, signal) => {
await state.switching?.catch(() => {})
const next = await ensureStream()
await next.handle.queuePromptTurn({
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles: false,
signal,
})
},
onAdmissionError: renderPromptError,
onCompact: async () => {
await state.sdk.session.compact({ sessionID: state.sessionID }, formRequestOptions(state.location))
},
settle: async () => {
const next = await ensureStream()
await next.handle.waitForIdle()
},
onNewSession: createSession
? async () => {
try {
await state.switching?.catch(() => {})
const created = await createSession(
state.sdk,
{
location: state.location,
agent: state.agent,
model: state.model,
variant: state.activeVariant,
},
runtimeController.signal,
)
if (!created.sessionID) throw new Error("Failed to create session")
await footer.idle().catch(() => {})
await state.stream?.then((item) => item.handle.close()).catch(() => {})
state.stream = undefined
state.session = undefined
state.selectSubagent = undefined
state.shown = false
state.sessionID = created.sessionID
state.sessionTitle = created.sessionTitle
state.agent = created.agent ?? state.agent
state.location = created.location
state.model = created.model
state.activeVariant = created.variant
footer.event({ type: "agent", agent: state.agent })
state.history = []
state.localRows = []
includeFiles = true
state.demo = input.demo ? await createDemo() : undefined
log?.write("session.new", {
sessionID: state.sessionID,
})
footer.event({
type: "stream.subagent",
state: {
tabs: [],
details: {},
permissions: [],
forms: [],
},
})
footer.event({ type: "stream.view", view: { type: "prompt" } })
footer.event({ type: "queued.prompts", prompts: [] })
footer.event({
type: "stream.patch",
patch: {
phase: "idle",
usage: "",
first: true,
},
})
footer.append({
kind: "system",
text: `new session ${state.sessionID}`,
phase: "final",
source: "system",
})
await state.demo?.start()
} catch (error) {
footer.event({
type: "stream.patch",
patch: {
phase: "idle",
status: "failed to start new session",
},
})
const commit = {
kind: "error",
text: error instanceof Error ? error.message : String(error),
phase: "start",
source: "system",
messageID: SessionMessage.ID.create(),
} as const
rememberLocal(commit)
footer.append(commit)
}
}
: undefined,
run: async (prompt, signal, admitted) => {
if (state.demo && (await state.demo.prompt(prompt, signal))) {
return
}
await state.switching?.catch(() => {})
try {
const next = await ensureStream()
await next.handle.runPromptTurn(
{
agent: state.agent,
model: state.model,
variant: state.activeVariant,
prompt,
files: input.files,
includeFiles,
signal,
},
admitted,
)
if (prompt.messageID) {
state.localRows = state.localRows.filter(
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
)
}
// Shell and skill turns never send CLI file attachments; keep them
// pending for the next prompt-shaped turn.
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
} catch (error) {
await renderPromptError(prompt, error, signal)
}
},
})
}
try {
if (input.demo) {
await firstPaint
if (footer.isClosed) return
await ensureStream()
} else {
void firstPaint
.then(() => {
if (footer.isClosed) {
return
}
return ensureStream()
})
.catch(() => {})
}
try {
await runQueue()
} finally {
if (resizeTimer) {
clearTimeout(resizeTimer)
}
offResize()
await state.stream?.then((item) => item.handle.close()).catch(() => {})
}
} finally {
runtimeController.abort()
offRuntimeClose()
await shell.close({
showExit: state.shown && !!state.sessionID,
sessionTitle: state.sessionTitle,
sessionID: state.sessionID,
history: state.history,
})
}
}
// Deferred mode paints before session resolution. The caller may back the
// generated client with a transport that is still acquiring a daemon.
export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: RunRuntimeDeps): Promise<void> {
const sdk = input.sdk
return runInteractiveRuntime(
{
host: input.host,
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
replay: input.replay,
replayLimit: input.replayLimit,
demo: input.demo,
tuiConfig: input.tuiConfig,
config: input.config,
reconnect: input.reconnect,
resolveSession: input.target,
createSession: input.createSession,
boot: async () => {
return {
sdk,
location: { directory: input.directory },
agent: input.agent,
model: input.model,
variant: input.variant,
}
},
},
deps,
)
}