mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 05:10:58 +08:00
add otel
This commit is contained in:
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
119
packages/opencode/src/cli/cmd/run/otel.ts
Normal file
119
packages/opencode/src/cli/cmd/run/otel.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,7 @@ function createFooter(renderer: TestRenderer) {
|
||||
findFiles: async () => [],
|
||||
agents: [],
|
||||
resources: [],
|
||||
sessionID: () => "session-1",
|
||||
agentLabel: "Build",
|
||||
modelLabel: "Model default",
|
||||
first: false,
|
||||
|
||||
Reference in New Issue
Block a user