2
0
Simon Klee 1 өдөр өмнө
parent
commit
39f9955876

+ 34 - 6
packages/opencode/src/cli/cmd/run/footer.ts

@@ -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 - 0
packages/opencode/src/cli/cmd/run/otel.ts

@@ -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),
+  )
+}

+ 129 - 103
packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts

@@ -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(() => {})
+  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,
+      }
+      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 { 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 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)
+      const sigint = () => {
+        footer.requestExit()
+      }
+      process.on("SIGINT", sigint)
 
-  let closed = false
-  const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string }) => {
-    if (closed) {
-      return
-    }
+      let closed = false
+      const close = async (next: { showExit: boolean; sessionTitle?: string; sessionID?: string }) => {
+        if (closed) {
+          return
+        }
 
-    closed = true
-    process.off("SIGINT", sigint)
+        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(() => {})
+            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,
-          }),
+              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)
+            }
+          },
         )
-        await renderer.idle().catch(() => {})
       }
-    } finally {
-      footer.close()
-      await footer.idle().catch(() => {})
-      footer.destroy()
-      shutdown(renderer)
-    }
-  }
 
-  return {
-    footer,
-    close,
-  }
+      return {
+        footer,
+        close,
+      }
+    },
+  )
 }

+ 440 - 368
packages/opencode/src/cli/cmd/run/runtime.ts

@@ -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,
+  return withRunSpan(
+    "RunInteractive.session",
+    {
+      "opencode.mode": input.resolveSession ? "local" : "attach",
+      "opencode.initial_input": !!input.initialInput,
+      "opencode.demo": input.demo,
+    },
+    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,
+          })
         })
-  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
+        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
-      }
+          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.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",
+          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
         }
-      }
 
-      activeVariant = cycleVariant(activeVariant, variants)
-      saveVariant(ctx.model, activeVariant)
-      return {
-        status: activeVariant ? `variant ${activeVariant}` : "variant default",
-        modelLabel: formatModelLabel(ctx.model, activeVariant),
-      }
-    },
-    onInterrupt: () => {
-      if (!hasSession) {
-        return
+        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
       }
 
-      if (aborting) {
-        return
+      void footer
+        .idle()
+        .then(() => {
+          if (footer.isClosed) {
+            return
+          }
+
+          void loadCatalog()
+        })
+        .catch(() => {})
+
+      if (Flag.OPENCODE_SHOW_TTFD) {
+        footer.append({
+          kind: "system",
+          text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
+          phase: "final",
+          source: "system",
+        })
       }
 
-      aborting = true
-      void ctx.sdk.session
-        .abort({
+      if (input.demo) {
+        await ensureSession()
+        demo = createRunDemo({
+          mode: input.demo,
+          text: input.demoText,
+          footer,
           sessionID,
+          thinking: input.thinking,
+          limits: () => limits,
         })
-        .catch(() => {})
-        .finally(() => {
-          aborting = false
+      }
+
+      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,
         })
-    },
-    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) {
+        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")
+          }
 
-      void loadCatalog()
-    })
-    .catch(() => {})
-
-  if (Flag.OPENCODE_SHOW_TTFD) {
-    footer.append({
-      kind: "system",
-      text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
-      phase: "final",
-      source: "system",
-    })
-  }
-
-  if (input.demo) {
-    await ensureSession()
-    demo = createRunDemo({
-      mode: input.demo,
-      text: input.demoText,
-      footer,
-      sessionID,
-      thinking: input.thinking,
-      limits: () => limits,
-    })
-  }
-
-  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")
-      }
+          const mod = await streamTask
+          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")
+          }
 
-      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
+        })
       }
 
-      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
+      const runQueue = async () => {
+        let includeFiles = true
+        if (demo) {
+          await demo.start()
         }
 
-        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 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
+            }
+
+            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
+                  }
+
+                  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 text =
-            stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error))
-          footer.append({ kind: "error", text, phase: "start", source: "system" })
+      try {
+        const eager = ctx.resume === true || !input.resolveSession || !!input.demo
+        if (eager) {
+          await ensureStream()
         }
-      },
-    })
-  }
-
-  try {
-    const eager = ctx.resume === true || !input.resolveSession || !!input.demo
-    if (eager) {
-      await ensureStream()
-    }
-
-    if (!eager && input.resolveSession) {
-      queueMicrotask(() => {
-        if (footer.isClosed) {
-          return
+
+        if (!eager && input.resolveSession) {
+          queueMicrotask(() => {
+            if (footer.isClosed) {
+              return
+            }
+
+            void ensureStream().catch(() => {})
+          })
         }
 
-        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,
-    })
-  }
+        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,
+        })
+      }
+    },
+  )
 }
 
 // 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
-      }
+  return withRunSpan(
+    "RunInteractive.localMode",
+    {
+      "opencode.directory": input.directory,
+      "opencode.initial_input": !!input.initialInput,
+      "opencode.demo": input.demo,
+    },
+    async () => {
+      const sdk = createOpencodeClient({
+        baseUrl: "http://opencode.internal",
+        fetch: input.fetch,
+        directory: input.directory,
+      })
+      let pending: Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }> | undefined
 
-      pending = Promise.all([input.resolveAgent(), input.session(sdk)]).then(([agent, session]) => {
-        if (!session?.id) {
-          throw new Error("Session not found")
-        }
+      return runInteractiveRuntime({
+        files: input.files,
+        initialInput: input.initialInput,
+        thinking: input.thinking,
+        demo: input.demo,
+        demoText: input.demoText,
+        resolveSession: () => {
+          if (pending) {
+            return pending
+          }
 
-        void input.share(sdk, session.id).catch(() => {})
-        return {
-          sessionID: session.id,
-          sessionTitle: session.title,
-          agent,
-        }
+          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,
+          }
+        },
       })
-      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,
+        }),
+      }),
+  )
 }

+ 15 - 1
packages/opencode/src/cli/cmd/run/scrollback.surface.ts

@@ -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 {

+ 1 - 0
packages/opencode/test/cli/run/footer.test.ts

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