Selaa lähdekoodia

feat(plugin): expose app metadata (#38179)

Dax 3 viikkoa sitten
vanhempi
sitoutus
8de40be6ea
71 muutettua tiedostoa jossa 477 lisäystä ja 286 poistoa
  1. 2 2
      packages/cli/src/acp/service.ts
  2. 2 0
      packages/cli/src/commands/handlers/default.ts
  3. 10 8
      packages/cli/src/index.ts
  4. 9 5
      packages/cli/src/server-process.ts
  5. 3 3
      packages/cli/src/services/server-connection.ts
  6. 10 10
      packages/cli/src/services/service-config.ts
  7. 3 3
      packages/cli/src/services/update-preflight.tsx
  8. 11 15
      packages/cli/src/services/updater.ts
  9. 8 0
      packages/cli/src/version.ts
  10. 4 4
      packages/cli/test/mini.test.ts
  11. 3 3
      packages/cli/test/server-connection.test.ts
  12. 5 5
      packages/cli/test/service.test.ts
  13. 33 0
      packages/core/src/app.ts
  14. 3 2
      packages/core/src/mcp/client.ts
  15. 28 7
      packages/core/src/mcp/index.ts
  16. 4 5
      packages/core/src/models-dev.ts
  17. 2 0
      packages/core/src/plugin.ts
  18. 3 0
      packages/core/src/plugin/host.ts
  19. 1 0
      packages/core/src/plugin/promise.ts
  20. 4 4
      packages/core/src/plugin/provider/cloudflare-ai-gateway.ts
  21. 10 7
      packages/core/src/plugin/provider/cloudflare-workers-ai.ts
  22. 17 11
      packages/core/src/plugin/provider/github-copilot.ts
  23. 2 2
      packages/core/src/plugin/provider/gitlab.ts
  24. 24 22
      packages/core/src/plugin/provider/openai.ts
  25. 20 20
      packages/core/src/plugin/provider/xai.ts
  26. 7 6
      packages/core/src/plugin/skill.ts
  27. 4 2
      packages/core/src/session.ts
  28. 6 6
      packages/core/src/session/compaction.ts
  29. 4 4
      packages/core/src/session/generate-node.ts
  30. 4 4
      packages/core/src/session/model-headers.ts
  31. 4 4
      packages/core/src/session/model-request.ts
  32. 6 6
      packages/core/src/session/title.ts
  33. 2 2
      packages/core/src/tool/websearch.ts
  34. 8 0
      packages/core/test/app.test.ts
  35. 4 3
      packages/core/test/effect/observability.test.ts
  36. 9 2
      packages/core/test/mcp.test.ts
  37. 1 1
      packages/core/test/models.test.ts
  38. 1 0
      packages/core/test/plugin/host.ts
  39. 3 0
      packages/core/test/plugin/provider-github-copilot.test.ts
  40. 3 2
      packages/core/test/plugin/skill.test.ts
  41. 3 3
      packages/core/test/session-compaction.test.ts
  42. 3 3
      packages/core/test/session-runner.test.ts
  43. 3 3
      packages/core/test/session-title.test.ts
  44. 5 0
      packages/plugin/src/v2/app.ts
  45. 2 0
      packages/plugin/src/v2/effect/plugin.ts
  46. 2 0
      packages/plugin/src/v2/promise/plugin.ts
  47. 1 1
      packages/sdk-next/src/opencode.ts
  48. 21 0
      packages/sdk-next/test/embedded.test.ts
  49. 8 5
      packages/server/src/handlers/health.ts
  50. 7 1
      packages/server/src/options.ts
  51. 7 6
      packages/server/src/process.ts
  52. 22 9
      packages/server/src/routes.ts
  53. 7 5
      packages/server/src/server-info.ts
  54. 6 0
      packages/server/test/options.test.ts
  55. 2 0
      packages/server/test/process.test.ts
  56. 2 1
      packages/simulation/src/backend/index.ts
  57. 5 4
      packages/simulation/src/backend/simulated-provider.ts
  58. 8 5
      packages/simulation/src/frontend/server.ts
  59. 2 2
      packages/simulation/src/frontend/simulation.ts
  60. 2 2
      packages/simulation/test/simulated-provider.test.ts
  61. 10 5
      packages/tui/src/app.tsx
  62. 3 2
      packages/tui/src/component/dialog-debug.tsx
  63. 6 5
      packages/tui/src/component/error-component.tsx
  64. 15 0
      packages/tui/src/context/runtime.tsx
  65. 4 4
      packages/tui/src/feature-plugins/home/footer.tsx
  66. 2 0
      packages/tui/test/app-lifecycle.test.tsx
  67. 0 14
      packages/util/src/client.ts
  68. 0 6
      packages/util/src/installation/version.ts
  69. 14 7
      packages/util/src/observability.ts
  70. 4 4
      packages/util/src/observability/logging.ts
  71. 14 9
      packages/util/src/observability/otlp.ts

+ 2 - 2
packages/cli/src/acp/service.ts

@@ -38,7 +38,7 @@ import type {
   SetSessionModeRequest,
   SetSessionModeRequest,
   SetSessionModeResponse,
   SetSessionModeResponse,
 } from "@agentclientprotocol/sdk"
 } from "@agentclientprotocol/sdk"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { OPENCODE_VERSION } from "../version"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { SessionMessage } from "@opencode-ai/schema/session-message"
 import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
 import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
 import { promptContentToParts } from "./content"
 import { promptContentToParts } from "./content"
@@ -176,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
           sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
           sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
         },
         },
         authMethods: [authMethod],
         authMethods: [authMethod],
-        agentInfo: { name: "OpenCode", version: InstallationVersion },
+        agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
       }
       }
     },
     },
     authenticate: async (params) => {
     authenticate: async (params) => {

+ 2 - 0
packages/cli/src/commands/handlers/default.ts

@@ -9,6 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
 import { Updater } from "../../services/updater"
 import { Updater } from "../../services/updater"
 import { UpdatePreflight } from "../../services/update-preflight"
 import { UpdatePreflight } from "../../services/update-preflight"
 import { Npm } from "@opencode-ai/util/npm"
 import { Npm } from "@opencode-ai/util/npm"
+import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
 
 
 export default Runtime.handler(Commands, (input) =>
 export default Runtime.handler(Commands, (input) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
@@ -44,6 +45,7 @@ export default Runtime.handler(Commands, (input) =>
     const runPromise = Effect.runPromiseWith(context)
     const runPromise = Effect.runPromiseWith(context)
     const service = server.service
     const service = server.service
     yield* run({
     yield* run({
+      app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
       server: {
       server: {
         endpoint: server.endpoint,
         endpoint: server.endpoint,
         service: service
         service: service

+ 10 - 8
packages/cli/src/index.ts

@@ -1,13 +1,12 @@
 #!/usr/bin/env bun
 #!/usr/bin/env bun
 
 
 import { NodeRuntime, NodeServices } from "@effect/platform-node"
 import { NodeRuntime, NodeServices } from "@effect/platform-node"
-import { Effect, Layer } from "effect"
+import { Effect } from "effect"
 import { Commands } from "./commands/commands"
 import { Commands } from "./commands/commands"
 import { Runtime } from "./framework/runtime"
 import { Runtime } from "./framework/runtime"
 import { Observability } from "@opencode-ai/util/observability"
 import { Observability } from "@opencode-ai/util/observability"
-import { Client } from "@opencode-ai/util/client"
 import { Updater } from "./services/updater"
 import { Updater } from "./services/updater"
-import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/util/installation/version"
+import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "./version"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { Global } from "@opencode-ai/util/global"
 import { Global } from "@opencode-ai/util/global"
 import { AppProcess } from "@opencode-ai/util/process"
 import { AppProcess } from "@opencode-ai/util/process"
@@ -53,12 +52,12 @@ const Handlers = Runtime.handlers(Commands, {
 })
 })
 
 
 Effect.logInfo("cli starting", {
 Effect.logInfo("cli starting", {
-  version: InstallationVersion,
-  channel: InstallationChannel,
-  local: InstallationLocal,
+  version: OPENCODE_VERSION,
+  channel: OPENCODE_CHANNEL,
+  local: OPENCODE_LOCAL,
   args: process.argv.slice(2),
   args: process.argv.slice(2),
 }).pipe(
 }).pipe(
-  Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
+  Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
   Effect.annotateLogs({ role: "cli" }),
   Effect.annotateLogs({ role: "cli" }),
   Effect.provide(Config.layer),
   Effect.provide(Config.layer),
   Effect.provide(Updater.layer),
   Effect.provide(Updater.layer),
@@ -74,7 +73,10 @@ Effect.logInfo("cli starting", {
     Observability.layer({
     Observability.layer({
       endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
       endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
       headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
       headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
-    }).pipe(Layer.provide(Client.layer(process.env.OPENCODE_CLIENT))),
+      client: process.env.OPENCODE_CLIENT ?? "cli",
+      version: OPENCODE_VERSION,
+      channel: OPENCODE_CHANNEL,
+    }),
   ),
   ),
   Effect.provide(NodeServices.layer),
   Effect.provide(NodeServices.layer),
   Effect.scoped,
   Effect.scoped,

+ 9 - 5
packages/cli/src/server-process.ts

@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
 import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
 import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { Global } from "@opencode-ai/util/global"
 import { Global } from "@opencode-ai/util/global"
-import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
+import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
 import { AppProcess } from "@opencode-ai/util/process"
 import { AppProcess } from "@opencode-ai/util/process"
 import { randomBytes, randomUUID } from "node:crypto"
 import { randomBytes, randomUUID } from "node:crypto"
 import path from "node:path"
 import path from "node:path"
@@ -69,7 +69,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
       const instanceID = randomUUID()
       const instanceID = randomUUID()
       const server = yield* start(
       const server = yield* start(
         {
         {
-          client: process.env.OPENCODE_CLIENT ?? "cli",
+          app: {
+            name: process.env.OPENCODE_CLIENT ?? "cli",
+            version: OPENCODE_VERSION,
+            channel: OPENCODE_CHANNEL,
+          },
           hostname,
           hostname,
           port,
           port,
           password,
           password,
@@ -77,11 +81,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
           database: {
           database: {
             path:
             path:
               process.env.OPENCODE_DB ??
               process.env.OPENCODE_DB ??
-              (["latest", "beta", "prod"].includes(InstallationChannel) ||
+              (["latest", "beta", "prod"].includes(OPENCODE_CHANNEL) ||
               process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
               process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
               process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
               process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
                 ? "opencode.db"
                 ? "opencode.db"
-                : `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
+                : `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
           },
           },
           models: {
           models: {
             url: process.env.OPENCODE_MODELS_URL,
             url: process.env.OPENCODE_MODELS_URL,
@@ -173,7 +177,7 @@ const register = Effect.fnUntraced(function* (
   yield* fs.makeDirectory(path.dirname(file), { recursive: true })
   yield* fs.makeDirectory(path.dirname(file), { recursive: true })
   const info = {
   const info = {
     id,
     id,
-    version: InstallationVersion,
+    version: OPENCODE_VERSION,
     url: HttpServer.formatAddress(address),
     url: HttpServer.formatAddress(address),
     pid: process.pid,
     pid: process.pid,
     password,
     password,

+ 3 - 3
packages/cli/src/services/server-connection.ts

@@ -1,6 +1,6 @@
 import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
 import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service"
 import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
 import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { OPENCODE_VERSION } from "../version"
 import { Effect, Redacted } from "effect"
 import { Effect, Redacted } from "effect"
 import { Env } from "../env"
 import { Env } from "../env"
 import { ServiceConfig } from "./service-config"
 import { ServiceConfig } from "./service-config"
@@ -32,9 +32,9 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
       try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
       try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
       catch: (cause) => connectError(endpoint, cause),
       catch: (cause) => connectError(endpoint, cause),
     })
     })
-    if (health.version !== InstallationVersion)
+    if (health.version !== OPENCODE_VERSION)
       process.stderr.write(
       process.stderr.write(
-        `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${InstallationVersion}. Continuing anyway.\n`,
+        `Warning: Server at ${endpoint.url} has version ${health.version}; this client is ${OPENCODE_VERSION}. Continuing anyway.\n`,
       )
       )
     return { endpoint } satisfies Resolved
     return { endpoint } satisfies Resolved
   }
   }

+ 10 - 10
packages/cli/src/services/service-config.ts

@@ -1,5 +1,5 @@
 import { Global } from "@opencode-ai/util/global"
 import { Global } from "@opencode-ai/util/global"
-import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
+import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../version"
 import { Hash } from "@opencode-ai/util/hash"
 import { Hash } from "@opencode-ai/util/hash"
 import { Service } from "@opencode-ai/client/effect/service"
 import { Service } from "@opencode-ai/client/effect/service"
 import { Effect, FileSystem, Option, Schema } from "effect"
 import { Effect, FileSystem, Option, Schema } from "effect"
@@ -24,26 +24,26 @@ type Key = (typeof keys)[number]
 const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
 const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
 const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
 const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
 
 
-export function filename(channel = InstallationChannel) {
+export function filename(channel = OPENCODE_CHANNEL) {
   if (channel === "latest" || channel === "next") return "service.json"
   if (channel === "latest" || channel === "next") return "service.json"
   return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
   return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
 }
 }
 
 
-export function defaultPort(channel = InstallationChannel) {
+export function defaultPort(channel = OPENCODE_CHANNEL) {
   if (channel === "latest" || channel === "next") return 0xc0de
   if (channel === "latest" || channel === "next") return 0xc0de
   if (channel === "local") return 0xc0df
   if (channel === "local") return 0xc0df
   return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
   return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
 }
 }
 
 
-export function legacyFilename(channel = InstallationChannel) {
+export function legacyFilename(channel = OPENCODE_CHANNEL) {
   if (channel === "latest" || channel === "local") return
   if (channel === "latest" || channel === "local") return
   return `service-${Hash.fast(channel)}.json`
   return `service-${Hash.fast(channel)}.json`
 }
 }
 
 
 export function versionBelongsToChannel(
 export function versionBelongsToChannel(
   version: string | undefined,
   version: string | undefined,
-  channel = InstallationChannel,
-  installedVersion = InstallationVersion,
+  channel = OPENCODE_CHANNEL,
+  installedVersion = OPENCODE_VERSION,
 ) {
 ) {
   if (version === undefined) return false
   if (version === undefined) return false
   if (version === installedVersion) return true
   if (version === installedVersion) return true
@@ -55,8 +55,8 @@ export function versionBelongsToChannel(
 export const migrateRegistration = Effect.fnUntraced(function* (
 export const migrateRegistration = Effect.fnUntraced(function* (
   legacy: string,
   legacy: string,
   file: string,
   file: string,
-  channel = InstallationChannel,
-  installedVersion = InstallationVersion,
+  channel = OPENCODE_CHANNEL,
+  installedVersion = OPENCODE_VERSION,
 ) {
 ) {
   const fs = yield* FileSystem.FileSystem
   const fs = yield* FileSystem.FileSystem
   const text = yield* fs.readFileString(legacy).pipe(Effect.option)
   const text = yield* fs.readFileString(legacy).pipe(Effect.option)
@@ -92,7 +92,7 @@ const paths = Effect.gen(function* () {
     legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
     legacyConfigFile: legacy ? path.join(global.config, legacy) : undefined,
     legacyRegistrationFiles: [
     legacyRegistrationFiles: [
       ...(legacy ? [path.join(global.state, legacy)] : []),
       ...(legacy ? [path.join(global.state, legacy)] : []),
-      ...(name !== "service.json" && InstallationChannel !== "local" ? [path.join(global.state, "service.json")] : []),
+      ...(name !== "service.json" && OPENCODE_CHANNEL !== "local" ? [path.join(global.state, "service.json")] : []),
     ],
     ],
     configFile: path.join(global.config, name),
     configFile: path.join(global.config, name),
   }
   }
@@ -103,7 +103,7 @@ export const options = Effect.fnUntraced(function* () {
   yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
   yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file))
   return {
   return {
     file,
     file,
-    version: InstallationVersion,
+    version: OPENCODE_VERSION,
     command: [...selfCommand(), "serve", "--service"],
     command: [...selfCommand(), "serve", "--service"],
   }
   }
 })
 })

+ 3 - 3
packages/cli/src/services/update-preflight.tsx

@@ -3,7 +3,7 @@
 // version-mismatched background service before the TUI attaches.
 // version-mismatched background service before the TUI attaches.
 import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
 import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
 import { render, useTerminalDimensions } from "@opentui/solid"
 import { render, useTerminalDimensions } from "@opentui/solid"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { OPENCODE_VERSION } from "../version"
 import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
 import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
 import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
 import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
 import { go } from "@opencode-ai/tui/logo"
 import { go } from "@opencode-ai/tui/logo"
@@ -356,12 +356,12 @@ function UpdateFooter(props: {
           ] as const)
           ] as const)
         : []),
         : []),
       ["to", colors.muted],
       ["to", colors.muted],
-      [InstallationVersion, colors.accent],
+      [OPENCODE_VERSION, colors.accent],
     )
     )
   const completedHeader = phrase(
   const completedHeader = phrase(
     ["OpenCode", colors.muted, true],
     ["OpenCode", colors.muted, true],
     ["updated to", colors.muted],
     ["updated to", colors.muted],
-    [InstallationVersion, colors.accent],
+    [OPENCODE_VERSION, colors.accent],
   )
   )
   const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
   const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
   const outcomeStatus = () =>
   const outcomeStatus = () =>

+ 11 - 15
packages/cli/src/services/updater.ts

@@ -1,10 +1,6 @@
 import { Global } from "@opencode-ai/util/global"
 import { Global } from "@opencode-ai/util/global"
 import { AppProcess } from "@opencode-ai/util/process"
 import { AppProcess } from "@opencode-ai/util/process"
-import {
-  InstallationChannel,
-  InstallationLocal,
-  InstallationVersion,
-} from "@opencode-ai/util/installation/version"
+import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
 import { Context, Duration, Effect, FileSystem, Layer } from "effect"
 import { Context, Duration, Effect, FileSystem, Layer } from "effect"
 import { ChildProcess } from "effect/unstable/process"
 import { ChildProcess } from "effect/unstable/process"
 import { parse, type ParseError } from "jsonc-parser"
 import { parse, type ParseError } from "jsonc-parser"
@@ -52,7 +48,7 @@ export const layer = Layer.effect(
     const fs = yield* FileSystem.FileSystem
     const fs = yield* FileSystem.FileSystem
     const global = yield* Global.Service
     const global = yield* Global.Service
     const appProcess = yield* AppProcess.Service
     const appProcess = yield* AppProcess.Service
-    const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
+    const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
 
 
     const readPolicy = Effect.fnUntraced(function* () {
     const readPolicy = Effect.fnUntraced(function* () {
       const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
       const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -99,8 +95,8 @@ export const layer = Layer.effect(
       const response = yield* Effect.tryPromise({
       const response = yield* Effect.tryPromise({
         try: () =>
         try: () =>
           fetch(
           fetch(
-            `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
-            { headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
+            `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(OPENCODE_CHANNEL)}`,
+            { headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) },
           ),
           ),
         catch: (cause) => new Error("Failed to check for updates", { cause }),
         catch: (cause) => new Error("Failed to check for updates", { cause }),
       })
       })
@@ -138,13 +134,13 @@ export const layer = Layer.effect(
 
 
     const check = Effect.fn("cli.updater.check")(function* () {
     const check = Effect.fn("cli.updater.check")(function* () {
       if (
       if (
-        InstallationLocal ||
+        OPENCODE_LOCAL ||
         ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
         ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")
       )
       )
         return yield* Effect.logInfo("update check skipped", {
         return yield* Effect.logInfo("update check skipped", {
-          reason: InstallationLocal ? "local-install" : "disabled",
-          version: InstallationVersion,
-          channel: InstallationChannel,
+          reason: OPENCODE_LOCAL ? "local-install" : "disabled",
+          version: OPENCODE_VERSION,
+          channel: OPENCODE_CHANNEL,
         })
         })
       const policy = yield* readPolicy()
       const policy = yield* readPolicy()
       if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
       if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
@@ -152,15 +148,15 @@ export const layer = Layer.effect(
       return yield* Effect.gen(function* () {
       return yield* Effect.gen(function* () {
         const version = yield* latest()
         const version = yield* latest()
         yield* Effect.logInfo("update check", {
         yield* Effect.logInfo("update check", {
-          current: InstallationVersion,
+          current: OPENCODE_VERSION,
           latest: version,
           latest: version,
         })
         })
-        const next = action(InstallationVersion, version, policy)
+        const next = action(OPENCODE_VERSION, version, policy)
         if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
         if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
         const detected = yield* method()
         const detected = yield* method()
         if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
         if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
         yield* upgrade(detected, version)
         yield* upgrade(detected, version)
-        yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
+        yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
       })
       })
     }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
     }, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
 
 

+ 8 - 0
packages/cli/src/version.ts

@@ -0,0 +1,8 @@
+declare const OPENCODE_VERSION: string
+declare const OPENCODE_CHANNEL: string
+
+const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
+const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
+
+export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
+export const OPENCODE_LOCAL = channel === "local"

+ 4 - 4
packages/cli/test/mini.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
 import { ClientError, OpenCode } from "@opencode-ai/client/promise"
 import { ClientError, OpenCode } from "@opencode-ai/client/promise"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { OPENCODE_VERSION } from "../src/version"
 import path from "node:path"
 import path from "node:path"
 import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
 import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
 import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
 import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
@@ -31,14 +31,14 @@ describe("mini command", () => {
     const initial = Bun.serve({
     const initial = Bun.serve({
       port: 0,
       port: 0,
       fetch() {
       fetch() {
-        return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
+        return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
       },
       },
     })
     })
     const replacement = Bun.serve({
     const replacement = Bun.serve({
       port: 0,
       port: 0,
       fetch(request) {
       fetch(request) {
         authorization.push(request.headers.get("authorization"))
         authorization.push(request.headers.get("authorization"))
-        return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
+        return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
       },
       },
     })
     })
     const controller = new AbortController()
     const controller = new AbortController()
@@ -145,7 +145,7 @@ describe("mini command", () => {
       fetch(request) {
       fetch(request) {
         const url = new URL(request.url)
         const url = new URL(request.url)
         if (url.pathname === "/api/health")
         if (url.pathname === "/api/health")
-          return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid })
+          return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
         if (url.pathname === "/api/location")
         if (url.pathname === "/api/location")
           return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
           return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
         if (url.pathname === "/api/model") {
         if (url.pathname === "/api/model") {

+ 3 - 3
packages/cli/test/server-connection.test.ts

@@ -1,6 +1,6 @@
 import { NodeFileSystem } from "@effect/platform-node"
 import { NodeFileSystem } from "@effect/platform-node"
 import { Global } from "@opencode-ai/util/global"
 import { Global } from "@opencode-ai/util/global"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { OPENCODE_VERSION } from "../src/version"
 import { expect, test } from "bun:test"
 import { expect, test } from "bun:test"
 import { Effect, FileSystem, Scope } from "effect"
 import { Effect, FileSystem, Scope } from "effect"
 import fs from "node:fs/promises"
 import fs from "node:fs/promises"
@@ -17,7 +17,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
     fetch() {
     fetch() {
       return Response.json({
       return Response.json({
         healthy: true,
         healthy: true,
-        version: InstallationVersion,
+        version: OPENCODE_VERSION,
         pid: process.pid,
         pid: process.pid,
       })
       })
     },
     },
@@ -33,7 +33,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
       registration,
       registration,
       JSON.stringify({
       JSON.stringify({
         id,
         id,
-        version: InstallationVersion,
+        version: OPENCODE_VERSION,
         url: server.url.toString(),
         url: server.url.toString(),
         pid: process.pid,
         pid: process.pid,
       }),
       }),

+ 5 - 5
packages/cli/test/service.test.ts

@@ -1,7 +1,7 @@
 import { NodeFileSystem } from "@effect/platform-node"
 import { NodeFileSystem } from "@effect/platform-node"
 import { Service, type Info } from "@opencode-ai/client/effect/service"
 import { Service, type Info } from "@opencode-ai/client/effect/service"
 import { Global } from "@opencode-ai/util/global"
 import { Global } from "@opencode-ai/util/global"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { OPENCODE_VERSION } from "../src/version"
 import { expect, test } from "bun:test"
 import { expect, test } from "bun:test"
 import { Effect, Schema } from "effect"
 import { Effect, Schema } from "effect"
 import fs from "node:fs/promises"
 import fs from "node:fs/promises"
@@ -332,7 +332,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async ()
   )
   )
   const stale = {
   const stale = {
     id: "stale",
     id: "stale",
-    version: InstallationVersion,
+    version: OPENCODE_VERSION,
     url: "http://127.0.0.1:1",
     url: "http://127.0.0.1:1",
     pid: process.pid,
     pid: process.pid,
     password: "stale",
     password: "stale",
@@ -368,7 +368,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
     fetch() {
     fetch() {
       requests.count += 1
       requests.count += 1
       if (requests.count === 2) recognizing.resolve()
       if (requests.count === 2) recognizing.resolve()
-      return Response.json({ healthy: true, version: InstallationVersion, pid: process.pid }, { status: 503 })
+      return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 })
     },
     },
   })
   })
   const registration = path.join(root, "state", "opencode", "service-local.json")
   const registration = path.join(root, "state", "opencode", "service-local.json")
@@ -380,7 +380,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
     registration,
     registration,
     JSON.stringify({
     JSON.stringify({
       id: "stale",
       id: "stale",
-      version: InstallationVersion,
+      version: OPENCODE_VERSION,
       url: "http://127.0.0.1:1",
       url: "http://127.0.0.1:1",
       pid: 2_147_483_647,
       pid: 2_147_483_647,
       password: "stale",
       password: "stale",
@@ -397,7 +397,7 @@ test("port contender recognizes an incumbent registered during the bind race", a
     await Bun.sleep(8_000)
     await Bun.sleep(8_000)
     const info = {
     const info = {
       id: "incumbent",
       id: "incumbent",
-      version: InstallationVersion,
+      version: OPENCODE_VERSION,
       url: `http://127.0.0.1:${listener.port}`,
       url: `http://127.0.0.1:${listener.port}`,
       pid: process.pid,
       pid: process.pid,
       password: "incumbent",
       password: "incumbent",

+ 33 - 0
packages/core/src/app.ts

@@ -0,0 +1,33 @@
+export * as App from "./app"
+
+import { Context, Layer } from "effect"
+import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
+
+export interface Info {
+  readonly name: string
+  readonly version: string
+  readonly channel: string
+}
+
+export const Metadata = Context.Reference<Info>("@opencode/App", {
+  defaultValue: () => make(),
+})
+
+export function make(input: Partial<Info> = {}): Info {
+  return {
+    name: input.name ?? "opencode",
+    version: input.version ?? "unknown",
+    channel: input.channel ?? "unknown",
+  }
+}
+
+export function useragent(app: Info) {
+  return `opencode/${app.channel}/${app.version}/${app.name}`
+}
+
+export const layer = (input?: Partial<Info>) => Layer.succeed(Metadata, make(input))
+
+export const configured = (input?: Partial<Info>) =>
+  makeGlobalNode({ service: Metadata, layer: layer(input), deps: [] })
+
+export const node = configured()

+ 3 - 2
packages/core/src/mcp/client.ts

@@ -12,6 +12,7 @@ import {
   ElicitationCompleteNotificationSchema,
   ElicitationCompleteNotificationSchema,
   ElicitRequestSchema,
   ElicitRequestSchema,
   GetPromptResultSchema,
   GetPromptResultSchema,
+  type Implementation,
   type ElicitRequestFormParams,
   type ElicitRequestFormParams,
   type ElicitRequestParams,
   type ElicitRequestParams,
   type ElicitRequestURLParams,
   type ElicitRequestURLParams,
@@ -29,7 +30,6 @@ import {
 } from "@modelcontextprotocol/sdk/types.js"
 } from "@modelcontextprotocol/sdk/types.js"
 import { Cause, Effect, Exit, Schema } from "effect"
 import { Cause, Effect, Exit, Schema } from "effect"
 import { ConfigMCP } from "../config/mcp"
 import { ConfigMCP } from "../config/mcp"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 
 
 const DEFAULT_STARTUP_TIMEOUT = 30_000
 const DEFAULT_STARTUP_TIMEOUT = 30_000
 const DEFAULT_CATALOG_TIMEOUT = 30_000
 const DEFAULT_CATALOG_TIMEOUT = 30_000
@@ -185,6 +185,7 @@ export const connect = Effect.fnUntraced(function* (
   // stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
   // stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
   authProvider?: OAuthClientProvider,
   authProvider?: OAuthClientProvider,
   elicitation?: ElicitationHandler,
   elicitation?: ElicitationHandler,
+  clientInfo: Implementation = { name: "opencode", version: "unknown" },
 ) {
 ) {
   const transport: Transport = yield* Effect.gen(function* () {
   const transport: Transport = yield* Effect.gen(function* () {
     if (config.type === "local") {
     if (config.type === "local") {
@@ -209,7 +210,7 @@ export const connect = Effect.fnUntraced(function* (
     })
     })
   })
   })
   const client = new Client(
   const client = new Client(
-    { name: "opencode", version: InstallationVersion },
+    clientInfo,
     {
     {
       capabilities: {
       capabilities: {
         ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
         ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),

+ 28 - 7
packages/core/src/mcp/index.ts

@@ -157,7 +157,17 @@ export interface Interface {
 
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
 
 
-export const layer = Layer.effect(
+export const Options = Schema.Struct({
+  clientInfo: Schema.optional(
+    Schema.Struct({
+      name: Schema.String,
+      version: Schema.String,
+    }),
+  ),
+})
+export type Options = typeof Options.Type
+
+export const layer = (options?: Options) => Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
     const config = yield* Config.Service
     const config = yield* Config.Service
@@ -498,7 +508,14 @@ export const layer = Layer.effect(
         const authProvider = yield* connectProvider(entry)
         const authProvider = yield* connectProvider(entry)
         // List tools as part of connect so a failure here marks the server failed rather than
         // List tools as part of connect so a failure here marks the server failed rather than
         // leaving it connected with a silently empty tool list and no path to recover.
         // leaving it connected with a silently empty tool list and no path to recover.
-        const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe(
+        const result = yield* MCPClient.connect(
+          name,
+          entry.config,
+          location.directory,
+          authProvider,
+          elicitation,
+          options?.clientInfo,
+        ).pipe(
           Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
           Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
           Scope.provide(scope),
           Scope.provide(scope),
           Effect.exit,
           Effect.exit,
@@ -772,11 +789,15 @@ export const layer = Layer.effect(
   }),
   }),
 )
 )
 
 
-export const node = makeLocationNode({
-  service: Service,
-  layer,
-  deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
-})
+export function configured(options?: Options) {
+  return makeLocationNode({
+    service: Service,
+    layer: layer(options),
+    deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
+  })
+}
+
+export const node = configured()
 
 
 // Schema `optional` strips undefined-valued properties on encode, so fields can assign
 // Schema `optional` strips undefined-valued properties on encode, so fields can assign
 // optional properties directly instead of conditionally spreading them.
 // optional properties directly instead of conditionally spreading them.

+ 4 - 5
packages/core/src/models-dev.ts

@@ -3,12 +3,11 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { ModelsDev } from "@opencode-ai/schema/models-dev"
 import { ModelsDev } from "@opencode-ai/schema/models-dev"
 import { Money } from "@opencode-ai/schema/money"
 import { Money } from "@opencode-ai/schema/money"
-import { Client } from "@opencode-ai/util/client"
+import { App } from "./app"
 import { Global } from "@opencode-ai/util/global"
 import { Global } from "@opencode-ai/util/global"
 import { Flock } from "@opencode-ai/util/flock"
 import { Flock } from "@opencode-ai/util/flock"
 import { Hash } from "@opencode-ai/util/hash"
 import { Hash } from "@opencode-ai/util/hash"
 import { FSUtil } from "@opencode-ai/util/fs-util"
 import { FSUtil } from "@opencode-ai/util/fs-util"
-import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
 import { EventV2 } from "./event"
 import { EventV2 } from "./event"
 import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
 import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
 import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
 import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
@@ -543,7 +542,7 @@ export const layer = (options?: Options) =>
   Effect.gen(function* () {
   Effect.gen(function* () {
     const fs = yield* FSUtil.Service
     const fs = yield* FSUtil.Service
     const events = yield* EventV2.Service
     const events = yield* EventV2.Service
-      const client = yield* Client.Name
+      const app = yield* App.Metadata
     const http = HttpClient.filterStatusOk(
     const http = HttpClient.filterStatusOk(
       (yield* HttpClient.HttpClient).pipe(
       (yield* HttpClient.HttpClient).pipe(
         HttpClient.retryTransient({
         HttpClient.retryTransient({
@@ -556,7 +555,7 @@ export const layer = (options?: Options) =>
 
 
     const source = options?.url || "https://models.dev"
     const source = options?.url || "https://models.dev"
     const fetch = options?.fetch ?? true
     const fetch = options?.fetch ?? true
-      const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${client}`
+      const userAgent = App.useragent(app)
     const filepath = path.join(
     const filepath = path.join(
       Global.Path.cache,
       Global.Path.cache,
       source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
       source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
@@ -660,7 +659,7 @@ export function configured(options?: Options) {
   return makeGlobalNode({
   return makeGlobalNode({
     service: Service,
     service: Service,
     layer: layer(options),
     layer: layer(options),
-    deps: [FSUtil.node, EventV2.node, Client.node, httpClient],
+    deps: [FSUtil.node, EventV2.node, App.node, httpClient],
   })
   })
 }
 }
 
 

+ 2 - 0
packages/core/src/plugin.ts

@@ -3,6 +3,7 @@ export * as PluginV2 from "./plugin"
 import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
 import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
 import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
+import { App } from "./app"
 import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
 import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
 import { AgentV2 } from "./agent"
 import { AgentV2 } from "./agent"
 import { AISDK } from "./aisdk"
 import { AISDK } from "./aisdk"
@@ -144,6 +145,7 @@ export const node = makeLocationNode({
   layer,
   layer,
   deps: [
   deps: [
     EventV2.node,
     EventV2.node,
+    App.node,
     AgentV2.node,
     AgentV2.node,
     AISDK.node,
     AISDK.node,
     Catalog.node,
     Catalog.node,

+ 3 - 0
packages/core/src/plugin/host.ts

@@ -2,6 +2,7 @@ export * as PluginHost from "./host"
 
 
 import type { Plugin } from "@opencode-ai/plugin/v2/effect"
 import type { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { EventManifest } from "@opencode-ai/schema/event-manifest"
 import { EventManifest } from "@opencode-ai/schema/event-manifest"
+import { App } from "../app"
 import { Effect, Schema, Stream } from "effect"
 import { Effect, Schema, Stream } from "effect"
 import { AgentV2 } from "../agent"
 import { AgentV2 } from "../agent"
 import { AISDK } from "../aisdk"
 import { AISDK } from "../aisdk"
@@ -26,6 +27,7 @@ import { PluginHooks } from "./hooks"
 
 
 const mutable = <T>(value: T) => value as DeepMutable<T>
 const mutable = <T>(value: T) => value as DeepMutable<T>
 export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
 export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
+  const app = yield* App.Metadata
   const agents = yield* AgentV2.Service
   const agents = yield* AgentV2.Service
   const aisdk = yield* AISDK.Service
   const aisdk = yield* AISDK.Service
   const catalog = yield* Catalog.Service
   const catalog = yield* Catalog.Service
@@ -61,6 +63,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
     effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
     effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
 
 
   return {
   return {
+    app,
     options: {},
     options: {},
     agent: {
     agent: {
       get: (id) => agents.get(AgentV2.ID.make(id)),
       get: (id) => agents.get(AgentV2.ID.make(id)),

+ 1 - 0
packages/core/src/plugin/promise.ts

@@ -57,6 +57,7 @@ export function fromPromise(plugin: Plugin) {
             )
             )
 
 
         const context2: Context = {
         const context2: Context = {
+          app: host.app,
           options: host.options,
           options: host.options,
           agent: {
           agent: {
             get: (id) => run(host.agent.get(id)),
             get: (id) => run(host.agent.get(id)),

+ 4 - 4
packages/core/src/plugin/provider/cloudflare-ai-gateway.ts

@@ -1,5 +1,5 @@
 import os from "os"
 import os from "os"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "../../app"
 import { Effect, Option, Schema } from "effect"
 import { Effect, Option, Schema } from "effect"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 
 
@@ -23,7 +23,7 @@ export const CloudflareAIGatewayPlugin = define({
           accountId: config.accountId,
           accountId: config.accountId,
           gateway: config.gatewayId,
           gateway: config.gatewayId,
           apiKey: config.apiKey,
           apiKey: config.apiKey,
-          options: gatewayOptions(evt.options, metadata),
+          options: gatewayOptions(evt.options, metadata, ctx.app),
         } as any)
         } as any)
         const unified = createUnified({ apiKey: config.apiKey })
         const unified = createUnified({ apiKey: config.apiKey })
         evt.sdk = {
         evt.sdk = {
@@ -64,7 +64,7 @@ function gatewayMetadata(options: Record<string, unknown>) {
   return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
   return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined
 }
 }
 
 
-function gatewayOptions(options: Record<string, unknown>, metadata: unknown) {
+function gatewayOptions(options: Record<string, unknown>, metadata: unknown, app: App.Info) {
   return {
   return {
     metadata,
     metadata,
     cacheTtl: options.cacheTtl,
     cacheTtl: options.cacheTtl,
@@ -72,7 +72,7 @@ function gatewayOptions(options: Record<string, unknown>, metadata: unknown) {
     skipCache: options.skipCache,
     skipCache: options.skipCache,
     collectLog: options.collectLog,
     collectLog: options.collectLog,
     headers: {
     headers: {
-      "User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
+      "User-Agent": `${App.useragent(app)} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
     },
     },
   }
   }
 }
 }

+ 10 - 7
packages/core/src/plugin/provider/cloudflare-workers-ai.ts

@@ -1,5 +1,5 @@
 import os from "os"
 import os from "os"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "../../app"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ProviderV2 } from "../../provider"
 import { ProviderV2 } from "../../provider"
@@ -29,10 +29,13 @@ export const CloudflareWorkersAIPlugin = define({
         if (!hasWorkersEndpoint(evt.model) && !accountId) return
         if (!hasWorkersEndpoint(evt.model) && !accountId) return
         const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
         const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
         evt.sdk = mod.createOpenAICompatible(
         evt.sdk = mod.createOpenAICompatible(
-          sdkOptions({
-            ...evt.options,
-            baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
-          }) as any,
+          sdkOptions(
+            {
+              ...evt.options,
+              baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
+            },
+            ctx.app,
+          ) as any,
         )
         )
       }),
       }),
     )
     )
@@ -61,13 +64,13 @@ function hasWorkersEndpoint(model: {
   return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
   return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string"
 }
 }
 
 
-function sdkOptions(options: Record<string, any>) {
+function sdkOptions(options: Record<string, any>, app: App.Info) {
   return {
   return {
     ...options,
     ...options,
     baseURL: expandAccountId(options.baseURL),
     baseURL: expandAccountId(options.baseURL),
     apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
     apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
     headers: {
     headers: {
-      "User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
+      "User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
       ...options.headers,
       ...options.headers,
     },
     },
     name: providerID,
     name: providerID,

+ 17 - 11
packages/core/src/plugin/provider/github-copilot.ts

@@ -4,7 +4,7 @@ import { Catalog } from "../../catalog"
 import { Credential } from "../../credential"
 import { Credential } from "../../credential"
 import { EventV2 } from "../../event"
 import { EventV2 } from "../../event"
 import { CopilotModels } from "../../github-copilot/models"
 import { CopilotModels } from "../../github-copilot/models"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "../../app"
 import { Integration } from "../../integration"
 import { Integration } from "../../integration"
 import { ModelV2 } from "../../model"
 import { ModelV2 } from "../../model"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
@@ -30,7 +30,7 @@ const Token = Schema.Struct({
 const JsonBody = Schema.UnknownFromJsonString
 const JsonBody = Schema.UnknownFromJsonString
 const decodeBody = Schema.decodeUnknownOption(JsonBody)
 const decodeBody = Schema.decodeUnknownOption(JsonBody)
 
 
-const oauth = {
+const oauth = (app: App.Info) => ({
   integrationID: Integration.ID.make("github-copilot"),
   integrationID: Integration.ID.make("github-copilot"),
   method: {
   method: {
     id: methodID,
     id: methodID,
@@ -63,7 +63,7 @@ const oauth = {
       const urls = oauthURLs(domain)
       const urls = oauthURLs(domain)
       const device = yield* request(urls.device, {
       const device = yield* request(urls.device, {
         method: "POST",
         method: "POST",
-        headers: headers(),
+        headers: headers(app),
         body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
         body: JSON.stringify({ client_id: clientID, scope: "read:user" }),
       }).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
       }).pipe(Effect.map(Schema.decodeUnknownSync(Device)))
       const interval = Math.max(device.interval, 1) * 1000
       const interval = Math.max(device.interval, 1) * 1000
@@ -71,7 +71,7 @@ const oauth = {
       const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
       const poll = (wait: number): Effect.Effect<Credential.OAuth, unknown> =>
         request(urls.token, {
         request(urls.token, {
           method: "POST",
           method: "POST",
-          headers: headers(),
+          headers: headers(app),
           body: JSON.stringify({
           body: JSON.stringify({
             client_id: clientID,
             client_id: clientID,
             device_code: device.device_code,
             device_code: device.device_code,
@@ -109,7 +109,7 @@ const oauth = {
         callback: poll(interval),
         callback: poll(interval),
       }
       }
     }),
     }),
-} satisfies IntegrationOAuthMethodRegistration
+}) satisfies IntegrationOAuthMethodRegistration
 
 
 function shouldUseResponses(modelID: string) {
 function shouldUseResponses(modelID: string) {
   // Copilot supports Responses for GPT-5 class models, except mini variants
   // Copilot supports Responses for GPT-5 class models, except mini variants
@@ -152,7 +152,7 @@ export const GithubCopilotPlugin = define({
             {
             {
               ...provider?.headers,
               ...provider?.headers,
               Authorization: `Bearer ${credential.refresh}`,
               Authorization: `Bearer ${credential.refresh}`,
-              "User-Agent": `opencode/${InstallationVersion}`,
+              "User-Agent": App.useragent(ctx.app),
               "X-GitHub-Api-Version": apiVersion,
               "X-GitHub-Api-Version": apiVersion,
             },
             },
             existing,
             existing,
@@ -166,7 +166,7 @@ export const GithubCopilotPlugin = define({
     })
     })
 
 
     yield* ctx.integration.transform((draft) => {
     yield* ctx.integration.transform((draft) => {
-      draft.method.update(oauth)
+      draft.method.update(oauth(ctx.app))
     })
     })
     yield* ctx.catalog.transform((evt) => {
     yield* ctx.catalog.transform((evt) => {
       const item = evt.provider.get(ProviderV2.ID.githubCopilot)
       const item = evt.provider.get(ProviderV2.ID.githubCopilot)
@@ -209,6 +209,7 @@ export const GithubCopilotPlugin = define({
           typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
           typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined,
           evt.options.fetch,
           evt.options.fetch,
           evt.package === "@ai-sdk/anthropic",
           evt.package === "@ai-sdk/anthropic",
+          ctx.app,
         )
         )
         if (evt.package === "@ai-sdk/anthropic") {
         if (evt.package === "@ai-sdk/anthropic") {
           evt.options.headers = {
           evt.options.headers = {
@@ -261,11 +262,11 @@ function baseURL(enterprise?: string) {
   return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
   return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com"
 }
 }
 
 
-function headers() {
+function headers(app: App.Info) {
   return {
   return {
     Accept: "application/json",
     Accept: "application/json",
     "Content-Type": "application/json",
     "Content-Type": "application/json",
-    "User-Agent": `opencode/${InstallationVersion}`,
+    "User-Agent": App.useragent(app),
   }
   }
 }
 }
 
 
@@ -282,7 +283,12 @@ function request(url: string, init: RequestInit) {
 
 
 type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
 type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
 
 
-export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch {
+export function copilotFetch(
+  token: string | undefined,
+  upstream: Fetch | undefined,
+  anthropic: boolean,
+  app: App.Info,
+): Fetch {
   const send = upstream ?? fetch
   const send = upstream ?? fetch
   return async (input, init) => {
   return async (input, init) => {
     const requestHeaders = new Headers(init?.headers)
     const requestHeaders = new Headers(init?.headers)
@@ -291,7 +297,7 @@ export function copilotFetch(token: string | undefined, upstream: Fetch | undefi
       requestHeaders.delete("x-api-key")
       requestHeaders.delete("x-api-key")
       requestHeaders.set("Authorization", `Bearer ${token}`)
       requestHeaders.set("Authorization", `Bearer ${token}`)
     }
     }
-    requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`)
+    requestHeaders.set("User-Agent", App.useragent(app))
     requestHeaders.set("Openai-Intent", "conversation-edits")
     requestHeaders.set("Openai-Intent", "conversation-edits")
     requestHeaders.set("X-GitHub-Api-Version", apiVersion)
     requestHeaders.set("X-GitHub-Api-Version", apiVersion)
     if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")
     if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14")

+ 2 - 2
packages/core/src/plugin/provider/gitlab.ts

@@ -1,5 +1,5 @@
 import os from "os"
 import os from "os"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "../../app"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { ProviderV2 } from "../../provider"
 import { ProviderV2 } from "../../provider"
@@ -20,7 +20,7 @@ export const GitLabPlugin = define({
               : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"),
               : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"),
           apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN,
           apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN,
           aiGatewayHeaders: {
           aiGatewayHeaders: {
-            "User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
+            "User-Agent": `${App.useragent(ctx.app)} gitlab-ai-provider/${mod.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
             "anthropic-beta": "context-1m-2025-08-07",
             "anthropic-beta": "context-1m-2025-08-07",
             ...evt.options.aiGatewayHeaders,
             ...evt.options.aiGatewayHeaders,
           },
           },

+ 24 - 22
packages/core/src/plugin/provider/openai.ts

@@ -2,9 +2,9 @@ import { createServer } from "node:http"
 import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
 import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
 import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
+import { App } from "../../app"
 import { Credential } from "../../credential"
 import { Credential } from "../../credential"
 import { EventV2 } from "../../event"
 import { EventV2 } from "../../event"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { Integration } from "../../integration"
 import { Integration } from "../../integration"
 import { ModelV2 } from "../../model"
 import { ModelV2 } from "../../model"
 import { OauthCallbackPage } from "../../oauth/page"
 import { OauthCallbackPage } from "../../oauth/page"
@@ -42,7 +42,7 @@ const Claims = Schema.fromJsonString(
 )
 )
 const decodeClaims = Schema.decodeUnknownOption(Claims)
 const decodeClaims = Schema.decodeUnknownOption(Claims)
 
 
-const browser = {
+const browser = (app: App.Info) => ({
   integrationID: Integration.ID.make("openai"),
   integrationID: Integration.ID.make("openai"),
   method: {
   method: {
     id: browserMethodID,
     id: browserMethodID,
@@ -91,15 +91,15 @@ const browser = {
         url: authorizeURL(redirect, pkce, state),
         url: authorizeURL(redirect, pkce, state),
         instructions: "Complete authorization in your browser. This window will close automatically.",
         instructions: "Complete authorization in your browser. This window will close automatically.",
         callback: Deferred.await(code).pipe(
         callback: Deferred.await(code).pipe(
-          Effect.flatMap((value) => exchange(value, redirect, pkce)),
+          Effect.flatMap((value) => exchange(value, redirect, pkce, app)),
           Effect.map((tokens) => credential(browserMethodID, tokens)),
           Effect.map((tokens) => credential(browserMethodID, tokens)),
         ),
         ),
       }
       }
     }),
     }),
-  refresh: (value) => refresh(browserMethodID, value),
-} satisfies IntegrationOAuthMethodRegistration
+  refresh: (value) => refresh(browserMethodID, value, app),
+}) satisfies IntegrationOAuthMethodRegistration
 
 
-const headless = {
+const headless = (app: App.Info) => ({
   integrationID: Integration.ID.make("openai"),
   integrationID: Integration.ID.make("openai"),
   method: {
   method: {
     id: headlessMethodID,
     id: headlessMethodID,
@@ -112,7 +112,7 @@ const headless = {
         `${issuer}/api/accounts/deviceauth/usercode`,
         `${issuer}/api/accounts/deviceauth/usercode`,
         {
         {
           method: "POST",
           method: "POST",
-          headers: headers("application/json"),
+          headers: headers("application/json", app),
           body: JSON.stringify({ client_id: clientID }),
           body: JSON.stringify({ client_id: clientID }),
         },
         },
       )
       )
@@ -127,7 +127,7 @@ const headless = {
               try: (signal) =>
               try: (signal) =>
                 fetch(`${issuer}/api/accounts/deviceauth/token`, {
                 fetch(`${issuer}/api/accounts/deviceauth/token`, {
                   method: "POST",
                   method: "POST",
-                  headers: headers("application/json"),
+                  headers: headers("application/json", app),
                   body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
                   body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
                   signal,
                   signal,
                 }),
                 }),
@@ -140,10 +140,12 @@ const headless = {
               }
               }
               return credential(
               return credential(
                 headlessMethodID,
                 headlessMethodID,
-                yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
-                  verifier: data.code_verifier,
-                  challenge: "",
-                }),
+                yield* exchange(
+                  data.authorization_code,
+                  `${issuer}/deviceauth/callback`,
+                  { verifier: data.code_verifier, challenge: "" },
+                  app,
+                ),
               )
               )
             }
             }
             if (response.status !== 403 && response.status !== 404) {
             if (response.status !== 403 && response.status !== 404) {
@@ -154,8 +156,8 @@ const headless = {
         }),
         }),
       }
       }
     }),
     }),
-  refresh: (value) => refresh(headlessMethodID, value),
-} satisfies IntegrationOAuthMethodRegistration
+  refresh: (value) => refresh(headlessMethodID, value, app),
+}) satisfies IntegrationOAuthMethodRegistration
 
 
 export const OpenAIPlugin = define({
 export const OpenAIPlugin = define({
   id: "opencode.provider.openai",
   id: "opencode.provider.openai",
@@ -173,8 +175,8 @@ export const OpenAIPlugin = define({
     })
     })
 
 
     yield* ctx.integration.transform((draft) => {
     yield* ctx.integration.transform((draft) => {
-      draft.method.update(browser)
-      draft.method.update(headless)
+      draft.method.update(browser(ctx.app))
+      draft.method.update(headless(ctx.app))
     })
     })
     yield* load()
     yield* load()
     yield* ctx.catalog.transform((evt) => {
     yield* ctx.catalog.transform((evt) => {
@@ -232,14 +234,14 @@ export const OpenAIPlugin = define({
   }),
   }),
 } satisfies PluginInternal.InternalPlugin)
 } satisfies PluginInternal.InternalPlugin)
 
 
-function headers(contentType: string) {
-  return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
+function headers(contentType: string, app: App.Info) {
+  return { "Content-Type": contentType, "User-Agent": App.useragent(app) }
 }
 }
 
 
-function exchange(code: string, redirect: string, pkce: Pkce) {
+function exchange(code: string, redirect: string, pkce: Pkce, app: App.Info) {
   return request<TokenResponse>(`${issuer}/oauth/token`, {
   return request<TokenResponse>(`${issuer}/oauth/token`, {
     method: "POST",
     method: "POST",
-    headers: headers("application/x-www-form-urlencoded"),
+    headers: headers("application/x-www-form-urlencoded", app),
     body: new URLSearchParams({
     body: new URLSearchParams({
       grant_type: "authorization_code",
       grant_type: "authorization_code",
       code,
       code,
@@ -250,10 +252,10 @@ function exchange(code: string, redirect: string, pkce: Pkce) {
   })
   })
 }
 }
 
 
-function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">) {
+function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">, app: App.Info) {
   return request<TokenResponse>(`${issuer}/oauth/token`, {
   return request<TokenResponse>(`${issuer}/oauth/token`, {
     method: "POST",
     method: "POST",
-    headers: headers("application/x-www-form-urlencoded"),
+    headers: headers("application/x-www-form-urlencoded", app),
     body: new URLSearchParams({
     body: new URLSearchParams({
       grant_type: "refresh_token",
       grant_type: "refresh_token",
       refresh_token: value.refresh,
       refresh_token: value.refresh,

+ 20 - 20
packages/core/src/plugin/provider/xai.ts

@@ -2,8 +2,8 @@ import { createServer } from "node:http"
 import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
 import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { define } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Clock, Deferred, Effect, Option, Schema } from "effect"
 import { Clock, Deferred, Effect, Option, Schema } from "effect"
+import { App } from "../../app"
 import { Credential } from "../../credential"
 import { Credential } from "../../credential"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { Integration } from "../../integration"
 import { Integration } from "../../integration"
 import { OauthCallbackPage } from "../../oauth/page"
 import { OauthCallbackPage } from "../../oauth/page"
 import { ProviderV2 } from "../../provider"
 import { ProviderV2 } from "../../provider"
@@ -48,7 +48,7 @@ const DeviceError = Schema.Struct({
 })
 })
 const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
 const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
 
 
-const browser = {
+const browser = (app: App.Info) => ({
   integrationID: Integration.ID.make("xai"),
   integrationID: Integration.ID.make("xai"),
   method: {
   method: {
     id: browserMethodID,
     id: browserMethodID,
@@ -108,15 +108,15 @@ const browser = {
         url: authorizeURL(pkce, state, randomString(32)),
         url: authorizeURL(pkce, state, randomString(32)),
         instructions: "Complete authorization in your browser. This window will close automatically.",
         instructions: "Complete authorization in your browser. This window will close automatically.",
         callback: Deferred.await(code).pipe(
         callback: Deferred.await(code).pipe(
-          Effect.flatMap((value) => exchange(value, pkce)),
+          Effect.flatMap((value) => exchange(value, pkce, app)),
           Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
           Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
         ),
         ),
       }
       }
     }),
     }),
-  refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })),
-} satisfies IntegrationOAuthMethodRegistration
+  refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app),
+}) satisfies IntegrationOAuthMethodRegistration
 
 
-const device = {
+const device = (app: App.Info) => ({
   integrationID: Integration.ID.make("xai"),
   integrationID: Integration.ID.make("xai"),
   method: {
   method: {
     id: deviceMethodID,
     id: deviceMethodID,
@@ -128,7 +128,7 @@ const device = {
       `${issuer}/device/code`,
       `${issuer}/device/code`,
       {
       {
         method: "POST",
         method: "POST",
-        headers: headers(),
+        headers: headers(app),
         body: new URLSearchParams({ client_id: clientID, scope }).toString(),
         body: new URLSearchParams({ client_id: clientID, scope }).toString(),
       },
       },
       Device,
       Device,
@@ -142,14 +142,14 @@ const device = {
               url: value.verification_uri_complete ?? value.verification_uri,
               url: value.verification_uri_complete ?? value.verification_uri,
               instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
               instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`,
               ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
               ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}),
-              callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
+              callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))),
             }
             }
           }),
           }),
         ),
         ),
       ),
       ),
     ),
     ),
-  refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })),
-} satisfies IntegrationOAuthMethodRegistration
+  refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app),
+}) satisfies IntegrationOAuthMethodRegistration
 
 
 export const XAIPlugin = define({
 export const XAIPlugin = define({
   id: "opencode.provider.xai",
   id: "opencode.provider.xai",
@@ -158,8 +158,8 @@ export const XAIPlugin = define({
       draft.update("xai", (integration) => {
       draft.update("xai", (integration) => {
         integration.name = "xAI"
         integration.name = "xAI"
       })
       })
-      draft.method.update(browser)
-      draft.method.update(device)
+      draft.method.update(browser(ctx.app))
+      draft.method.update(device(ctx.app))
       draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
       draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
     })
     })
     yield* ctx.aisdk.hook(
     yield* ctx.aisdk.hook(
@@ -180,12 +180,12 @@ export const XAIPlugin = define({
   }),
   }),
 })
 })
 
 
-function exchange(code: string, pkce: Pkce) {
+function exchange(code: string, pkce: Pkce, app: App.Info) {
   return request(
   return request(
     `${issuer}/token`,
     `${issuer}/token`,
     {
     {
       method: "POST",
       method: "POST",
-      headers: headers(),
+      headers: headers(app),
       body: new URLSearchParams({
       body: new URLSearchParams({
         grant_type: "authorization_code",
         grant_type: "authorization_code",
         code,
         code,
@@ -198,12 +198,12 @@ function exchange(code: string, pkce: Pkce) {
   )
   )
 }
 }
 
 
-function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
+function refresh(methodID: Integration.MethodID, value: Credential.OAuth, app: App.Info) {
   return request(
   return request(
     `${issuer}/token`,
     `${issuer}/token`,
     {
     {
       method: "POST",
       method: "POST",
-      headers: headers(),
+      headers: headers(app),
       body: new URLSearchParams({
       body: new URLSearchParams({
         grant_type: "refresh_token",
         grant_type: "refresh_token",
         refresh_token: value.refresh,
         refresh_token: value.refresh,
@@ -214,7 +214,7 @@ function refresh(methodID: Integration.MethodID, value: Credential.OAuth) {
   ).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
   ).pipe(Effect.flatMap((tokens) => credential(methodID, tokens, value.refresh, value.metadata)))
 }
 }
 
 
-function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
+function poll(device: typeof Device.Type, app: App.Info): Effect.Effect<Token, unknown> {
   return Effect.gen(function* () {
   return Effect.gen(function* () {
     const started = yield* Clock.currentTimeMillis
     const started = yield* Clock.currentTimeMillis
     const expires = started + positiveSeconds(device.expires_in, 300) * 1000
     const expires = started + positiveSeconds(device.expires_in, 300) * 1000
@@ -225,7 +225,7 @@ function poll(device: typeof Device.Type): Effect.Effect<Token, unknown> {
         }
         }
         const response = yield* send(`${issuer}/token`, {
         const response = yield* send(`${issuer}/token`, {
           method: "POST",
           method: "POST",
-          headers: headers(),
+          headers: headers(app),
           body: new URLSearchParams({
           body: new URLSearchParams({
             grant_type: deviceGrant,
             grant_type: deviceGrant,
             client_id: clientID,
             client_id: clientID,
@@ -314,11 +314,11 @@ function tokenExpiration(tokens: Token) {
   return expiration ? expiration * 1000 : Date.now() + 3600 * 1000
   return expiration ? expiration * 1000 : Date.now() + 3600 * 1000
 }
 }
 
 
-function headers() {
+function headers(app: App.Info) {
   return {
   return {
     "Content-Type": "application/x-www-form-urlencoded",
     "Content-Type": "application/x-www-form-urlencoded",
     Accept: "application/json",
     Accept: "application/json",
-    "User-Agent": `opencode/${InstallationVersion}`,
+    "User-Agent": App.useragent(app),
   }
   }
 }
 }
 
 

+ 7 - 6
packages/core/src/plugin/skill.ts

@@ -2,11 +2,10 @@
 
 
 export * as SkillPlugin from "./skill"
 export * as SkillPlugin from "./skill"
 
 
-import { define } from "@opencode-ai/plugin/v2/effect/plugin"
+import { define, type Context } from "@opencode-ai/plugin/v2/effect/plugin"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { AbsolutePath } from "../schema"
 import { AbsolutePath } from "../schema"
 import { SkillV2 } from "../skill"
 import { SkillV2 } from "../skill"
-import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
 import { Config } from "../config"
 import { Config } from "../config"
 import { Location } from "../location"
 import { Location } from "../location"
 import { FSUtil } from "@opencode-ai/util/fs-util"
 import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -27,7 +26,7 @@ const REPORT_DESCRIPTION =
 export const Plugin = define({
 export const Plugin = define({
   id: "opencode.skill",
   id: "opencode.skill",
   effect: Effect.fn(function* (ctx) {
   effect: Effect.fn(function* (ctx) {
-    const reportContent = yield* reportContentWithDiagnostics()
+    const reportContent = yield* reportContentWithDiagnostics(ctx.app)
     yield* ctx.skill.transform((draft) => {
     yield* ctx.skill.transform((draft) => {
       draft.source(
       draft.source(
         SkillV2.EmbeddedSource.make({
         SkillV2.EmbeddedSource.make({
@@ -58,7 +57,9 @@ export const Plugin = define({
   }),
   }),
 })
 })
 
 
-const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () {
+const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
+  app: Context["app"],
+) {
   const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
   const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
   return [
   return [
     ReportContent,
     ReportContent,
@@ -67,8 +68,8 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
     "",
     "",
     "These values were captured when the built-in report skill was registered. Verify them before publishing.",
     "These values were captured when the built-in report skill was registered. Verify them before publishing.",
     "",
     "",
-    `- opencode version: ${InstallationVersion}`,
-    `- install/channel: ${InstallationChannel}`,
+    `- opencode version: ${app.version}`,
+    `- install/channel: ${app.channel}`,
     `- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
     `- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
     `- Terminal: ${terminal()}`,
     `- Terminal: ${terminal()}`,
     `- Shell: ${shell()}`,
     `- Shell: ${shell()}`,

+ 4 - 2
packages/core/src/session.ts

@@ -20,7 +20,7 @@ import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
 import { AgentV2 } from "./agent"
 import { AgentV2 } from "./agent"
 import { SessionV1 } from "./v1/session"
 import { SessionV1 } from "./v1/session"
 import { Money } from "@opencode-ai/schema/money"
 import { Money } from "@opencode-ai/schema/money"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "./app"
 import { Slug } from "./util/slug"
 import { Slug } from "./util/slug"
 import { ProjectTable } from "./project/sql"
 import { ProjectTable } from "./project/sql"
 import path from "path"
 import path from "path"
@@ -305,6 +305,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
 const layer = Layer.effect(
 const layer = Layer.effect(
   Service,
   Service,
   Effect.gen(function* () {
   Effect.gen(function* () {
+    const app = yield* App.Metadata
     const database = yield* Database.Service
     const database = yield* Database.Service
     const db = database.db
     const db = database.db
     const events = yield* EventV2.Service
     const events = yield* EventV2.Service
@@ -352,7 +353,7 @@ const layer = Layer.effect(
         const info = SessionV1.SessionInfo.make({
         const info = SessionV1.SessionInfo.make({
           id: sessionID,
           id: sessionID,
           slug: Slug.create(),
           slug: Slug.create(),
-          version: InstallationVersion,
+          version: app.version,
           projectID: project.id,
           projectID: project.id,
           parentID: input.parentID,
           parentID: input.parentID,
           directory: location.directory,
           directory: location.directory,
@@ -1040,5 +1041,6 @@ export const node = makeGlobalNode({
     SessionProjector.node,
     SessionProjector.node,
     FSUtil.node,
     FSUtil.node,
     Global.node,
     Global.node,
+    App.node,
   ],
   ],
 })
 })

+ 6 - 6
packages/core/src/session/compaction.ts

@@ -10,7 +10,7 @@ import { llmClient } from "../effect/app-node-platform"
 import { SessionEvent } from "./event"
 import { SessionEvent } from "./event"
 import type { SessionMessage } from "./message"
 import type { SessionMessage } from "./message"
 import { SessionModelHeaders } from "./model-headers"
 import { SessionModelHeaders } from "./model-headers"
-import { Client } from "@opencode-ai/util/client"
+import { App } from "../app"
 import { SessionRunnerModel } from "./runner/model"
 import { SessionRunnerModel } from "./runner/model"
 import { SessionSchema } from "./schema"
 import { SessionSchema } from "./schema"
 import { toSessionError } from "./to-session-error"
 import { toSessionError } from "./to-session-error"
@@ -61,7 +61,7 @@ type Settings = {
 }
 }
 
 
 type Dependencies = {
 type Dependencies = {
-  readonly client: string
+  readonly app: App.Info
   readonly events: EventV2.Interface
   readonly events: EventV2.Interface
   readonly llm: {
   readonly llm: {
     readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
     readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
@@ -260,7 +260,7 @@ const make = (dependencies: Dependencies) => {
       .stream(
       .stream(
         LLM.request({
         LLM.request({
           model: plan.model,
           model: plan.model,
-          http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) },
+          http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
           messages: [Message.user(plan.prompt)],
           messages: [Message.user(plan.prompt)],
           tools: [],
           tools: [],
         }),
         }),
@@ -399,13 +399,13 @@ export const layer = Layer.effect(
     const llm = yield* LLMClient.Service
     const llm = yield* LLMClient.Service
     const config = yield* Config.Service
     const config = yield* Config.Service
     const models = yield* SessionRunnerModel.Service
     const models = yield* SessionRunnerModel.Service
-    const client = yield* Client.Name
-    return make({ events, llm, models, config: settings(yield* config.entries()), client })
+    const app = yield* App.Metadata
+    return make({ events, llm, models, config: settings(yield* config.entries()), app })
   }),
   }),
 )
 )
 
 
 export const node = makeLocationNode({
 export const node = makeLocationNode({
   service: Service,
   service: Service,
   layer,
   layer,
-  deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node],
+  deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, App.node],
 })
 })

+ 4 - 4
packages/core/src/session/generate-node.ts

@@ -4,7 +4,7 @@ import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
 import { Effect, Layer } from "effect"
 import { Effect, Layer } from "effect"
 import { Database } from "../database/database"
 import { Database } from "../database/database"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
-import { Client } from "@opencode-ai/util/client"
+import { App } from "../app"
 import { llmClient } from "../effect/app-node-platform"
 import { llmClient } from "../effect/app-node-platform"
 import { PluginHooks } from "../plugin/hooks"
 import { PluginHooks } from "../plugin/hooks"
 import { SessionContext } from "./context"
 import { SessionContext } from "./context"
@@ -23,7 +23,7 @@ export const layer = Layer.effect(
     const hooks = yield* PluginHooks.Service
     const hooks = yield* PluginHooks.Service
     const llm = yield* LLMClient.Service
     const llm = yield* LLMClient.Service
     const models = yield* SessionRunnerModel.Service
     const models = yield* SessionRunnerModel.Service
-    const client = yield* Client.Name
+    const app = yield* App.Metadata
 
 
     return SessionGenerate.Service.of({
     return SessionGenerate.Service.of({
       generate: Effect.fn("SessionGenerate.generate")(function* (input) {
       generate: Effect.fn("SessionGenerate.generate")(function* (input) {
@@ -51,7 +51,7 @@ export const layer = Layer.effect(
         return (yield* llm.generate(
         return (yield* llm.generate(
           LLM.request({
           LLM.request({
             model: model.model,
             model: model.model,
-            http: { headers: SessionModelHeaders.make(selection.session, client) },
+            http: { headers: SessionModelHeaders.make(selection.session, app) },
             providerOptions: { openai: { promptCacheKey } },
             providerOptions: { openai: { promptCacheKey } },
             system: contextEvent.system,
             system: contextEvent.system,
             messages: contextEvent.messages,
             messages: contextEvent.messages,
@@ -67,5 +67,5 @@ export const layer = Layer.effect(
 export const node = makeLocationNode({
 export const node = makeLocationNode({
   service: SessionGenerate.Service,
   service: SessionGenerate.Service,
   layer,
   layer,
-  deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, Client.node, llmClient],
+  deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient],
 })
 })

+ 4 - 4
packages/core/src/session/model-headers.ts

@@ -1,14 +1,14 @@
 export * as SessionModelHeaders from "./model-headers"
 export * as SessionModelHeaders from "./model-headers"
 
 
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "../app"
 import { SessionSchema } from "./schema"
 import { SessionSchema } from "./schema"
 
 
-export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, client: string) => ({
+export const make = (session: Pick<SessionSchema.Info, "id" | "parentID" | "projectID">, app: App.Info) => ({
   "x-session-affinity": session.id,
   "x-session-affinity": session.id,
   "X-Session-Id": session.id,
   "X-Session-Id": session.id,
   ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
   ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
-  "User-Agent": `opencode/${InstallationVersion}`,
+  "User-Agent": App.useragent(app),
   "x-opencode-project": session.projectID,
   "x-opencode-project": session.projectID,
   "x-opencode-session": session.id,
   "x-opencode-session": session.id,
-  "x-opencode-client": client,
+  "x-opencode-client": app.name,
 })
 })

+ 4 - 4
packages/core/src/session/model-request.ts

@@ -4,7 +4,7 @@ import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@op
 import { SessionError } from "@opencode-ai/schema/session-error"
 import { SessionError } from "@opencode-ai/schema/session-error"
 import { Context, Effect, Layer } from "effect"
 import { Context, Effect, Layer } from "effect"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
-import { Client } from "@opencode-ai/util/client"
+import { App } from "../app"
 import { ModelV2 } from "../model"
 import { ModelV2 } from "../model"
 import { PluginHooks } from "../plugin/hooks"
 import { PluginHooks } from "../plugin/hooks"
 import { ToolRegistry } from "../tool/registry"
 import { ToolRegistry } from "../tool/registry"
@@ -85,7 +85,7 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
   Effect.gen(function* () {
     const hooks = yield* PluginHooks.Service
     const hooks = yield* PluginHooks.Service
     const registry = yield* ToolRegistry.Service
     const registry = yield* ToolRegistry.Service
-    const client = yield* Client.Name
+    const app = yield* App.Metadata
 
 
     const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
     const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) {
       const session = input.context.session
       const session = input.context.session
@@ -123,7 +123,7 @@ export const layer = Layer.effect(
       const request = LLM.request({
       const request = LLM.request({
         model,
         model,
         http: {
         http: {
-          headers: SessionModelHeaders.make(session, client),
+          headers: SessionModelHeaders.make(session, app),
         },
         },
         providerOptions: { openai: { promptCacheKey } },
         providerOptions: { openai: { promptCacheKey } },
         system: contextEvent.system,
         system: contextEvent.system,
@@ -157,5 +157,5 @@ export const layer = Layer.effect(
 export const node = makeLocationNode({
 export const node = makeLocationNode({
   service: Service,
   service: Service,
   layer,
   layer,
-  deps: [PluginHooks.node, ToolRegistry.node, Client.node],
+  deps: [PluginHooks.node, ToolRegistry.node, App.node],
 })
 })

+ 6 - 6
packages/core/src/session/title.ts

@@ -6,7 +6,7 @@ import { AgentV2 } from "../agent"
 import { Database } from "../database/database"
 import { Database } from "../database/database"
 import { EventV2 } from "../event"
 import { EventV2 } from "../event"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
-import { Client } from "@opencode-ai/util/client"
+import { App } from "../app"
 import { llmClient } from "../effect/app-node-platform"
 import { llmClient } from "../effect/app-node-platform"
 import { SessionEvent } from "./event"
 import { SessionEvent } from "./event"
 import { SessionHistory } from "./history"
 import { SessionHistory } from "./history"
@@ -18,7 +18,7 @@ import { SessionUsage } from "./usage"
 const MAX_LENGTH = 100
 const MAX_LENGTH = 100
 
 
 type Dependencies = {
 type Dependencies = {
-  readonly client: string
+  readonly app: App.Info
   readonly events: EventV2.Interface
   readonly events: EventV2.Interface
   readonly llm: {
   readonly llm: {
     readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
     readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
@@ -68,7 +68,7 @@ const make = (dependencies: Dependencies) => {
       .stream(
       .stream(
         LLM.request({
         LLM.request({
           model: resolved.model,
           model: resolved.model,
-          http: { headers: SessionModelHeaders.make(session, dependencies.client) },
+          http: { headers: SessionModelHeaders.make(session, dependencies.app) },
           system: agent.system,
           system: agent.system,
           messages: [Message.user(firstUser.text)],
           messages: [Message.user(firstUser.text)],
           tools: [],
           tools: [],
@@ -112,8 +112,8 @@ export const layer = Layer.effect(
     const agents = yield* AgentV2.Service
     const agents = yield* AgentV2.Service
     const models = yield* SessionRunnerModel.Service
     const models = yield* SessionRunnerModel.Service
     const database = yield* Database.Service
     const database = yield* Database.Service
-    const client = yield* Client.Name
-    const title = make({ events, llm, agents, models, client })
+    const app = yield* App.Metadata
+    const title = make({ events, llm, agents, models, app })
     return Service.of({
     return Service.of({
       generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
       generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
     })
     })
@@ -123,5 +123,5 @@ export const layer = Layer.effect(
 export const node = makeLocationNode({
 export const node = makeLocationNode({
   service: Service,
   service: Service,
   layer,
   layer,
-  deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, Client.node],
+  deps: [EventV2.node, llmClient, AgentV2.node, SessionRunnerModel.node, Database.node, App.node],
 })
 })

+ 2 - 2
packages/core/src/tool/websearch.ts

@@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
 import { Context, Duration, Effect, Layer, Schema } from "effect"
 import { Context, Duration, Effect, Layer, Schema } from "effect"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { HttpClient, HttpClientRequest } from "effect/unstable/http"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "../app"
 import { PositiveInt } from "../schema"
 import { PositiveInt } from "../schema"
 import { PermissionV2 } from "../permission"
 import { PermissionV2 } from "../permission"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
@@ -241,7 +241,7 @@ export const Plugin = {
                           // V2 invocation context does not safely expose the model yet.
                           // V2 invocation context does not safely expose the model yet.
                         },
                         },
                         {
                         {
-                          "User-Agent": `opencode/${InstallationVersion}`,
+                          "User-Agent": App.useragent(ctx.app),
                           ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
                           ...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
                         },
                         },
                       )
                       )

+ 8 - 0
packages/core/test/app.test.ts

@@ -0,0 +1,8 @@
+import { expect, test } from "bun:test"
+import { App } from "@opencode-ai/core/app"
+
+test("formats app metadata as a user agent", () => {
+  expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe(
+    "opencode/beta/1.2.3/sdk",
+  )
+})

+ 4 - 3
packages/core/test/effect/observability.test.ts

@@ -39,12 +39,13 @@ describe("resource", () => {
     process.env.OTEL_RESOURCE_ATTRIBUTES =
     process.env.OTEL_RESOURCE_ATTRIBUTES =
       "opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
       "opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
 
 
-    expect(resource("cli").attributes).toMatchObject({
+    const app = { client: "cli", version: "1.2.3", channel: "beta" }
+    expect(resource(app).attributes).toMatchObject({
       "opencode.client": "cli",
       "opencode.client": "cli",
       "service.namespace": "anomalyco",
       "service.namespace": "anomalyco",
     })
     })
-    expect(resource("cli").attributes["service.instance.id"]).not.toBe("override")
-    expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
+    expect(resource(app).attributes["service.instance.id"]).not.toBe("override")
+    expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
   })
   })
 })
 })
 
 

+ 9 - 2
packages/core/test/mcp.test.ts

@@ -136,6 +136,7 @@ function resourceServer(
       return {
       return {
         state,
         state,
         url: http.url.toString(),
         url: http.url.toString(),
+        clientVersion: () => protocol.getClientVersion(),
         sendResourceListChanged: () => protocol.sendResourceListChanged(),
         sendResourceListChanged: () => protocol.sendResourceListChanged(),
         completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
         completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
         close: async () => {
         close: async () => {
@@ -151,10 +152,11 @@ function resourceServer(
 function resourceMcpLayer(
 function resourceMcpLayer(
   server: string | typeof ConfigMCP.Server.Type,
   server: string | typeof ConfigMCP.Server.Type,
   onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
   onFormCreated?: (form: Form.Info) => Effect.Effect<void>,
+  options?: MCP.Options,
 ) {
 ) {
   const directory = AbsolutePath.make(import.meta.dir)
   const directory = AbsolutePath.make(import.meta.dir)
   const unusedIntegration = () => Effect.die("unused integration service")
   const unusedIntegration = () => Effect.die("unused integration service")
-  return MCP.layer.pipe(
+  return MCP.layer(options).pipe(
     Layer.provideMerge(Form.layer),
     Layer.provideMerge(Form.layer),
     Layer.provide(
     Layer.provide(
       Layer.mergeAll(
       Layer.mergeAll(
@@ -643,7 +645,12 @@ test("loads and reads MCP resources", async () => {
               { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
               { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
             ],
             ],
           })
           })
-        }).pipe(Effect.provide(resourceMcpLayer(server.url)))
+          expect(server.clientVersion()).toMatchObject({ name: "sdk", version: "1.2.3" })
+        }).pipe(
+          Effect.provide(
+            resourceMcpLayer(server.url, undefined, { clientInfo: { name: "sdk", version: "1.2.3" } }),
+          ),
+        )
       }),
       }),
     ),
     ),
   )
   )

+ 1 - 1
packages/core/test/models.test.ts

@@ -302,7 +302,7 @@ describe("ModelsDev Service", () => {
       const final = yield* Ref.get(state)
       const final = yield* Ref.get(state)
       expect(final.calls.length).toBe(1)
       expect(final.calls.length).toBe(1)
       expect(final.calls[0].url).toContain("/api.json")
       expect(final.calls[0].url).toContain("/api.json")
-      expect(final.calls[0].userAgent).toContain("/cli")
+      expect(final.calls[0].userAgent).toContain("/opencode")
     }),
     }),
   )
   )
 
 

+ 1 - 0
packages/core/test/plugin/host.ts

@@ -19,6 +19,7 @@ type Overrides = Partial<Omit<PluginContext, "options" | "session">> & {
 
 
 export function host(overrides: Overrides = {}): PluginContext {
 export function host(overrides: Overrides = {}): PluginContext {
   return {
   return {
+    app: overrides.app ?? { name: "test", version: "test", channel: "test" },
     options: {},
     options: {},
     agent: overrides.agent ?? {
     agent: overrides.agent ?? {
       get: () => Effect.die("unused agent.get"),
       get: () => Effect.die("unused agent.get"),

+ 3 - 0
packages/core/test/plugin/provider-github-copilot.test.ts

@@ -1,4 +1,5 @@
 import { AISDK } from "@opencode-ai/core/aisdk"
 import { AISDK } from "@opencode-ai/core/aisdk"
+import { App } from "@opencode-ai/core/app"
 import { describe, expect } from "bun:test"
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { Catalog } from "@opencode-ai/core/catalog"
 import { Catalog } from "@opencode-ai/core/catalog"
@@ -62,6 +63,7 @@ describe("GithubCopilotPlugin", () => {
           return Response.json({ ok: true })
           return Response.json({ ok: true })
         },
         },
         false,
         false,
+        App.make({ name: "test", version: "1.2.3", channel: "beta" }),
       )
       )
       yield* Effect.promise(() =>
       yield* Effect.promise(() =>
         send("https://api.githubcopilot.com/chat/completions", {
         send("https://api.githubcopilot.com/chat/completions", {
@@ -77,6 +79,7 @@ describe("GithubCopilotPlugin", () => {
       expect(requests[0]?.get("x-initiator")).toBe("user")
       expect(requests[0]?.get("x-initiator")).toBe("user")
       expect(requests[0]?.get("copilot-vision-request")).toBe("true")
       expect(requests[0]?.get("copilot-vision-request")).toBe("true")
       expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
       expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01")
+      expect(requests[0]?.get("user-agent")).toBe("opencode/beta/1.2.3/test")
     }),
     }),
   )
   )
 
 

+ 3 - 2
packages/core/test/plugin/skill.test.ts

@@ -3,7 +3,6 @@ import { NodeFileSystem } from "@effect/platform-node"
 import { Config } from "@opencode-ai/core/config"
 import { Config } from "@opencode-ai/core/config"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { FSUtil } from "@opencode-ai/util/fs-util"
 import { FSUtil } from "@opencode-ai/util/fs-util"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { Location } from "@opencode-ai/core/location"
 import { Location } from "@opencode-ai/core/location"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
 import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
@@ -21,6 +20,7 @@ describe("SkillPlugin.Plugin", () => {
       const skill = yield* SkillV2.Service
       const skill = yield* SkillV2.Service
       yield* SkillPlugin.Plugin.effect(
       yield* SkillPlugin.Plugin.effect(
         host({
         host({
+          app: { name: "test", version: "1.2.3", channel: "beta" },
           skill: {
           skill: {
             list: () => Effect.die("unused skill.list"),
             list: () => Effect.die("unused skill.list"),
             transform: skill.transform,
             transform: skill.transform,
@@ -54,7 +54,8 @@ describe("SkillPlugin.Plugin", () => {
         }),
         }),
       )
       )
       expect(report?.slash).toBe(true)
       expect(report?.slash).toBe(true)
-      expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`)
+      expect(report?.content).toContain("- opencode version: 1.2.3")
+      expect(report?.content).toContain("- install/channel: beta")
     }),
     }),
   )
   )
 })
 })

+ 3 - 3
packages/core/test/session-compaction.test.ts

@@ -18,7 +18,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { Project } from "@opencode-ai/core/project"
 import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "@opencode-ai/core/app"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { Money } from "@opencode-ai/schema/money"
 import { Money } from "@opencode-ai/schema/money"
 import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
 import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
@@ -192,10 +192,10 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
       "x-session-affinity": sessionID,
       "x-session-affinity": sessionID,
       "X-Session-Id": sessionID,
       "X-Session-Id": sessionID,
       "x-parent-session-id": parentID,
       "x-parent-session-id": parentID,
-      "User-Agent": `opencode/${InstallationVersion}`,
+      "User-Agent": App.useragent(App.make()),
       "x-opencode-project": Project.ID.global,
       "x-opencode-project": Project.ID.global,
       "x-opencode-session": sessionID,
       "x-opencode-session": sessionID,
-      "x-opencode-client": "cli",
+      "x-opencode-client": "opencode",
     })
     })
     expect(requests[0]?.generation).toBeUndefined()
     expect(requests[0]?.generation).toBeUndefined()
     expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
     expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")

+ 3 - 3
packages/core/test/session-runner.test.ts

@@ -22,7 +22,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
 import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "@opencode-ai/core/app"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { Project } from "@opencode-ai/core/project"
 import { Project } from "@opencode-ai/core/project"
@@ -3180,10 +3180,10 @@ describe("SessionRunnerLLM", () => {
       expect(requests[0]?.http?.headers).toEqual({
       expect(requests[0]?.http?.headers).toEqual({
         "x-session-affinity": sessionID,
         "x-session-affinity": sessionID,
         "X-Session-Id": sessionID,
         "X-Session-Id": sessionID,
-        "User-Agent": `opencode/${InstallationVersion}`,
+        "User-Agent": App.useragent(App.make()),
         "x-opencode-project": Project.ID.global,
         "x-opencode-project": Project.ID.global,
         "x-opencode-session": sessionID,
         "x-opencode-session": sessionID,
-        "x-opencode-client": "cli",
+        "x-opencode-client": "opencode",
       })
       })
     }),
     }),
   )
   )

+ 3 - 3
packages/core/test/session-title.test.ts

@@ -17,7 +17,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { Project } from "@opencode-ai/core/project"
 import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
+import { App } from "@opencode-ai/core/app"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { Money } from "@opencode-ai/schema/money"
 import { Money } from "@opencode-ai/schema/money"
 import { Effect, Layer, Stream } from "effect"
 import { Effect, Layer, Stream } from "effect"
@@ -155,10 +155,10 @@ it.effect("generates a title from the sole user message and renames the session"
     expect(requests[0]?.http?.headers).toEqual({
     expect(requests[0]?.http?.headers).toEqual({
       "x-session-affinity": sessionID,
       "x-session-affinity": sessionID,
       "X-Session-Id": sessionID,
       "X-Session-Id": sessionID,
-      "User-Agent": `opencode/${InstallationVersion}`,
+      "User-Agent": App.useragent(App.make()),
       "x-opencode-project": Project.ID.global,
       "x-opencode-project": Project.ID.global,
       "x-opencode-session": sessionID,
       "x-opencode-session": sessionID,
-      "x-opencode-client": "cli",
+      "x-opencode-client": "opencode",
     })
     })
     expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
     expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
     const renamed = yield* store.get(sessionID)
     const renamed = yield* store.get(sessionID)

+ 5 - 0
packages/plugin/src/v2/app.ts

@@ -0,0 +1,5 @@
+export interface App {
+  readonly name: string
+  readonly version: string
+  readonly channel: string
+}

+ 2 - 0
packages/plugin/src/v2/effect/plugin.ts

@@ -1,6 +1,7 @@
 import type { PluginApi } from "@opencode-ai/client/effect/api"
 import type { PluginApi } from "@opencode-ai/client/effect/api"
 import type { Effect, Scope } from "effect"
 import type { Effect, Scope } from "effect"
 import type { PluginOptions } from "../options.js"
 import type { PluginOptions } from "../options.js"
+import type { App } from "../app.js"
 import type { AgentDomain } from "./agent.js"
 import type { AgentDomain } from "./agent.js"
 import type { AISDKDomain } from "./aisdk.js"
 import type { AISDKDomain } from "./aisdk.js"
 import type { CatalogDomain } from "./catalog.js"
 import type { CatalogDomain } from "./catalog.js"
@@ -13,6 +14,7 @@ import type { SkillDomain } from "./skill.js"
 import type { ToolDomain } from "./tool.js"
 import type { ToolDomain } from "./tool.js"
 
 
 export interface Context {
 export interface Context {
+  readonly app: App
   readonly options: PluginOptions
   readonly options: PluginOptions
   readonly agent: AgentDomain
   readonly agent: AgentDomain
   readonly aisdk: AISDKDomain
   readonly aisdk: AISDKDomain

+ 2 - 0
packages/plugin/src/v2/promise/plugin.ts

@@ -1,5 +1,6 @@
 import type { PluginApi } from "@opencode-ai/client/promise/api"
 import type { PluginApi } from "@opencode-ai/client/promise/api"
 import type { PluginOptions } from "../options.js"
 import type { PluginOptions } from "../options.js"
+import type { App } from "../app.js"
 import type { AgentDomain } from "./agent.js"
 import type { AgentDomain } from "./agent.js"
 import type { AISDKDomain } from "./aisdk.js"
 import type { AISDKDomain } from "./aisdk.js"
 import type { CatalogDomain } from "./catalog.js"
 import type { CatalogDomain } from "./catalog.js"
@@ -12,6 +13,7 @@ import type { SkillDomain } from "./skill.js"
 import type { ToolDomain } from "./tool.js"
 import type { ToolDomain } from "./tool.js"
 
 
 export interface Context {
 export interface Context {
+  readonly app: App
   readonly options: PluginOptions
   readonly options: PluginOptions
   readonly agent: AgentDomain
   readonly agent: AgentDomain
   readonly aisdk: AISDKDomain
   readonly aisdk: AISDKDomain

+ 1 - 1
packages/sdk-next/src/opencode.ts

@@ -11,7 +11,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt
       ManagedRuntime.make(
       ManagedRuntime.make(
         createEmbeddedRoutes({
         createEmbeddedRoutes({
           ...options,
           ...options,
-          client: options.client ?? "sdk",
+          app: { ...options.app, name: options.app?.name ?? "sdk" },
           database: { path: ":memory:", ...options.database },
           database: { path: ":memory:", ...options.database },
         }).pipe(Layer.provide(HttpServer.layerServices)),
         }).pipe(Layer.provide(HttpServer.layerServices)),
       ),
       ),

+ 21 - 0
packages/sdk-next/test/embedded.test.ts

@@ -25,6 +25,27 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
 const location = (fixture: Fixture) =>
 const location = (fixture: Fixture) =>
   fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
   fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
 
 
+it.live("exposes app metadata to plugins", () =>
+  withEmbedded("opencode-embedded-app-", (fixture) =>
+    Effect.gen(function* () {
+      const opencode = yield* fixture.sdk.OpenCode.create({
+        app: { name: "test", version: "1.2.3", channel: "beta" },
+      })
+      const app = yield* Deferred.make<{ readonly name: string; readonly version: string; readonly channel: string }>()
+      yield* opencode.plugin({
+        id: `app-${crypto.randomUUID()}`,
+        effect: (ctx) => Deferred.succeed(app, ctx.app).pipe(Effect.asVoid),
+      })
+      yield* opencode.plugin.list({ location: location(fixture) })
+      expect(yield* Deferred.await(app).pipe(Effect.timeout("4 seconds"))).toEqual({
+        name: "test",
+        version: "1.2.3",
+        channel: "beta",
+      })
+    }),
+  ),
+)
+
 it.live(
 it.live(
   "reloads every booted Location after SDK plugin registration",
   "reloads every booted Location after SDK plugin registration",
   () =>
   () =>

+ 8 - 5
packages/server/src/handlers/health.ts

@@ -1,15 +1,18 @@
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { Api } from "../api"
 import { Api } from "../api"
+import { ServerInfo } from "../server-info"
 
 
 export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
 export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
   handlers
   handlers
     .handle("health.get", () =>
     .handle("health.get", () =>
-      Effect.succeed({
-        healthy: true as const,
-        version: InstallationVersion,
-        pid: process.pid,
+      Effect.gen(function* () {
+        const info = yield* ServerInfo.Service
+        return {
+          healthy: true as const,
+          version: info.app.version ?? "unknown",
+          pid: process.pid,
+        }
       }),
       }),
     )
     )
     .handle("health.stop", () => Effect.succeed({ accepted: false })),
     .handle("health.stop", () => Effect.succeed({ accepted: false })),

+ 7 - 1
packages/server/src/options.ts

@@ -4,7 +4,13 @@ import { Observability } from "@opencode-ai/util/observability"
 import { Schema } from "effect"
 import { Schema } from "effect"
 
 
 export const ServerOptions = Schema.Struct({
 export const ServerOptions = Schema.Struct({
-  client: Schema.optional(Schema.String),
+  app: Schema.optional(
+    Schema.Struct({
+      name: Schema.optional(Schema.String),
+      version: Schema.optional(Schema.String),
+      channel: Schema.optional(Schema.String),
+    }),
+  ),
   hostname: Schema.optional(Schema.String),
   hostname: Schema.optional(Schema.String),
   port: Schema.optional(
   port: Schema.optional(
     Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535)),
     Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535)),

+ 7 - 6
packages/server/src/process.ts

@@ -1,7 +1,6 @@
 export * as ServerProcess from "./process"
 export * as ServerProcess from "./process"
 
 
 import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
 import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
 import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
 import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
 import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
 import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
 import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
@@ -50,7 +49,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
   // Request fibers may continue inbound trace context, but must not inherit the server startup parent.
   // Request fibers may continue inbound trace context, but must not inherit the server startup parent.
   yield* bound.http
   yield* bound.http
     .serve(
     .serve(
-      dispatch(password, status, application, shutdown).pipe(
+      dispatch(password, status, application, shutdown, options.app?.version ?? "unknown").pipe(
         HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
         HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
       ),
       ),
       HttpMiddleware.logger,
       HttpMiddleware.logger,
@@ -153,6 +152,7 @@ function dispatch(
   status: Status.Interface,
   status: Status.Interface,
   application: Ref.Ref<Option.Option<App>>,
   application: Ref.Ref<Option.Option<App>>,
   shutdown: Deferred.Deferred<void>,
   shutdown: Deferred.Deferred<void>,
+  version: string,
 ): App {
 ): App {
   const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
   const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
   return Effect.gen(function* () {
   return Effect.gen(function* () {
@@ -166,7 +166,7 @@ function dispatch(
           : undefined
           : undefined
     if (lifecycle !== undefined) {
     if (lifecycle !== undefined) {
       if (!(yield* authorizedRequest(request, auth))) return unauthorized()
       if (!(yield* authorizedRequest(request, auth))) return unauthorized()
-      return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void))
+      return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
     }
     }
     const state = yield* status.current
     const state = yield* status.current
     const app = yield* Ref.get(application)
     const app = yield* Ref.get(application)
@@ -189,8 +189,9 @@ const control = Effect.fnUntraced(function* (
   route: "health" | "stop",
   route: "health" | "stop",
   status: Status.Interface,
   status: Status.Interface,
   stop: () => void,
   stop: () => void,
+  version: string,
 ) {
 ) {
-  if (route === "health") return yield* healthResponse(status)
+  if (route === "health") return yield* healthResponse(status, version)
   const body = yield* request.json.pipe(Effect.option)
   const body = yield* request.json.pipe(Effect.option)
   const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
   const input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none()
   if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
   if (Option.isNone(input)) return HttpServerResponse.jsonUnsafe({ code: "invalid_request" }, { status: 400 })
@@ -210,10 +211,10 @@ const control = Effect.fnUntraced(function* (
   return HttpServerResponse.jsonUnsafe({ accepted })
   return HttpServerResponse.jsonUnsafe({ accepted })
 })
 })
 
 
-const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
+const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
   const state = yield* status.current
   const state = yield* status.current
   return HttpServerResponse.jsonUnsafe(
   return HttpServerResponse.jsonUnsafe(
-    { healthy: true, version: InstallationVersion, pid: process.pid },
+    { healthy: true, version, pid: process.pid },
     {
     {
       status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
       status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
       headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
       headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,

+ 22 - 9
packages/server/src/routes.ts

@@ -1,4 +1,5 @@
 import { Database } from "@opencode-ai/core/database/database"
 import { Database } from "@opencode-ai/core/database/database"
+import { App } from "@opencode-ai/core/app"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
 import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
 import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -6,7 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event"
 import { EventLogger } from "@opencode-ai/core/event-logger"
 import { EventLogger } from "@opencode-ai/core/event-logger"
 import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
 import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
 import { Observability } from "@opencode-ai/util/observability"
 import { Observability } from "@opencode-ai/util/observability"
-import { Client } from "@opencode-ai/util/client"
 import { Credential } from "@opencode-ai/core/credential"
 import { Credential } from "@opencode-ai/core/credential"
 import { Config } from "@opencode-ai/core/config"
 import { Config } from "@opencode-ai/core/config"
 import { CommandV2 } from "@opencode-ai/core/command"
 import { CommandV2 } from "@opencode-ai/core/command"
@@ -15,12 +15,9 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
 import { Pty } from "@opencode-ai/core/pty"
 import { Pty } from "@opencode-ai/core/pty"
 import { Project } from "@opencode-ai/core/project"
 import { Project } from "@opencode-ai/core/project"
 import { SessionV2 } from "@opencode-ai/core/session"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { SessionCompaction } from "@opencode-ai/core/session/compaction"
-import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
-import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
-import { SessionTitle } from "@opencode-ai/core/session/title"
 import { Shell } from "@opencode-ai/core/shell"
 import { Shell } from "@opencode-ai/core/shell"
 import { Job } from "@opencode-ai/core/job"
 import { Job } from "@opencode-ai/core/job"
+import { MCP } from "@opencode-ai/core/mcp/index"
 import { Global } from "@opencode-ai/util/global"
 import { Global } from "@opencode-ai/util/global"
 import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
 import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
 import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
 import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
@@ -88,7 +85,7 @@ function makeRoutes<AuthError, AuthServices>(
   const pluginRuntimeCell = PluginRuntime.makeCell()
   const pluginRuntimeCell = PluginRuntime.makeCell()
   const replacements: LayerNode.Replacements = [
   const replacements: LayerNode.Replacements = [
     [Database.node, Database.configured(options.database)],
     [Database.node, Database.configured(options.database)],
-    [Client.node, Client.configured(options.client)],
+    [App.node, App.configured(options.app)],
     [ModelsDev.node, ModelsDev.configured(options.models)],
     [ModelsDev.node, ModelsDev.configured(options.models)],
     [Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
     [Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
     [FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
     [FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
@@ -105,6 +102,15 @@ function makeRoutes<AuthError, AuthServices>(
     [CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })],
     [CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })],
     [Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
     [Pty.node, Pty.configured({ gitbash: options.windows?.gitbash })],
     [Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
     [Shell.node, Shell.configured({ gitbash: options.windows?.gitbash })],
+    [
+      MCP.node,
+      MCP.configured({
+        clientInfo: {
+          name: options.app?.name ?? "opencode",
+          version: options.app?.version ?? "unknown",
+        },
+      }),
+    ],
     [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
     [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
     [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
     [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
   ]
   ]
@@ -112,7 +118,7 @@ function makeRoutes<AuthError, AuthServices>(
     ? Layer.unwrap(
     ? Layer.unwrap(
         Effect.gen(function* () {
         Effect.gen(function* () {
           const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
           const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
-          const simulation = yield* simulationReplacements()
+          const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
           return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
           return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
         }),
         }),
       )
       )
@@ -123,7 +129,7 @@ function makeRoutes<AuthError, AuthServices>(
       const services = Layer.succeedContext(context)
       const services = Layer.succeedContext(context)
       const requestServices = Layer.merge(
       const requestServices = Layer.merge(
         Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
         Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
-        ServerInfo.layer(serviceURLs),
+        ServerInfo.layer(serviceURLs, options.app),
       )
       )
       return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
       return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
         Layer.provide(handlers.pipe(Layer.provide(services))),
         Layer.provide(handlers.pipe(Layer.provide(services))),
@@ -133,7 +139,14 @@ function makeRoutes<AuthError, AuthServices>(
         Layer.provide(authorizationLayer),
         Layer.provide(authorizationLayer),
         Layer.provide(schemaErrorLayer),
         Layer.provide(schemaErrorLayer),
         Layer.provide(auth),
         Layer.provide(auth),
-        Layer.provide(Observability.layer(options.observability).pipe(Layer.provide(Client.layer(options.client)))),
+        Layer.provide(
+          Observability.layer({
+            ...options.observability,
+            client: options.app?.name,
+            version: options.app?.version,
+            channel: options.app?.channel,
+          }),
+        ),
         HttpRouter.provideRequest(requestServices),
         HttpRouter.provideRequest(requestServices),
         Layer.provideMerge(services),
         Layer.provideMerge(services),
         Layer.provideMerge(HttpRouter.layer),
         Layer.provideMerge(HttpRouter.layer),

+ 7 - 5
packages/server/src/server-info.ts

@@ -1,12 +1,14 @@
 import { Context, Layer } from "effect"
 import { Context, Layer } from "effect"
 import { networkInterfaces } from "node:os"
 import { networkInterfaces } from "node:os"
+import type { ServerOptions } from "./options"
 
 
-export class Service extends Context.Service<Service, { readonly urls: () => ReadonlyArray<string> }>()(
-  "@opencode-ai/server/ServerInfo",
-) {}
+export class Service extends Context.Service<
+  Service,
+  { readonly urls: () => ReadonlyArray<string>; readonly app: NonNullable<ServerOptions["app"]> }
+>()("@opencode-ai/server/ServerInfo") {}
 
 
-export function layer(urls: () => ReadonlyArray<string>) {
-  return Layer.succeed(Service, Service.of({ urls }))
+export function layer(urls: () => ReadonlyArray<string>, app: ServerOptions["app"] = {}) {
+  return Layer.succeed(Service, Service.of({ urls, app }))
 }
 }
 
 
 export function connectionURLs(value: string, requestedHostname?: string) {
 export function connectionURLs(value: string, requestedHostname?: string) {

+ 6 - 0
packages/server/test/options.test.ts

@@ -12,3 +12,9 @@ test("rejects ports outside the valid range", () => {
   expect(Option.isNone(decode({ port: -1 }))).toBe(true)
   expect(Option.isNone(decode({ port: -1 }))).toBe(true)
   expect(Option.isNone(decode({ port: 65_536 }))).toBe(true)
   expect(Option.isNone(decode({ port: 65_536 }))).toBe(true)
 })
 })
+
+test("accepts optional app metadata", () => {
+  expect(
+    Option.getOrThrow(decode({ app: { name: "sdk", version: "1.2.3", channel: "beta" } })).app,
+  ).toEqual({ name: "sdk", version: "1.2.3", channel: "beta" })
+})

+ 2 - 0
packages/server/test/process.test.ts

@@ -10,6 +10,7 @@ it.live("allows browser preflight requests without credentials", () =>
       hostname: "127.0.0.1",
       hostname: "127.0.0.1",
       port: 0,
       port: 0,
       password: "secret",
       password: "secret",
+      app: { version: "test-version" },
       database: { path: ":memory:" },
       database: { path: ":memory:" },
     })
     })
     const response = yield* Effect.promise(() =>
     const response = yield* Effect.promise(() =>
@@ -38,5 +39,6 @@ it.live("allows browser preflight requests without credentials", () =>
 
 
     expect(health.status).toBe(200)
     expect(health.status).toBe(200)
     expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
     expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
+    expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
   }),
   }),
 )
 )

+ 2 - 1
packages/simulation/src/backend/index.ts

@@ -21,7 +21,7 @@ import { SimulatedProvider } from "./simulated-provider"
  *
  *
  */
  */
 
 
-export const simulationReplacements = Effect.fn("Simulation.replacements")(function* () {
+export const simulationReplacements = Effect.fn("Simulation.replacements")(function* (app: { readonly version: string }) {
   // ModelsDev dies when its catalog fetch fails, so simulation answers it with
   // ModelsDev dies when its catalog fetch fails, so simulation answers it with
   // an empty catalog; providers come from seeded config instead.
   // an empty catalog; providers come from seeded config instead.
   const models = SimulationNetwork.json("GET", "https://models.dev/api.json", {})
   const models = SimulationNetwork.json("GET", "https://models.dev/api.json", {})
@@ -40,6 +40,7 @@ export const simulationReplacements = Effect.fn("Simulation.replacements")(funct
     Layer.provide(
     Layer.provide(
       SimulatedProvider.layerDrive({
       SimulatedProvider.layerDrive({
         endpoint: manifest.endpoints.backend,
         endpoint: manifest.endpoints.backend,
+        version: app.version,
       }),
       }),
     ),
     ),
   )
   )

+ 5 - 4
packages/simulation/src/backend/simulated-provider.ts

@@ -1,4 +1,3 @@
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
 import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
 import { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
 import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
@@ -152,7 +151,7 @@ class ToolControllerError extends Schema.TaggedErrorClass<ToolControllerError>()
   { message: Schema.String },
   { message: Schema.String },
 ) {}
 ) {}
 
 
-export const layerDrive = (options: { readonly endpoint: string }) =>
+export const layerDrive = (options: { readonly endpoint: string; readonly version: string }) =>
   Layer.effect(
   Layer.effect(
     Service,
     Service,
     Effect.gen(function* () {
     Effect.gen(function* () {
@@ -277,7 +276,8 @@ export const layerDrive = (options: { readonly endpoint: string }) =>
         label: "opencode drive backend websocket",
         label: "opencode drive backend websocket",
         data: () => ({}),
         data: () => ({}),
         decode: SimulationProtocol.Backend.decodeRequestEffect,
         decode: SimulationProtocol.Backend.decodeRequestEffect,
-        handle: (socket, request) => handle(driver, tools, fibers, activeController, controllerLock, socket, request),
+        handle: (socket, request) =>
+          handle(driver, tools, fibers, activeController, controllerLock, socket, request, options.version),
         close: (socket) =>
         close: (socket) =>
           Effect.all([releaseController(activeController, controllerLock, socket), tools.release(socket)], {
           Effect.all([releaseController(activeController, controllerLock, socket), tools.release(socket)], {
             discard: true,
             discard: true,
@@ -306,13 +306,14 @@ function handle(
   controllerLock: Semaphore.Semaphore,
   controllerLock: Semaphore.Semaphore,
   socket: ControlSocket,
   socket: ControlSocket,
   request: SimulationProtocol.Backend.Request,
   request: SimulationProtocol.Backend.Request,
+  version: string,
 ) {
 ) {
   switch (request.method) {
   switch (request.method) {
     case "simulation.handshake":
     case "simulation.handshake":
       return SimulationProtocol.Handshake.dispatch(
       return SimulationProtocol.Handshake.dispatch(
         {
         {
           role: "backend",
           role: "backend",
-          server: { name: "opencode", version: InstallationVersion },
+          server: { name: "opencode", version },
           capabilities: SimulationProtocol.Backend.Capabilities,
           capabilities: SimulationProtocol.Backend.Capabilities,
         },
         },
         request.params,
         request.params,

+ 8 - 5
packages/simulation/src/frontend/server.ts

@@ -1,17 +1,16 @@
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { Effect } from "effect"
 import { Effect } from "effect"
 import { SimulationControlServer } from "../control-server"
 import { SimulationControlServer } from "../control-server"
 import { SimulationProtocol } from "../protocol"
 import { SimulationProtocol } from "../protocol"
 import { SimulationActions, type Harness } from "./actions"
 import { SimulationActions, type Harness } from "./actions"
 import { SimulationRenderer } from "./renderer"
 import { SimulationRenderer } from "./renderer"
 
 
-function handle(harness: Harness, request: SimulationProtocol.Frontend.Request) {
+function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, version: string) {
   switch (request.method) {
   switch (request.method) {
     case "simulation.handshake":
     case "simulation.handshake":
       return SimulationProtocol.Handshake.dispatch(
       return SimulationProtocol.Handshake.dispatch(
         {
         {
           role: "ui",
           role: "ui",
-          server: { name: "opencode", version: InstallationVersion },
+          server: { name: "opencode", version },
           capabilities: SimulationProtocol.Frontend.Capabilities,
           capabilities: SimulationProtocol.Frontend.Capabilities,
         },
         },
         request.params,
         request.params,
@@ -59,13 +58,17 @@ function handle(harness: Harness, request: SimulationProtocol.Frontend.Request)
   }
   }
 }
 }
 
 
-export const start = Effect.fn("SimulationServer.start")(function* (harness: Harness, endpoint: string) {
+export const start = Effect.fn("SimulationServer.start")(function* (
+  harness: Harness,
+  endpoint: string,
+  version = "unknown",
+) {
   return yield* SimulationControlServer.start({
   return yield* SimulationControlServer.start({
     endpoint,
     endpoint,
     label: "opencode drive ui websocket",
     label: "opencode drive ui websocket",
     data: () => ({ drive: true as const }),
     data: () => ({ drive: true as const }),
     decode: SimulationProtocol.Frontend.decodeRequestEffect,
     decode: SimulationProtocol.Frontend.decodeRequestEffect,
-    handle: (_socket, request) => handle(harness, request),
+    handle: (_socket, request) => handle(harness, request, version),
   })
   })
 })
 })
 
 

+ 2 - 2
packages/simulation/src/frontend/simulation.ts

@@ -6,7 +6,7 @@ import { SimulationRenderer } from "./renderer"
 import { SimulationServer } from "./server"
 import { SimulationServer } from "./server"
 
 
 /** Drive-mode renderer and control-server acquisition. */
 /** Drive-mode renderer and control-server acquisition. */
-export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig) {
+export const create = Effect.fn("Drive.create")(function* (options: CliRendererConfig, version: string) {
   const headless = (yield* Config.string("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless"
   const headless = (yield* Config.string("OPENCODE_DRIVE_RENDERER").pipe(Config.withDefault("visible"))) === "headless"
   const manifest = yield* DriveManifest.resolve()
   const manifest = yield* DriveManifest.resolve()
   const renderer = headless
   const renderer = headless
@@ -19,7 +19,7 @@ export const create = Effect.fn("Drive.create")(function* (options: CliRendererC
           }),
           }),
       )
       )
   if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows)
   if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows)
-  const server = yield* SimulationServer.start(SimulationActions.createHarness(renderer), manifest.endpoints.ui)
+  const server = yield* SimulationServer.start(SimulationActions.createHarness(renderer), manifest.endpoints.ui, version)
   yield* Effect.sync(() => process.stderr.write(`opencode drive ui websocket: ${server.url}\n`))
   yield* Effect.sync(() => process.stderr.write(`opencode drive ui websocket: ${server.url}\n`))
   return renderer
   return renderer
 })
 })

+ 2 - 2
packages/simulation/test/simulated-provider.test.ts

@@ -685,7 +685,7 @@ function runProvider<E>(
 }
 }
 
 
 const providerLayer = (endpoint: string) =>
 const providerLayer = (endpoint: string) =>
-  SimulatedProvider.layerDrive({ endpoint }).pipe(
+  SimulatedProvider.layerDrive({ endpoint, version: "test" }).pipe(
     Layer.provide(
     Layer.provide(
       Layer.succeed(SdkPlugins.Service, SdkPlugins.Service.of({ register: () => Effect.void, all: () => [] })),
       Layer.succeed(SdkPlugins.Service, SdkPlugins.Service.of({ register: () => Effect.void, all: () => [] })),
     ),
     ),
@@ -694,7 +694,7 @@ const providerLayer = (endpoint: string) =>
 const toolLifecycleLayer = (endpoint: string) => {
 const toolLifecycleLayer = (endpoint: string) => {
   const provider = makeGlobalNode({
   const provider = makeGlobalNode({
     service: SimulatedProvider.Service,
     service: SimulatedProvider.Service,
-    layer: SimulatedProvider.layerDrive({ endpoint }),
+    layer: SimulatedProvider.layerDrive({ endpoint, version: "test" }),
     deps: [SdkPlugins.node],
     deps: [SdkPlugins.node],
   })
   })
   return AppNodeBuilder.build(
   return AppNodeBuilder.build(

+ 10 - 5
packages/tui/src/app.tsx

@@ -32,10 +32,12 @@ import {
 } from "solid-js"
 } from "solid-js"
 import {
 import {
   TuiLifecycleProvider,
   TuiLifecycleProvider,
+  TuiAppProvider,
   TuiPathsProvider,
   TuiPathsProvider,
   TuiStartupProvider,
   TuiStartupProvider,
   TuiTerminalEnvironmentProvider,
   TuiTerminalEnvironmentProvider,
   useTuiStartup,
   useTuiStartup,
+  type TuiApp,
 } from "./context/runtime"
 } from "./context/runtime"
 import { DialogProvider, useDialog } from "./ui/dialog"
 import { DialogProvider, useDialog } from "./ui/dialog"
 import { DialogIntegration } from "./component/dialog-integration"
 import { DialogIntegration } from "./component/dialog-integration"
@@ -139,6 +141,7 @@ const appBindingCommands = [
 ] as const
 ] as const
 
 
 export type TuiInput = {
 export type TuiInput = {
+  app: TuiApp
   server: {
   server: {
     endpoint: Endpoint
     endpoint: Endpoint
     service?: {
     service?: {
@@ -224,7 +227,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
         }
         }
         if (process.env.OPENCODE_DRIVE) {
         if (process.env.OPENCODE_DRIVE) {
           const { Drive } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend"))
           const { Drive } = yield* Effect.promise(() => import("@opencode-ai/simulation/frontend"))
-          return yield* Drive.create(options)
+          return yield* Drive.create(options, input.app.version)
         }
         }
         return yield* Effect.acquireRelease(
         return yield* Effect.acquireRelease(
           Effect.tryPromise({
           Effect.tryPromise({
@@ -273,9 +276,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                 }}
                 }}
               >
               >
                 <EpilogueProvider set={(value) => (exit.epilogue = value)}>
                 <EpilogueProvider set={(value) => (exit.epilogue = value)}>
-                  <ErrorBoundary
-                    fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
-                  >
+                  <TuiAppProvider value={input.app}>
+                    <ErrorBoundary
+                      fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}
+                    >
                     <TuiPathsProvider
                     <TuiPathsProvider
                       value={{
                       value={{
                         cwd: process.cwd(),
                         cwd: process.cwd(),
@@ -381,7 +385,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
                         </TuiTerminalEnvironmentProvider>
                         </TuiTerminalEnvironmentProvider>
                       </TuiLifecycleProvider>
                       </TuiLifecycleProvider>
                     </TuiPathsProvider>
                     </TuiPathsProvider>
-                  </ErrorBoundary>
+                    </ErrorBoundary>
+                  </TuiAppProvider>
                 </EpilogueProvider>
                 </EpilogueProvider>
               </ExitProvider>
               </ExitProvider>
             </LogProvider>
             </LogProvider>

+ 3 - 2
packages/tui/src/component/dialog-debug.tsx

@@ -1,6 +1,5 @@
 import { TextAttributes } from "@opentui/core"
 import { TextAttributes } from "@opentui/core"
 import { createMemo, createSignal, For } from "solid-js"
 import { createMemo, createSignal, For } from "solid-js"
-import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
 import { Keymap } from "../context/keymap"
 import { Keymap } from "../context/keymap"
 import { useTheme } from "../context/theme"
 import { useTheme } from "../context/theme"
 import { useDialog } from "../ui/dialog"
 import { useDialog } from "../ui/dialog"
@@ -9,6 +8,7 @@ import { useLocal } from "../context/local"
 import { useClipboard } from "../context/clipboard"
 import { useClipboard } from "../context/clipboard"
 import { useToast } from "../ui/toast"
 import { useToast } from "../ui/toast"
 import { describeOS, describeTerminal } from "../util/system"
 import { describeOS, describeTerminal } from "../util/system"
+import { useTuiApp } from "../context/runtime"
 
 
 export function DialogDebug() {
 export function DialogDebug() {
   const { themeV2 } = useTheme()
   const { themeV2 } = useTheme()
@@ -17,6 +17,7 @@ export function DialogDebug() {
   const local = useLocal()
   const local = useLocal()
   const clipboard = useClipboard()
   const clipboard = useClipboard()
   const toast = useToast()
   const toast = useToast()
+  const app = useTuiApp()
   const [copied, setCopied] = createSignal(false)
   const [copied, setCopied] = createSignal(false)
 
 
   dialog.setSize("large")
   dialog.setSize("large")
@@ -24,7 +25,7 @@ export function DialogDebug() {
   const entries = createMemo(() => {
   const entries = createMemo(() => {
     const model = local.model.current()
     const model = local.model.current()
     return [
     return [
-      { label: "Version", value: `${InstallationVersion} (${InstallationChannel})` },
+      { label: "Version", value: `${app.version} (${app.channel})` },
       { label: "Date", value: new Date().toISOString() },
       { label: "Date", value: new Date().toISOString() },
       { label: "OS", value: describeOS() },
       { label: "OS", value: describeOS() },
       { label: "Terminal", value: describeTerminal() },
       { label: "Terminal", value: describeTerminal() },

+ 6 - 5
packages/tui/src/component/error-component.tsx

@@ -3,14 +3,15 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
 import { createSignal, For, Show } from "solid-js"
 import { createSignal, For, Show } from "solid-js"
 import { getScrollAcceleration } from "../util/scroll"
 import { getScrollAcceleration } from "../util/scroll"
 import { useClipboard } from "../context/clipboard"
 import { useClipboard } from "../context/clipboard"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { useExit } from "../context/exit"
 import { useExit } from "../context/exit"
+import { useTuiApp } from "../context/runtime"
 import { describeOS, describeTerminal } from "../util/system"
 import { describeOS, describeTerminal } from "../util/system"
 
 
 export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
 export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) {
   const term = useTerminalDimensions()
   const term = useTerminalDimensions()
   const exit = useExit()
   const exit = useExit()
   const clipboard = useClipboard()
   const clipboard = useClipboard()
+  const app = useTuiApp()
   const [copied, setCopied] = createSignal(false)
   const [copied, setCopied] = createSignal(false)
 
 
   // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
   // Safe fallback palette per mode (mirrors theme/assets/opencode.json) since the
@@ -42,7 +43,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
 
 
   const message = props.error.message || "An unknown error occurred."
   const message = props.error.message || "An unknown error occurred."
   const stack = props.error.stack || "No stack trace available."
   const stack = props.error.stack || "No stack trace available."
-  const issueURL = buildIssueURL(message, stack)
+  const issueURL = buildIssueURL(message, stack, app.version)
 
 
   const copyReport = () => {
   const copyReport = () => {
     void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
     void clipboard.write?.(issueURL.toString()).then(() => setCopied(true))
@@ -192,7 +193,7 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
                 ? "Report copied — paste it into a new GitHub issue."
                 ? "Report copied — paste it into a new GitHub issue."
                 : "Copy the report and open a GitHub issue to help us fix this."}
                 : "Copy the report and open a GitHub issue to help us fix this."}
             </text>
             </text>
-            <text fg={colors.muted}>OpenCode {InstallationVersion}</text>
+            <text fg={colors.muted}>OpenCode {app.version}</text>
           </box>
           </box>
         </Show>
         </Show>
       </box>
       </box>
@@ -200,13 +201,13 @@ export function ErrorComponent(props: { error: Error; reset: () => void; mode?:
   )
   )
 }
 }
 
 
-function buildIssueURL(message: string, stack: string) {
+function buildIssueURL(message: string, stack: string, version: string) {
   // Field keys match the ids in .github/ISSUE_TEMPLATE/bug-report.yml so the issue
   // Field keys match the ids in .github/ISSUE_TEMPLATE/bug-report.yml so the issue
   // form opens pre-filled. Populating os/terminal/reproduce keeps the report past
   // form opens pre-filled. Populating os/terminal/reproduce keeps the report past
   // the contributing-guidelines compliance check, which pushes for system info.
   // the contributing-guidelines compliance check, which pushes for system info.
   const url = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
   const url = new URL("https://github.com/anomalyco/opencode/issues/new?template=bug-report.yml")
   url.searchParams.set("title", `TUI crash: ${message}`)
   url.searchParams.set("title", `TUI crash: ${message}`)
-  url.searchParams.set("opencode-version", InstallationVersion)
+  url.searchParams.set("opencode-version", version)
   url.searchParams.set("os", describeOS())
   url.searchParams.set("os", describeOS())
   url.searchParams.set("terminal", describeTerminal())
   url.searchParams.set("terminal", describeTerminal())
   url.searchParams.set(
   url.searchParams.set(

+ 15 - 0
packages/tui/src/context/runtime.tsx

@@ -1,5 +1,11 @@
 import { createComponent, createContext, type JSX, useContext } from "solid-js"
 import { createComponent, createContext, type JSX, useContext } from "solid-js"
 
 
+export type TuiApp = Readonly<{
+  name: string
+  version: string
+  channel: string
+}>
+
 export type TuiPaths = Readonly<{
 export type TuiPaths = Readonly<{
   cwd: string
   cwd: string
   home: string
   home: string
@@ -23,6 +29,7 @@ export type TuiLifecycle = Readonly<{
 }>
 }>
 
 
 const PathsContext = createContext<TuiPaths>()
 const PathsContext = createContext<TuiPaths>()
+const AppContext = createContext<TuiApp>()
 const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
 const TerminalEnvironmentContext = createContext<TuiTerminalEnvironment>()
 const StartupContext = createContext<TuiStartup>()
 const StartupContext = createContext<TuiStartup>()
 const LifecycleContext = createContext<TuiLifecycle>()
 const LifecycleContext = createContext<TuiLifecycle>()
@@ -40,6 +47,10 @@ export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element
   return provider(PathsContext, props.value, () => props.children)
   return provider(PathsContext, props.value, () => props.children)
 }
 }
 
 
+export function TuiAppProvider(props: { value: TuiApp; children: JSX.Element }) {
+  return provider(AppContext, props.value, () => props.children)
+}
+
 export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) {
 export function TuiTerminalEnvironmentProvider(props: { value: TuiTerminalEnvironment; children: JSX.Element }) {
   return provider(TerminalEnvironmentContext, props.value, () => props.children)
   return provider(TerminalEnvironmentContext, props.value, () => props.children)
 }
 }
@@ -62,6 +73,10 @@ export function useTuiPaths() {
   return required(PathsContext, "TuiPathsProvider")
   return required(PathsContext, "TuiPathsProvider")
 }
 }
 
 
+export function useTuiApp() {
+  return required(AppContext, "TuiAppProvider")
+}
+
 export function useTuiTerminalEnvironment() {
 export function useTuiTerminalEnvironment() {
   return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider")
   return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider")
 }
 }

+ 4 - 4
packages/tui/src/feature-plugins/home/footer.tsx

@@ -1,8 +1,7 @@
 import { Plugin } from "@opencode-ai/plugin/v2/tui"
 import { Plugin } from "@opencode-ai/plugin/v2/tui"
-import { InstallationVersion } from "@opencode-ai/util/installation/version"
 import { createMemo, Match, Show, Switch } from "solid-js"
 import { createMemo, Match, Show, Switch } from "solid-js"
 import { useTerminalDimensions } from "@opentui/solid"
 import { useTerminalDimensions } from "@opentui/solid"
-import { useTuiPaths } from "../../context/runtime"
+import { useTuiApp, useTuiPaths } from "../../context/runtime"
 import { useTheme } from "../../context/theme"
 import { useTheme } from "../../context/theme"
 import { abbreviateHome } from "../../runtime"
 import { abbreviateHome } from "../../runtime"
 import { FilePath } from "../../ui/file-path"
 import { FilePath } from "../../ui/file-path"
@@ -50,6 +49,7 @@ function Mcp(props: { context: Plugin.Context }) {
 
 
 function View(props: { context: Plugin.Context }) {
 function View(props: { context: Plugin.Context }) {
   const { themeV2 } = useTheme()
   const { themeV2 } = useTheme()
+  const app = useTuiApp()
   const dimensions = useTerminalDimensions()
   const dimensions = useTerminalDimensions()
   const mcpWidth = createMemo(() => {
   const mcpWidth = createMemo(() => {
     const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
     const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
@@ -71,12 +71,12 @@ function View(props: { context: Plugin.Context }) {
     >
     >
       <Directory
       <Directory
         context={props.context}
         context={props.context}
-        maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(InstallationVersion) - mcpWidth())}
+        maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(app.version) - mcpWidth())}
       />
       />
       <Mcp context={props.context} />
       <Mcp context={props.context} />
       <box flexGrow={1} />
       <box flexGrow={1} />
       <box flexShrink={0}>
       <box flexShrink={0}>
-        <text fg={themeV2.text.subdued}>{InstallationVersion}</text>
+        <text fg={themeV2.text.subdued}>{app.version}</text>
       </box>
       </box>
     </box>
     </box>
   )
   )

+ 2 - 0
packages/tui/test/app-lifecycle.test.tsx

@@ -28,6 +28,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
     const { run } = await import("../src/app")
     const { run } = await import("../src/app")
     const task = Effect.runPromise(
     const task = Effect.runPromise(
       run({
       run({
+        app: { name: "test", version: "test", channel: "test" },
         server: { endpoint: { url: server.url.toString() } },
         server: { endpoint: { url: server.url.toString() } },
         config: { get: async () => ({}), update: async () => ({}) },
         config: { get: async () => ({}), update: async () => ({}) },
         packages: { resolve: async () => undefined },
         packages: { resolve: async () => undefined },
@@ -100,6 +101,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
     const { run } = await import("../src/app")
     const { run } = await import("../src/app")
     const task = Effect.runPromise(
     const task = Effect.runPromise(
       run({
       run({
+        app: { name: "test", version: "test", channel: "test" },
         server: { endpoint: { url: server.url.toString() } },
         server: { endpoint: { url: server.url.toString() } },
         config: { get: async () => ({}), update: async () => ({}) },
         config: { get: async () => ({}), update: async () => ({}) },
         packages: { resolve: async () => undefined },
         packages: { resolve: async () => undefined },

+ 0 - 14
packages/util/src/client.ts

@@ -1,14 +0,0 @@
-export * as Client from "./client.js"
-
-import { Context, Layer } from "effect"
-import { makeGlobalNode } from "./effect/app-node.js"
-
-export const Name = Context.Reference<string>("@opencode/Client/Name", {
-  defaultValue: () => "cli",
-})
-
-export const layer = (name = "cli") => Layer.succeed(Name, name)
-
-export const configured = (name?: string) => makeGlobalNode({ service: Name, layer: layer(name), deps: [] })
-
-export const node = configured()

+ 0 - 6
packages/util/src/installation/version.ts

@@ -1,6 +0,0 @@
-declare const OPENCODE_VERSION: string
-declare const OPENCODE_CHANNEL: string
-
-export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
-export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
-export const InstallationLocal = InstallationChannel === "local"

+ 14 - 7
packages/util/src/observability.ts

@@ -7,11 +7,13 @@ import { FetchHttpClient } from "effect/unstable/http"
 import { OtlpSerialization } from "effect/unstable/observability"
 import { OtlpSerialization } from "effect/unstable/observability"
 import { Logging } from "./observability/logging.js"
 import { Logging } from "./observability/logging.js"
 import { Otlp } from "./observability/otlp.js"
 import { Otlp } from "./observability/otlp.js"
-import { Client } from "./client.js"
 
 
 export const Options = Schema.Struct({
 export const Options = Schema.Struct({
   endpoint: Schema.optional(Schema.String),
   endpoint: Schema.optional(Schema.String),
   headers: Schema.optional(Schema.String),
   headers: Schema.optional(Schema.String),
+  client: Schema.optional(Schema.String),
+  version: Schema.optional(Schema.String),
+  channel: Schema.optional(Schema.String),
 })
 })
 export type Options = typeof Options.Type
 export type Options = typeof Options.Type
 
 
@@ -21,24 +23,29 @@ export function layer(
     headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
     headers: process.env.OTEL_EXPORTER_OTLP_HEADERS,
   },
   },
 ) {
 ) {
-  const local = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
+  const app = {
+    client: options.client ?? "opencode",
+    version: options.version ?? "unknown",
+    channel: options.channel ?? "local",
+  }
+  const local = Logger.layer(Logging.loggers(app.channel === "local", app.channel), { mergeWithExisting: false }).pipe(
     Layer.provide(NodeFileSystem.layer),
     Layer.provide(NodeFileSystem.layer),
     Layer.orDie,
     Layer.orDie,
     Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
     Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
   )
   )
   return Layer.unwrap(
   return Layer.unwrap(
     Effect.gen(function* () {
     Effect.gen(function* () {
-      const client = yield* Client.Name
-      const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options, client)], {
-        mergeWithExisting: false,
-      }).pipe(
+      const logs = Logger.layer(
+        [...Logging.loggers(app.channel === "local", app.channel), ...Otlp.loggers(options, app)],
+        { mergeWithExisting: false },
+      ).pipe(
         Layer.provide(NodeFileSystem.layer),
         Layer.provide(NodeFileSystem.layer),
         Layer.provide(OtlpSerialization.layerJson),
         Layer.provide(OtlpSerialization.layerJson),
         Layer.provide(FetchHttpClient.layer),
         Layer.provide(FetchHttpClient.layer),
         Layer.orDie,
         Layer.orDie,
         Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
         Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
       )
       )
-      return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options, client)))
+      return Layer.merge(logs, yield* Effect.promise(() => Otlp.tracingLayer(options, app)))
     }),
     }),
   ).pipe(Layer.catchCause(() => local))
   ).pipe(Layer.catchCause(() => local))
 }
 }

+ 4 - 4
packages/util/src/observability/logging.ts

@@ -1,7 +1,6 @@
 import { Formatter, Logger, type LogLevel } from "effect"
 import { Formatter, Logger, type LogLevel } from "effect"
 import path from "path"
 import path from "path"
 import { Global } from "../global.js"
 import { Global } from "../global.js"
-import { InstallationChannel, InstallationLocal } from "../installation/version.js"
 import { runID } from "./shared.js"
 import { runID } from "./shared.js"
 
 
 function formatter(id: string = runID) {
 function formatter(id: string = runID) {
@@ -47,7 +46,7 @@ function format(input: unknown) {
   return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
   return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
 }
 }
 
 
-export function file(local = InstallationLocal, channel = InstallationChannel) {
+export function file(local = true, channel = "local") {
   if (!local) return path.join(Global.Path.log, "opencode.log")
   if (!local) return path.join(Global.Path.log, "opencode.log")
   return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
   return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
 }
 }
@@ -70,8 +69,9 @@ export function minimumLogLevel() {
   return value && value in levels ? levels[value as keyof typeof levels] : levels.INFO
   return value && value in levels ? levels[value as keyof typeof levels] : levels.INFO
 }
 }
 
 
-export function loggers() {
-  return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()]
+export function loggers(local = true, channel = "local") {
+  const logger = fileLogger(file(local, channel))
+  return process.env.OPENCODE_PRINT_LOGS === "1" ? [logger, stderrLogger] : [logger]
 }
 }
 
 
 export * as Logging from "./logging.js"
 export * as Logging from "./logging.js"

+ 14 - 9
packages/util/src/observability/otlp.ts

@@ -1,6 +1,5 @@
 import { Layer } from "effect"
 import { Layer } from "effect"
 import { OtlpLogger } from "effect/unstable/observability"
 import { OtlpLogger } from "effect/unstable/observability"
-import { InstallationChannel, InstallationVersion } from "../installation/version.js"
 import { runID } from "./shared.js"
 import { runID } from "./shared.js"
 
 
 export interface Options {
 export interface Options {
@@ -8,6 +7,12 @@ export interface Options {
   readonly headers?: string
   readonly headers?: string
 }
 }
 
 
+export interface App {
+  readonly client: string
+  readonly version: string
+  readonly channel: string
+}
+
 function parseHeaders(value?: string) {
 function parseHeaders(value?: string) {
   return value
   return value
     ? value.split(",").reduce(
     ? value.split(",").reduce(
@@ -37,36 +42,36 @@ function resourceAttributes() {
   }
   }
 }
 }
 
 
-export function resource(client = "cli"): {
+export function resource(app: App = { client: "opencode", version: "unknown", channel: "local" }): {
   serviceName: string
   serviceName: string
   serviceVersion: string
   serviceVersion: string
   attributes: Record<string, string>
   attributes: Record<string, string>
 } {
 } {
   return {
   return {
     serviceName: "opencode",
     serviceName: "opencode",
-    serviceVersion: InstallationVersion,
+    serviceVersion: app.version,
     attributes: {
     attributes: {
       ...resourceAttributes(),
       ...resourceAttributes(),
-      "deployment.environment.name": InstallationChannel,
-      "opencode.client": client,
+      "deployment.environment.name": app.channel,
+      "opencode.client": app.client,
       "opencode.run": runID,
       "opencode.run": runID,
       "service.instance.id": runID,
       "service.instance.id": runID,
     },
     },
   }
   }
 }
 }
 
 
-export function loggers(options: Options | undefined, client: string) {
+export function loggers(options: Options | undefined, app: App) {
   if (!options?.endpoint) return []
   if (!options?.endpoint) return []
   return [
   return [
     OtlpLogger.make({
     OtlpLogger.make({
       url: `${options.endpoint}/v1/logs`,
       url: `${options.endpoint}/v1/logs`,
-      resource: resource(client),
+      resource: resource(app),
       headers: parseHeaders(options.headers),
       headers: parseHeaders(options.headers),
     }),
     }),
   ]
   ]
 }
 }
 
 
-export async function tracingLayer(options: Options | undefined, client: string) {
+export async function tracingLayer(options: Options | undefined, app: App) {
   if (!options?.endpoint) return Layer.empty
   if (!options?.endpoint) return Layer.empty
   const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
   const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
   const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
   const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
@@ -80,7 +85,7 @@ export async function tracingLayer(options: Options | undefined, client: string)
   context.setGlobalContextManager(manager)
   context.setGlobalContextManager(manager)
 
 
   return NodeSdk.layer(() => ({
   return NodeSdk.layer(() => ({
-    resource: resource(client),
+    resource: resource(app),
     spanProcessor: new SdkBase.BatchSpanProcessor(
     spanProcessor: new SdkBase.BatchSpanProcessor(
       new OTLP.OTLPTraceExporter({
       new OTLP.OTLPTraceExporter({
         url: `${options.endpoint}/v1/traces`,
         url: `${options.endpoint}/v1/traces`,