This commit is contained in:
Simon Klee
2026-04-18 23:08:19 +02:00
parent 212aa46b3e
commit ff5005dec6
6 changed files with 748 additions and 488 deletions

View File

@@ -27,6 +27,7 @@ import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opent
import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { withRunSpan } from "./otel"
import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
import { printableBinding } from "./prompt.shared"
@@ -62,6 +63,7 @@ type RunFooterOptions = {
findFiles: (query: string) => Promise<string[]>
agents: RunAgent[]
resources: RunResource[]
sessionID: () => string | undefined
agentLabel: string
modelLabel: string
first: boolean
@@ -203,6 +205,7 @@ export class RunFooter implements FooterApi {
this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
diffStyle: options.diffStyle,
sessionID: options.sessionID,
treeSitterClient: options.treeSitterClient,
})
@@ -338,7 +341,21 @@ export class RunFooter implements FooterApi {
}
private completeScrollback(): void {
this.flushing = this.flushing.then(() => this.scrollback.complete()).catch(() => {})
const phase = this.state().phase
this.flushing = this.flushing
.then(() =>
withRunSpan(
"RunFooter.completeScrollback",
{
"opencode.footer.phase": phase,
"session.id": this.options.sessionID() || undefined,
},
async () => {
await this.scrollback.complete()
},
),
)
.catch(() => {})
}
private present(view: FooterView): void {
@@ -662,12 +679,23 @@ export class RunFooter implements FooterApi {
}
const batch = this.queue.splice(0)
const phase = this.state().phase
this.flushing = this.flushing
.then(async () => {
for (const item of batch) {
await this.scrollback.append(item)
}
})
.then(() =>
withRunSpan(
"RunFooter.flush",
{
"opencode.batch.commits": batch.length,
"opencode.footer.phase": phase,
"session.id": this.options.sessionID() || undefined,
},
async () => {
for (const item of batch) {
await this.scrollback.append(item)
}
},
),
)
.catch(() => {})
}
}

View File

@@ -0,0 +1,119 @@
import { INVALID_SPAN_CONTEXT, context, trace, SpanStatusCode, type Span } from "@opentelemetry/api"
import { Effect, ManagedRuntime } from "effect"
import { memoMap } from "@/effect/memo-map"
import { Observability } from "@/effect/observability"
type AttributeValue = string | number | boolean | undefined
export type RunSpanAttributes = Record<string, AttributeValue>
const noop = trace.wrapSpanContext(INVALID_SPAN_CONTEXT)
const tracer = trace.getTracer("opencode.run")
const runtime = ManagedRuntime.make(Observability.layer, { memoMap })
let ready: Promise<void> | undefined
function attributes(input?: RunSpanAttributes) {
if (!input) {
return
}
const out = Object.entries(input).flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const]))
if (out.length === 0) {
return
}
return Object.fromEntries(out)
}
function message(error: unknown) {
if (typeof error === "string") {
return error
}
if (error instanceof Error) {
return error.message || error.name
}
return String(error)
}
function ensure() {
if (!Observability.enabled) {
return Promise.resolve()
}
if (ready) {
return ready
}
ready = runtime.runPromise(Effect.void).then(
() => undefined,
(error) => {
ready = undefined
throw error
},
)
return ready
}
function finish<A>(span: Span, out: Promise<A>) {
return out.then(
(value) => {
span.end()
return value
},
(error) => {
recordRunSpanError(span, error)
span.end()
throw error
},
)
}
export function setRunSpanAttributes(span: Span, input?: RunSpanAttributes): void {
const next = attributes(input)
if (!next) {
return
}
span.setAttributes(next)
}
export function recordRunSpanError(span: Span, error: unknown): void {
const next = message(error)
span.recordException(error instanceof Error ? error : next)
span.setStatus({
code: SpanStatusCode.ERROR,
message: next,
})
}
export function withRunSpan<A>(
name: string,
input: RunSpanAttributes | undefined,
fn: (span: Span) => Promise<A> | A,
): A | Promise<A> {
if (!Observability.enabled) {
return fn(noop)
}
return ensure().then(
() => {
const span = tracer.startSpan(name, {
attributes: attributes(input),
})
return context.with(
trace.setSpan(context.active(), span),
() =>
finish(
span,
new Promise<A>((resolve) => {
resolve(fn(span))
}),
),
)
},
() => fn(noop),
)
}

View File

@@ -10,6 +10,7 @@
// sequence through RunFooter.requestExit().
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import * as Locale from "../../../util/locale"
import { withRunSpan } from "./otel"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
import type {
@@ -51,6 +52,7 @@ export type LifecycleInput = {
resources: RunResource[]
sessionID: string
sessionTitle?: string
getSessionID?: () => string | undefined
first: boolean
history: RunPrompt[]
agent: string | undefined
@@ -149,117 +151,141 @@ function queueSplash(
// scrollback commits and footer repaints happen in the same frame. After
// the entry splash, RunFooter takes over the footer region.
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const renderer = await createCliRenderer({
targetFps: 30,
maxFps: 60,
useMouse: false,
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
useKittyKeyboard: { events: process.platform === "win32" },
screenMode: "split-footer",
footerHeight: FOOTER_HEIGHT,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
clearOnShutdown: false,
})
let theme = await resolveRunTheme(renderer)
renderer.setBackgroundColor(theme.background)
const state: SplashState = {
entry: false,
exit: false,
}
const splash = splashInfo(input.sessionTitle, input.history)
const meta = splashMeta({
title: splash.title,
session_id: input.sessionID,
})
const footerTask = import("./footer")
queueSplash(
renderer,
state,
"entry",
entrySplash({
...meta,
theme: theme.entry,
background: theme.background,
showSession: splash.showSession,
}),
)
await renderer.idle().catch(() => {})
const { RunFooter } = await footerTask
const labels = footerLabels({
agent: input.agent,
model: input.model,
variant: input.variant,
})
const footer = new RunFooter(renderer, {
directory: input.directory,
findFiles: input.findFiles,
agents: input.agents,
resources: input.resources,
...labels,
first: input.first,
history: input.history,
theme,
keybinds: input.keybinds,
diffStyle: input.diffStyle,
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
onQuestionReject: input.onQuestionReject,
onCycleVariant: input.onCycleVariant,
onInterrupt: input.onInterrupt,
onSubagentSelect: input.onSubagentSelect,
})
const sigint = () => {
footer.requestExit()
}
process.on("SIGINT", sigint)
let closed = false
const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string }) => {
if (closed) {
return
}
closed = true
process.off("SIGINT", sigint)
try {
await footer.idle().catch(() => {})
const show = renderer.isDestroyed ? false : next.showExit
if (!renderer.isDestroyed && show) {
const sessionID = next.sessionID ?? input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, input.history)
queueSplash(
renderer,
state,
"exit",
exitSplash({
...splashMeta({
title: splash.title,
session_id: sessionID,
}),
theme: theme.entry,
background: theme.background,
}),
)
await renderer.idle().catch(() => {})
return withRunSpan(
"RunLifecycle.boot",
{
"opencode.agent.name": input.agent,
"opencode.directory": input.directory,
"opencode.first": input.first,
"opencode.model.provider": input.model?.providerID,
"opencode.model.id": input.model?.modelID,
"opencode.model.variant": input.variant,
"session.id": input.getSessionID?.() || input.sessionID || undefined,
},
async () => {
const renderer = await createCliRenderer({
targetFps: 30,
maxFps: 60,
useMouse: false,
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
useKittyKeyboard: { events: process.platform === "win32" },
screenMode: "split-footer",
footerHeight: FOOTER_HEIGHT,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
clearOnShutdown: false,
})
const theme = await resolveRunTheme(renderer)
renderer.setBackgroundColor(theme.background)
const state: SplashState = {
entry: false,
exit: false,
}
} finally {
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
shutdown(renderer)
}
}
const splash = splashInfo(input.sessionTitle, input.history)
const meta = splashMeta({
title: splash.title,
session_id: input.sessionID,
})
const footerTask = import("./footer")
queueSplash(
renderer,
state,
"entry",
entrySplash({
...meta,
theme: theme.entry,
background: theme.background,
showSession: splash.showSession,
}),
)
await renderer.idle().catch(() => {})
return {
footer,
close,
}
const { RunFooter } = await footerTask
const labels = footerLabels({
agent: input.agent,
model: input.model,
variant: input.variant,
})
const footer = new RunFooter(renderer, {
directory: input.directory,
findFiles: input.findFiles,
agents: input.agents,
resources: input.resources,
sessionID: input.getSessionID ?? (() => input.sessionID),
...labels,
first: input.first,
history: input.history,
theme,
keybinds: input.keybinds,
diffStyle: input.diffStyle,
onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply,
onQuestionReject: input.onQuestionReject,
onCycleVariant: input.onCycleVariant,
onInterrupt: input.onInterrupt,
onSubagentSelect: input.onSubagentSelect,
})
const sigint = () => {
footer.requestExit()
}
process.on("SIGINT", sigint)
let closed = false
const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string }) => {
if (closed) {
return
}
closed = true
return withRunSpan(
"RunLifecycle.close",
{
"opencode.show_exit": next.showExit,
"session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
},
async () => {
process.off("SIGINT", sigint)
try {
await footer.idle().catch(() => {})
const show = renderer.isDestroyed ? false : next.showExit
if (!renderer.isDestroyed && show) {
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, input.history)
queueSplash(
renderer,
state,
"exit",
exitSplash({
...splashMeta({
title: splash.title,
session_id: sessionID,
}),
theme: theme.entry,
background: theme.background,
}),
)
await renderer.idle().catch(() => {})
}
} finally {
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
shutdown(renderer)
}
},
)
}
return {
footer,
close,
}
},
)
}

View File

@@ -17,6 +17,7 @@ import { Flag } from "@/flag/flag"
import { createRunDemo } from "./demo"
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
import { reusePendingTask } from "./runtime.shared"
import { trace } from "./trace"
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
@@ -74,418 +75,489 @@ type StreamState = {
// 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): Promise<void> {
const start = performance.now()
const log = trace()
const keybindTask = resolveFooterKeybinds()
const diffTask = resolveDiffStyle()
const ctx = await input.boot()
const modelTask = resolveModelInfo(ctx.sdk, ctx.model)
const sessionTask =
ctx.resume === true
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
: Promise.resolve({
first: true,
history: [],
variant: undefined,
})
const savedTask = resolveSavedVariant(ctx.model)
let variants: string[] = []
let limits: Record<string, number> = {}
let aborting = false
let shown = false
let demo: ReturnType<typeof createRunDemo> | undefined
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
keybindTask,
diffTask,
sessionTask,
savedTask,
])
shown = !session.first
let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
let sessionID = ctx.sessionID
let sessionTitle = ctx.sessionTitle
let agent = ctx.agent
let hasSession = !input.resolveSession
let resolving: Promise<void> | undefined
const ensureSession = () => {
if (!input.resolveSession) {
return Promise.resolve()
}
if (resolving) {
return resolving
}
resolving = input.resolveSession(ctx).then((next) => {
sessionID = next.sessionID
sessionTitle = next.sessionTitle
agent = next.agent
hasSession = true
})
return resolving
}
let selectSubagent: ((sessionID: string | undefined) => void) | undefined
const shell = await createRuntimeLifecycle({
directory: ctx.directory,
findFiles: (query) =>
ctx.sdk.find
.files({ query, directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
agents: [],
resources: [],
sessionID,
sessionTitle,
first: session.first,
history: session.history,
agent,
model: ctx.model,
variant: activeVariant,
keybinds,
diffStyle,
onPermissionReply: async (next) => {
if (demo?.permission(next)) {
return
}
log?.write("send.permission.reply", next)
await ctx.sdk.permission.reply(next)
return withRunSpan(
"RunInteractive.session",
{
"opencode.mode": input.resolveSession ? "local" : "attach",
"opencode.initial_input": !!input.initialInput,
"opencode.demo": input.demo,
},
onQuestionReply: async (next) => {
if (demo?.questionReply(next)) {
return
}
await ctx.sdk.question.reply(next)
},
onQuestionReject: async (next) => {
if (demo?.questionReject(next)) {
return
}
await ctx.sdk.question.reject(next)
},
onCycleVariant: () => {
if (!ctx.model || variants.length === 0) {
return {
status: "no variants available",
async (span) => {
const start = performance.now()
const log = trace()
const keybindTask = resolveFooterKeybinds()
const diffTask = resolveDiffStyle()
const ctx = await input.boot()
const modelTask = resolveModelInfo(ctx.sdk, ctx.model)
const sessionTask =
ctx.resume === true
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
: Promise.resolve({
first: true,
history: [],
variant: undefined,
})
const savedTask = resolveSavedVariant(ctx.model)
let variants: string[] = []
let limits: Record<string, number> = {}
let aborting = false
let shown = false
let demo: ReturnType<typeof createRunDemo> | undefined
const [keybinds, diffStyle, session, savedVariant] = await Promise.all([
keybindTask,
diffTask,
sessionTask,
savedTask,
])
shown = !session.first
let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
let sessionID = ctx.sessionID
let sessionTitle = ctx.sessionTitle
let agent = ctx.agent
let hasSession = !input.resolveSession
setRunSpanAttributes(span, {
"opencode.directory": ctx.directory,
"opencode.resume": ctx.resume === true,
"opencode.agent.name": agent,
"opencode.model.provider": ctx.model?.providerID,
"opencode.model.id": ctx.model?.modelID,
"opencode.model.variant": activeVariant,
"session.id": sessionID || undefined,
})
let resolving: Promise<void> | undefined
const ensureSession = () => {
if (!input.resolveSession) {
return Promise.resolve()
}
if (resolving) {
return resolving
}
resolving = input.resolveSession(ctx).then((next) => {
sessionID = next.sessionID
sessionTitle = next.sessionTitle
agent = next.agent
hasSession = true
setRunSpanAttributes(span, {
"opencode.agent.name": agent,
"session.id": sessionID,
})
})
return resolving
}
let selectSubagent: ((sessionID: string | undefined) => void) | undefined
const shell = await createRuntimeLifecycle({
directory: ctx.directory,
findFiles: (query) =>
ctx.sdk.find
.files({ query, directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
agents: [],
resources: [],
sessionID,
sessionTitle,
getSessionID: () => sessionID,
first: session.first,
history: session.history,
agent,
model: ctx.model,
variant: activeVariant,
keybinds,
diffStyle,
onPermissionReply: async (next) => {
if (demo?.permission(next)) {
return
}
log?.write("send.permission.reply", next)
await ctx.sdk.permission.reply(next)
},
onQuestionReply: async (next) => {
if (demo?.questionReply(next)) {
return
}
await ctx.sdk.question.reply(next)
},
onQuestionReject: async (next) => {
if (demo?.questionReject(next)) {
return
}
await ctx.sdk.question.reject(next)
},
onCycleVariant: () => {
if (!ctx.model || variants.length === 0) {
return {
status: "no variants available",
}
}
activeVariant = cycleVariant(activeVariant, variants)
saveVariant(ctx.model, activeVariant)
setRunSpanAttributes(span, {
"opencode.model.variant": activeVariant,
})
return {
status: activeVariant ? `variant ${activeVariant}` : "variant default",
modelLabel: formatModelLabel(ctx.model, activeVariant),
}
},
onInterrupt: () => {
if (!hasSession) {
return
}
if (aborting) {
return
}
aborting = true
void ctx.sdk.session
.abort({
sessionID,
})
.catch(() => {})
.finally(() => {
aborting = false
})
},
onSubagentSelect: (sessionID) => {
selectSubagent?.(sessionID)
log?.write("subagent.select", {
sessionID,
})
},
})
const footer = shell.footer
let catalogTask: Promise<void> | undefined
const loadCatalog = () => {
if (catalogTask) {
return catalogTask
}
catalogTask = Promise.all([
ctx.sdk.app
.agents({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
ctx.sdk.experimental.resource
.list({ directory: ctx.directory })
.then((x) => Object.values(x.data ?? {}))
.catch(() => []),
])
.then(([agents, resources]) => {
if (footer.isClosed) {
return
}
footer.event({
type: "catalog",
agents,
resources,
})
})
.catch(() => {})
return catalogTask
}
activeVariant = cycleVariant(activeVariant, variants)
saveVariant(ctx.model, activeVariant)
return {
status: activeVariant ? `variant ${activeVariant}` : "variant default",
modelLabel: formatModelLabel(ctx.model, activeVariant),
}
},
onInterrupt: () => {
if (!hasSession) {
return
}
void footer
.idle()
.then(() => {
if (footer.isClosed) {
return
}
if (aborting) {
return
}
aborting = true
void ctx.sdk.session
.abort({
sessionID,
void loadCatalog()
})
.catch(() => {})
.finally(() => {
aborting = false
if (Flag.OPENCODE_SHOW_TTFD) {
footer.append({
kind: "system",
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
phase: "final",
source: "system",
})
},
onSubagentSelect: (sessionID) => {
selectSubagent?.(sessionID)
log?.write("subagent.select", {
sessionID,
})
},
})
const footer = shell.footer
}
let catalogTask: Promise<void> | undefined
const loadCatalog = () => {
if (catalogTask) {
return catalogTask
}
if (input.demo) {
await ensureSession()
demo = createRunDemo({
mode: input.demo,
text: input.demoText,
footer,
sessionID,
thinking: input.thinking,
limits: () => limits,
})
}
catalogTask = Promise.all([
ctx.sdk.app
.agents({ directory: ctx.directory })
.then((x) => x.data ?? [])
.catch(() => []),
ctx.sdk.experimental.resource
.list({ directory: ctx.directory })
.then((x) => Object.values(x.data ?? {}))
.catch(() => []),
])
.then(([agents, resources]) => {
if (footer.isClosed) {
if (input.afterPaint) {
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
}
void modelTask.then((info) => {
variants = info.variants
limits = info.limits
const next = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
if (next === activeVariant) {
return
}
activeVariant = next
setRunSpanAttributes(span, {
"opencode.model.variant": activeVariant,
})
if (!ctx.model || footer.isClosed) {
return
}
footer.event({
type: "catalog",
agents,
resources,
type: "model",
model: formatModelLabel(ctx.model, activeVariant),
})
})
.catch(() => {})
return catalogTask
}
const streamTask = import("./stream.transport")
let stream: StreamState | undefined
const loading: { current?: Promise<StreamState> } = {}
const ensureStream = () => {
if (stream) {
return Promise.resolve(stream)
}
void footer
.idle()
.then(() => {
if (footer.isClosed) {
return
return reusePendingTask(loading, async () => {
await ensureSession()
if (footer.isClosed) {
throw new Error("runtime closed")
}
const mod = await streamTask
if (footer.isClosed) {
throw new Error("runtime closed")
}
const handle = await mod.createSessionTransport({
sdk: ctx.sdk,
sessionID,
thinking: input.thinking,
limits: () => limits,
footer,
trace: log,
})
if (footer.isClosed) {
await handle.close()
throw new Error("runtime closed")
}
selectSubagent = handle.selectSubagent
const next = { mod, handle }
stream = next
return next
})
}
void loadCatalog()
})
.catch(() => {})
const runQueue = async () => {
let includeFiles = true
if (demo) {
await demo.start()
}
if (Flag.OPENCODE_SHOW_TTFD) {
footer.append({
kind: "system",
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
phase: "final",
source: "system",
})
}
const mod = await import("./runtime.queue")
await mod.runPromptQueue({
footer,
initialInput: input.initialInput,
trace: log,
onPrompt: () => {
shown = true
},
run: async (prompt, signal) => {
if (demo && (await demo.prompt(prompt, signal))) {
return
}
if (input.demo) {
await ensureSession()
demo = createRunDemo({
mode: input.demo,
text: input.demoText,
footer,
sessionID,
thinking: input.thinking,
limits: () => limits,
})
}
return withRunSpan(
"RunInteractive.turn",
{
"opencode.agent.name": agent,
"opencode.model.provider": ctx.model?.providerID,
"opencode.model.id": ctx.model?.modelID,
"opencode.model.variant": activeVariant,
"opencode.prompt.chars": prompt.text.length,
"opencode.prompt.parts": prompt.parts.length,
"opencode.prompt.include_files": includeFiles,
"opencode.prompt.file_parts": includeFiles ? input.files.length : 0,
"session.id": sessionID || undefined,
},
async (span) => {
try {
const next = await ensureStream()
setRunSpanAttributes(span, {
"opencode.agent.name": agent,
"opencode.model.variant": activeVariant,
"session.id": sessionID || undefined,
})
await next.handle.runPromptTurn({
agent,
model: ctx.model,
variant: activeVariant,
prompt,
files: input.files,
includeFiles,
signal,
})
includeFiles = false
} catch (error) {
if (signal.aborted || footer.isClosed) {
return
}
if (input.afterPaint) {
void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
}
void modelTask.then((info) => {
variants = info.variants
limits = info.limits
const next = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
if (next === activeVariant) {
return
}
activeVariant = next
if (!ctx.model || footer.isClosed) {
return
}
footer.event({
type: "model",
model: formatModelLabel(ctx.model, activeVariant),
})
})
const streamTask = import("./stream.transport")
let stream: StreamState | undefined
const loading: { current?: Promise<StreamState> } = {}
const ensureStream = () => {
if (stream) {
return Promise.resolve(stream)
}
return reusePendingTask(loading, async () => {
await ensureSession()
if (footer.isClosed) {
throw new Error("runtime closed")
recordRunSpanError(span, error)
const text =
stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error))
footer.append({ kind: "error", text, phase: "start", source: "system" })
}
},
)
},
})
}
const mod = await streamTask
if (footer.isClosed) {
throw new Error("runtime closed")
}
try {
const eager = ctx.resume === true || !input.resolveSession || !!input.demo
if (eager) {
await ensureStream()
}
const handle = await mod.createSessionTransport({
sdk: ctx.sdk,
sessionID,
thinking: input.thinking,
limits: () => limits,
footer,
trace: log,
})
if (footer.isClosed) {
await handle.close()
throw new Error("runtime closed")
}
if (!eager && input.resolveSession) {
queueMicrotask(() => {
if (footer.isClosed) {
return
}
selectSubagent = handle.selectSubagent
const next = { mod, handle }
stream = next
return next
})
}
const runQueue = async () => {
let includeFiles = true
if (demo) {
await demo.start()
}
const mod = await import("./runtime.queue")
await mod.runPromptQueue({
footer,
initialInput: input.initialInput,
trace: log,
onPrompt: () => {
shown = true
},
run: async (prompt, signal) => {
if (demo && (await demo.prompt(prompt, signal))) {
return
void ensureStream().catch(() => {})
})
}
try {
const next = await ensureStream()
await next.handle.runPromptTurn({
agent,
model: ctx.model,
variant: activeVariant,
prompt,
files: input.files,
includeFiles,
signal,
})
includeFiles = false
} catch (error) {
if (signal.aborted || footer.isClosed) {
return
}
const text =
stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error))
footer.append({ kind: "error", text, phase: "start", source: "system" })
await runQueue()
} finally {
await stream?.handle.close()
}
},
})
}
} finally {
const title =
shown && hasSession
? await ctx.sdk.session
.get({
sessionID,
})
.then((x) => x.data?.title)
.catch(() => undefined)
: undefined
try {
const eager = ctx.resume === true || !input.resolveSession || !!input.demo
if (eager) {
await ensureStream()
}
if (!eager && input.resolveSession) {
queueMicrotask(() => {
if (footer.isClosed) {
return
}
void ensureStream().catch(() => {})
})
}
try {
await runQueue()
} finally {
await stream?.handle.close()
}
} finally {
const title =
shown && hasSession
? await ctx.sdk.session
.get({
sessionID,
})
.then((x) => x.data?.title)
.catch(() => undefined)
: undefined
await shell.close({
showExit: shown && hasSession,
sessionTitle: title,
sessionID,
})
}
await shell.close({
showExit: shown && hasSession,
sessionTitle: title,
sessionID,
})
}
},
)
}
// Local in-process mode. Creates an SDK client backed by a direct fetch to
// the in-process server, so no external HTTP server is needed.
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: input.fetch,
directory: input.directory,
})
let pending: Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> | undefined
return runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
demo: input.demo,
demoText: input.demoText,
resolveSession: () => {
if (pending) {
return pending
}
pending = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, session]) => {
if (!session?.id) {
throw new Error("Session not found")
}
void input.share(sdk, session.id).catch(() => {})
return {
sessionID: session.id,
sessionTitle: session.title,
agent,
}
})
return pending
return withRunSpan(
"RunInteractive.localMode",
{
"opencode.directory": input.directory,
"opencode.initial_input": !!input.initialInput,
"opencode.demo": input.demo,
},
boot: async () => {
return {
sdk,
async () => {
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
fetch: input.fetch,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
}
})
let pending: Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> | undefined
return runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
demo: input.demo,
demoText: input.demoText,
resolveSession: () => {
if (pending) {
return pending
}
pending = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, session]) => {
if (!session?.id) {
throw new Error("Session not found")
}
void input.share(sdk, session.id).catch(() => {})
return {
sessionID: session.id,
sessionTitle: session.title,
agent,
}
})
return pending
},
boot: async () => {
return {
sdk,
directory: input.directory,
sessionID: "",
sessionTitle: undefined,
resume: false,
agent: input.agent,
model: input.model,
variant: input.variant,
}
},
})
},
})
)
}
// Attach mode. Uses the caller-provided SDK client directly.
export async function runInteractiveMode(input: RunInput): Promise<void> {
return runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
demo: input.demo,
demoText: input.demoText,
boot: async () => ({
sdk: input.sdk,
directory: input.directory,
sessionID: input.sessionID,
sessionTitle: input.sessionTitle,
resume: input.resume,
agent: input.agent,
model: input.model,
variant: input.variant,
}),
})
return withRunSpan(
"RunInteractive.attachMode",
{
"opencode.directory": input.directory,
"opencode.initial_input": !!input.initialInput,
"session.id": input.sessionID,
},
async () =>
runInteractiveRuntime({
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
demo: input.demo,
demoText: input.demoText,
boot: async () => ({
sdk: input.sdk,
directory: input.directory,
sessionID: input.sessionID,
sessionTitle: input.sessionTitle,
resume: input.resume,
agent: input.agent,
model: input.model,
variant: input.variant,
}),
}),
)
}

View File

@@ -14,6 +14,7 @@ import {
type ScrollbackSurface,
} from "@opentui/core"
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
import { withRunSpan } from "./otel"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer"
import { type RunTheme } from "./theme"
@@ -66,6 +67,7 @@ export class RunScrollbackStream {
private active: ActiveEntry | undefined
private wrote: boolean
private diffStyle: RunDiffStyle | undefined
private sessionID?: () => string | undefined
private treeSitterClient: TreeSitterClient | undefined
constructor(
@@ -74,11 +76,13 @@ export class RunScrollbackStream {
options: {
wrote?: boolean
diffStyle?: RunDiffStyle
sessionID?: () => string | undefined
treeSitterClient?: TreeSitterClient
} = {},
) {
this.wrote = options.wrote ?? true
this.diffStyle = options.diffStyle
this.sessionID = options.sessionID
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
}
@@ -282,7 +286,17 @@ export class RunScrollbackStream {
}
public async complete(trailingNewline = false): Promise<void> {
await this.finishActive(trailingNewline)
return withRunSpan(
"RunScrollbackStream.complete",
{
"opencode.entry.active": !!this.active,
"opencode.trailing_newline": trailingNewline,
"session.id": this.sessionID?.() || undefined,
},
async () => {
await this.finishActive(trailingNewline)
},
)
}
public destroy(): void {

View File

@@ -22,6 +22,7 @@ function createFooter(renderer: TestRenderer) {
findFiles: async () => [],
agents: [],
resources: [],
sessionID: () => "session-1",
agentLabel: "Build",
modelLabel: "Model default",
first: false,