diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index ff68dd93ef..f20f31c9bc 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -38,7 +38,7 @@ import type { SetSessionModeRequest, SetSessionModeResponse, } 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 { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option" import { promptContentToParts } from "./content" @@ -176,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} }, }, authMethods: [authMethod], - agentInfo: { name: "OpenCode", version: InstallationVersion }, + agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, } }, authenticate: async (params) => { diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 325f2c2b3a..66f7469aa5 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -9,6 +9,7 @@ import { ServerConnection } from "../../services/server-connection" import { Updater } from "../../services/updater" import { UpdatePreflight } from "../../services/update-preflight" import { Npm } from "@opencode-ai/util/npm" +import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version" export default Runtime.handler(Commands, (input) => Effect.gen(function* () { @@ -44,6 +45,7 @@ export default Runtime.handler(Commands, (input) => const runPromise = Effect.runPromiseWith(context) const service = server.service yield* run({ + app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL }, server: { endpoint: server.endpoint, service: service diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5140909cc2..ecfbd50282 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,13 +1,12 @@ #!/usr/bin/env bun import { NodeRuntime, NodeServices } from "@effect/platform-node" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Commands } from "./commands/commands" import { Runtime } from "./framework/runtime" import { Observability } from "@opencode-ai/util/observability" -import { Client } from "@opencode-ai/util/client" 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 { Global } from "@opencode-ai/util/global" import { AppProcess } from "@opencode-ai/util/process" @@ -53,12 +52,12 @@ const Handlers = Runtime.handlers(Commands, { }) Effect.logInfo("cli starting", { - version: InstallationVersion, - channel: InstallationChannel, - local: InstallationLocal, + version: OPENCODE_VERSION, + channel: OPENCODE_CHANNEL, + local: OPENCODE_LOCAL, args: process.argv.slice(2), }).pipe( - Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })), + Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })), Effect.annotateLogs({ role: "cli" }), Effect.provide(Config.layer), Effect.provide(Updater.layer), @@ -74,7 +73,10 @@ Effect.logInfo("cli starting", { Observability.layer({ endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, 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.scoped, diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index ecfacc66ac..273b11a0da 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node" import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service" import { LayerNode } from "@opencode-ai/util/effect/layer-node" 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 { randomBytes, randomUUID } from "node:crypto" import path from "node:path" @@ -69,7 +69,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { const instanceID = randomUUID() const server = yield* start( { - client: process.env.OPENCODE_CLIENT ?? "cli", + app: { + name: process.env.OPENCODE_CLIENT ?? "cli", + version: OPENCODE_VERSION, + channel: OPENCODE_CHANNEL, + }, hostname, port, password, @@ -77,11 +81,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { database: { path: 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 === "true" ? "opencode.db" - : `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`), + : `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`), }, models: { url: process.env.OPENCODE_MODELS_URL, @@ -173,7 +177,7 @@ const register = Effect.fnUntraced(function* ( yield* fs.makeDirectory(path.dirname(file), { recursive: true }) const info = { id, - version: InstallationVersion, + version: OPENCODE_VERSION, url: HttpServer.formatAddress(address), pid: process.pid, password, diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 70bd2fcda2..6f854eefb2 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -1,6 +1,6 @@ import { Service, type Endpoint, type EnsureOptions } from "@opencode-ai/client/effect/service" 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 { Env } from "../env" 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) }), catch: (cause) => connectError(endpoint, cause), }) - if (health.version !== InstallationVersion) + if (health.version !== OPENCODE_VERSION) 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 } diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index b96bb578c8..57c3102993 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -1,5 +1,5 @@ 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 { Service } from "@opencode-ai/client/effect/service" import { Effect, FileSystem, Option, Schema } from "effect" @@ -24,26 +24,26 @@ type Key = (typeof keys)[number] const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(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" 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 === "local") return 0xc0df 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 return `service-${Hash.fast(channel)}.json` } export function versionBelongsToChannel( version: string | undefined, - channel = InstallationChannel, - installedVersion = InstallationVersion, + channel = OPENCODE_CHANNEL, + installedVersion = OPENCODE_VERSION, ) { if (version === undefined) return false if (version === installedVersion) return true @@ -55,8 +55,8 @@ export function versionBelongsToChannel( export const migrateRegistration = Effect.fnUntraced(function* ( legacy: string, file: string, - channel = InstallationChannel, - installedVersion = InstallationVersion, + channel = OPENCODE_CHANNEL, + installedVersion = OPENCODE_VERSION, ) { const fs = yield* FileSystem.FileSystem 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, legacyRegistrationFiles: [ ...(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), } @@ -103,7 +103,7 @@ export const options = Effect.fnUntraced(function* () { yield* Effect.forEach(legacyRegistrationFiles, (legacy) => migrateRegistration(legacy, file)) return { file, - version: InstallationVersion, + version: OPENCODE_VERSION, command: [...selfCommand(), "serve", "--service"], } }) diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index 87a4c46ed7..9deb28bae8 100644 --- a/packages/cli/src/services/update-preflight.tsx +++ b/packages/cli/src/services/update-preflight.tsx @@ -3,7 +3,7 @@ // version-mismatched background service before the TUI attaches. import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core" 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 { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner" import { go } from "@opencode-ai/tui/logo" @@ -356,12 +356,12 @@ function UpdateFooter(props: { ] as const) : []), ["to", colors.muted], - [InstallationVersion, colors.accent], + [OPENCODE_VERSION, colors.accent], ) const completedHeader = phrase( ["OpenCode", colors.muted, true], ["updated to", colors.muted], - [InstallationVersion, colors.accent], + [OPENCODE_VERSION, colors.accent], ) const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted]) const outcomeStatus = () => diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index a1b10b699a..7f8c9fa946 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -1,10 +1,6 @@ import { Global } from "@opencode-ai/util/global" import { AppProcess } from "@opencode-ai/util/process" -import { - InstallationChannel, - InstallationLocal, - InstallationVersion, -} from "@opencode-ai/util/installation/version" +import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version" import { Context, Duration, Effect, FileSystem, Layer } from "effect" import { ChildProcess } from "effect/unstable/process" import { parse, type ParseError } from "jsonc-parser" @@ -52,7 +48,7 @@ export const layer = Layer.effect( const fs = yield* FileSystem.FileSystem const global = yield* Global.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 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({ try: () => fetch( - `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`, - { headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) }, + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(OPENCODE_CHANNEL)}`, + { headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` }, signal: AbortSignal.timeout(10_000) }, ), catch: (cause) => new Error("Failed to check for updates", { cause }), }) @@ -138,13 +134,13 @@ export const layer = Layer.effect( const check = Effect.fn("cli.updater.check")(function* () { if ( - InstallationLocal || + OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "") ) return yield* Effect.logInfo("update check skipped", { - reason: InstallationLocal ? "local-install" : "disabled", - version: InstallationVersion, - channel: InstallationChannel, + reason: OPENCODE_LOCAL ? "local-install" : "disabled", + version: OPENCODE_VERSION, + channel: OPENCODE_CHANNEL, }) const policy = yield* readPolicy() 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* () { const version = yield* latest() yield* Effect.logInfo("update check", { - current: InstallationVersion, + current: OPENCODE_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" }) const detected = yield* method() if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found") 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 }))) diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts new file mode 100644 index 0000000000..04c3fb3f39 --- /dev/null +++ b/packages/cli/src/version.ts @@ -0,0 +1,8 @@ +declare const OPENCODE_VERSION: string +declare const OPENCODE_CHANNEL: string + +const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" +const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" + +export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL } +export const OPENCODE_LOCAL = channel === "local" diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 50af6e205f..3eb4185981 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" 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 { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini" import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run" @@ -31,14 +31,14 @@ describe("mini command", () => { const initial = Bun.serve({ port: 0, 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({ port: 0, fetch(request) { 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() @@ -145,7 +145,7 @@ describe("mini command", () => { fetch(request) { const url = new URL(request.url) 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") return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } }) if (url.pathname === "/api/model") { diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts index c5407f7f32..f87854a7a1 100644 --- a/packages/cli/test/server-connection.test.ts +++ b/packages/cli/test/server-connection.test.ts @@ -1,6 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" 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 { Effect, FileSystem, Scope } from "effect" import fs from "node:fs/promises" @@ -17,7 +17,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed fetch() { return Response.json({ healthy: true, - version: InstallationVersion, + version: OPENCODE_VERSION, pid: process.pid, }) }, @@ -33,7 +33,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed registration, JSON.stringify({ id, - version: InstallationVersion, + version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid, }), diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 801fe5ad77..3e29d6e443 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,7 +1,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { Service, type Info } from "@opencode-ai/client/effect/service" 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 { Effect, Schema } from "effect" import fs from "node:fs/promises" @@ -332,7 +332,7 @@ test("unresponsive managed port occupancy reports a bounded conflict", async () ) const stale = { id: "stale", - version: InstallationVersion, + version: OPENCODE_VERSION, url: "http://127.0.0.1:1", pid: process.pid, password: "stale", @@ -368,7 +368,7 @@ test("port contender recognizes an incumbent registered during the bind race", a fetch() { requests.count += 1 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") @@ -380,7 +380,7 @@ test("port contender recognizes an incumbent registered during the bind race", a registration, JSON.stringify({ id: "stale", - version: InstallationVersion, + version: OPENCODE_VERSION, url: "http://127.0.0.1:1", pid: 2_147_483_647, password: "stale", @@ -397,7 +397,7 @@ test("port contender recognizes an incumbent registered during the bind race", a await Bun.sleep(8_000) const info = { id: "incumbent", - version: InstallationVersion, + version: OPENCODE_VERSION, url: `http://127.0.0.1:${listener.port}`, pid: process.pid, password: "incumbent", diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts new file mode 100644 index 0000000000..3276038975 --- /dev/null +++ b/packages/core/src/app.ts @@ -0,0 +1,33 @@ +export * as App from "./app" + +import { Context, Layer } from "effect" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" + +export interface Info { + readonly name: string + readonly version: string + readonly channel: string +} + +export const Metadata = Context.Reference("@opencode/App", { + defaultValue: () => make(), +}) + +export function make(input: Partial = {}): 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) => Layer.succeed(Metadata, make(input)) + +export const configured = (input?: Partial) => + makeGlobalNode({ service: Metadata, layer: layer(input), deps: [] }) + +export const node = configured() diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 98e125bfca..630ff88b98 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -12,6 +12,7 @@ import { ElicitationCompleteNotificationSchema, ElicitRequestSchema, GetPromptResultSchema, + type Implementation, type ElicitRequestFormParams, type ElicitRequestParams, type ElicitRequestURLParams, @@ -29,7 +30,6 @@ import { } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit, Schema } from "effect" import { ConfigMCP } from "../config/mcp" -import { InstallationVersion } from "@opencode-ai/util/installation/version" const DEFAULT_STARTUP_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. authProvider?: OAuthClientProvider, elicitation?: ElicitationHandler, + clientInfo: Implementation = { name: "opencode", version: "unknown" }, ) { const transport: Transport = yield* Effect.gen(function* () { if (config.type === "local") { @@ -209,7 +210,7 @@ export const connect = Effect.fnUntraced(function* ( }) }) const client = new Client( - { name: "opencode", version: InstallationVersion }, + clientInfo, { capabilities: { ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}), diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index d8807ae4c8..225919c27f 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -157,7 +157,17 @@ export interface Interface { export class Service extends Context.Service()("@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, Effect.gen(function* () { const config = yield* Config.Service @@ -498,7 +508,14 @@ export const layer = Layer.effect( const authProvider = yield* connectProvider(entry) // 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. - 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 })))), Scope.provide(scope), Effect.exit, @@ -772,11 +789,15 @@ export const layer = Layer.effect( }), ) -export const node = makeLocationNode({ - service: Service, - layer, - deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node], -}) +export function configured(options?: Options) { + return makeLocationNode({ + service: Service, + layer: layer(options), + deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node], + }) +} + +export const node = configured() // Schema `optional` strips undefined-valued properties on encode, so fields can assign // optional properties directly instead of conditionally spreading them. diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 3bc763882e..6fb3011363 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -3,12 +3,11 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" 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 { Flock } from "@opencode-ai/util/flock" import { Hash } from "@opencode-ai/util/hash" import { FSUtil } from "@opencode-ai/util/fs-util" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" import { EventV2 } from "./event" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" @@ -543,7 +542,7 @@ export const layer = (options?: Options) => Effect.gen(function* () { const fs = yield* FSUtil.Service const events = yield* EventV2.Service - const client = yield* Client.Name + const app = yield* App.Metadata const http = HttpClient.filterStatusOk( (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ @@ -556,7 +555,7 @@ export const layer = (options?: Options) => const source = options?.url || "https://models.dev" const fetch = options?.fetch ?? true - const userAgent = `opencode/${InstallationChannel}/${InstallationVersion}/${client}` + const userAgent = App.useragent(app) const filepath = path.join( Global.Path.cache, source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, @@ -660,7 +659,7 @@ export function configured(options?: Options) { return makeGlobalNode({ service: Service, layer: layer(options), - deps: [FSUtil.node, EventV2.node, Client.node, httpClient], + deps: [FSUtil.node, EventV2.node, App.node, httpClient], }) } diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 59c5373679..6007b67abb 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -3,6 +3,7 @@ export * as PluginV2 from "./plugin" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event, ID, type Info } from "@opencode-ai/schema/plugin" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" +import { App } from "./app" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" @@ -144,6 +145,7 @@ export const node = makeLocationNode({ layer, deps: [ EventV2.node, + App.node, AgentV2.node, AISDK.node, Catalog.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index d9659bd94a..a97b6bf0bb 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -2,6 +2,7 @@ export * as PluginHost from "./host" import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { EventManifest } from "@opencode-ai/schema/event-manifest" +import { App } from "../app" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" import { AISDK } from "../aisdk" @@ -26,6 +27,7 @@ import { PluginHooks } from "./hooks" const mutable = (value: T) => value as DeepMutable export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { + const app = yield* App.Metadata const agents = yield* AgentV2.Service const aisdk = yield* AISDK.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 }))) return { + app, options: {}, agent: { get: (id) => agents.get(AgentV2.ID.make(id)), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index d3b3332b61..e54365e79f 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -57,6 +57,7 @@ export function fromPromise(plugin: Plugin) { ) const context2: Context = { + app: host.app, options: host.options, agent: { get: (id) => run(host.agent.get(id)), diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index a24cd711b3..6787e91697 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,5 +1,5 @@ import os from "os" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../../app" import { Effect, Option, Schema } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" @@ -23,7 +23,7 @@ export const CloudflareAIGatewayPlugin = define({ accountId: config.accountId, gateway: config.gatewayId, apiKey: config.apiKey, - options: gatewayOptions(evt.options, metadata), + options: gatewayOptions(evt.options, metadata, ctx.app), } as any) const unified = createUnified({ apiKey: config.apiKey }) evt.sdk = { @@ -64,7 +64,7 @@ function gatewayMetadata(options: Record) { return raw ? Option.getOrUndefined(decodeJson(raw)) : undefined } -function gatewayOptions(options: Record, metadata: unknown) { +function gatewayOptions(options: Record, metadata: unknown, app: App.Info) { return { metadata, cacheTtl: options.cacheTtl, @@ -72,7 +72,7 @@ function gatewayOptions(options: Record, metadata: unknown) { skipCache: options.skipCache, collectLog: options.collectLog, 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()})`, }, } } diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 435d7086c2..99f94320ba 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,5 +1,5 @@ import os from "os" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../../app" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" @@ -29,10 +29,13 @@ export const CloudflareWorkersAIPlugin = define({ if (!hasWorkersEndpoint(evt.model) && !accountId) return const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) evt.sdk = mod.createOpenAICompatible( - sdkOptions({ - ...evt.options, - baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), - }) as any, + sdkOptions( + { + ...evt.options, + baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), + }, + ctx.app, + ) as any, ) }), ) @@ -61,13 +64,13 @@ function hasWorkersEndpoint(model: { return ProviderV2.isAISDK(model.package) && typeof model.settings?.baseURL === "string" } -function sdkOptions(options: Record) { +function sdkOptions(options: Record, app: App.Info) { return { ...options, baseURL: expandAccountId(options.baseURL), apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey, 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, }, name: providerID, diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 68ed4591c7..71de0b7b6b 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -4,7 +4,7 @@ import { Catalog } from "../../catalog" import { Credential } from "../../credential" import { EventV2 } from "../../event" import { CopilotModels } from "../../github-copilot/models" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../../app" import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { define } from "@opencode-ai/plugin/v2/effect/plugin" @@ -30,7 +30,7 @@ const Token = Schema.Struct({ const JsonBody = Schema.UnknownFromJsonString const decodeBody = Schema.decodeUnknownOption(JsonBody) -const oauth = { +const oauth = (app: App.Info) => ({ integrationID: Integration.ID.make("github-copilot"), method: { id: methodID, @@ -63,7 +63,7 @@ const oauth = { const urls = oauthURLs(domain) const device = yield* request(urls.device, { method: "POST", - headers: headers(), + headers: headers(app), body: JSON.stringify({ client_id: clientID, scope: "read:user" }), }).pipe(Effect.map(Schema.decodeUnknownSync(Device))) const interval = Math.max(device.interval, 1) * 1000 @@ -71,7 +71,7 @@ const oauth = { const poll = (wait: number): Effect.Effect => request(urls.token, { method: "POST", - headers: headers(), + headers: headers(app), body: JSON.stringify({ client_id: clientID, device_code: device.device_code, @@ -109,7 +109,7 @@ const oauth = { callback: poll(interval), } }), -} satisfies IntegrationOAuthMethodRegistration +}) satisfies IntegrationOAuthMethodRegistration function shouldUseResponses(modelID: string) { // Copilot supports Responses for GPT-5 class models, except mini variants @@ -152,7 +152,7 @@ export const GithubCopilotPlugin = define({ { ...provider?.headers, Authorization: `Bearer ${credential.refresh}`, - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(ctx.app), "X-GitHub-Api-Version": apiVersion, }, existing, @@ -166,7 +166,7 @@ export const GithubCopilotPlugin = define({ }) yield* ctx.integration.transform((draft) => { - draft.method.update(oauth) + draft.method.update(oauth(ctx.app)) }) yield* ctx.catalog.transform((evt) => { 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, evt.options.fetch, evt.package === "@ai-sdk/anthropic", + ctx.app, ) if (evt.package === "@ai-sdk/anthropic") { evt.options.headers = { @@ -261,11 +262,11 @@ function baseURL(enterprise?: string) { return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com" } -function headers() { +function headers(app: App.Info) { return { Accept: "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[0], init?: RequestInit) => Promise -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 return async (input, init) => { 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.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("X-GitHub-Api-Version", apiVersion) if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14") diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 9cb3974f1d..9772a38b3a 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,5 +1,5 @@ import os from "os" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../../app" import { Effect } from "effect" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" @@ -20,7 +20,7 @@ export const GitLabPlugin = define({ : (process.env.GITLAB_INSTANCE_URL ?? "https://gitlab.com"), apiKey: typeof evt.options.apiKey === "string" ? evt.options.apiKey : process.env.GITLAB_TOKEN, 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", ...evt.options.aiGatewayHeaders, }, diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 195fdd317b..b6ff9e5810 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -2,9 +2,9 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" +import { App } from "../../app" import { Credential } from "../../credential" import { EventV2 } from "../../event" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { OauthCallbackPage } from "../../oauth/page" @@ -42,7 +42,7 @@ const Claims = Schema.fromJsonString( ) const decodeClaims = Schema.decodeUnknownOption(Claims) -const browser = { +const browser = (app: App.Info) => ({ integrationID: Integration.ID.make("openai"), method: { id: browserMethodID, @@ -91,15 +91,15 @@ const browser = { url: authorizeURL(redirect, pkce, state), instructions: "Complete authorization in your browser. This window will close automatically.", 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)), ), } }), - refresh: (value) => refresh(browserMethodID, value), -} satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(browserMethodID, value, app), +}) satisfies IntegrationOAuthMethodRegistration -const headless = { +const headless = (app: App.Info) => ({ integrationID: Integration.ID.make("openai"), method: { id: headlessMethodID, @@ -112,7 +112,7 @@ const headless = { `${issuer}/api/accounts/deviceauth/usercode`, { method: "POST", - headers: headers("application/json"), + headers: headers("application/json", app), body: JSON.stringify({ client_id: clientID }), }, ) @@ -127,7 +127,7 @@ const headless = { try: (signal) => fetch(`${issuer}/api/accounts/deviceauth/token`, { 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 }), signal, }), @@ -140,10 +140,12 @@ const headless = { } return credential( headlessMethodID, - yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { - verifier: data.code_verifier, - challenge: "", - }), + yield* exchange( + data.authorization_code, + `${issuer}/deviceauth/callback`, + { verifier: data.code_verifier, challenge: "" }, + app, + ), ) } if (response.status !== 403 && response.status !== 404) { @@ -154,8 +156,8 @@ const headless = { }), } }), - refresh: (value) => refresh(headlessMethodID, value), -} satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(headlessMethodID, value, app), +}) satisfies IntegrationOAuthMethodRegistration export const OpenAIPlugin = define({ id: "opencode.provider.openai", @@ -173,8 +175,8 @@ export const OpenAIPlugin = define({ }) yield* ctx.integration.transform((draft) => { - draft.method.update(browser) - draft.method.update(headless) + draft.method.update(browser(ctx.app)) + draft.method.update(headless(ctx.app)) }) yield* load() yield* ctx.catalog.transform((evt) => { @@ -232,14 +234,14 @@ export const OpenAIPlugin = define({ }), } satisfies PluginInternal.InternalPlugin) -function headers(contentType: string) { - return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` } +function headers(contentType: string, app: App.Info) { + return { "Content-Type": contentType, "User-Agent": App.useragent(app) } } -function exchange(code: string, redirect: string, pkce: Pkce) { +function exchange(code: string, redirect: string, pkce: Pkce, app: App.Info) { return request(`${issuer}/oauth/token`, { method: "POST", - headers: headers("application/x-www-form-urlencoded"), + headers: headers("application/x-www-form-urlencoded", app), body: new URLSearchParams({ grant_type: "authorization_code", code, @@ -250,10 +252,10 @@ function exchange(code: string, redirect: string, pkce: Pkce) { }) } -function refresh(methodID: Integration.MethodID, value: Pick) { +function refresh(methodID: Integration.MethodID, value: Pick, app: App.Info) { return request(`${issuer}/oauth/token`, { method: "POST", - headers: headers("application/x-www-form-urlencoded"), + headers: headers("application/x-www-form-urlencoded", app), body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: value.refresh, diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 647c560ad0..c97f8d68f4 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -2,8 +2,8 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Clock, Deferred, Effect, Option, Schema } from "effect" +import { App } from "../../app" import { Credential } from "../../credential" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Integration } from "../../integration" import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" @@ -48,7 +48,7 @@ const DeviceError = Schema.Struct({ }) const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError)) -const browser = { +const browser = (app: App.Info) => ({ integrationID: Integration.ID.make("xai"), method: { id: browserMethodID, @@ -108,15 +108,15 @@ const browser = { url: authorizeURL(pkce, state, randomString(32)), instructions: "Complete authorization in your browser. This window will close automatically.", callback: Deferred.await(code).pipe( - Effect.flatMap((value) => exchange(value, pkce)), + Effect.flatMap((value) => exchange(value, pkce, app)), Effect.flatMap((tokens) => credential(browserMethodID, tokens)), ), } }), - refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID })), -} satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app), +}) satisfies IntegrationOAuthMethodRegistration -const device = { +const device = (app: App.Info) => ({ integrationID: Integration.ID.make("xai"), method: { id: deviceMethodID, @@ -128,7 +128,7 @@ const device = { `${issuer}/device/code`, { method: "POST", - headers: headers(), + headers: headers(app), body: new URLSearchParams({ client_id: clientID, scope }).toString(), }, Device, @@ -142,14 +142,14 @@ const device = { url: value.verification_uri_complete ?? value.verification_uri, instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`, ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}), - callback: poll(value).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), + callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), } }), ), ), ), - refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID })), -} satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app), +}) satisfies IntegrationOAuthMethodRegistration export const XAIPlugin = define({ id: "opencode.provider.xai", @@ -158,8 +158,8 @@ export const XAIPlugin = define({ draft.update("xai", (integration) => { integration.name = "xAI" }) - draft.method.update(browser) - draft.method.update(device) + draft.method.update(browser(ctx.app)) + draft.method.update(device(ctx.app)) draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } }) }) 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( `${issuer}/token`, { method: "POST", - headers: headers(), + headers: headers(app), body: new URLSearchParams({ grant_type: "authorization_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( `${issuer}/token`, { method: "POST", - headers: headers(), + headers: headers(app), body: new URLSearchParams({ grant_type: "refresh_token", 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))) } -function poll(device: typeof Device.Type): Effect.Effect { +function poll(device: typeof Device.Type, app: App.Info): Effect.Effect { return Effect.gen(function* () { const started = yield* Clock.currentTimeMillis const expires = started + positiveSeconds(device.expires_in, 300) * 1000 @@ -225,7 +225,7 @@ function poll(device: typeof Device.Type): Effect.Effect { } const response = yield* send(`${issuer}/token`, { method: "POST", - headers: headers(), + headers: headers(app), body: new URLSearchParams({ grant_type: deviceGrant, client_id: clientID, @@ -314,11 +314,11 @@ function tokenExpiration(tokens: Token) { return expiration ? expiration * 1000 : Date.now() + 3600 * 1000 } -function headers() { +function headers(app: App.Info) { return { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(app), } } diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index ed2cdccb64..c0edc4a431 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,11 +2,10 @@ 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 { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" import { Config } from "../config" import { Location } from "../location" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -27,7 +26,7 @@ const REPORT_DESCRIPTION = export const Plugin = define({ id: "opencode.skill", effect: Effect.fn(function* (ctx) { - const reportContent = yield* reportContentWithDiagnostics() + const reportContent = yield* reportContentWithDiagnostics(ctx.app) yield* ctx.skill.transform((draft) => { draft.source( 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"])) return [ 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.", "", - `- opencode version: ${InstallationVersion}`, - `- install/channel: ${InstallationChannel}`, + `- opencode version: ${app.version}`, + `- install/channel: ${app.channel}`, `- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`, `- Terminal: ${terminal()}`, `- Shell: ${shell()}`, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 822c93465a..53a64866c2 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -20,7 +20,7 @@ import { AbsolutePath, PositiveInt, RelativePath } from "./schema" import { AgentV2 } from "./agent" import { SessionV1 } from "./v1/session" 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 { ProjectTable } from "./project/sql" import path from "path" @@ -305,6 +305,7 @@ export class Service extends Context.Service()("@opencode/v2 const layer = Layer.effect( Service, Effect.gen(function* () { + const app = yield* App.Metadata const database = yield* Database.Service const db = database.db const events = yield* EventV2.Service @@ -352,7 +353,7 @@ const layer = Layer.effect( const info = SessionV1.SessionInfo.make({ id: sessionID, slug: Slug.create(), - version: InstallationVersion, + version: app.version, projectID: project.id, parentID: input.parentID, directory: location.directory, @@ -1040,5 +1041,6 @@ export const node = makeGlobalNode({ SessionProjector.node, FSUtil.node, Global.node, + App.node, ], }) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 7a18f55f38..a5b2c626b6 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -10,7 +10,7 @@ import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import type { SessionMessage } from "./message" import { SessionModelHeaders } from "./model-headers" -import { Client } from "@opencode-ai/util/client" +import { App } from "../app" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { toSessionError } from "./to-session-error" @@ -61,7 +61,7 @@ type Settings = { } type Dependencies = { - readonly client: string + readonly app: App.Info readonly events: EventV2.Interface readonly llm: { readonly stream: (request: LLMRequest) => Stream.Stream @@ -260,7 +260,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: plan.model, - http: { headers: SessionModelHeaders.make(plan.session, dependencies.client) }, + http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) }, messages: [Message.user(plan.prompt)], tools: [], }), @@ -399,13 +399,13 @@ export const layer = Layer.effect( const llm = yield* LLMClient.Service const config = yield* Config.Service const models = yield* SessionRunnerModel.Service - const client = yield* Client.Name - return make({ events, llm, models, config: settings(yield* config.entries()), client }) + const app = yield* App.Metadata + return make({ events, llm, models, config: settings(yield* config.entries()), app }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, Client.node], + deps: [EventV2.node, llmClient, Config.node, SessionRunnerModel.node, App.node], }) diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index 24be6f835c..1ca5fa1ac6 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -4,7 +4,7 @@ import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai" import { Effect, Layer } from "effect" import { Database } from "../database/database" 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 { PluginHooks } from "../plugin/hooks" import { SessionContext } from "./context" @@ -23,7 +23,7 @@ export const layer = Layer.effect( const hooks = yield* PluginHooks.Service const llm = yield* LLMClient.Service const models = yield* SessionRunnerModel.Service - const client = yield* Client.Name + const app = yield* App.Metadata return SessionGenerate.Service.of({ generate: Effect.fn("SessionGenerate.generate")(function* (input) { @@ -51,7 +51,7 @@ export const layer = Layer.effect( return (yield* llm.generate( LLM.request({ model: model.model, - http: { headers: SessionModelHeaders.make(selection.session, client) }, + http: { headers: SessionModelHeaders.make(selection.session, app) }, providerOptions: { openai: { promptCacheKey } }, system: contextEvent.system, messages: contextEvent.messages, @@ -67,5 +67,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: SessionGenerate.Service, 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], }) diff --git a/packages/core/src/session/model-headers.ts b/packages/core/src/session/model-headers.ts index cbe9d78f2b..1491038a2a 100644 --- a/packages/core/src/session/model-headers.ts +++ b/packages/core/src/session/model-headers.ts @@ -1,14 +1,14 @@ export * as SessionModelHeaders from "./model-headers" -import { InstallationVersion } from "@opencode-ai/util/installation/version" +import { App } from "../app" import { SessionSchema } from "./schema" -export const make = (session: Pick, client: string) => ({ +export const make = (session: Pick, app: App.Info) => ({ "x-session-affinity": session.id, "X-Session-Id": session.id, ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(app), "x-opencode-project": session.projectID, "x-opencode-session": session.id, - "x-opencode-client": client, + "x-opencode-client": app.name, }) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 7e5f84d20c..6afe3318ac 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -4,7 +4,7 @@ import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@op import { SessionError } from "@opencode-ai/schema/session-error" import { Context, Effect, Layer } from "effect" 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 { PluginHooks } from "../plugin/hooks" import { ToolRegistry } from "../tool/registry" @@ -85,7 +85,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const hooks = yield* PluginHooks.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 session = input.context.session @@ -123,7 +123,7 @@ export const layer = Layer.effect( const request = LLM.request({ model, http: { - headers: SessionModelHeaders.make(session, client), + headers: SessionModelHeaders.make(session, app), }, providerOptions: { openai: { promptCacheKey } }, system: contextEvent.system, @@ -157,5 +157,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [PluginHooks.node, ToolRegistry.node, Client.node], + deps: [PluginHooks.node, ToolRegistry.node, App.node], }) diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index a506cb1cc1..d9cba66a8a 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -6,7 +6,7 @@ import { AgentV2 } from "../agent" import { Database } from "../database/database" import { EventV2 } from "../event" 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 { SessionEvent } from "./event" import { SessionHistory } from "./history" @@ -18,7 +18,7 @@ import { SessionUsage } from "./usage" const MAX_LENGTH = 100 type Dependencies = { - readonly client: string + readonly app: App.Info readonly events: EventV2.Interface readonly llm: { readonly stream: (request: LLMRequest) => Stream.Stream @@ -68,7 +68,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: resolved.model, - http: { headers: SessionModelHeaders.make(session, dependencies.client) }, + http: { headers: SessionModelHeaders.make(session, dependencies.app) }, system: agent.system, messages: [Message.user(firstUser.text)], tools: [], @@ -112,8 +112,8 @@ export const layer = Layer.effect( const agents = yield* AgentV2.Service const models = yield* SessionRunnerModel.Service const database = yield* Database.Service - const client = yield* Client.Name - const title = make({ events, llm, agents, models, client }) + const app = yield* App.Metadata + const title = make({ events, llm, agents, models, app }) return Service.of({ generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session), }) @@ -123,5 +123,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, 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], }) diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 75752de6fb..c74a5d642b 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" 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 { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -241,7 +241,7 @@ export const Plugin = { // 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}` } : {}), }, ) diff --git a/packages/core/test/app.test.ts b/packages/core/test/app.test.ts new file mode 100644 index 0000000000..8d62f00141 --- /dev/null +++ b/packages/core/test/app.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test" +import { App } from "@opencode-ai/core/app" + +test("formats app metadata as a user agent", () => { + expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe( + "opencode/beta/1.2.3/sdk", + ) +}) diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts index 27a1251eff..416d9da4b6 100644 --- a/packages/core/test/effect/observability.test.ts +++ b/packages/core/test/effect/observability.test.ts @@ -39,12 +39,13 @@ describe("resource", () => { process.env.OTEL_RESOURCE_ATTRIBUTES = "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", "service.namespace": "anomalyco", }) - expect(resource("cli").attributes["service.instance.id"]).not.toBe("override") - expect(resource("cli").attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/) + expect(resource(app).attributes["service.instance.id"]).not.toBe("override") + expect(resource(app).attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/) }) }) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 2d2661ddab..158ad32c67 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -136,6 +136,7 @@ function resourceServer( return { state, url: http.url.toString(), + clientVersion: () => protocol.getClientVersion(), sendResourceListChanged: () => protocol.sendResourceListChanged(), completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(), close: async () => { @@ -151,10 +152,11 @@ function resourceServer( function resourceMcpLayer( server: string | typeof ConfigMCP.Server.Type, onFormCreated?: (form: Form.Info) => Effect.Effect, + options?: MCP.Options, ) { const directory = AbsolutePath.make(import.meta.dir) const unusedIntegration = () => Effect.die("unused integration service") - return MCP.layer.pipe( + return MCP.layer(options).pipe( Layer.provideMerge(Form.layer), Layer.provide( Layer.mergeAll( @@ -643,7 +645,12 @@ test("loads and reads MCP resources", async () => { { 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" } }), + ), + ) }), ), ) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 5d9366bbfa..0a85eac11a 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -302,7 +302,7 @@ describe("ModelsDev Service", () => { const final = yield* Ref.get(state) expect(final.calls.length).toBe(1) expect(final.calls[0].url).toContain("/api.json") - expect(final.calls[0].userAgent).toContain("/cli") + expect(final.calls[0].userAgent).toContain("/opencode") }), ) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 85c43d4304..1b28d6a840 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -19,6 +19,7 @@ type Overrides = Partial> & { export function host(overrides: Overrides = {}): PluginContext { return { + app: overrides.app ?? { name: "test", version: "test", channel: "test" }, options: {}, agent: overrides.agent ?? { get: () => Effect.die("unused agent.get"), diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index b850d9ac73..328007f2da 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,4 +1,5 @@ import { AISDK } from "@opencode-ai/core/aisdk" +import { App } from "@opencode-ai/core/app" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -62,6 +63,7 @@ describe("GithubCopilotPlugin", () => { return Response.json({ ok: true }) }, false, + App.make({ name: "test", version: "1.2.3", channel: "beta" }), ) yield* Effect.promise(() => 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("copilot-vision-request")).toBe("true") 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") }), ) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index c35d7073eb..cd9a9b3d82 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -3,7 +3,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/util/fs-util" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Location } from "@opencode-ai/core/location" import { Effect } from "effect" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" @@ -21,6 +20,7 @@ describe("SkillPlugin.Plugin", () => { const skill = yield* SkillV2.Service yield* SkillPlugin.Plugin.effect( host({ + app: { name: "test", version: "1.2.3", channel: "beta" }, skill: { list: () => Effect.die("unused skill.list"), transform: skill.transform, @@ -54,7 +54,8 @@ describe("SkillPlugin.Plugin", () => { }), ) 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") }), ) }) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index c58433b9cd..40386568b6 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -18,7 +18,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" 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 { Money } from "@opencode-ai/schema/money" 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-Id": sessionID, "x-parent-session-id": parentID, - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(App.make()), "x-opencode-project": Project.ID.global, "x-opencode-session": sessionID, - "x-opencode-client": "cli", + "x-opencode-client": "opencode", }) expect(requests[0]?.generation).toBeUndefined() expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 1208161827..dc2cb2d9df 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -22,7 +22,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/util/effect/layer-node" 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 { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -3180,10 +3180,10 @@ describe("SessionRunnerLLM", () => { expect(requests[0]?.http?.headers).toEqual({ "x-session-affinity": sessionID, "X-Session-Id": sessionID, - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(App.make()), "x-opencode-project": Project.ID.global, "x-opencode-session": sessionID, - "x-opencode-client": "cli", + "x-opencode-client": "opencode", }) }), ) diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index cb53c3dcde..3673509ae5 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -17,7 +17,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" 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 { Money } from "@opencode-ai/schema/money" 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({ "x-session-affinity": sessionID, "X-Session-Id": sessionID, - "User-Agent": `opencode/${InstallationVersion}`, + "User-Agent": App.useragent(App.make()), "x-opencode-project": Project.ID.global, "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") const renamed = yield* store.get(sessionID) diff --git a/packages/plugin/src/v2/app.ts b/packages/plugin/src/v2/app.ts new file mode 100644 index 0000000000..7c9b80d813 --- /dev/null +++ b/packages/plugin/src/v2/app.ts @@ -0,0 +1,5 @@ +export interface App { + readonly name: string + readonly version: string + readonly channel: string +} diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 3b0b6eb918..02cb8a681f 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,6 +1,7 @@ import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" import type { PluginOptions } from "../options.js" +import type { App } from "../app.js" import type { AgentDomain } from "./agent.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" @@ -13,6 +14,7 @@ import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" export interface Context { + readonly app: App readonly options: PluginOptions readonly agent: AgentDomain readonly aisdk: AISDKDomain diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index 2e05b1fded..a6a3389f7a 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,5 +1,6 @@ import type { PluginApi } from "@opencode-ai/client/promise/api" import type { PluginOptions } from "../options.js" +import type { App } from "../app.js" import type { AgentDomain } from "./agent.js" import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" @@ -12,6 +13,7 @@ import type { SkillDomain } from "./skill.js" import type { ToolDomain } from "./tool.js" export interface Context { + readonly app: App readonly options: PluginOptions readonly agent: AgentDomain readonly aisdk: AISDKDomain diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 60adaec4bc..90495c964a 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -11,7 +11,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: ServerOpt ManagedRuntime.make( createEmbeddedRoutes({ ...options, - client: options.client ?? "sdk", + app: { ...options.app, name: options.app?.name ?? "sdk" }, database: { path: ":memory:", ...options.database }, }).pipe(Layer.provide(HttpServer.layerServices)), ), diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 9ce482ff87..09980bd8cb 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -25,6 +25,27 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create() const location = (fixture: Fixture) => 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( "reloads every booted Location after SDK plugin registration", () => diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index 19d32a6b10..785f277ed0 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -1,15 +1,18 @@ -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { ServerInfo } from "../server-info" export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) => handlers .handle("health.get", () => - Effect.succeed({ - healthy: true as const, - version: InstallationVersion, - pid: process.pid, + Effect.gen(function* () { + const info = yield* ServerInfo.Service + return { + healthy: true as const, + version: info.app.version ?? "unknown", + pid: process.pid, + } }), ) .handle("health.stop", () => Effect.succeed({ accepted: false })), diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index 4ff968a592..b4f8eccd5b 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -4,7 +4,13 @@ import { Observability } from "@opencode-ai/util/observability" import { Schema } from "effect" 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), port: Schema.optional( Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535)), diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 6f57ed723d..483745b404 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,7 +1,6 @@ export * as ServerProcess from "./process" 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 { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" @@ -50,7 +49,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( // Request fibers may continue inbound trace context, but must not inherit the server startup parent. yield* bound.http .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.logger, @@ -153,6 +152,7 @@ function dispatch( status: Status.Interface, application: Ref.Ref>, shutdown: Deferred.Deferred, + version: string, ): App { const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" }) return Effect.gen(function* () { @@ -166,7 +166,7 @@ function dispatch( : undefined if (lifecycle !== undefined) { 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 app = yield* Ref.get(application) @@ -189,8 +189,9 @@ const control = Effect.fnUntraced(function* ( route: "health" | "stop", status: Status.Interface, 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 input = Option.isSome(body) ? Schema.decodeUnknownOption(ServiceStatus.StopRequest)(body.value) : Option.none() 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 }) }) -const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) { +const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) { const state = yield* status.current 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, headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined, diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index f8985cc007..553e91281e 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,4 +1,5 @@ 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 { httpClient } from "@opencode-ai/util/effect/app-node-platform" 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 { FileSystemSearch } from "@opencode-ai/core/filesystem/search" import { Observability } from "@opencode-ai/util/observability" -import { Client } from "@opencode-ai/util/client" import { Credential } from "@opencode-ai/core/credential" import { Config } from "@opencode-ai/core/config" 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 { Project } from "@opencode-ai/core/project" 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 { Job } from "@opencode-ai/core/job" +import { MCP } from "@opencode-ai/core/mcp/index" import { Global } from "@opencode-ai/util/global" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" @@ -88,7 +85,7 @@ function makeRoutes( const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ [Database.node, Database.configured(options.database)], - [Client.node, Client.configured(options.client)], + [App.node, App.configured(options.app)], [ModelsDev.node, ModelsDev.configured(options.models)], [Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })], [FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })], @@ -105,6 +102,15 @@ function makeRoutes( [CommandV2.node, CommandV2.configured({ gitbash: options.windows?.gitbash })], [Pty.node, Pty.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.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ] @@ -112,7 +118,7 @@ function makeRoutes( ? Layer.unwrap( Effect.gen(function* () { 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]) }), ) @@ -123,7 +129,7 @@ function makeRoutes( const services = Layer.succeedContext(context) const requestServices = Layer.merge( 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( Layer.provide(handlers.pipe(Layer.provide(services))), @@ -133,7 +139,14 @@ function makeRoutes( Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), 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), Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer), diff --git a/packages/server/src/server-info.ts b/packages/server/src/server-info.ts index 7d1ba4b094..1f4f8c5afa 100644 --- a/packages/server/src/server-info.ts +++ b/packages/server/src/server-info.ts @@ -1,12 +1,14 @@ import { Context, Layer } from "effect" import { networkInterfaces } from "node:os" +import type { ServerOptions } from "./options" -export class Service extends Context.Service ReadonlyArray }>()( - "@opencode-ai/server/ServerInfo", -) {} +export class Service extends Context.Service< + Service, + { readonly urls: () => ReadonlyArray; readonly app: NonNullable } +>()("@opencode-ai/server/ServerInfo") {} -export function layer(urls: () => ReadonlyArray) { - return Layer.succeed(Service, Service.of({ urls })) +export function layer(urls: () => ReadonlyArray, app: ServerOptions["app"] = {}) { + return Layer.succeed(Service, Service.of({ urls, app })) } export function connectionURLs(value: string, requestedHostname?: string) { diff --git a/packages/server/test/options.test.ts b/packages/server/test/options.test.ts index 8b7d4665d7..2cae02d393 100644 --- a/packages/server/test/options.test.ts +++ b/packages/server/test/options.test.ts @@ -12,3 +12,9 @@ test("rejects ports outside the valid range", () => { expect(Option.isNone(decode({ port: -1 }))).toBe(true) expect(Option.isNone(decode({ port: 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" }) +}) diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index eeec517e04..b462720bd3 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -10,6 +10,7 @@ it.live("allows browser preflight requests without credentials", () => hostname: "127.0.0.1", port: 0, password: "secret", + app: { version: "test-version" }, database: { path: ":memory:" }, }) const response = yield* Effect.promise(() => @@ -38,5 +39,6 @@ it.live("allows browser preflight requests without credentials", () => expect(health.status).toBe(200) expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" }) }), ) diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index bd430e0f1d..86014a1d1a 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -21,7 +21,7 @@ import { SimulatedProvider } from "./simulated-provider" * */ -export const simulationReplacements = Effect.fn("Simulation.replacements")(function* () { +export const simulationReplacements = Effect.fn("Simulation.replacements")(function* (app: { readonly version: string }) { // ModelsDev dies when its catalog fetch fails, so simulation answers it with // an empty catalog; providers come from seeded config instead. const models = SimulationNetwork.json("GET", "https://models.dev/api.json", {}) @@ -40,6 +40,7 @@ export const simulationReplacements = Effect.fn("Simulation.replacements")(funct Layer.provide( SimulatedProvider.layerDrive({ endpoint: manifest.endpoints.backend, + version: app.version, }), ), ) diff --git a/packages/simulation/src/backend/simulated-provider.ts b/packages/simulation/src/backend/simulated-provider.ts index 7d1a674701..ba64f21bca 100644 --- a/packages/simulation/src/backend/simulated-provider.ts +++ b/packages/simulation/src/backend/simulated-provider.ts @@ -1,4 +1,3 @@ -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Tool } from "@opencode-ai/plugin/v2/effect/tool" @@ -152,7 +151,7 @@ class ToolControllerError extends Schema.TaggedErrorClass() { message: Schema.String }, ) {} -export const layerDrive = (options: { readonly endpoint: string }) => +export const layerDrive = (options: { readonly endpoint: string; readonly version: string }) => Layer.effect( Service, Effect.gen(function* () { @@ -277,7 +276,8 @@ export const layerDrive = (options: { readonly endpoint: string }) => label: "opencode drive backend websocket", data: () => ({}), 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) => Effect.all([releaseController(activeController, controllerLock, socket), tools.release(socket)], { discard: true, @@ -306,13 +306,14 @@ function handle( controllerLock: Semaphore.Semaphore, socket: ControlSocket, request: SimulationProtocol.Backend.Request, + version: string, ) { switch (request.method) { case "simulation.handshake": return SimulationProtocol.Handshake.dispatch( { role: "backend", - server: { name: "opencode", version: InstallationVersion }, + server: { name: "opencode", version }, capabilities: SimulationProtocol.Backend.Capabilities, }, request.params, diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index 4aaabd882c..2318a27ada 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,17 +1,16 @@ -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { Effect } from "effect" import { SimulationControlServer } from "../control-server" import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" 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) { case "simulation.handshake": return SimulationProtocol.Handshake.dispatch( { role: "ui", - server: { name: "opencode", version: InstallationVersion }, + server: { name: "opencode", version }, capabilities: SimulationProtocol.Frontend.Capabilities, }, 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({ endpoint, label: "opencode drive ui websocket", data: () => ({ drive: true as const }), decode: SimulationProtocol.Frontend.decodeRequestEffect, - handle: (_socket, request) => handle(harness, request), + handle: (_socket, request) => handle(harness, request, version), }) }) diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index 0843754ff1..26b1329e82 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -6,7 +6,7 @@ import { SimulationRenderer } from "./renderer" import { SimulationServer } from "./server" /** 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 manifest = yield* DriveManifest.resolve() 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) - 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`)) return renderer }) diff --git a/packages/simulation/test/simulated-provider.test.ts b/packages/simulation/test/simulated-provider.test.ts index b91922f138..8aac0cd723 100644 --- a/packages/simulation/test/simulated-provider.test.ts +++ b/packages/simulation/test/simulated-provider.test.ts @@ -685,7 +685,7 @@ function runProvider( } const providerLayer = (endpoint: string) => - SimulatedProvider.layerDrive({ endpoint }).pipe( + SimulatedProvider.layerDrive({ endpoint, version: "test" }).pipe( Layer.provide( Layer.succeed(SdkPlugins.Service, SdkPlugins.Service.of({ register: () => Effect.void, all: () => [] })), ), @@ -694,7 +694,7 @@ const providerLayer = (endpoint: string) => const toolLifecycleLayer = (endpoint: string) => { const provider = makeGlobalNode({ service: SimulatedProvider.Service, - layer: SimulatedProvider.layerDrive({ endpoint }), + layer: SimulatedProvider.layerDrive({ endpoint, version: "test" }), deps: [SdkPlugins.node], }) return AppNodeBuilder.build( diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 27f561b69f..b4e6fb815e 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -32,10 +32,12 @@ import { } from "solid-js" import { TuiLifecycleProvider, + TuiAppProvider, TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup, + type TuiApp, } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogIntegration } from "./component/dialog-integration" @@ -139,6 +141,7 @@ const appBindingCommands = [ ] as const export type TuiInput = { + app: TuiApp server: { endpoint: Endpoint service?: { @@ -224,7 +227,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { } if (process.env.OPENCODE_DRIVE) { 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( Effect.tryPromise({ @@ -273,9 +276,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }} > (exit.epilogue = value)}> - } - > + + } + > - + + diff --git a/packages/tui/src/component/dialog-debug.tsx b/packages/tui/src/component/dialog-debug.tsx index 43b24b2d0c..ed13a8ffe0 100644 --- a/packages/tui/src/component/dialog-debug.tsx +++ b/packages/tui/src/component/dialog-debug.tsx @@ -1,6 +1,5 @@ import { TextAttributes } from "@opentui/core" import { createMemo, createSignal, For } from "solid-js" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version" import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" @@ -9,6 +8,7 @@ import { useLocal } from "../context/local" import { useClipboard } from "../context/clipboard" import { useToast } from "../ui/toast" import { describeOS, describeTerminal } from "../util/system" +import { useTuiApp } from "../context/runtime" export function DialogDebug() { const { themeV2 } = useTheme() @@ -17,6 +17,7 @@ export function DialogDebug() { const local = useLocal() const clipboard = useClipboard() const toast = useToast() + const app = useTuiApp() const [copied, setCopied] = createSignal(false) dialog.setSize("large") @@ -24,7 +25,7 @@ export function DialogDebug() { const entries = createMemo(() => { const model = local.model.current() return [ - { label: "Version", value: `${InstallationVersion} (${InstallationChannel})` }, + { label: "Version", value: `${app.version} (${app.channel})` }, { label: "Date", value: new Date().toISOString() }, { label: "OS", value: describeOS() }, { label: "Terminal", value: describeTerminal() }, diff --git a/packages/tui/src/component/error-component.tsx b/packages/tui/src/component/error-component.tsx index c17ee29573..d500e36c0d 100644 --- a/packages/tui/src/component/error-component.tsx +++ b/packages/tui/src/component/error-component.tsx @@ -3,14 +3,15 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { createSignal, For, Show } from "solid-js" import { getScrollAcceleration } from "../util/scroll" import { useClipboard } from "../context/clipboard" -import { InstallationVersion } from "@opencode-ai/util/installation/version" import { useExit } from "../context/exit" +import { useTuiApp } from "../context/runtime" import { describeOS, describeTerminal } from "../util/system" export function ErrorComponent(props: { error: Error; reset: () => void; mode?: "dark" | "light" }) { const term = useTerminalDimensions() const exit = useExit() const clipboard = useClipboard() + const app = useTuiApp() const [copied, setCopied] = createSignal(false) // 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 stack = props.error.stack || "No stack trace available." - const issueURL = buildIssueURL(message, stack) + const issueURL = buildIssueURL(message, stack, app.version) const copyReport = () => { 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." : "Copy the report and open a GitHub issue to help us fix this."} - OpenCode {InstallationVersion} + OpenCode {app.version} @@ -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 // form opens pre-filled. Populating os/terminal/reproduce keeps the report past // 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") 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("terminal", describeTerminal()) url.searchParams.set( diff --git a/packages/tui/src/context/runtime.tsx b/packages/tui/src/context/runtime.tsx index ced1cf1a44..d59a63abe6 100644 --- a/packages/tui/src/context/runtime.tsx +++ b/packages/tui/src/context/runtime.tsx @@ -1,5 +1,11 @@ import { createComponent, createContext, type JSX, useContext } from "solid-js" +export type TuiApp = Readonly<{ + name: string + version: string + channel: string +}> + export type TuiPaths = Readonly<{ cwd: string home: string @@ -23,6 +29,7 @@ export type TuiLifecycle = Readonly<{ }> const PathsContext = createContext() +const AppContext = createContext() const TerminalEnvironmentContext = createContext() const StartupContext = createContext() const LifecycleContext = createContext() @@ -40,6 +47,10 @@ export function TuiPathsProvider(props: { value: TuiPaths; children: JSX.Element 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 }) { return provider(TerminalEnvironmentContext, props.value, () => props.children) } @@ -62,6 +73,10 @@ export function useTuiPaths() { return required(PathsContext, "TuiPathsProvider") } +export function useTuiApp() { + return required(AppContext, "TuiAppProvider") +} + export function useTuiTerminalEnvironment() { return required(TerminalEnvironmentContext, "TuiTerminalEnvironmentProvider") } diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index ad66916a98..e8d39ee82f 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,8 +1,7 @@ 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 { useTerminalDimensions } from "@opentui/solid" -import { useTuiPaths } from "../../context/runtime" +import { useTuiApp, useTuiPaths } from "../../context/runtime" import { useTheme } from "../../context/theme" import { abbreviateHome } from "../../runtime" import { FilePath } from "../../ui/file-path" @@ -50,6 +49,7 @@ function Mcp(props: { context: Plugin.Context }) { function View(props: { context: Plugin.Context }) { const { themeV2 } = useTheme() + const app = useTuiApp() const dimensions = useTerminalDimensions() const mcpWidth = createMemo(() => { const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] @@ -71,12 +71,12 @@ function View(props: { context: Plugin.Context }) { > - {InstallationVersion} + {app.version} ) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 7c6584f250..81a61684ba 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -28,6 +28,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const { run } = await import("../src/app") const task = Effect.runPromise( run({ + app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, 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 task = Effect.runPromise( run({ + app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, packages: { resolve: async () => undefined }, diff --git a/packages/util/src/client.ts b/packages/util/src/client.ts deleted file mode 100644 index 8375e23616..0000000000 --- a/packages/util/src/client.ts +++ /dev/null @@ -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("@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() diff --git a/packages/util/src/installation/version.ts b/packages/util/src/installation/version.ts deleted file mode 100644 index d142b46a8b..0000000000 --- a/packages/util/src/installation/version.ts +++ /dev/null @@ -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" diff --git a/packages/util/src/observability.ts b/packages/util/src/observability.ts index f356aa03fb..f2474e3e7f 100644 --- a/packages/util/src/observability.ts +++ b/packages/util/src/observability.ts @@ -7,11 +7,13 @@ import { FetchHttpClient } from "effect/unstable/http" import { OtlpSerialization } from "effect/unstable/observability" import { Logging } from "./observability/logging.js" import { Otlp } from "./observability/otlp.js" -import { Client } from "./client.js" export const Options = Schema.Struct({ endpoint: 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 @@ -21,24 +23,29 @@ export function layer( 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.orDie, Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), ) return Layer.unwrap( Effect.gen(function* () { - const client = yield* Client.Name - const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers(options, client)], { - mergeWithExisting: false, - }).pipe( + const logs = Logger.layer( + [...Logging.loggers(app.channel === "local", app.channel), ...Otlp.loggers(options, app)], + { mergeWithExisting: false }, + ).pipe( Layer.provide(NodeFileSystem.layer), Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer), Layer.orDie, 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)) } diff --git a/packages/util/src/observability/logging.ts b/packages/util/src/observability/logging.ts index b83c0cc150..b8e162bad3 100644 --- a/packages/util/src/observability/logging.ts +++ b/packages/util/src/observability/logging.ts @@ -1,7 +1,6 @@ import { Formatter, Logger, type LogLevel } from "effect" import path from "path" import { Global } from "../global.js" -import { InstallationChannel, InstallationLocal } from "../installation/version.js" import { runID } from "./shared.js" function formatter(id: string = runID) { @@ -47,7 +46,7 @@ function format(input: unknown) { 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") 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 } -export function loggers() { - return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()] +export function loggers(local = true, channel = "local") { + const logger = fileLogger(file(local, channel)) + return process.env.OPENCODE_PRINT_LOGS === "1" ? [logger, stderrLogger] : [logger] } export * as Logging from "./logging.js" diff --git a/packages/util/src/observability/otlp.ts b/packages/util/src/observability/otlp.ts index 9ac4b7ce6d..e9f700d308 100644 --- a/packages/util/src/observability/otlp.ts +++ b/packages/util/src/observability/otlp.ts @@ -1,6 +1,5 @@ import { Layer } from "effect" import { OtlpLogger } from "effect/unstable/observability" -import { InstallationChannel, InstallationVersion } from "../installation/version.js" import { runID } from "./shared.js" export interface Options { @@ -8,6 +7,12 @@ export interface Options { readonly headers?: string } +export interface App { + readonly client: string + readonly version: string + readonly channel: string +} + function parseHeaders(value?: string) { return value ? 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 serviceVersion: string attributes: Record } { return { serviceName: "opencode", - serviceVersion: InstallationVersion, + serviceVersion: app.version, attributes: { ...resourceAttributes(), - "deployment.environment.name": InstallationChannel, - "opencode.client": client, + "deployment.environment.name": app.channel, + "opencode.client": app.client, "opencode.run": 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 [] return [ OtlpLogger.make({ url: `${options.endpoint}/v1/logs`, - resource: resource(client), + resource: resource(app), 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 const NodeSdk = await import("@effect/opentelemetry/NodeSdk") 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) return NodeSdk.layer(() => ({ - resource: resource(client), + resource: resource(app), spanProcessor: new SdkBase.BatchSpanProcessor( new OTLP.OTLPTraceExporter({ url: `${options.endpoint}/v1/traces`,