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

This commit is contained in:
Dax 2026-07-21 18:16:25 -04:00 committed by GitHub
commit 8de40be6ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 477 additions and 286 deletions

View file

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

View file

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

View file

@ -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, version: OPENCODE_VERSION,
channel: InstallationChannel, channel: OPENCODE_CHANNEL,
local: InstallationLocal, 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,

View file

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

View file

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

View file

@ -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, channel = OPENCODE_CHANNEL,
installedVersion = InstallationVersion, 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, channel = OPENCODE_CHANNEL,
installedVersion = InstallationVersion, 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"],
} }
}) })

View file

@ -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 = () =>

View file

@ -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 { import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
InstallationChannel,
InstallationLocal,
InstallationVersion,
} from "@opencode-ai/util/installation/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)}`, `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(OPENCODE_CHANNEL)}`,
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) }, { 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", reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: InstallationVersion, version: OPENCODE_VERSION,
channel: InstallationChannel, 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 })))

View file

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

View file

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

View file

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

View file

@ -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
packages/core/src/app.ts Normal file
View file

@ -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()

View file

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

View file

@ -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({ export function configured(options?: Options) {
service: Service, return makeLocationNode({
layer, service: Service,
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node], 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({ sdkOptions(
...evt.options, {
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), ...evt.options,
}) as any, 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,

View file

@ -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")

View file

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

View file

@ -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), refresh: (value) => refresh(browserMethodID, value, app),
} satisfies IntegrationOAuthMethodRegistration }) 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`, { yield* exchange(
verifier: data.code_verifier, data.authorization_code,
challenge: "", `${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), refresh: (value) => refresh(headlessMethodID, value, app),
} satisfies IntegrationOAuthMethodRegistration }) 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(browser(ctx.app))
draft.method.update(headless) 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) { function headers(contentType: string, app: App.Info) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } 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,

View file

@ -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 })), refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app),
} satisfies IntegrationOAuthMethodRegistration }) 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 })), refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app),
} satisfies IntegrationOAuthMethodRegistration }) 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(browser(ctx.app))
draft.method.update(device) 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),
} }
} }

View file

@ -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}`, `- opencode version: ${app.version}`,
`- install/channel: ${InstallationChannel}`, `- 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()}`,

View file

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

View file

@ -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 const app = yield* App.Metadata
return make({ events, llm, models, config: settings(yield* config.entries()), client }) 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],
}) })

View file

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

View file

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

View file

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

View file

@ -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 app = yield* App.Metadata
const title = make({ events, llm, agents, models, client }) 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],
}) })

View file

@ -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}` } : {}),
}, },
) )

View file

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

View file

@ -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(app).attributes["service.instance.id"]).not.toBe("override")
expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/) expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
}) })
}) })

View file

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

View file

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

View file

@ -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"),

View file

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

View file

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

View file

@ -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.")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",
() => () =>

View file

@ -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({ Effect.gen(function* () {
healthy: true as const, const info = yield* ServerInfo.Service
version: InstallationVersion, return {
pid: process.pid, 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 })),

View file

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

View file

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

View file

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

View file

@ -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> }>()( export class Service extends Context.Service<
"@opencode-ai/server/ServerInfo", Service,
) {} { readonly urls: () => ReadonlyArray<string>; readonly app: NonNullable<ServerOptions["app"]> }
>()("@opencode-ai/server/ServerInfo") {}
export function layer(urls: () => ReadonlyArray<string>) { export function layer(urls: () => ReadonlyArray<string>, app: ServerOptions["app"] = {}) {
return Layer.succeed(Service, Service.of({ urls })) return Layer.succeed(Service, Service.of({ urls, app }))
} }
export function connectionURLs(value: string, requestedHostname?: string) { export function connectionURLs(value: string, requestedHostname?: string) {

View file

@ -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" })
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <TuiAppProvider value={input.app}>
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />} <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>

View file

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

View file

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

View file

@ -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")
} }

View file

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

View file

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

View file

@ -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()

View file

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

View file

@ -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(
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options, client)], { [...Logging.loggers(app.channel === "local", app.channel), ...Otlp.loggers(options, app)],
mergeWithExisting: false, { mergeWithExisting: false },
}).pipe( ).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))
} }

View file

@ -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() { export function loggers(local = true, channel = "local") {
return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()] 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"

View file

@ -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, "deployment.environment.name": app.channel,
"opencode.client": client, "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`,