refactor(core): replace legacy logger with Effect logging (#31310)

This commit is contained in:
Dax 2026-06-08 15:41:56 -04:00 committed by GitHub
commit c06ad7c881
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
152 changed files with 697 additions and 2242 deletions

View file

@ -1,73 +0,0 @@
import { Cause, Effect, Logger, References } from "effect"
import * as Log from "../util/log"
type Fields = Record<string, unknown>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
// oxlint-disable-next-line no-base-to-string
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})

View file

@ -1,107 +0,0 @@
import { Effect, Layer, Logger } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { ensureProcessMetadata } from "../util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, ...value] = x.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
const processMetadata = ensureProcessMetadata("main")
const attributes: Record<string, string> = (() => {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
})()
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...attributes,
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
}
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
// @effect/opentelemetry creates a NodeTracerProvider but never calls
// register(), so the global @opentelemetry/api context manager stays
// as the no-op default. Non-Effect code (like the AI SDK) that calls
// tracer.startActiveSpan() relies on context.active() to find the
// parent span - without a real context manager every span starts a
// new trace. Registering AsyncLocalStorageContextManager fixes this.
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
const mgr = new AsyncLocalStorageContextManager()
mgr.enable()
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
headers,
}),
),
}))
}
export const layer = !base
? EffectLogger.layer
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs())
}),
)
export const Observability = { enabled, layer }

View file

@ -1,6 +1,6 @@
import { Layer, type Context, ManagedRuntime, type Effect } from "effect" import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
import { memoMap } from "./memo-map" import { memoMap } from "./memo-map"
import { Observability } from "./observability" import { Observability } from "../observability"
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) { export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined

View file

@ -410,9 +410,7 @@ export const layerWith = (options?: LayerOptions) =>
Effect.catchCauseIf( Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause), (cause) => !Cause.hasInterrupts(cause),
(cause) => (cause) =>
Effect.logError("Event observer failed").pipe( Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }),
),
), ),
) )

View file

@ -10,11 +10,8 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner
import { CrossSpawnSpawner } from "../cross-spawn-spawner" import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { Global } from "../global" import { Global } from "../global"
import { NonNegativeInt } from "../schema" import { NonNegativeInt } from "../schema"
import * as Log from "../util/log"
import { sanitizedProcessEnv } from "../util/opencode-process"
import { which } from "../util/which" import { which } from "../util/which"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0" const VERSION = "15.1.0"
const PLATFORM = { const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" }, "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
@ -146,7 +143,9 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ri
export const use = serviceUse(Service) export const use = serviceUse(Service)
function env() { function env() {
const env = sanitizedProcessEnv() const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH delete env.RIPGREP_CONFIG_PATH
return env return env
} }
@ -307,7 +306,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpa
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}` const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename) const archive = path.join(Global.Path.bin, filename)
log.info("downloading ripgrep", { url }) yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie) yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe( const bytes = yield* HttpClientRequest.get(url).pipe(
@ -418,7 +417,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpa
}) })
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) { const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
log.info("tree", input) yield* Effect.logInfo("tree", input)
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect)) const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node { interface Node {

View file

@ -4,13 +4,11 @@ import type { PlatformError } from "effect/PlatformError"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { Global } from "../global" import { Global } from "../global"
import * as Log from "../util/log"
import { serviceUse } from "../effect/service-use" import { serviceUse } from "../effect/service-use"
import { makeRuntime } from "../effect/runtime" import { makeRuntime } from "../effect/runtime"
import { Fff } from "#fff" import { Fff } from "#fff"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
const log = Log.create({ service: "file.search" })
const root = path.join(Global.Path.cache, "fff") const root = path.join(Global.Path.cache, "fff")
export type Item = Ripgrep.Item export type Item = Ripgrep.Item
@ -220,12 +218,14 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
if (!scanned.ok || !scanned.value) { if (!scanned.ok || !scanned.value) {
yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore) yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
state.pick.delete(dir) state.pick.delete(dir)
log.warn("fff scan not ready", { dir }) yield* Effect.logWarning("fff scan not ready", { dir })
return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error)) return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
} }
const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus()) const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error }) if (!git.ok) {
yield* Effect.logWarning("fff git refresh failed", { dir, error: git.error })
}
}) })
// Create (or return) the picker for a directory. Creation is synchronous // Create (or return) the picker for a directory. Creation is synchronous
@ -244,10 +244,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
if (pending) return yield* Deferred.await(pending) if (pending) return yield* Deferred.await(pending)
const available = yield* fffSync("check availability", () => Fff.available()).pipe( const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => { Effect.catch((error) => Effect.logWarning("fff availability check failed", { error }).pipe(Effect.as(false))),
log.warn("fff availability check failed", { error })
return Effect.succeed(false)
}),
) )
if (!available) return undefined if (!available) return undefined
@ -272,7 +269,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
}), }),
) )
if (!made.ok) { if (!made.ok) {
log.warn("fff init failed", { dir, error: made.error }) yield* Effect.logWarning("fff init failed", { dir, error: made.error })
const err = new Error(made.error) const err = new Error(made.error)
yield* Deferred.fail(gate, err) yield* Deferred.fail(gate, err)
return yield* Effect.fail(err) return yield* Effect.fail(err)
@ -355,14 +352,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
pageSize: limit, pageSize: limit,
}), }),
).pipe( ).pipe(
Effect.catch((error) => { Effect.catch((error) =>
log.warn(`fff ${kind} search failed`, { dir, query, error }) Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe(
return Effect.succeed<Fff.Result<string[]> | undefined>(undefined) Effect.as<Fff.Result<string[]> | undefined>(undefined),
}), ),
),
) )
if (!fffResult) return undefined if (!fffResult) return undefined
if (!fffResult.ok) { if (!fffResult.ok) {
log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error }) yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
return undefined return undefined
} }
@ -393,14 +391,15 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
timeBudgetMs: 1_500, timeBudgetMs: 1_500,
}), }),
).pipe( ).pipe(
Effect.catch((error) => { Effect.catch((error) =>
log.warn("fff grep failed", { dir, pattern: input.pattern, error }) Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error }).pipe(
return Effect.succeed<Fff.Result<Fff.Grep> | undefined>(undefined) Effect.as<Fff.Result<Fff.Grep> | undefined>(undefined),
}), ),
),
) )
if (!fffGrep) return yield* rip(input) if (!fffGrep) return yield* rip(input)
if (!fffGrep.ok) { if (!fffGrep.ok) {
log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error }) yield* Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
return yield* rip(input) return yield* rip(input)
} }
@ -432,10 +431,11 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
pageSize: limit, pageSize: limit,
}), }),
).pipe( ).pipe(
Effect.catch((error) => { Effect.catch((error) =>
log.warn("fff glob failed", { dir, pattern: input.pattern, error }) Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error }).pipe(
return Effect.succeed<Fff.Result<Fff.Search> | undefined>(undefined) Effect.as<Fff.Result<Fff.Search> | undefined>(undefined),
}), ),
),
) )
if (fffGlob?.ok) { if (fffGlob?.ok) {
@ -453,7 +453,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
truncated: fffGlob.value.totalMatched > rows.length, truncated: fffGlob.value.totalMatched > rows.length,
} }
} else if (fffGlob) { } else if (fffGlob) {
log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error }) yield* Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
// fall through to the fallback // fall through to the fallback
} }
} }
@ -500,13 +500,16 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
if (!entry) return if (!entry) return
const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe( const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
Effect.catch((error) => { Effect.catch((error) =>
log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error }) Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error }).pipe(
return Effect.succeed<Fff.Result<boolean> | undefined>(undefined) Effect.as<Fff.Result<boolean> | undefined>(undefined),
}), ),
),
) )
if (!out) return if (!out) return
if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error }) if (!out.ok) {
yield* Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
}
}) })
return Service.of({ files, tree, search, file, glob, open, warm, release }) return Service.of({ files, tree, search, file, glob, open, warm, release })

View file

@ -12,13 +12,11 @@ import { FSUtil } from "../fs-util"
import { Git } from "../git" import { Git } from "../git"
import { Location } from "../location" import { Location } from "../location"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import * as Log from "../util/log"
import { Ignore } from "./ignore" import { Ignore } from "./ignore"
import { Protected } from "./protected" import { Protected } from "./protected"
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000 const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = { export const Event = {
@ -38,8 +36,7 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`, `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
) )
return createWrapper(binding) as typeof import("@parcel/watcher") return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) { } catch {
log.error("failed to load watcher binding", { error })
return return
} }
}) })
@ -71,14 +68,17 @@ export const layer = Layer.effect(
const backend = getBackend() const backend = getBackend()
const location = yield* Location.Service const location = yield* Location.Service
if (!backend) { if (!backend) {
log.error("watcher backend not supported", { directory: location.directory, platform: process.platform }) yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({}) return Service.of({})
} }
const w = watcher() const w = watcher()
if (!w) return Service.of({}) if (!w) return Service.of({})
log.info("watcher backend", { directory: location.directory, platform: process.platform, backend }) yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service const events = yield* EventV2.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const git = yield* Git.Service const git = yield* Git.Service
@ -103,9 +103,8 @@ export const layer = Layer.effect(
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))), Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) })
pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.void return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
}), }),
) )
} }
@ -133,8 +132,9 @@ export const layer = Layer.effect(
return Service.of({}) return Service.of({})
}).pipe( }).pipe(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
return Effect.succeed(Service.of({})) Effect.as(Service.of({})),
)
}), }),
), ),
) )

View file

@ -228,7 +228,7 @@ export const layer = Layer.effect(
}), }),
).pipe( ).pipe(
Effect.tapCause((cause) => Effect.tapCause((cause) =>
Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)), Effect.logError("Failed to fetch models.dev", { cause: cause }),
), ),
Effect.ignore, Effect.ignore,
) )

View file

@ -0,0 +1,21 @@
export * as Observability from "./observability"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Logger, References } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging"
import { Otlp } from "./observability/otlp"
export const layer = Layer.unwrap(
Effect.gen(function* () {
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { 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))
}),
)

View file

@ -0,0 +1,66 @@
import { Formatter, Logger, type LogLevel } from "effect"
import path from "path"
import { Global } from "../global"
import { runID } from "./shared"
function formatter(id: string = runID) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
return [
["timestamp", output.timestamp],
["level", output.level],
["run", id],
...messages.flatMap((value) => (plain(value) ? flatten(value) : [["message", value] as const])),
...(output.cause === undefined ? [] : [["cause", output.cause] as const]),
...flatten(output.spans),
...flatten(output.annotations),
]
.map(([key, value]) => `${key}=${format(value)}`)
.join(" ")
})
}
function flatten(input: Record<string, unknown>, prefix = "", seen = new WeakSet<object>()): Array<readonly [string, unknown]> {
if (seen.has(input)) return [[prefix, "[Circular]"]]
seen.add(input)
const entries = Object.entries(input)
if (entries.length === 0 && prefix) return [[prefix, input]]
return entries.flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key
return plain(value) ? flatten(value, path, seen) : [[path, value] as const]
})
}
function plain(input: unknown): input is Record<string, unknown> {
if (input === null || typeof input !== "object" || Array.isArray(input)) return false
const prototype = Object.getPrototypeOf(input)
return prototype === Object.prototype || prototype === null
}
function format(input: unknown) {
const value = typeof input === "string" ? input : Formatter.format(input)
return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
}
export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) {
return Logger.toFile(formatter(id), file, { flag: "a", batchWindow: 0 })
}
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
export function minimumLogLevel() {
const value = process.env.OPENCODE_LOG_LEVEL?.toUpperCase()
const levels = {
DEBUG: "Debug",
INFO: "Info",
WARN: "Warn",
ERROR: "Error",
} as const satisfies Record<string, LogLevel.LogLevel>
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 * as Logging from "./logging"

View file

@ -0,0 +1,79 @@
import { Layer } from "effect"
import { OtlpLogger } from "effect/unstable/observability"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { runID } from "./shared"
const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, entry) => {
const [key, ...value] = entry.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
function resourceAttributes() {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
}
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...resourceAttributes(),
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.run": runID,
"service.instance.id": runID,
},
}
}
export function loggers() {
if (!endpoint) return []
return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })]
}
export async function tracingLayer() {
if (!endpoint) return Layer.empty
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
const manager = new AsyncLocalStorageContextManager()
manager.enable()
context.setGlobalContextManager(manager)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
headers,
}),
),
}))
}
export * as Otlp from "./otlp"

View file

@ -0,0 +1 @@
export const runID = crypto.randomUUID().slice(0, 8)

View file

@ -103,9 +103,7 @@ export const layer = Layer.effect(
(materializer) => (materializer) =>
materializer.run.pipe( materializer.run.pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize project reference").pipe( Effect.logWarning("failed to materialize project reference", { name: materializer.name, repository: materializer.repository, cause }),
Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }),
),
), ),
), ),
{ concurrency: 4, discard: true }, { concurrency: 4, discard: true },

View file

@ -7,9 +7,7 @@ import { Location } from "./location"
import { NonNegativeInt, PositiveInt } from "./schema" import { NonNegativeInt, PositiveInt } from "./schema"
import { PtyID } from "./pty/schema" import { PtyID } from "./pty/schema"
import { lazy } from "./util/lazy" import { lazy } from "./util/lazy"
import * as Log from "./util/log"
const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2 const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024 const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder() const encoder = new TextEncoder()
@ -158,7 +156,7 @@ export const layer = Layer.effect(
const session = sessions.get(id) const session = sessions.get(id)
if (!session) return false if (!session) return false
sessions.delete(id) sessions.delete(id)
log.info("removing session", { id }) yield* Effect.logInfo("removing session", { id })
teardown(session) teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id }) yield* events.publish(Event.Deleted, { id: session.info.id })
return true return true
@ -179,7 +177,7 @@ export const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) { const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
const id = PtyID.ascending() const id = PtyID.ascending()
log.info("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd }) yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
const { spawn } = yield* Effect.promise(() => pty()) const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() => const proc = yield* Effect.sync(() =>
spawn(input.command, input.args, { spawn(input.command, input.args, {
@ -231,7 +229,7 @@ export const layer = Layer.effect(
if (session.info.status === "exited") return if (session.info.status === "exited") return
runFork( runFork(
Effect.gen(function* () { Effect.gen(function* () {
log.info("session exited", { id, exitCode }) yield* Effect.logInfo("session exited", { id, exitCode })
session.info.status = "exited" session.info.status = "exited"
yield* events.publish(Event.Exited, { id, exitCode }) yield* events.publish(Event.Exited, { id, exitCode })
yield* removeSession(id) yield* removeSession(id)
@ -263,7 +261,7 @@ export const layer = Layer.effect(
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close()))) const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
log.info("client connected to session", { id, directory: location.directory }) yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
const sub = sock(ws) const sub = sock(ws)
session.subscribers.delete(sub) session.subscribers.delete(sub)
session.subscribers.set(sub, ws) session.subscribers.set(sub, ws)
@ -299,7 +297,6 @@ export const layer = Layer.effect(
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message)) session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
}, },
onClose: () => { onClose: () => {
log.info("client disconnected from session", { id })
cleanup() cleanup()
}, },
} }

View file

@ -5,4 +5,4 @@ export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session", message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>, cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs("sessionID", sessionID)) ) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))

View file

@ -6,7 +6,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { Global } from "../global" import { Global } from "../global"
import { AbsolutePath } from "../schema" import { AbsolutePath } from "../schema"
import * as Log from "../util/log"
const skillConcurrency = 4 const skillConcurrency = 4
const fileConcurrency = 8 const fileConcurrency = 8
@ -71,7 +70,6 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const global = yield* Global.Service const global = yield* Global.Service
const log = Log.create({ service: "skill-discovery" })
const http = (yield* HttpClient.HttpClient).pipe( const http = (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({ HttpClient.retryTransient({
retryOn: "errors-and-responses", retryOn: "errors-and-responses",
@ -87,7 +85,7 @@ export const layer = Layer.effect(
http.execute, http.execute,
Effect.flatMap((response) => response.arrayBuffer), Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))), Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.catch((error) => Effect.sync(() => log.error("failed to download skill file", { url, error }))), Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
) )
}) })
@ -100,10 +98,9 @@ export const layer = Layer.effect(
HttpClientRequest.acceptJson, HttpClientRequest.acceptJson,
http.execute, http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((error) => { Effect.catch((error) =>
log.error("failed to fetch skill index", { url: index, error }) Effect.logError("failed to fetch skill index", { url: index, error }).pipe(Effect.as(undefined)),
return Effect.succeed(undefined) ),
}),
) )
if (!data) return [] if (!data) return []
@ -111,17 +108,14 @@ export const layer = Layer.effect(
return yield* Effect.forEach( return yield* Effect.forEach(
data.skills.flatMap((skill) => { data.skills.flatMap((skill) => {
if (!isSafeSegment(skill.name)) { if (!isSafeSegment(skill.name)) {
log.warn("skill entry has unsafe name", { url: index, skill: skill.name })
return [] return []
} }
if (!skill.files.includes("SKILL.md") && !skill.files.includes(`${skill.name}.md`)) { if (!skill.files.includes("SKILL.md") && !skill.files.includes(`${skill.name}.md`)) {
log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name })
return [] return []
} }
const root = path.resolve(sourceRoot, skill.name) const root = path.resolve(sourceRoot, skill.name)
if (!FSUtil.contains(sourceRoot, root) || root === sourceRoot) { if (!FSUtil.contains(sourceRoot, root) || root === sourceRoot) {
log.warn("skill entry escapes cache root", { url: index, skill: skill.name })
return [] return []
} }
@ -144,7 +138,6 @@ export const layer = Layer.effect(
} }
}) })
if (files.some((file) => file === undefined)) { if (files.some((file) => file === undefined)) {
log.warn("skill entry has unsafe file", { url: index, skill: skill.name })
return [] return []
} }
return [{ skill, root, files: files as { url: string; destination: string }[] }] return [{ skill, root, files: files as { url: string; destination: string }[] }]

View file

@ -1,197 +0,0 @@
export * as Log from "./log"
import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import * as Global from "../global"
import { Schema } from "effect"
import { Glob } from "./glob"
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}
const keep = 10
const initializedRunID = "OPENCODE_LOG_INITIALIZED_RUN_ID"
let level: Level = "INFO"
function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[level]
}
export type Logger = {
debug(message?: any, extra?: Record<string, any>): void
info(message?: any, extra?: Record<string, any>): void
error(message?: any, extra?: Record<string, any>): void
warn(message?: any, extra?: Record<string, any>): void
tag(key: string, value: string): Logger
clone(): Logger
time(
message: string,
extra?: Record<string, any>,
): {
stop(): void
[Symbol.dispose](): void
}
}
const loggers = new Map<string, Logger>()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
dev?: boolean
level?: Level
}
let logpath = ""
export function file() {
return logpath
}
export function getLevel(): Level {
return level
}
let write = (msg: any) => {
process.stderr.write(msg)
return msg.length
}
export async function init(options: Options) {
if (options.level) level = options.level
void cleanup(Global.Path.log)
if (options.print) return
logpath = path.join(
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const runID = process.env.OPENCODE_RUN_ID
const shouldTruncate = !options.dev || !runID || process.env[initializedRunID] !== runID
if (shouldTruncate) await fs.truncate(logpath).catch(() => {})
if (options.dev && runID) process.env[initializedRunID] = runID
const stream = createWriteStream(logpath, { flags: "a" })
write = async (msg: any) => {
return new Promise((resolve, reject) => {
stream.write(msg, (err) => {
if (err) reject(err)
else resolve(msg.length)
})
})
}
}
async function cleanup(dir: string) {
const files = (
await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: false,
include: "file",
}).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {
const result = error.message
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result
}
let last = Date.now()
export function create(tags?: Record<string, any>) {
tags = tags || {}
const service = tags["service"]
if (service && typeof service === "string") {
const cached = loggers.get(service)
if (cached) {
return cached
}
}
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const prefix = `${key}=`
if (value instanceof Error) return prefix + formatError(value)
if (typeof value === "object") return prefix + JSON.stringify(value)
return prefix + value
})
.join(" ")
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
write("DEBUG " + build(message, extra))
}
},
info(message?: any, extra?: Record<string, any>) {
if (shouldLog("INFO")) {
write("INFO " + build(message, extra))
}
},
error(message?: any, extra?: Record<string, any>) {
if (shouldLog("ERROR")) {
write("ERROR " + build(message, extra))
}
},
warn(message?: any, extra?: Record<string, any>) {
if (shouldLog("WARN")) {
write("WARN " + build(message, extra))
}
},
tag(key: string, value: string) {
if (tags) tags[key] = value
return result
},
clone() {
return create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
if (service && typeof service === "string") {
loggers.set(service, result)
}
return result
}

View file

@ -1,24 +0,0 @@
export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID"
export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE"
export function ensureRunID() {
return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID())
}
export function ensureProcessRole(fallback: "main" | "worker") {
return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback)
}
export function ensureProcessMetadata(fallback: "main" | "worker") {
return {
runID: ensureRunID(),
processRole: ensureProcessRole(fallback),
}
}
export function sanitizedProcessEnv(overrides?: Record<string, string>) {
const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
return overrides ? Object.assign(env, overrides) : env
}

View file

@ -1,5 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { resource } from "@opencode-ai/core/effect/observability" import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Logger } from "effect"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileLogger } from "../../src/observability/logging"
import { resource } from "../../src/observability/otlp"
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
const opencodeClient = process.env.OPENCODE_CLIENT const opencodeClient = process.env.OPENCODE_CLIENT
@ -42,5 +48,62 @@ describe("resource", () => {
"service.namespace": "anomalyco", "service.namespace": "anomalyco",
}) })
expect(resource().attributes["service.instance.id"]).not.toBe("override") expect(resource().attributes["service.instance.id"]).not.toBe("override")
expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
}) })
}) })
test("file logger appends concurrent runs with a run on every line", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
await using _ = {
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
const file = path.join(dir, "opencode.log")
const write = (runID: string) =>
Effect.forEach(
Array.from({ length: 50 }, (_, index) => index),
(index) => Effect.logInfo(`entry-${index}`),
).pipe(
Effect.provide(Logger.layer([fileLogger(file, runID)]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
Effect.scoped,
)
await Effect.runPromise(Effect.all([write("run-a"), write("run-b")], { concurrency: "unbounded" }))
const lines = (await Bun.file(file).text()).trim().split("\n")
expect(lines).toHaveLength(100)
expect(lines.filter((line) => line.includes("run=run-a"))).toHaveLength(50)
expect(lines.filter((line) => line.includes("run=run-b"))).toHaveLength(50)
expect(lines.every((line) => line.startsWith("timestamp=") && line.includes(" level=INFO "))).toBe(true)
expect(lines.every((line) => !line.includes(" fiber="))).toBe(true)
expect(lines.every((line) => !line.startsWith("{"))).toBe(true)
})
test("file logger flattens nested objects", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
await using _ = {
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
const file = path.join(dir, "opencode.log")
await Effect.logInfo("request complete", {
request: { method: "GET", timing: { duration: 42 } },
tags: ["api", "test"],
}).pipe(
Effect.annotateLogs({ session: { id: "session-1" } }),
Effect.provide(Logger.layer([fileLogger(file, "run-a")]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
Effect.scoped,
Effect.runPromise,
)
const line = (await Bun.file(file).text()).trim()
expect(line).toContain('message="request complete"')
expect(line).toContain("request.method=GET")
expect(line).toContain("request.timing.duration=42")
expect(line).toContain('tags="[\\\"api\\\",\\\"test\\\"]"')
expect(line).toContain("session.id=session-1")
expect(line).not.toContain("request={")
})

View file

@ -15,8 +15,5 @@ declare module "virtual:opencode-server" {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info export type Info = import("../../../opencode/dist/types/src/node").Config.Info
} }
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
} }

View file

@ -54,8 +54,7 @@ async function start(command: StartCommand) {
ensureLoopbackNoProxy() ensureLoopbackNoProxy()
useSystemCertificates() useSystemCertificates()
useEnvProxy() useEnvProxy()
const { Log, Server } = await import("virtual:opencode-server") const { Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
listener = await Server.listen({ listener = await Server.listen({
port: command.port, port: command.port,

View file

@ -1,5 +1,4 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { import type {
Event, Event,
EventMessagePartDelta, EventMessagePartDelta,
@ -22,8 +21,6 @@ import {
completedToolUpdate, completedToolUpdate,
} from "./tool" } from "./tool"
const log = Log.create({ service: "acp-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate"> & type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">> Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
type GlobalEventEnvelope = { type GlobalEventEnvelope = {
@ -59,9 +56,8 @@ export class Subscription {
start() { start() {
if (this.started) return if (this.started) return
this.started = true this.started = true
this.run().catch((error: unknown) => { this.run().catch(() => {
if (this.abort.signal.aborted) return if (this.abort.signal.aborted) return
log.error("event subscription failed", { error })
}) })
} }
@ -125,9 +121,7 @@ export class Subscription {
for await (const event of events.stream) { for await (const event of events.stream) {
if (this.abort.signal.aborted) return if (this.abort.signal.aborted) return
if (!event.payload) continue if (!event.payload) continue
await this.handle(event.payload).catch((error: unknown) => { await this.handle(event.payload).catch(() => {})
log.error("failed to handle event", { error, type: event.payload?.type })
})
} }
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000)) if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
} }
@ -214,10 +208,7 @@ export class Subscription {
{ throwOnError: true }, { throwOnError: true },
) )
.then((response) => response.data) .then((response) => response.data)
.catch((error: unknown) => { .catch(() => undefined)
log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId })
return undefined
})
if (!message) return if (!message) return
const part = message.parts.find((item) => item.id === partId) const part = message.parts.find((item) => item.id === partId)

View file

@ -1,5 +1,4 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk" import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff" import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem" import { exists, readText } from "@/util/filesystem"
@ -7,8 +6,6 @@ import type { ACPSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool" import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect" import { Effect } from "effect"
const log = Log.create({ service: "acp-permission" })
type PermissionEvent = Extract<Event, { type: "permission.asked" }> type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject" type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">> type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
@ -35,9 +32,7 @@ export class Handler {
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve() const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
const next = previous const next = previous
.then(() => this.process(event)) .then(() => this.process(event))
.catch((error: unknown) => { .catch(() => {})
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => { .finally(() => {
if (this.queues.get(permission.sessionID) === next) { if (this.queues.get(permission.sessionID) === next) {
this.queues.delete(permission.sessionID) this.queues.delete(permission.sessionID)
@ -52,10 +47,6 @@ export class Handler {
if (!session) return if (!session) return
if (!this.input.connection.requestPermission) { if (!this.input.connection.requestPermission) {
log.error("ACP connection cannot request permission", {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd) await this.reply(permission.id, "reject", session.cwd)
return return
} }
@ -73,12 +64,7 @@ export class Handler {
}, },
options: permissionOptions, options: permissionOptions,
}) })
.catch(async (error: unknown) => { .catch(async () => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd) await this.reply(permission.id, "reject", session.cwd)
return undefined return undefined
}) })
@ -92,13 +78,7 @@ export class Handler {
} }
if (permission.permission === "edit") { if (permission.permission === "edit") {
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => { await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
log.error("failed to write proposed edit through ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
})
} }
await this.reply(permission.id, reply, session.cwd) await this.reply(permission.id, reply, session.cwd)
@ -120,7 +100,6 @@ export class Handler {
const content = (await exists(filepath)) ? await readText(filepath) : "" const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff) const next = applyPatch(content, diff)
if (next === false) { if (next === false) {
log.error("Failed to apply unified diff (context mismatch)")
return return
} }

View file

@ -30,7 +30,6 @@ import {
type SetSessionModeResponse, type SetSessionModeResponse,
} from "@agentclientprotocol/sdk" } from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect" import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPError from "./error" import * as ACPError from "./error"
@ -47,7 +46,6 @@ import { Provider } from "@/provider/provider"
import type { Command } from "@/command" import type { Command } from "@/command"
export const AuthMethodID = "opencode-login" export const AuthMethodID = "opencode-login"
const log = Log.create({ service: "acp-service" })
export type Error = ACPError.Error export type Error = ACPError.Error
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> & type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
@ -333,9 +331,7 @@ export function make(input: {
"session", "session",
).pipe( ).pipe(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.logError("failed to abort ACP backing session", { error: error, sessionID: current.id }),
log.error("failed to abort ACP backing session", { error, sessionID: current.id })
}),
), ),
) )
}) })
@ -610,10 +606,7 @@ function makeUsageService(sdk: OpencodeClient) {
) as Record<ProviderV2.ID, Provider.Info> ) as Record<ProviderV2.ID, Provider.Info>
return UsageService.findContextLimit(providers, params.providerID, params.modelID) return UsageService.findContextLimit(providers, params.providerID, params.modelID)
}) })
.catch((error: unknown) => { .catch(() => undefined)
log.error("failed to get providers for usage context limit", { error })
return undefined
})
limits.set(key, next) limits.set(key, next)
return yield* Effect.promise(() => next) return yield* Effect.promise(() => next)
}, },
@ -633,10 +626,7 @@ function makeUsageService(sdk: OpencodeClient) {
).pipe( ).pipe(
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]), Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)),
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
), ),
) )
if (!messages) return if (!messages) return
@ -662,9 +652,7 @@ function makeUsageService(sdk: OpencodeClient) {
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
}, },
}) })
.catch((error) => { .catch(() => {}),
log.error("failed to send usage update", { error })
}),
) )
}) })
@ -681,9 +669,7 @@ function replayMessages(subscription: ACPEvent.Subscription | undefined, message
if (!subscription) return Effect.void if (!subscription) return Effect.void
return Effect.promise(async () => { return Effect.promise(async () => {
for (const message of messages) { for (const message of messages) {
await subscription.replayMessage(message).catch((error: unknown) => { await subscription.replayMessage(message).catch(() => {})
log.error("failed to replay ACP message", { error, messageID: message.info.id })
})
} }
}) })
} }

View file

@ -1,5 +1,4 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2" import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store" import { InstanceStore } from "@/project/instance-store"
@ -8,8 +7,6 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect" import { Context, Effect, Layer, SynchronizedRef } from "effect"
const log = Log.create({ service: "acp-usage" })
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens"> export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
export type AssistantMessage = AssistantTokenCost & export type AssistantMessage = AssistantTokenCost &
@ -157,10 +154,7 @@ export const layer = Layer.effect(
contextLimitLoader.providers(input.directory).pipe( contextLimitLoader.providers(input.directory).pipe(
Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)), Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)),
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.logError("failed to get providers for usage context limit", { error: error }).pipe(Effect.as(undefined)),
log.error("failed to get providers for usage context limit", { error })
return undefined
}),
), ),
), ),
) )
@ -184,10 +178,7 @@ export const layer = Layer.effect(
}) { }) {
const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe( const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)),
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
), ),
) )
if (!messages) return if (!messages) return
@ -214,9 +205,7 @@ export const layer = Layer.effect(
cost: { amount: totalSessionCost(messages), currency: "USD" }, cost: { amount: totalSessionCost(messages), currency: "USD" },
}, },
}) })
.catch((error) => { .catch(() => {}),
log.error("failed to send usage update", { error })
}),
) )
}) })

View file

@ -1,4 +1,3 @@
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect" import { Effect } from "effect"
import { effectCmd } from "../effect-cmd" import { effectCmd } from "../effect-cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
@ -7,8 +6,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network" import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { ACPProfile } from "@/acp/profile" import { ACPProfile } from "@/acp/profile"
const log = Log.create({ service: "acp-command" })
export const AcpCommand = effectCmd({ export const AcpCommand = effectCmd({
command: "acp", command: "acp",
describe: "start ACP (Agent Client Protocol) server", describe: "start ACP (Agent Client Protocol) server",
@ -63,7 +60,7 @@ export const AcpCommand = effectCmd({
return agent.create(conn) return agent.create(conn)
}, stream) }, stream)
log.info("setup connection") yield* Effect.logInfo("setup connection")
process.stdin.resume() process.stdin.resume()
yield* Effect.promise( yield* Effect.promise(
() => () =>

View file

@ -2,7 +2,6 @@ import { LSP } from "@/lsp/lsp"
import { Effect } from "effect" import { Effect } from "effect"
import { effectCmd } from "../../effect-cmd" import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd" import { cmd } from "../cmd"
import * as Log from "@opencode-ai/core/util/log"
import { EOL } from "os" import { EOL } from "os"
export const LSPCommand = cmd({ export const LSPCommand = cmd({
@ -33,7 +32,7 @@ export const SymbolsCommand = effectCmd({
describe: "search workspace symbols", describe: "search workspace symbols",
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
using _ = Log.Default.time("symbols") yield* Effect.logInfo("symbols")
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
process.stdout.write(JSON.stringify(results, null, 2) + EOL) process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}), }),
@ -44,7 +43,7 @@ export const DocumentSymbolsCommand = effectCmd({
describe: "get symbols from a document", describe: "get symbols from a document",
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
using _ = Log.Default.time("document-symbols") yield* Effect.logInfo("document-symbols")
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
process.stdout.write(JSON.stringify(results, null, 2) + EOL) process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}), }),

View file

@ -1,5 +1,4 @@
import { EOL } from "os" import { EOL } from "os"
import * as Log from "@opencode-ai/core/util/log"
import { cmd } from "../cmd" import { cmd } from "../cmd"
export const ScrapCommand = cmd({ export const ScrapCommand = cmd({
@ -10,9 +9,7 @@ export const ScrapCommand = cmd({
const { Project } = await import("@/project/project") const { Project } = await import("@/project/project")
const { makeRuntime } = await import("@opencode-ai/core/effect/runtime") const { makeRuntime } = await import("@opencode-ai/core/effect/runtime")
const runtime = makeRuntime(Project.Service, Project.defaultLayer) const runtime = makeRuntime(Project.Service, Project.defaultLayer)
const timer = Log.Default.time("scrap")
const list = await runtime.runPromise((project) => project.list()) const list = await runtime.runPromise((project) => project.list())
process.stdout.write(JSON.stringify(list, null, 2) + EOL) process.stdout.write(JSON.stringify(list, null, 2) + EOL)
timer.stop()
}, },
}) })

View file

@ -30,7 +30,6 @@ import { render } from "@opentui/solid"
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js" import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap" import { OpencodeKeymapProvider } from "@opencode-ai/tui/keymap"
import { withRunSpan } from "./otel"
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command" import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent" import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt" import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
@ -513,20 +512,8 @@ export class RunFooter implements FooterApi {
} }
private completeScrollback(): void { private completeScrollback(): void {
const phase = this.state().phase
this.flushing = this.flushing this.flushing = this.flushing
.then(() => .then(() => this.scrollback.complete())
withRunSpan(
"RunFooter.completeScrollback",
{
"opencode.footer.phase": phase,
"session.id": this.options.sessionID() || undefined,
},
async () => {
await this.scrollback.complete()
},
),
)
.catch((error) => { .catch((error) => {
this.flushError = error this.flushError = error
}) })
@ -1129,23 +1116,12 @@ export class RunFooter implements FooterApi {
} }
const batch = this.queue.splice(0) const batch = this.queue.splice(0)
const phase = this.state().phase
this.flushing = this.flushing this.flushing = this.flushing
.then(() => .then(async () => {
withRunSpan( for (const item of batch) {
"RunFooter.flush", await this.scrollback.append(item)
{ }
"opencode.batch.commits": batch.length, })
"opencode.footer.phase": phase,
"session.id": this.options.sessionID() || undefined,
},
async () => {
for (const item of batch) {
await this.scrollback.append(item)
}
},
),
)
.catch((error) => { .catch((error) => {
this.flushError = error this.flushError = error
}) })

View file

@ -1,117 +0,0 @@
import { INVALID_SPAN_CONTEXT, context, trace, SpanStatusCode, type Span } from "@opentelemetry/api"
import { Effect, ManagedRuntime } from "effect"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { Observability } from "@opencode-ai/core/effect/observability"
type AttributeValue = string | number | boolean | undefined
export type RunSpanAttributes = Record<string, AttributeValue>
const noop = trace.wrapSpanContext(INVALID_SPAN_CONTEXT)
const tracer = trace.getTracer("opencode.run")
const runtime = ManagedRuntime.make(Observability.layer, { memoMap })
let ready: Promise<void> | undefined
function attributes(input?: RunSpanAttributes): Record<string, string | number | boolean> | undefined {
if (!input) {
return undefined
}
const out = Object.entries(input).flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const]))
if (out.length === 0) {
return undefined
}
return Object.fromEntries(out)
}
function message(error: unknown) {
if (typeof error === "string") {
return error
}
if (error instanceof Error) {
return error.message || error.name
}
return String(error)
}
function ensure() {
if (!Observability.enabled) {
return Promise.resolve()
}
if (ready) {
return ready
}
ready = runtime.runPromise(Effect.void).then(
() => undefined,
(error) => {
ready = undefined
throw error
},
)
return ready
}
function finish<A>(span: Span, out: Promise<A>) {
return out.then(
(value) => {
span.end()
return value
},
(error) => {
recordRunSpanError(span, error)
span.end()
throw error
},
)
}
export function setRunSpanAttributes(span: Span, input?: RunSpanAttributes): void {
const next = attributes(input)
if (!next) {
return
}
span.setAttributes(next)
}
export function recordRunSpanError(span: Span, error: unknown): void {
const next = message(error)
span.recordException(error instanceof Error ? error : next)
span.setStatus({
code: SpanStatusCode.ERROR,
message: next,
})
}
export function withRunSpan<A>(
name: string,
input: RunSpanAttributes | undefined,
fn: (span: Span) => Promise<A> | A,
): A | Promise<A> {
if (!Observability.enabled) {
return fn(noop)
}
return ensure().then(
() => {
const span = tracer.startSpan(name, {
attributes: attributes(input),
})
return context.with(trace.setSpan(context.active(), span), () =>
finish(
span,
new Promise<A>((resolve) => {
resolve(fn(span))
}),
),
)
},
() => fn(noop),
)
}

View file

@ -16,7 +16,6 @@ import { openEditor } from "@opencode-ai/tui/editor"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap" import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { Session as SessionApi } from "@/session/session" import { Session as SessionApi } from "@/session/session"
import * as Locale from "@/util/locale" import * as Locale from "@/util/locale"
import { withRunSpan } from "./otel"
import { resolveInteractiveStdin } from "./runtime.stdin" import { resolveInteractiveStdin } from "./runtime.stdin"
import { entrySplash, exitSplash, splashMeta } from "./splash" import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme" import { resolveRunTheme } from "./theme"
@ -175,18 +174,6 @@ function queueSplash(
// scrollback commits and footer repaints happen in the same frame. After // scrollback commits and footer repaints happen in the same frame. After
// the entry splash, RunFooter takes over the footer region. // the entry splash, RunFooter takes over the footer region.
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> { export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
return withRunSpan(
"RunLifecycle.boot",
{
"opencode.agent.name": input.agent,
"opencode.directory": input.directory,
"opencode.first": input.first,
"opencode.model.provider": input.model?.providerID,
"opencode.model.id": input.model?.modelID,
"opencode.model.variant": input.variant,
"session.id": input.getSessionID?.() || input.sessionID || undefined,
},
async () => {
const source = resolveInteractiveStdin() const source = resolveInteractiveStdin()
let unregisterKeymap: (() => void) | undefined let unregisterKeymap: (() => void) | undefined
@ -326,13 +313,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
} }
closed = true closed = true
return withRunSpan(
"RunLifecycle.close",
{
"opencode.show_exit": next.showExit,
"session.id": next.sessionID || input.getSessionID?.() || input.sessionID || undefined,
},
async () => {
detachSigint() detachSigint()
let wroteExit = false let wroteExit = false
@ -368,8 +348,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
} }
source.cleanup?.() source.cleanup?.()
} }
},
)
} }
return { return {
@ -425,6 +403,4 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
source.cleanup?.() source.cleanup?.()
throw error throw error
} }
},
)
} }

View file

@ -18,7 +18,6 @@ import { MessageID } from "@/session/schema"
import { createRunDemo } from "./demo" import { createRunDemo } from "./demo"
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle" import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
import { trace } from "./trace" import { trace } from "./trace"
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared" import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types" import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types"
@ -180,14 +179,6 @@ async function resolveExitTitle(
// Files only attach on the first prompt turn -- after that, includeFiles // Files only attach on the first prompt turn -- after that, includeFiles
// flips to false so subsequent turns don't re-send attachments. // flips to false so subsequent turns don't re-send attachments.
async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> { async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDeps = {}): Promise<void> {
return withRunSpan(
"RunInteractive.session",
{
"opencode.mode": input.resolveSession ? "local" : "attach",
"opencode.initial_input": !!input.initialInput,
"opencode.demo": input.demo,
},
async (span) => {
const start = performance.now() const start = performance.now()
const log = trace() const log = trace()
const tuiConfigTask = resolveRunTuiConfig() const tuiConfigTask = resolveRunTuiConfig()
@ -217,15 +208,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
sessionTitle: ctx.sessionTitle, sessionTitle: ctx.sessionTitle,
agent: ctx.agent, agent: ctx.agent,
} }
setRunSpanAttributes(span, {
"opencode.directory": ctx.directory,
"opencode.resume": ctx.resume === true,
"opencode.agent.name": state.agent,
"opencode.model.provider": state.model?.providerID,
"opencode.model.id": state.model?.modelID,
"opencode.model.variant": state.activeVariant,
"session.id": state.sessionID || undefined,
})
const ensureSession = () => { const ensureSession = () => {
if (!input.resolveSession || state.sessionID) { if (!input.resolveSession || state.sessionID) {
return Promise.resolve() return Promise.resolve()
@ -239,10 +221,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.sessionID = next.sessionID state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent state.agent = next.agent
setRunSpanAttributes(span, {
"opencode.agent.name": state.agent,
"session.id": state.sessionID,
})
}) })
return state.session return state.session
} }
@ -297,9 +275,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.activeVariant = cycleVariant(state.activeVariant, state.variants) state.activeVariant = cycleVariant(state.activeVariant, state.variants)
saveVariant(state.model, state.activeVariant) saveVariant(state.model, state.activeVariant)
setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant,
})
return { return {
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default", status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers), modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
@ -333,11 +308,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return return
} }
setRunSpanAttributes(span, {
"opencode.model.provider": model.providerID,
"opencode.model.id": model.modelID,
"opencode.model.variant": state.activeVariant,
})
return { return {
modelLabel: formatModelLabel(model, state.activeVariant, state.providers), modelLabel: formatModelLabel(model, state.activeVariant, state.providers),
status: `model ${model.modelID}`, status: `model ${model.modelID}`,
@ -360,9 +330,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.activeVariant = variant state.activeVariant = variant
saveVariant(state.model, state.activeVariant) saveVariant(state.model, state.activeVariant)
setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant,
})
return { return {
status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default", status: state.activeVariant ? `variant ${state.activeVariant}` : "variant default",
modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers), modelLabel: formatModelLabel(state.model, state.activeVariant, state.providers),
@ -468,9 +435,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants) const next = resolveVariant(ctx.variant, session.variant, savedVariant, state.variants)
if (next !== state.activeVariant) { if (next !== state.activeVariant) {
state.activeVariant = next state.activeVariant = next
setRunSpanAttributes(span, {
"opencode.model.variant": state.activeVariant,
})
} }
if (footer.isClosed) { if (footer.isClosed) {
@ -624,13 +588,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
limits: () => state.limits, limits: () => state.limits,
}) })
: undefined : undefined
setRunSpanAttributes(span, {
"opencode.agent.name": state.agent,
"opencode.model.provider": state.model?.providerID,
"opencode.model.id": state.model?.modelID,
"opencode.model.variant": state.activeVariant,
"session.id": state.sessionID,
})
log?.write("session.new", { log?.write("session.new", {
sessionID: state.sessionID, sessionID: state.sessionID,
}) })
@ -688,29 +645,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
await state.switching?.catch(() => {}) await state.switching?.catch(() => {})
let outputAnchor: LocalReplayAnchor | undefined let outputAnchor: LocalReplayAnchor | undefined
return withRunSpan(
"RunInteractive.turn",
{
"opencode.agent.name": state.agent,
"opencode.model.provider": state.model?.providerID,
"opencode.model.id": state.model?.modelID,
"opencode.model.variant": state.activeVariant,
"opencode.prompt.chars": prompt.text.length,
"opencode.prompt.parts": prompt.parts.length,
"opencode.prompt.include_files": includeFiles,
"opencode.prompt.file_parts": includeFiles ? input.files.length : 0,
"session.id": state.sessionID || undefined,
},
async (span) => {
try { try {
const next = await ensureStream() const next = await ensureStream()
setRunSpanAttributes(span, {
"opencode.agent.name": state.agent,
"opencode.model.provider": state.model?.providerID,
"opencode.model.id": state.model?.modelID,
"opencode.model.variant": state.activeVariant,
"session.id": state.sessionID || undefined,
})
await next.handle.runPromptTurn({ await next.handle.runPromptTurn({
agent: state.agent, agent: state.agent,
model: state.model, model: state.model,
@ -734,7 +670,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
return return
} }
recordRunSpanError(span, error)
const text = const text =
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ?? (await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
(error instanceof Error ? error.message : String(error)) (error instanceof Error ? error.message : String(error))
@ -748,8 +683,6 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
rememberLocal(commit, outputAnchor) rememberLocal(commit, outputAnchor)
footer.append(commit) footer.append(commit)
} }
},
)
}, },
}) })
} }
@ -795,21 +728,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
history: state.history, history: state.history,
}) })
} }
},
)
} }
// Local in-process mode. Creates an SDK client backed by a direct fetch to // Local in-process mode. Creates an SDK client backed by a direct fetch to
// the in-process server, so no external HTTP server is needed. // the in-process server, so no external HTTP server is needed.
export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> { export async function runInteractiveLocalMode(input: RunLocalInput): Promise<void> {
return withRunSpan(
"RunInteractive.localMode",
{
"opencode.directory": input.directory,
"opencode.initial_input": !!input.initialInput,
"opencode.demo": input.demo,
},
async () => {
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal", baseUrl: "http://opencode.internal",
fetch: input.fetch, fetch: input.fetch,
@ -858,8 +781,6 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
} }
}, },
}) })
},
)
} }
// Attach mode. Uses the caller-provided SDK client directly. // Attach mode. Uses the caller-provided SDK client directly.
@ -867,15 +788,7 @@ export async function runInteractiveMode(
input: RunInput & { createSession?: CreateSession }, input: RunInput & { createSession?: CreateSession },
deps?: RunRuntimeDeps, deps?: RunRuntimeDeps,
): Promise<void> { ): Promise<void> {
return withRunSpan( return runInteractiveRuntime(
"RunInteractive.attachMode",
{
"opencode.directory": input.directory,
"opencode.initial_input": !!input.initialInput,
"session.id": input.sessionID,
},
async () =>
runInteractiveRuntime(
{ {
files: input.files, files: input.files,
initialInput: input.initialInput, initialInput: input.initialInput,
@ -897,6 +810,5 @@ export async function runInteractiveMode(
createSession: createSessionResolver(input.createSession), createSession: createSessionResolver(input.createSession),
}, },
deps, deps,
),
) )
} }

View file

@ -14,7 +14,6 @@ import {
type ScrollbackSurface, type ScrollbackSurface,
} from "@opentui/core" } from "@opentui/core"
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body" import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
import { withRunSpan } from "./otel"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { turnSummaryCommit } from "./turn-summary" import { turnSummaryCommit } from "./turn-summary"
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer" import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
@ -424,17 +423,7 @@ export class RunScrollbackStream {
} }
public async complete(trailingNewline = false): Promise<void> { public async complete(trailingNewline = false): Promise<void> {
return withRunSpan( this.markRendered(await this.finishActive(trailingNewline))
"RunScrollbackStream.complete",
{
"opencode.entry.active": !!this.active,
"opencode.trailing_newline": trailingNewline,
"session.id": this.sessionID?.() || undefined,
},
async () => {
this.markRendered(await this.finishActive(trailingNewline))
},
)
} }
public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> { public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> {

View file

@ -4,7 +4,6 @@ import { type rpc } from "../tui/worker"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import * as Log from "@opencode-ai/core/util/log"
import { errorMessage } from "@opencode-ai/tui/util/error" import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
@ -12,12 +11,6 @@ import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import type { EventSource } from "@opencode-ai/tui/context/sdk" import type { EventSource } from "@opencode-ai/tui/context/sdk"
import { writeHeapSnapshot } from "v8" import { writeHeapSnapshot } from "v8"
import {
OPENCODE_PROCESS_ROLE,
OPENCODE_RUN_ID,
ensureRunID,
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "../tui/validate-session" import { validateSession } from "../tui/validate-session"
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
@ -132,51 +125,20 @@ export const TuiThreadCommand = cmd({
return return
} }
const cwd = Filesystem.resolve(process.cwd()) const cwd = Filesystem.resolve(process.cwd())
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
})
const worker = new Worker(file, {
env,
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error,
})
}
const worker = new Worker(file)
const client = Rpc.client<typeof rpc>(worker) const client = Rpc.client<typeof rpc>(worker)
const error = (e: unknown) => {
Log.Default.error("process error", { error: errorMessage(e) })
}
const reload = () => { const reload = () => {
client.call("reload", undefined).catch((err) => { client.call("reload", undefined).catch(() => {})
Log.Default.warn("worker reload failed", {
error: errorMessage(err),
})
})
} }
process.on("uncaughtException", error)
process.on("unhandledRejection", error)
process.on("SIGUSR2", reload) process.on("SIGUSR2", reload)
let stopped = false let stopped = false
const stop = async () => { const stop = async () => {
if (stopped) return if (stopped) return
stopped = true stopped = true
process.off("uncaughtException", error)
process.off("unhandledRejection", error)
process.off("SIGUSR2", reload) process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => { await withTimeout(client.call("shutdown", undefined), 5000).catch(() => {})
Log.Default.warn("worker shutdown failed", {
error: errorMessage(error),
})
})
worker.terminate() worker.terminate()
} }
@ -255,9 +217,7 @@ export const TuiThreadCommand = cmd({
} finally { } finally {
try { try {
unguard?.() unguard?.()
} catch (error) { } catch {}
Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) })
}
} }
}, },
}) })

View file

@ -2,9 +2,6 @@ import path from "path"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "heap" })
const MINUTE = 60_000 const MINUTE = 60_000
const LIMIT = 2 * 1024 * 1024 * 1024 const LIMIT = 2 * 1024 * 1024 * 1024
@ -32,20 +29,9 @@ export function start() {
Global.Path.log, Global.Path.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`, `heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
) )
log.warn("heap usage exceeded limit", {
rss: stat.rss,
heap: stat.heapUsed,
file,
})
await Promise.resolve() await Promise.resolve()
.then(() => writeHeapSnapshot(file)) .then(() => writeHeapSnapshot(file))
.catch((err) => { .catch(() => {})
log.error("failed to write heap snapshot", {
error: err instanceof Error ? err.message : String(err),
file,
})
})
lock = false lock = false
} }

View file

@ -1,6 +1,4 @@
import { Installation } from "@/installation"
import { Server } from "@/server/server" import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { InstanceRuntime } from "@/project/instance-runtime" import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
@ -10,35 +8,11 @@ import { ServerAuth } from "@/server/auth"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
import { Effect } from "effect" import { Effect } from "effect"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
ensureProcessMetadata("worker")
await Log.init({
print: process.argv.includes("--print-logs"),
dev: Installation.isLocal(),
level: (() => {
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
})
Heap.start() Heap.start()
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
e: e instanceof Error ? e.message : e,
})
})
// Subscribe to global events and forward them via RPC // Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => { GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event) Rpc.emit("global.event", event)
@ -89,8 +63,6 @@ export const rpc = {
) )
}, },
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down")
await InstanceRuntime.disposeAllInstances() await InstanceRuntime.disposeAllInstances()
if (server) await server.stop(true) if (server) await server.stop(true)
}, },

View file

@ -2,15 +2,12 @@ export * as ConfigAgent from "./agent"
import path from "path" import path from "path"
import { Exit, Schema } from "effect" import { Exit, Schema } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { Glob } from "@opencode-ai/core/util/glob" import { Glob } from "@opencode-ai/core/util/glob"
import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent" import { ConfigAgentV1 } from "@opencode-ai/core/v1/config/agent"
import { configEntryNameFromPath } from "./entry-name" import { configEntryNameFromPath } from "./entry-name"
import * as ConfigMarkdown from "./markdown" import * as ConfigMarkdown from "./markdown"
import { ConfigParse } from "./parse" import { ConfigParse } from "./parse"
const log = Log.create({ service: "config" })
export async function load(dir: string) { export async function load(dir: string) {
const result: Record<string, ConfigAgentV1.Info> = {} const result: Record<string, ConfigAgentV1.Info> = {}
for (const item of await Glob.scan("{agent,agents}/**/*.md", { for (const item of await Glob.scan("{agent,agents}/**/*.md", {
@ -19,10 +16,7 @@ export async function load(dir: string) {
dot: true, dot: true,
symlink: true, symlink: true,
})) { })) {
const md = await ConfigMarkdown.parse(item).catch((err) => { const md = await ConfigMarkdown.parse(item).catch(() => undefined)
log.error("failed to load agent", { agent: item, err })
return undefined
})
if (!md) continue if (!md) continue
const name = configEntryNameFromPath(path.relative(dir, item), ["agent/", "agents/"]) const name = configEntryNameFromPath(path.relative(dir, item), ["agent/", "agents/"])
@ -45,10 +39,7 @@ export async function loadMode(dir: string) {
dot: true, dot: true,
symlink: true, symlink: true,
})) { })) {
const md = await ConfigMarkdown.parse(item).catch((err) => { const md = await ConfigMarkdown.parse(item).catch(() => undefined)
log.error("failed to load mode", { mode: item, err })
return undefined
})
if (!md) continue if (!md) continue
const config = { const config = {

View file

@ -1,7 +1,6 @@
export * as ConfigCommand from "./command" export * as ConfigCommand from "./command"
import path from "path" import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Exit, Schema } from "effect" import { Cause, Exit, Schema } from "effect"
import { Glob } from "@opencode-ai/core/util/glob" import { Glob } from "@opencode-ai/core/util/glob"
import { ConfigCommandV1 } from "@opencode-ai/core/v1/config/command" import { ConfigCommandV1 } from "@opencode-ai/core/v1/config/command"
@ -9,8 +8,6 @@ import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "@opencode-ai/core/v1/config/error" import { InvalidError } from "@opencode-ai/core/v1/config/error"
import * as ConfigMarkdown from "./markdown" import * as ConfigMarkdown from "./markdown"
const log = Log.create({ service: "config" })
const decodeInfo = Schema.decodeUnknownExit(ConfigCommandV1.Info) const decodeInfo = Schema.decodeUnknownExit(ConfigCommandV1.Info)
export async function load(dir: string) { export async function load(dir: string) {
@ -21,10 +18,7 @@ export async function load(dir: string) {
dot: true, dot: true,
symlink: true, symlink: true,
})) { })) {
const md = await ConfigMarkdown.parse(item).catch((err) => { const md = await ConfigMarkdown.parse(item).catch(() => undefined)
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue if (!md) continue
const name = configEntryNameFromPath(path.relative(dir, item), ["command/", "commands/"]) const name = configEntryNameFromPath(path.relative(dir, item), ["command/", "commands/"])

View file

@ -1,4 +1,3 @@
import * as Log from "@opencode-ai/core/util/log"
import { serviceUse } from "@opencode-ai/core/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
@ -34,8 +33,6 @@ import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
const log = Log.create({ service: "config" })
// Custom merge function that concatenates array fields instead of replacing them // Custom merge function that concatenates array fields instead of replacing them
// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here. // Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here.
function mergeConfig(target: Info, source: Info): Info { function mergeConfig(target: Info, source: Info): Info {
@ -50,7 +47,7 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
return merged return merged
} }
function normalizeLoadedConfig(data: unknown, source: string) { function normalizeLoadedConfig(data: unknown) {
if (!isRecord(data)) return data if (!isRecord(data)) return data
const copy = { ...data } const copy = { ...data }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
@ -58,7 +55,6 @@ function normalizeLoadedConfig(data: unknown, source: string) {
delete copy.theme delete copy.theme
delete copy.keybinds delete copy.keybinds
delete copy.tui delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy return copy
} }
@ -216,7 +212,7 @@ export const layer = Layer.effect(
), ),
) )
const parsed = ConfigParse.jsonc(expanded, source) const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(ConfigV1.Info, normalizeLoadedConfig(parsed, source), source) const data = ConfigParse.schema(ConfigV1.Info, normalizeLoadedConfig(parsed), source)
if (!("path" in options)) return data if (!("path" in options)) return data
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
@ -229,7 +225,7 @@ export const layer = Layer.effect(
}) })
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) { const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
log.info("loading", { path: filepath }) yield* Effect.logInfo("loading", { path: filepath })
const text = yield* readConfigFile(filepath) const text = yield* readConfigFile(filepath)
if (!text) return {} as Info if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath }, env) return yield* loadConfig(text, { path: filepath }, env)
@ -273,7 +269,7 @@ export const layer = Layer.effect(
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
loadGlobal().pipe( loadGlobal().pipe(
Effect.tapError((error) => Effect.tapError((error) =>
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })), Effect.logError("failed to load global config, using defaults", { error: String(error) }),
), ),
Effect.orElseSucceed((): Info => ({})), Effect.orElseSucceed((): Info => ({})),
), ),
@ -349,7 +345,7 @@ export const layer = Layer.effect(
const url = key.replace(/\/+$/, "") const url = key.replace(/\/+$/, "")
authEnv[value.key] = value.token authEnv[value.key] = value.token
const wellknownURL = `${url}/.well-known/opencode` const wellknownURL = `${url}/.well-known/opencode`
log.debug("fetching remote config", { url: wellknownURL }) yield* Effect.logDebug("fetching remote config", { url: wellknownURL })
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown) const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown)
const remote = yield* Effect.promise(() => const remote = yield* Effect.promise(() =>
substituteWellKnownRemoteConfig({ substituteWellKnownRemoteConfig({
@ -361,7 +357,7 @@ export const layer = Layer.effect(
) )
const fetchedConfig = remote const fetchedConfig = remote
? yield* Effect.gen(function* () { ? yield* Effect.gen(function* () {
log.debug("fetching remote config", { url: remote.url }) yield* Effect.logDebug("fetching remote config", { url: remote.url })
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json) const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
if (isRecord(data) && isRecord(data.config)) return data.config if (isRecord(data) && isRecord(data.config)) return data.config
if (isRecord(data)) return data if (isRecord(data)) return data
@ -382,7 +378,7 @@ export const layer = Layer.effect(
authEnv, authEnv,
) )
yield* merge(source, next, "global") yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url }) yield* Effect.logDebug("loaded remote config from well-known", { url })
} }
} }
@ -391,7 +387,7 @@ export const layer = Layer.effect(
if (Flag.OPENCODE_CONFIG) { if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv)) yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) yield* Effect.logDebug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
} }
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
@ -407,7 +403,7 @@ export const layer = Layer.effect(
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree) const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
if (Flag.OPENCODE_CONFIG_DIR) { if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) yield* Effect.logDebug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
} }
const deps: Fiber.Fiber<void>[] = [] const deps: Fiber.Fiber<void>[] = []
@ -416,7 +412,7 @@ export const layer = Layer.effect(
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file) const source = path.join(dir, file)
log.debug(`loading config from ${source}`) yield* Effect.logDebug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source, authEnv)) yield* merge(source, yield* loadFile(source, authEnv))
result.agent ??= {} result.agent ??= {}
result.mode ??= {} result.mode ??= {}
@ -439,9 +435,7 @@ export const layer = Layer.effect(
Effect.exit, Effect.exit,
Effect.tap((exit) => Effect.tap((exit) =>
Exit.isFailure(exit) Exit.isFailure(exit)
? Effect.sync(() => { ? Effect.logWarning("background dependency install failed", { dir, error: String(exit.cause) })
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void, : Effect.void,
), ),
Effect.asVoid, Effect.asVoid,
@ -465,7 +459,7 @@ export const layer = Layer.effect(
source, source,
}) })
yield* merge(source, next, "local") yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
const activeAccount = Option.getOrUndefined( const activeAccount = Option.getOrUndefined(
@ -498,12 +492,11 @@ export const layer = Layer.effect(
} }
}).pipe( }).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"), Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) => { Effect.catch((err) =>
log.debug("failed to fetch remote account config", { Effect.logDebug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}) }),
return Effect.void ),
}),
) )
} }
@ -540,7 +533,7 @@ export const layer = Layer.effect(
try { try {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
} catch (err) { } catch (err) {
log.warn("OPENCODE_PERMISSION contains invalid JSON, skipping", { err }) yield* Effect.logWarning("OPENCODE_PERMISSION contains invalid JSON, skipping", { err })
} }
} }
@ -561,7 +554,7 @@ export const layer = Layer.effect(
try { try {
result.username = os.userInfo().username || "user" result.username = os.userInfo().username || "user"
} catch (err) { } catch (err) {
log.warn("failed to read system username, using fallback", { err }) yield* Effect.logWarning("failed to read system username, using fallback", { err })
result.username = "user" result.username = "user"
} }
} }

View file

@ -3,11 +3,8 @@ export * as ConfigManaged from "./managed"
import { existsSync } from "fs" import { existsSync } from "fs"
import os from "os" import os from "os"
import path from "path" import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process" import { Process } from "@/util/process"
const log = Log.create({ service: "config" })
const MANAGED_PLIST_DOMAIN = "ai.opencode.managed" const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config // Keys injected by macOS/MDM into the managed plist that are not OpenCode config
@ -49,8 +46,7 @@ export async function readManagedPreferences() {
const user = (() => { const user = (() => {
try { try {
return os.userInfo().username || "user" return os.userInfo().username || "user"
} catch (err) { } catch {
log.warn("failed to read system username, using fallback", { err })
return "user" return "user"
} }
})() })()
@ -61,12 +57,8 @@ export async function readManagedPreferences() {
for (const plist of paths) { for (const plist of paths) {
if (!existsSync(plist)) continue if (!existsSync(plist)) continue
log.info("reading macOS managed preferences", { path: plist })
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true }) const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
if (result.code !== 0) { if (result.code !== 0) continue
log.warn("failed to convert managed preferences plist", { path: plist })
continue
}
return { return {
source: `mobileconfig:${plist}`, source: `mobileconfig:${plist}`,
text: parseManagedPlist(result.stdout.toString()), text: parseManagedPlist(result.stdout.toString()),

View file

@ -6,11 +6,8 @@ import { TuiConfig } from "@opencode-ai/tui/config"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import * as ConfigPaths from "@/config/paths" import * as ConfigPaths from "@/config/paths"
const log = Log.create({ service: "tui.migrate" })
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json" const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const decodeTheme = Schema.decodeUnknownOption(Schema.String) const decodeTheme = Schema.decodeUnknownOption(Schema.String)
@ -32,10 +29,7 @@ interface MigrateInput {
export async function migrateTuiConfig(input: MigrateInput) { export async function migrateTuiConfig(input: MigrateInput) {
const opencode = await opencodeFiles(input) const opencode = await opencodeFiles(input)
for (const file of opencode) { for (const file of opencode) {
const source = await Filesystem.readText(file).catch((error) => { const source = await Filesystem.readText(file).catch(() => undefined)
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) continue if (!source) continue
const errors: JsoncParseError[] = [] const errors: JsoncParseError[] = []
const data = parseJsonc(source, errors, { allowTrailingComma: true }) const data = parseJsonc(source, errors, { allowTrailingComma: true })
@ -65,18 +59,11 @@ export async function migrateTuiConfig(input: MigrateInput) {
const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2)) const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
.then(() => true) .then(() => true)
.catch((error) => { .catch(() => false)
log.warn("failed to write tui migration target", { from: file, to: target, error })
return false
})
if (!wrote) continue if (!wrote) continue
const stripped = await backupAndStripLegacy(file, source) const stripped = await backupAndStripLegacy(file, source)
if (!stripped) { if (!stripped) continue
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
continue
}
log.info("migrated tui config", { from: file, to: target })
} }
} }
@ -106,10 +93,7 @@ async function backupAndStripLegacy(file: string, source: string) {
? true ? true
: await Filesystem.write(backup, source) : await Filesystem.write(backup, source)
.then(() => true) .then(() => true)
.catch((error) => { .catch(() => false)
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
return false
})
if (!backed) return false if (!backed) return false
const text = ["theme", "keybinds", "tui"].reduce((acc, key) => { const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
@ -124,14 +108,8 @@ async function backupAndStripLegacy(file: string, source: string) {
}, source) }, source)
return Filesystem.write(file, text) return Filesystem.write(file, text)
.then(() => { .then(() => true)
log.info("stripped tui keys from server config", { path: file, backup }) .catch(() => false)
return true
})
.catch((error) => {
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
return false
})
} }
async function opencodeFiles(input: { directories: string[]; cwd: string }) { async function opencodeFiles(input: { directories: string[]; cwd: string }) {

View file

@ -17,14 +17,11 @@ import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable" import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
import { TuiConfig } from "@opencode-ai/tui/config" import { TuiConfig } from "@opencode-ai/tui/config"
const log = Log.create({ service: "tui.config" })
export const Info = TuiConfig.Info export const Info = TuiConfig.Info
export type Info = TuiConfig.Info export type Info = TuiConfig.Info
@ -69,17 +66,12 @@ function normalize(raw: Record<string, unknown>) {
} }
} }
function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: string) { function dropUnknownKeybinds(input: Record<string, unknown>) {
if (!isRecord(input.keybinds)) return input if (!isRecord(input.keybinds)) return input
const invalid = TuiKeybind.unknownKeys(input.keybinds) const invalid = TuiKeybind.unknownKeys(input.keybinds)
if (!invalid.length) return input if (!invalid.length) return input
log.warn("ignored unknown tui keybinds", {
path: configFilepath,
keybinds: invalid,
hint: "Remove these entries or rename them to keys from the tui.json schema.",
})
return { return {
...input, ...input,
keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))), keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))),
@ -111,7 +103,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
if (!isRecord(data)) return {} as Info if (!isRecord(data)) return {} as Info
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied. // (mirroring the old opencode.json shape) still get their settings applied.
const normalized = dropUnknownKeybinds(normalize(data), configFilepath) const normalized = dropUnknownKeybinds(normalize(data))
const parsed = ConfigParse.schema(Info, normalized, configFilepath) const parsed = ConfigParse.schema(Info, normalized, configFilepath)
const validated = parsed.attention?.sounds const validated = parsed.attention?.sounds
? { ? {
@ -127,15 +119,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
// can sync-throw — those become defects, which orElseSucceed wouldn't catch. // can sync-throw — those become defects, which orElseSucceed wouldn't catch.
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.sync(() => { Effect.logWarning("skipping invalid tui config", {
const error = Cause.squash(cause) path: configFilepath,
const reason = FormatError(error) ?? FormatUnknownError(error) reason: FormatError(Cause.squash(cause)) ?? FormatUnknownError(Cause.squash(cause)),
log.warn("skipping invalid tui config", { }).pipe(Effect.as({} as Info)),
path: configFilepath,
reason,
})
return {} as Info
}),
), ),
) )
@ -146,19 +133,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
// broken-config path degrades gracefully rather than crashing TUI startup. // broken-config path degrades gracefully rather than crashing TUI startup.
const text = yield* afs.readFileStringSafe(filepath).pipe( const text = yield* afs.readFileStringSafe(filepath).pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.sync(() => { Effect.logWarning("failed to read tui config", {
const error = Cause.squash(cause) path: filepath,
const reason = FormatError(error) ?? FormatUnknownError(error) reason: FormatError(Cause.squash(cause)) ?? FormatUnknownError(Cause.squash(cause)),
log.warn("failed to read tui config", { }).pipe(Effect.as(undefined)),
path: filepath,
reason,
})
return undefined
}),
), ),
) )
if (!text) return {} as Info if (!text) return {} as Info
log.info("loading tui config", { path: filepath }) yield* Effect.logInfo("loading tui config", { path: filepath })
return yield* load(text, filepath) return yield* load(text, filepath)
}) })
@ -167,7 +149,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const data = yield* loadFile(file) const data = yield* loadFile(file)
if (Object.keys(data).length) { if (Object.keys(data).length) {
appliedOrder += 1 appliedOrder += 1
log.info("applying tui config", { path: file, order: appliedOrder }) yield* Effect.logInfo("applying tui config", { path: file, order: appliedOrder })
} }
acc.result = mergeDeep(acc.result, data) acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return if (!data.plugin?.length) return
@ -205,7 +187,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
if (Flag.OPENCODE_TUI_CONFIG) { if (Flag.OPENCODE_TUI_CONFIG) {
const configFile = Flag.OPENCODE_TUI_CONFIG const configFile = Flag.OPENCODE_TUI_CONFIG
yield* mergeFile(acc, configFile) yield* mergeFile(acc, configFile)
log.debug("loaded custom tui config", { path: configFile }) yield* Effect.logDebug("loaded custom tui config", { path: configFile })
} }
// 3. Project tui files, applied root-first so the closest file wins. // 3. Project tui files, applied root-first so the closest file wins.

View file

@ -12,7 +12,6 @@ import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import * as Log from "@opencode-ai/core/util/log"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { Slug } from "@opencode-ai/core/util/slug" import { Slug } from "@opencode-ai/core/util/slug"
@ -74,7 +73,6 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
} }
} }
const log = Log.create({ service: "workspace-sync" })
export const CreateInput = Schema.Struct({ export const CreateInput = Schema.Struct({
id: Schema.optional(WorkspaceV2.ID), id: Schema.optional(WorkspaceV2.ID),
@ -292,18 +290,16 @@ export const layer = Layer.effect(
const response = yield* http.execute(input.remote({ workspace, target })).pipe( const response = yield* http.execute(input.remote({ workspace, target })).pipe(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.logWarning("workspace target request failed", {
log.warn("workspace target request failed", { workspaceID: workspace.id,
workspaceID: workspace.id, error: errorData(error),
error: errorData(error), }).pipe(Effect.as(undefined)),
})
}),
), ),
) )
if (!response) return input.fallback if (!response) return input.fallback
if (response.status < 200 || response.status >= 300) { if (response.status < 200 || response.status >= 300) {
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
log.warn("workspace target request failed", { yield* Effect.logWarning("workspace target request failed", {
workspaceID: workspace.id, workspaceID: workspace.id,
status: response.status, status: response.status,
body, body,
@ -315,13 +311,10 @@ export const layer = Layer.effect(
return yield* body.pipe( return yield* body.pipe(
Effect.map((result) => result as A), Effect.map((result) => result as A),
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.logWarning("workspace target response decode failed", {
log.warn("workspace target response decode failed", { workspaceID: workspace.id,
workspaceID: workspace.id, error: errorData(error),
error: errorData(error), }).pipe(Effect.as(input.fallback)),
})
return input.fallback
}),
), ),
) )
}) })
@ -348,11 +341,6 @@ export const layer = Layer.effect(
) )
: {} : {}
log.info("syncing workspace history", {
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
})
const response = yield* http.execute( const response = yield* http.execute(
HttpClientRequest.post(route(url, "/sync/history"), { HttpClientRequest.post(route(url, "/sync/history"), {
@ -372,10 +360,6 @@ export const layer = Layer.effect(
const history = (yield* response.json) as HistoryEvent[] const history = (yield* response.json) as HistoryEvent[]
log.info("workspace history synced", {
workspaceID: space.id,
events: history.length,
})
yield* Effect.forEach( yield* Effect.forEach(
history, history,
@ -404,17 +388,16 @@ export const layer = Layer.effect(
let attempt = 0 let attempt = 0
while (true) { while (true) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting") setStatus(space.id, "connecting")
const stream = yield* connectSSE(target.url, target.headers).pipe( const stream = yield* connectSSE(target.url, target.headers).pipe(
Effect.tap(() => syncHistory(space, target.url, target.headers)), Effect.tap(() => syncHistory(space, target.url, target.headers)),
Effect.catch((err) => Effect.catch((err) =>
Effect.sync(() => { Effect.gen(function* () {
setStatus(space.id, "error") setStatus(space.id, "error")
log.info("failed to connect to global sync", { yield* Effect.logWarning("failed to connect to global sync", {
workspace: space.name, workspace: space.name,
err, error: errorData(err),
}) })
return null return null
}), }),
@ -424,7 +407,6 @@ export const layer = Layer.effect(
if (stream) { if (stream) {
attempt = 0 attempt = 0
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected") setStatus(space.id, "connected")
yield* parseSSE(stream, (evt) => yield* parseSSE(stream, (evt) =>
@ -437,13 +419,10 @@ export const layer = Layer.effect(
const failed = yield* events.replay(payload.syncEvent, { publish: true, ownerID: space.id }).pipe( const failed = yield* events.replay(payload.syncEvent, { publish: true, ownerID: space.id }).pipe(
Effect.as(false), Effect.as(false),
Effect.catchCause((error) => Effect.catchCause((error) =>
Effect.sync(() => { Effect.logWarning("failed to replay global event", error).pipe(
log.info("failed to replay global event", { Effect.annotateLogs({ workspaceID: space.id }),
workspaceID: space.id, Effect.as(true),
error, ),
})
return true
}),
), ),
) )
if (failed) return if (failed) return
@ -458,15 +437,14 @@ export const layer = Layer.effect(
payload: event.payload, payload: event.payload,
}) })
} catch (error) { } catch (error) {
log.info("failed to replay global event", { yield* Effect.logWarning("failed to emit global event", {
workspaceID: space.id, workspaceID: space.id,
error, error: errorData(error),
}) })
} }
}), }),
) )
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected") setStatus(space.id, "disconnected")
} }
@ -482,9 +460,9 @@ export const layer = Layer.effect(
const target = yield* WorkspaceAdapterRuntime.target(space).pipe( const target = yield* WorkspaceAdapterRuntime.target(space).pipe(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.gen(function* () {
setStatus(space.id, "error") setStatus(space.id, "error")
log.warn("workspace target failed", { yield* Effect.logWarning("workspace target failed", {
workspaceID: space.id, workspaceID: space.id,
error: errorData(error), error: errorData(error),
}) })
@ -511,11 +489,11 @@ export const layer = Layer.effect(
// allow the fiber to fail and automatically get removed // allow the fiber to fail and automatically get removed
syncWorkspaceLoop(space).pipe( syncWorkspaceLoop(space).pipe(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.gen(function* () {
setStatus(space.id, "error") setStatus(space.id, "error")
log.warn("workspace listener failed", { yield* Effect.logWarning("workspace listener failed", {
workspaceID: space.id, workspaceID: space.id,
error, error: errorData(error),
}) })
}), }),
), ),
@ -597,10 +575,6 @@ export const layer = Layer.effect(
const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) { const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
log.info("session warp requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const current = yield* db const current = yield* db
.select({ workspaceID: SessionTable.workspace_id }) .select({ workspaceID: SessionTable.workspace_id })
@ -618,11 +592,6 @@ export const layer = Layer.effect(
yield* syncHistory(previous, target.url, target.headers).pipe( yield* syncHistory(previous, target.url, target.headers).pipe(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.sync(() => {
log.warn("session warp final source sync failed", {
workspaceID: previous.id,
sessionID: input.sessionID,
error: errorData(error),
})
}), }),
), ),
) )
@ -669,11 +638,6 @@ export const layer = Layer.effect(
if (input.workspaceID === null) { if (input.workspaceID === null) {
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined }) yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined })
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: "local",
})
return return
} }
@ -690,11 +654,6 @@ export const layer = Layer.effect(
if (target.type === "local") { if (target.type === "local") {
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID })
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: target.directory,
})
return return
} }
@ -720,15 +679,6 @@ export const layer = Layer.effect(
const batches = Iterable.chunksOf(rows, 10) const batches = Iterable.chunksOf(rows, 10)
const total = Iterable.size(batches) const total = Iterable.size(batches)
log.info("session warp prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: String(route(target.url, "/sync/replay")),
events: rows.length,
batches: total,
first: rows[0]?.seq,
last: rows.at(-1)?.seq,
})
yield* Effect.forEach( yield* Effect.forEach(
batches, batches,
@ -746,14 +696,6 @@ export const layer = Layer.effect(
if (response.status < 200 || response.status >= 300) { if (response.status < 200 || response.status >= 300) {
const body = yield* response.text const body = yield* response.text
log.error("session warp batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
body,
})
return yield* new SessionWarpHttpError({ return yield* new SessionWarpHttpError({
message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID, workspaceID,
@ -763,13 +705,6 @@ export const layer = Layer.effect(
}) })
} }
log.info("session warp batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
})
}), }),
{ discard: true }, { discard: true },
) )
@ -782,12 +717,6 @@ export const layer = Layer.effect(
) )
if (response.status < 200 || response.status >= 300) { if (response.status < 200 || response.status >= 300) {
const body = yield* response.text const body = yield* response.text
log.error("session warp steal failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
return yield* new SessionWarpHttpError({ return yield* new SessionWarpHttpError({
message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID, workspaceID,
@ -799,22 +728,7 @@ export const layer = Layer.effect(
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID })
log.info("session warp complete", { })
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
}).pipe(
Effect.tapError((err) =>
Effect.sync(() =>
log.error("session warp failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
}),
),
),
)
}) })
const list = Effect.fn("Workspace.list")(function* (project: Project.Info) { const list = Effect.fn("Workspace.list")(function* (project: Project.Info) {
@ -836,7 +750,6 @@ export const layer = Layer.effect(
WorkspaceAdapterRuntime.list(adapter).pipe( WorkspaceAdapterRuntime.list(adapter).pipe(
Effect.catchCause((error) => Effect.catchCause((error) =>
Effect.sync(() => { Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return [] return []
}), }),
), ),
@ -916,7 +829,6 @@ export const layer = Layer.effect(
}), }),
() => () =>
Effect.sync(() => { Effect.sync(() => {
log.error("adapter not available when removing workspace", { type: row.type })
}), }),
) )
@ -973,10 +885,6 @@ export const layer = Layer.effect(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.sync(() => {
setStatus(workspace.id, "error") setStatus(workspace.id, "error")
log.warn("workspace sync failed to start", {
workspaceID: workspace.id,
error,
})
}), }),
), ),
Effect.forkDetach, Effect.forkDetach,

View file

@ -1,6 +1,6 @@
import { Layer, ManagedRuntime } from "effect" import { Layer, ManagedRuntime } from "effect"
import { attach } from "./run-service" import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability" import * as Observability from "@opencode-ai/core/observability"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"

View file

@ -7,7 +7,7 @@ import { ShareNext } from "@/share/share-next"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import * as Observability from "@opencode-ai/core/effect/observability" import * as Observability from "@opencode-ai/core/observability"
import { memoMap } from "@opencode-ai/core/effect/memo-map" import { memoMap } from "@opencode-ai/core/effect/memo-map"
export const BootstrapLayer = Layer.mergeAll( export const BootstrapLayer = Layer.mergeAll(

View file

@ -1,5 +1,4 @@
import { Effect, ScopedCache, Scope } from "effect" import { Effect, ScopedCache, Scope } from "effect"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import type { InstanceContext } from "@/project/instance-context" import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
@ -36,9 +35,7 @@ export const make = <A, E = never, R = never>(
}), }),
}) })
const off = registerDisposer((directory) => const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))),
)
yield* Effect.addFinalizer(() => Effect.sync(off)) yield* Effect.addFinalizer(() => Effect.sync(off))
return { return {

View file

@ -1,7 +1,7 @@
import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context" import * as Context from "effect/Context"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import * as Observability from "@opencode-ai/core/effect/observability" import * as Observability from "@opencode-ai/core/observability"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { InstanceContext } from "@/project/instance-context" import type { InstanceContext } from "@/project/instance-context"
import { memoMap } from "@opencode-ai/core/effect/memo-map" import { memoMap } from "@opencode-ai/core/effect/memo-map"

View file

@ -8,11 +8,8 @@ import { mergeDeep } from "remeda"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import * as Formatter from "./formatter" import * as Formatter from "./formatter"
const log = Log.create({ service: "format" })
export const Status = Schema.Struct({ export const Status = Schema.Struct({
name: Schema.String, name: Schema.String,
extensions: Schema.Array(Schema.String), extensions: Schema.Array(Schema.String),
@ -60,11 +57,7 @@ export const layer = Layer.effect(
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext)) const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
const checks = await Promise.all( const checks = await Promise.all(
matching.map(async (item) => { matching.map(async (item) => {
log.info("checking", { name: item.name, ext })
const cmd = await getCommand(item) const cmd = await getCommand(item)
if (cmd) {
log.info("enabled", { name: item.name, ext })
}
return { return {
item, item,
cmd, cmd,
@ -78,13 +71,13 @@ export const layer = Layer.effect(
function formatFile(filepath: string) { function formatFile(filepath: string) {
return Effect.gen(function* () { return Effect.gen(function* () {
log.info("formatting", { file: filepath }) yield* Effect.logInfo("formatting", { file: filepath })
const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath))) const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath)))
if (!formatters.length) return false if (!formatters.length) return false
for (const { item, cmd } of formatters) { for (const { item, cmd } of formatters) {
log.info("running", { command: cmd }) yield* Effect.logInfo("running", { command: cmd })
const replaced = cmd.map((x) => x.replace("$FILE", filepath)) const replaced = cmd.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory const dir = yield* InstanceState.directory
const result = yield* appProcess const result = yield* appProcess
@ -100,20 +93,17 @@ export const layer = Layer.effect(
) )
.pipe( .pipe(
Effect.catch((error) => Effect.catch((error) =>
Effect.sync(() => { Effect.logError("failed to format file", {
log.error("failed to format file", {
error: "spawn failed", error: "spawn failed",
command: cmd, command: cmd,
...item.environment, ...item.environment,
file: filepath, file: filepath,
cause: errorMessage(error.cause ?? error), cause: errorMessage(error.cause ?? error),
}) }).pipe(Effect.as(undefined)),
return undefined
}),
), ),
) )
if (result && result.exitCode !== 0) { if (result && result.exitCode !== 0) {
log.error("failed", { yield* Effect.logError("failed", {
command: cmd, command: cmd,
...item.environment, ...item.environment,
}) })
@ -127,8 +117,8 @@ export const layer = Layer.effect(
const cfg = yield* config.get() const cfg = yield* config.get()
if (!cfg.formatter) { if (!cfg.formatter) {
log.info("all formatters are disabled") yield* Effect.logInfo("all formatters are disabled")
log.info("init") yield* Effect.logInfo("init")
return { return {
formatters, formatters,
isEnabled, isEnabled,
@ -166,7 +156,7 @@ export const layer = Layer.effect(
} }
} }
log.info("init") yield* Effect.logInfo("init")
return { return {
formatters, formatters,

View file

@ -1,7 +1,6 @@
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Schema } from "effect" import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process" import { Process } from "@/util/process"
const SUPPORTED_IDES = [ const SUPPORTED_IDES = [
@ -12,8 +11,6 @@ const SUPPORTED_IDES = [
{ name: "VSCodium" as const, cmd: "codium" }, { name: "VSCodium" as const, cmd: "codium" },
] ]
const log = Log.create({ service: "ide" })
export const Event = { export const Event = {
Installed: EventV2.define({ Installed: EventV2.define({
type: "ide.installed", type: "ide.installed",
@ -53,12 +50,6 @@ export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
const stdout = p.stdout.toString() const stdout = p.stdout.toString()
const stderr = p.stderr.toString() const stderr = p.stderr.toString()
log.info("installed", {
ide,
stdout,
stderr,
})
if (p.code !== 0) { if (p.code !== 0) {
throw new InstallFailedError({ stderr }) throw new InstallFailedError({ stderr })
} }

View file

@ -1,7 +1,6 @@
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { MessageV2 } from "@/session/message-v2" import type { MessageV2 } from "@/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import path from "node:path" import path from "node:path"
@ -12,8 +11,6 @@ const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000 const MAX_HEIGHT = 2000
const AUTO_RESIZE = true const AUTO_RESIZE = true
const JPEG_QUALITIES = [80, 85, 70, 55, 40] const JPEG_QUALITIES = [80, 85, 70, 55, 40]
const log = Log.create({ service: "image" })
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()( export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
"ImageResizerUnavailableError", "ImageResizerUnavailableError",
{}, {},
@ -69,7 +66,7 @@ export const layer = Layer.effect(
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url)) path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
}).pipe( }).pipe(
Effect.andThen(() => Effect.tryPromise(() => import("@silvia-odwyer/photon-node"))), Effect.andThen(() => Effect.tryPromise(() => import("@silvia-odwyer/photon-node"))),
Effect.tapError((error) => Effect.sync(() => log.warn("failed to load photon", { error }))), Effect.tapError((error) => Effect.logWarning("failed to load photon", { error })),
Effect.mapError(() => new ResizerUnavailableError()), Effect.mapError(() => new ResizerUnavailableError()),
), ),
) )
@ -92,11 +89,8 @@ export const layer = Layer.effect(
const decoded = yield* Effect.try({ const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")), try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: (error) => { catch: () => new DecodeError(),
log.warn("failed to decode image", { error }) }).pipe(Effect.tapError((error) => Effect.logWarning("failed to decode image", { error })))
return new DecodeError()
},
})
try { try {
const originalWidth = decoded.get_width() const originalWidth = decoded.get_width()
@ -141,7 +135,7 @@ export const layer = Layer.effect(
resized.free() resized.free()
if (candidate) { if (candidate) {
log.info("using resized image", { yield* Effect.logInfo("using resized image", {
from_mime: input.mime, from_mime: input.mime,
to_mime: candidate.mime, to_mime: candidate.mime,
from: `${originalWidth}x${originalHeight}`, from: `${originalWidth}x${originalHeight}`,

View file

@ -2,7 +2,6 @@ import yargs from "yargs"
import { hideBin } from "yargs/helpers" import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run" import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate" import { GenerateCommand } from "./cli/cmd/generate"
import * as Log from "@opencode-ai/core/util/log"
import { ConsoleCommand } from "./cli/cmd/account" import { ConsoleCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers" import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent" import { AgentCommand } from "./cli/cmd/agent"
@ -10,9 +9,7 @@ import { UpgradeCommand } from "./cli/cmd/upgrade"
import { UninstallCommand } from "./cli/cmd/uninstall" import { UninstallCommand } from "./cli/cmd/uninstall"
import { ModelsCommand } from "./cli/cmd/models" import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui" import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { NamedError } from "@opencode-ai/core/util/error"
import { FormatError } from "./cli/error" import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve" import { ServeCommand } from "./cli/cmd/serve"
import { DebugCommand } from "./cli/cmd/debug" import { DebugCommand } from "./cli/cmd/debug"
@ -32,22 +29,6 @@ import { DbCommand } from "./cli/cmd/db"
import { errorMessage } from "./util/error" import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug" import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap" import { Heap } from "./cli/heap"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
import { isRecord } from "@/util/record"
const processMetadata = ensureProcessMetadata("main")
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: errorMessage(e),
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
e: errorMessage(e),
})
})
const args = hideBin(process.argv) const args = hideBin(process.argv)
@ -83,32 +64,18 @@ const cli = yargs(args)
type: "boolean", type: "boolean",
}) })
.middleware(async (opts) => { .middleware(async (opts) => {
if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1"
if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel
if (opts.pure) { if (opts.pure) {
process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PURE = "1"
} }
await Log.init({
print: process.argv.includes("--print-logs"),
dev: Installation.isLocal(),
level: (() => {
if (opts.logLevel) return opts.logLevel as Log.Level
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
})
Heap.start() Heap.start()
process.env.AGENT = "1" process.env.AGENT = "1"
process.env.OPENCODE = "1" process.env.OPENCODE = "1"
process.env.OPENCODE_PID = String(process.pid) process.env.OPENCODE_PID = String(process.pid)
Log.Default.info("opencode", {
version: InstallationVersion,
args: process.argv.slice(2),
process_role: processMetadata.processRole,
run_id: processMetadata.runID,
})
}) })
.usage("") .usage("")
.completion("completion", "generate shell completion script") .completion("completion", "generate shell completion script")
@ -160,42 +127,10 @@ try {
await cli.parse() await cli.parse()
} }
} catch (e) { } catch (e) {
let data: Record<string, any> = {}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
message: e.message,
cause: e.cause?.toString(),
stack: e.stack,
})
}
if (e instanceof NamedError) {
const obj = e.toObject()
if (isRecord(obj.data)) {
for (const [key, value] of Object.entries(obj.data)) {
if (key === "name" || key === "stack" || key === "cause") continue
data[key] = value
}
}
}
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,
message: e.message,
code: e.code,
specifier: e.specifier,
referrer: e.referrer,
position: e.position,
importKind: e.importKind,
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e) const formatted = FormatError(e)
if (formatted) UI.error(formatted) if (formatted) UI.error(formatted)
if (formatted === undefined) { if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) UI.error("Unexpected error" + EOL)
process.stderr.write(errorMessage(e) + EOL) process.stderr.write(errorMessage(e) + EOL)
} }
process.exitCode = 1 process.exitCode = 1

View file

@ -7,14 +7,11 @@ import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import path from "path" import path from "path"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import * as Log from "@opencode-ai/core/util/log"
import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver" import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { NpmConfig } from "@opencode-ai/core/npm-config" import { NpmConfig } from "@opencode-ai/core/npm-config"
const log = Log.create({ service: "installation" })
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
export type ReleaseType = "patch" | "minor" | "major" export type ReleaseType = "patch" | "minor" | "major"
@ -324,7 +321,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
if (!upgradeResult || upgradeResult.code !== 0) { if (!upgradeResult || upgradeResult.code !== 0) {
return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) }) return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) })
} }
log.info("upgraded", { yield* Effect.logInfo("upgraded", {
method: m, method: m,
target, target,
stdout: upgradeResult.stdout, stdout: upgradeResult.stdout,

View file

@ -2,7 +2,6 @@ import path from "path"
import { pathToFileURL, fileURLToPath } from "url" import { pathToFileURL, fileURLToPath } from "url"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { LANGUAGE_EXTENSIONS } from "./language" import { LANGUAGE_EXTENSIONS } from "./language"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
@ -23,7 +22,6 @@ const FILE_CHANGE_CREATED = 1
const FILE_CHANGE_CHANGED = 2 const FILE_CHANGE_CHANGED = 2
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
const log = Log.create({ service: "lsp.client" })
export type Info = NonNullable<Awaited<ReturnType<typeof create>>> export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
export type Diagnostic = VSCodeDiagnostic export type Diagnostic = VSCodeDiagnostic
@ -129,23 +127,13 @@ export async function create(input: {
directory: string directory: string
instance: InstanceContext instance: InstanceContext
}) { }) {
const logger = log.clone().tag("serverID", input.serverID)
logger.info("starting client")
const instance = input.instance const instance = input.instance
const connection = createMessageConnection( const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any), new StreamMessageReader(input.server.process.stdout as any),
new StreamMessageWriter(input.server.process.stdin as any), new StreamMessageWriter(input.server.process.stdin as any),
) )
// Server stderr can contain both real errors and routine informational logs, input.server.process.stderr?.resume()
// which is normal stderr practice for some tools. Keep the raw stream at
// debug so users can opt in with --print-logs --log-level DEBUG without
// polluting normal logs.
input.server.process.stderr?.on("data", (data: Buffer) => {
const text = data.toString().trim()
if (text) logger.debug("server stderr", { text: text.slice(0, 1000) })
})
// --- Connection state --- // --- Connection state ---
const pushDiagnostics = new Map<string, Diagnostic[]>() const pushDiagnostics = new Map<string, Diagnostic[]>()
@ -172,11 +160,6 @@ export async function create(input: {
connection.onNotification("textDocument/publishDiagnostics", (params) => { connection.onNotification("textDocument/publishDiagnostics", (params) => {
const filePath = getFilePath(params.uri) const filePath = getFilePath(params.uri)
if (!filePath) return if (!filePath) return
logger.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
version: params.version,
})
published.set(filePath, { published.set(filePath, {
at: Date.now(), at: Date.now(),
version: typeof params.version === "number" ? params.version : undefined, version: typeof params.version === "number" ? params.version : undefined,
@ -188,7 +171,6 @@ export async function create(input: {
updatePushDiagnostics(filePath, params.diagnostics) updatePushDiagnostics(filePath, params.diagnostics)
}) })
connection.onRequest("window/workDoneProgress/create", (params) => { connection.onRequest("window/workDoneProgress/create", (params) => {
logger.info("window/workDoneProgress/create", params)
return null return null
}) })
connection.onRequest("workspace/configuration", async (params) => { connection.onRequest("workspace/configuration", async (params) => {
@ -226,7 +208,6 @@ export async function create(input: {
// --- Initialize handshake --- // --- Initialize handshake ---
logger.info("sending initialize")
const initialized = await withTimeout( const initialized = await withTimeout(
connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
rootUri: pathToFileURL(input.root).href, rootUri: pathToFileURL(input.root).href,
@ -270,7 +251,6 @@ export async function create(input: {
}), }),
INITIALIZE_TIMEOUT_MS, INITIALIZE_TIMEOUT_MS,
).catch((err) => { ).catch((err) => {
logger.error("initialize error", { error: err })
throw new InitializeError({ serverID: input.serverID, cause: err }) throw new InitializeError({ serverID: input.serverID, cause: err })
}) })
@ -585,7 +565,6 @@ export async function create(input: {
// re-emit diagnostics when the content actually changes, so clearing // re-emit diagnostics when the content actually changes, so clearing
// here would lose errors for no-op touchFile calls. Let the server's // here would lose errors for no-op touchFile calls. Let the server's
// next push/pull overwrite naturally. // next push/pull overwrite naturally.
logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", { await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [ changes: [
{ {
@ -597,10 +576,6 @@ export async function create(input: {
const next = document.version + 1 const next = document.version + 1
files[request.path] = { version: next, text } files[request.path] = { version: next, text }
logger.info("textDocument/didChange", {
path: request.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", { await connection.sendNotification("textDocument/didChange", {
textDocument: { textDocument: {
uri: pathToFileURL(request.path).href, uri: pathToFileURL(request.path).href,
@ -622,7 +597,6 @@ export async function create(input: {
return next return next
} }
logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", { await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [ changes: [
{ {
@ -632,7 +606,6 @@ export async function create(input: {
], ],
}) })
logger.info("textDocument/didOpen", request)
pushDiagnostics.delete(request.path) pushDiagnostics.delete(request.path)
pullDiagnostics.delete(request.path) pullDiagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", { await connection.sendNotification("textDocument/didOpen", {
@ -658,11 +631,6 @@ export async function create(input: {
const normalizedPath = Filesystem.normalizePath( const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
) )
logger.info("waiting for diagnostics", {
path: normalizedPath,
mode: request.mode ?? "full",
version: request.version,
})
if (request.mode === "document") { if (request.mode === "document") {
await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
return return
@ -670,16 +638,12 @@ export async function create(input: {
await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
}, },
async shutdown() { async shutdown() {
logger.info("shutting down")
connection.end() connection.end()
connection.dispose() connection.dispose()
await Process.stop(input.server.process) await Process.stop(input.server.process)
logger.info("shutdown")
}, },
} }
logger.info("initialized")
return result return result
} }

View file

@ -1,6 +1,5 @@
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import * as Log from "@opencode-ai/core/util/log"
import * as LSPClient from "./client" import * as LSPClient from "./client"
import path from "path" import path from "path"
import { pathToFileURL, fileURLToPath } from "url" import { pathToFileURL, fileURLToPath } from "url"
@ -14,8 +13,6 @@ import { containsPath } from "@/project/instance-context"
import { NonNegativeInt } from "@opencode-ai/core/schema" import { NonNegativeInt } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "lsp" })
export const Event = { export const Event = {
Updated: EventV2.define({ type: "lsp.updated", schema: {} }), Updated: EventV2.define({ type: "lsp.updated", schema: {} }),
} }
@ -101,7 +98,6 @@ const kinds = [
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>, flags: RuntimeFlags.Info) => { const filterExperimentalServers = (servers: Record<string, LSPServer.Info>, flags: RuntimeFlags.Info) => {
if (flags.experimentalLspTy) { if (flags.experimentalLspTy) {
if (servers["pyright"]) { if (servers["pyright"]) {
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
delete servers["pyright"] delete servers["pyright"]
} }
} else { } else {
@ -153,7 +149,7 @@ export const layer = Layer.effect(
const servers: Record<string, LSPServer.Info> = {} const servers: Record<string, LSPServer.Info> = {}
if (!cfg.lsp) { if (!cfg.lsp) {
log.info("all LSPs are disabled") yield* Effect.logInfo("all LSPs are disabled")
} else { } else {
for (const server of Object.values(LSPServer)) { for (const server of Object.values(LSPServer)) {
servers[server.id] = server servers[server.id] = server
@ -165,7 +161,7 @@ export const layer = Layer.effect(
for (const [name, item] of Object.entries(cfg.lsp)) { for (const [name, item] of Object.entries(cfg.lsp)) {
const existing = servers[name] const existing = servers[name]
if (item.disabled) { if (item.disabled) {
log.info(`LSP server ${name} is disabled`) yield* Effect.logInfo(`LSP server ${name} is disabled`)
delete servers[name] delete servers[name]
continue continue
} }
@ -185,7 +181,7 @@ export const layer = Layer.effect(
} }
} }
log.info("enabled LSP servers", { yield* Effect.logInfo("enabled LSP servers", {
serverIds: Object.values(servers) serverIds: Object.values(servers)
.map((server) => server.id) .map((server) => server.id)
.join(", "), .join(", "),
@ -225,25 +221,21 @@ export const layer = Layer.effect(
if (!value) s.broken.add(key) if (!value) s.broken.add(key)
return value return value
}) })
.catch((err) => { .catch(() => {
s.broken.add(key) s.broken.add(key)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined return undefined
}) })
if (!handle) return undefined if (!handle) return undefined
log.info("spawned lsp server", { serverID: server.id, root })
const client = await LSPClient.create({ const client = await LSPClient.create({
serverID: server.id, serverID: server.id,
server: handle, server: handle,
root, root,
directory: ctx.directory, directory: ctx.directory,
instance: ctx, instance: ctx,
}).catch(async (err) => { }).catch(async () => {
s.broken.add(key) s.broken.add(key)
await Process.stop(handle.process) await Process.stop(handle.process)
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined return undefined
}) })
@ -350,7 +342,7 @@ export const layer = Layer.effect(
}) })
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") { const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {
log.info("touching file", { file: input }) yield* Effect.logInfo("touching file", { file: input })
const clients = yield* getClients(input) const clients = yield* getClients(input)
yield* Effect.promise(() => yield* Effect.promise(() =>
Promise.all( Promise.all(
@ -365,9 +357,7 @@ export const layer = Layer.effect(
after, after,
}) })
}), }),
).catch((err) => { ).catch(() => {}),
log.error("failed to touch file", { err, file: input })
}),
) )
}) })

View file

@ -2,7 +2,6 @@ import type { ChildProcessWithoutNullStreams } from "child_process"
import path from "path" import path from "path"
import os from "os" import os from "os"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
@ -15,7 +14,6 @@ import { spawn } from "./launch"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import type { RuntimeFlags } from "@/effect/runtime-flags" import type { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "lsp.server" })
const pathExists = async (p: string) => const pathExists = async (p: string) =>
fs fs
.stat(p) .stat(p)
@ -104,7 +102,6 @@ export const Deno: Info = {
async spawn(root) { async spawn(root) {
const deno = which("deno") const deno = which("deno")
if (!deno) { if (!deno) {
log.info("deno not found, please install deno first")
return return
} }
return { return {
@ -124,7 +121,6 @@ export const Typescript: Info = {
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(root, ctx) { async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return if (!tsserver) return
const bin = await Npm.which("typescript-language-server") const bin = await Npm.which("typescript-language-server")
if (!bin) return if (!bin) return
@ -181,11 +177,9 @@ export const ESLint: Info = {
async spawn(root, ctx, flags) { async spawn(root, ctx, flags) {
const eslint = Module.resolve("eslint", ctx.directory) const eslint = Module.resolve("eslint", ctx.directory)
if (!eslint) return if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
if (!(await Filesystem.exists(serverPath))) { if (!(await Filesystem.exists(serverPath))) {
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("downloading and building VS Code ESLint server")
const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip")
if (!response.ok) return if (!response.ok) return
@ -195,7 +189,6 @@ export const ESLint: Info = {
const ok = await Archive.extractZip(zipPath, Global.Path.bin) const ok = await Archive.extractZip(zipPath, Global.Path.bin)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract vscode-eslint archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -206,7 +199,6 @@ export const ESLint: Info = {
const stats = await fs.stat(finalPath).catch(() => undefined) const stats = await fs.stat(finalPath).catch(() => undefined)
if (stats) { if (stats) {
log.info("removing old eslint installation", { path: finalPath })
await fs.rm(finalPath, { force: true, recursive: true }) await fs.rm(finalPath, { force: true, recursive: true })
} }
await fs.rename(extractedPath, finalPath) await fs.rename(extractedPath, finalPath)
@ -215,7 +207,6 @@ export const ESLint: Info = {
await Process.run([npmCmd, "install"], { cwd: finalPath }) await Process.run([npmCmd, "install"], { cwd: finalPath })
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath }) await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
log.info("installed VS Code ESLint server", { serverPath })
} }
const proc = spawn("node", [serverPath, "--stdio"], { const proc = spawn("node", [serverPath, "--stdio"], {
@ -299,7 +290,6 @@ export const Oxlint: Info = {
} }
} }
log.info("oxlint not found, please install oxlint")
return return
}, },
} }
@ -380,7 +370,6 @@ export const Gopls: Info = {
if (!which("go")) return if (!which("go")) return
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("installing gopls")
const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
env: { ...process.env, GOBIN: Global.Path.bin }, env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe", stdout: "pipe",
@ -389,13 +378,9 @@ export const Gopls: Info = {
}) })
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) { if (exit !== 0) {
log.error("Failed to install gopls")
return return
} }
bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed gopls`, {
bin,
})
} }
return { return {
process: spawn(bin!, { process: spawn(bin!, {
@ -415,11 +400,9 @@ export const Rubocop: Info = {
const ruby = which("ruby") const ruby = which("ruby")
const gem = which("gem") const gem = which("gem")
if (!ruby || !gem) { if (!ruby || !gem) {
log.info("Ruby not found, please install Ruby first")
return return
} }
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("installing rubocop")
const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@ -427,13 +410,9 @@ export const Rubocop: Info = {
}) })
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) { if (exit !== 0) {
log.error("Failed to install rubocop")
return return
} }
bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed rubocop`, {
bin,
})
} }
return { return {
process: spawn(bin!, ["--lsp"], { process: spawn(bin!, ["--lsp"], {
@ -490,7 +469,6 @@ export const Ty: Info = {
} }
if (!binary) { if (!binary) {
log.error("ty not found, please install ty first")
return return
} }
@ -567,12 +545,10 @@ export const ElixirLS: Info = {
if (!(await Filesystem.exists(binary))) { if (!(await Filesystem.exists(binary))) {
const elixir = which("elixir") const elixir = which("elixir")
if (!elixir) { if (!elixir) {
log.error("elixir is required to run elixir-ls")
return return
} }
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("downloading elixir-ls from GitHub releases")
const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip")
if (!response.ok) return if (!response.ok) return
@ -582,7 +558,6 @@ export const ElixirLS: Info = {
const ok = await Archive.extractZip(zipPath, Global.Path.bin) const ok = await Archive.extractZip(zipPath, Global.Path.bin)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract elixir-ls archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -598,9 +573,6 @@ export const ElixirLS: Info = {
await Process.run(["mix", "compile"], { cwd, env }) await Process.run(["mix", "compile"], { cwd, env })
await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env }) await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
})
} }
} }
@ -622,16 +594,13 @@ export const Zls: Info = {
if (!bin) { if (!bin) {
const zig = which("zig") const zig = which("zig")
if (!zig) { if (!zig) {
log.error("Zig is required to use zls. Please install Zig first.")
return return
} }
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("downloading zls from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
if (!releaseResponse.ok) { if (!releaseResponse.ok) {
log.error("Failed to fetch zls release info")
return return
} }
@ -668,20 +637,17 @@ export const Zls: Info = {
] ]
if (!supportedCombos.includes(assetName)) { if (!supportedCombos.includes(assetName)) {
log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`)
return return
} }
const asset = release.assets?.find((a) => a.name === assetName) const asset = release.assets?.find((a) => a.name === assetName)
if (!asset?.browser_download_url) { if (!asset?.browser_download_url) {
log.error(`Could not find asset ${assetName} in latest zls release`)
return return
} }
const downloadUrl = asset.browser_download_url const downloadUrl = asset.browser_download_url
const downloadResponse = await fetch(downloadUrl) const downloadResponse = await fetch(downloadUrl)
if (!downloadResponse.ok) { if (!downloadResponse.ok) {
log.error("Failed to download zls")
return return
} }
@ -692,7 +658,6 @@ export const Zls: Info = {
const ok = await Archive.extractZip(tempPath, Global.Path.bin) const ok = await Archive.extractZip(tempPath, Global.Path.bin)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract zls archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -705,7 +670,6 @@ export const Zls: Info = {
bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
if (!(await Filesystem.exists(bin))) { if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract zls binary")
return return
} }
@ -713,7 +677,6 @@ export const Zls: Info = {
await fs.chmod(bin, 0o755).catch(() => {}) await fs.chmod(bin, 0o755).catch(() => {})
} }
log.info(`installed zls`, { bin })
} }
return { return {
@ -750,11 +713,9 @@ export const Razor: Info = {
const razor = await findVscodeRazorExtension() const razor = await findVscodeRazorExtension()
if (!razor) { if (!razor) {
log.info("VS Code C# extension with Razor support not found, skipping Razor LSP")
return return
} }
log.info("using VS Code Razor extension for roslyn-language-server", { extension: razor.extension })
return { return {
process: spawn( process: spawn(
bin, bin,
@ -791,12 +752,10 @@ async function getRoslynLanguageServer(disableLspDownload: boolean) {
async function installRoslynLanguageServer(disableLspDownload: boolean) { async function installRoslynLanguageServer(disableLspDownload: boolean) {
if (!which("dotnet")) { if (!which("dotnet")) {
log.error(".NET SDK is required to install roslyn-language-server")
return return
} }
if (disableLspDownload) return if (disableLspDownload) return
log.info("installing roslyn-language-server via dotnet tool")
const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], { const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@ -804,23 +763,19 @@ async function installRoslynLanguageServer(disableLspDownload: boolean) {
}) })
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) { if (exit !== 0) {
log.error("Failed to install roslyn-language-server")
return return
} }
const resolved = which("roslyn-language-server") const resolved = which("roslyn-language-server")
if (resolved) { if (resolved) {
log.info(`installed roslyn-language-server`, { bin: resolved })
return resolved return resolved
} }
const global = await roslynLanguageServerGlobalPath() const global = await roslynLanguageServerGlobalPath()
if (global) { if (global) {
log.info(`installed roslyn-language-server`, { bin: global })
return global return global
} }
log.error("Installed roslyn-language-server but could not resolve executable")
} }
async function roslynLanguageServerGlobalPath() { async function roslynLanguageServerGlobalPath() {
@ -877,12 +832,10 @@ export const FSharp: Info = {
let bin = which("fsautocomplete") let bin = which("fsautocomplete")
if (!bin) { if (!bin) {
if (!which("dotnet")) { if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
return return
} }
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("installing fsautocomplete via dotnet tool")
const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
@ -890,12 +843,10 @@ export const FSharp: Info = {
}) })
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) { if (exit !== 0) {
log.error("Failed to install fsautocomplete")
return return
} }
bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : ""))
log.info(`installed fsautocomplete`, { bin })
} }
return { return {
@ -975,7 +926,6 @@ export const RustAnalyzer: Info = {
async spawn(root) { async spawn(root) {
const bin = which("rust-analyzer") const bin = which("rust-analyzer")
if (!bin) { if (!bin) {
log.info("rust-analyzer not found in path, please install it")
return return
} }
return { return {
@ -1026,11 +976,9 @@ export const Clangd: Info = {
} }
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("downloading clangd from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
if (!releaseResponse.ok) { if (!releaseResponse.ok) {
log.error("Failed to fetch clangd release info")
return return
} }
@ -1041,7 +989,6 @@ export const Clangd: Info = {
const tag = release.tag_name const tag = release.tag_name
if (!tag) { if (!tag) {
log.error("clangd release did not include a tag name")
return return
} }
const platform = process.platform const platform = process.platform
@ -1052,7 +999,6 @@ export const Clangd: Info = {
} }
const token = tokens[platform] const token = tokens[platform]
if (!token) { if (!token) {
log.error(`Platform ${platform} is not supported by clangd auto-download`)
return return
} }
@ -1069,21 +1015,18 @@ export const Clangd: Info = {
assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ?? assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ??
assets.find((item) => valid(item)) assets.find((item) => valid(item))
if (!asset?.name || !asset.browser_download_url) { if (!asset?.name || !asset.browser_download_url) {
log.error("clangd could not match release asset", { tag, platform })
return return
} }
const name = asset.name const name = asset.name
const downloadResponse = await fetch(asset.browser_download_url) const downloadResponse = await fetch(asset.browser_download_url)
if (!downloadResponse.ok) { if (!downloadResponse.ok) {
log.error("Failed to download clangd")
return return
} }
const archive = path.join(Global.Path.bin, name) const archive = path.join(Global.Path.bin, name)
const buf = await downloadResponse.arrayBuffer() const buf = await downloadResponse.arrayBuffer()
if (buf.byteLength === 0) { if (buf.byteLength === 0) {
log.error("Failed to write clangd archive")
return return
} }
await Filesystem.write(archive, Buffer.from(buf)) await Filesystem.write(archive, Buffer.from(buf))
@ -1091,7 +1034,6 @@ export const Clangd: Info = {
const zip = name.endsWith(".zip") const zip = name.endsWith(".zip")
const tar = name.endsWith(".tar.xz") const tar = name.endsWith(".tar.xz")
if (!zip && !tar) { if (!zip && !tar) {
log.error("clangd encountered unsupported asset", { asset: name })
return return
} }
@ -1099,7 +1041,6 @@ export const Clangd: Info = {
const ok = await Archive.extractZip(archive, Global.Path.bin) const ok = await Archive.extractZip(archive, Global.Path.bin)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract clangd archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -1111,7 +1052,6 @@ export const Clangd: Info = {
const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext)
if (!(await Filesystem.exists(bin))) { if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract clangd binary")
return return
} }
@ -1122,7 +1062,6 @@ export const Clangd: Info = {
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {}) await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {})
log.info(`installed clangd`, { bin })
return { return {
process: spawn(bin, args, { process: spawn(bin, args, {
@ -1166,7 +1105,6 @@ export const Astro: Info = {
async spawn(root, ctx, flags) { async spawn(root, ctx, flags) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
if (!tsserver) { if (!tsserver) {
log.info("typescript not found, required for Astro language server")
return return
} }
const tsdk = path.dirname(tsserver) const tsdk = path.dirname(tsserver)
@ -1255,7 +1193,6 @@ export const JDTLS: Info = {
async spawn(root, _ctx, flags) { async spawn(root, _ctx, flags) {
const java = which("java") const java = which("java")
if (!java) { if (!java) {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return return
} }
const javaMajorVersion = await run(["java", "-version"]).then((result) => { const javaMajorVersion = await run(["java", "-version"]).then((result) => {
@ -1263,7 +1200,6 @@ export const JDTLS: Info = {
return !m ? undefined : parseInt(m[1]) return !m ? undefined : parseInt(m[1])
}) })
if (javaMajorVersion == null || javaMajorVersion < 21) { if (javaMajorVersion == null || javaMajorVersion < 21) {
log.error("JDTLS requires at least Java 21.")
return return
} }
const distPath = path.join(Global.Path.bin, "jdtls") const distPath = path.join(Global.Path.bin, "jdtls")
@ -1271,29 +1207,23 @@ export const JDTLS: Info = {
const installed = await pathExists(launcherDir) const installed = await pathExists(launcherDir)
if (!installed) { if (!installed) {
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("Downloading JDTLS LSP server.")
await fs.mkdir(distPath, { recursive: true }) await fs.mkdir(distPath, { recursive: true })
const releaseURL = const releaseURL =
"https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz"
const archiveName = "release.tar.gz" const archiveName = "release.tar.gz"
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
const download = await fetch(releaseURL) const download = await fetch(releaseURL)
if (!download.ok || !download.body) { if (!download.ok || !download.body) {
log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
return return
} }
await Filesystem.writeStream(path.join(distPath, archiveName), download.body) await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
log.info("Extracting JDTLS archive")
const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath }) const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath })
if (tarResult.code !== 0) { if (tarResult.code !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() })
return return
} }
await fs.rm(path.join(distPath, archiveName), { force: true }) await fs.rm(path.join(distPath, archiveName), { force: true })
log.info("JDTLS download and extraction completed")
} }
const jarFileName = const jarFileName =
(await fs.readdir(launcherDir).catch(() => [])) (await fs.readdir(launcherDir).catch(() => []))
@ -1301,7 +1231,6 @@ export const JDTLS: Info = {
?.trim() ?? "" ?.trim() ?? ""
const launcherJar = path.join(launcherDir, jarFileName) const launcherJar = path.join(launcherDir, jarFileName)
if (!(await pathExists(launcherJar))) { if (!(await pathExists(launcherJar))) {
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
return return
} }
const configFile = path.join( const configFile = path.join(
@ -1369,11 +1298,9 @@ export const KotlinLS: Info = {
const installed = await Filesystem.exists(launcherScript) const installed = await Filesystem.exists(launcherScript)
if (!installed) { if (!installed) {
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("Downloading Kotlin Language Server from GitHub.")
const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest")
if (!releaseResponse.ok) { if (!releaseResponse.ok) {
log.error("Failed to fetch kotlin-lsp release info")
return return
} }
@ -1381,7 +1308,6 @@ export const KotlinLS: Info = {
const version = release.name?.replace(/^v/, "") const version = release.name?.replace(/^v/, "")
if (!version) { if (!version) {
log.error("Could not determine Kotlin LSP version from release")
return return
} }
@ -1402,7 +1328,6 @@ export const KotlinLS: Info = {
const combo = `${kotlinPlatform}-${kotlinArch}` const combo = `${kotlinPlatform}-${kotlinArch}`
if (!supportedCombos.includes(combo)) { if (!supportedCombos.includes(combo)) {
log.error(`Platform ${platform}/${arch} is not supported by Kotlin LSP`)
return return
} }
@ -1413,17 +1338,12 @@ export const KotlinLS: Info = {
const archivePath = path.join(distPath, "kotlin-ls.zip") const archivePath = path.join(distPath, "kotlin-ls.zip")
const download = await fetch(releaseURL) const download = await fetch(releaseURL)
if (!download.ok || !download.body) { if (!download.ok || !download.body) {
log.error("Failed to download Kotlin Language Server", {
status: download.status,
statusText: download.statusText,
})
return return
} }
await Filesystem.writeStream(archivePath, download.body) await Filesystem.writeStream(archivePath, download.body)
const ok = await Archive.extractZip(archivePath, distPath) const ok = await Archive.extractZip(archivePath, distPath)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract Kotlin LS archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -1431,10 +1351,8 @@ export const KotlinLS: Info = {
if (process.platform !== "win32") { if (process.platform !== "win32") {
await fs.chmod(launcherScript, 0o755).catch(() => {}) await fs.chmod(launcherScript, 0o755).catch(() => {})
} }
log.info("Installed Kotlin Language Server", { path: launcherScript })
} }
if (!(await Filesystem.exists(launcherScript))) { if (!(await Filesystem.exists(launcherScript))) {
log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`)
return return
} }
return { return {
@ -1488,11 +1406,9 @@ export const LuaLS: Info = {
if (!bin) { if (!bin) {
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("downloading lua-language-server from GitHub releases")
const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest")
if (!releaseResponse.ok) { if (!releaseResponse.ok) {
log.error("Failed to fetch lua-language-server release info")
return return
} }
@ -1527,20 +1443,17 @@ export const LuaLS: Info = {
const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}`
if (!supportedCombos.includes(assetSuffix)) { if (!supportedCombos.includes(assetSuffix)) {
log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`)
return return
} }
const asset = release.assets.find((a: any) => a.name === assetName) const asset = release.assets.find((a: any) => a.name === assetName)
if (!asset) { if (!asset) {
log.error(`Could not find asset ${assetName} in latest lua-language-server release`)
return return
} }
const downloadUrl = asset.browser_download_url const downloadUrl = asset.browser_download_url
const downloadResponse = await fetch(downloadUrl) const downloadResponse = await fetch(downloadUrl)
if (!downloadResponse.ok) { if (!downloadResponse.ok) {
log.error("Failed to download lua-language-server")
return return
} }
@ -1564,7 +1477,6 @@ export const LuaLS: Info = {
const ok = await Archive.extractZip(tempPath, installDir) const ok = await Archive.extractZip(tempPath, installDir)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract lua-language-server archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -1572,7 +1484,6 @@ export const LuaLS: Info = {
const ok = await run(["tar", "-xzf", tempPath, "-C", installDir]) const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
.then((result) => result.code === 0) .then((result) => result.code === 0)
.catch((error: unknown) => { .catch((error: unknown) => {
log.error("Failed to extract lua-language-server archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -1584,7 +1495,6 @@ export const LuaLS: Info = {
bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
if (!(await Filesystem.exists(bin))) { if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract lua-language-server binary")
return return
} }
@ -1593,15 +1503,11 @@ export const LuaLS: Info = {
.chmod(bin, 0o755) .chmod(bin, 0o755)
.then(() => true) .then(() => true)
.catch((error: unknown) => { .catch((error: unknown) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
})
return false return false
}) })
if (!ok) return if (!ok) return
} }
log.info(`installed lua-language-server`, { bin })
} }
return { return {
@ -1650,7 +1556,6 @@ export const Prisma: Info = {
async spawn(root) { async spawn(root) {
const prisma = which("prisma") const prisma = which("prisma")
if (!prisma) { if (!prisma) {
log.info("prisma not found, please install prisma")
return return
} }
return { return {
@ -1668,7 +1573,6 @@ export const Dart: Info = {
async spawn(root) { async spawn(root) {
const dart = which("dart") const dart = which("dart")
if (!dart) { if (!dart) {
log.info("dart not found, please install dart first")
return return
} }
return { return {
@ -1686,7 +1590,6 @@ export const Ocaml: Info = {
async spawn(root) { async spawn(root) {
const bin = which("ocamllsp") const bin = which("ocamllsp")
if (!bin) { if (!bin) {
log.info("ocamllsp not found, please install ocaml-lsp-server")
return return
} }
return { return {
@ -1731,11 +1634,9 @@ export const TerraformLS: Info = {
if (!bin) { if (!bin) {
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("downloading terraform-ls from HashiCorp releases")
const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest")
if (!releaseResponse.ok) { if (!releaseResponse.ok) {
log.error("Failed to fetch terraform-ls release info")
return return
} }
@ -1753,13 +1654,11 @@ export const TerraformLS: Info = {
const builds = release.builds ?? [] const builds = release.builds ?? []
const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform) const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform)
if (!build?.url) { if (!build?.url) {
log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`)
return return
} }
const downloadResponse = await fetch(build.url) const downloadResponse = await fetch(build.url)
if (!downloadResponse.ok) { if (!downloadResponse.ok) {
log.error("Failed to download terraform-ls")
return return
} }
@ -1769,7 +1668,6 @@ export const TerraformLS: Info = {
const ok = await Archive.extractZip(tempPath, Global.Path.bin) const ok = await Archive.extractZip(tempPath, Global.Path.bin)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract terraform-ls archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -1778,7 +1676,6 @@ export const TerraformLS: Info = {
bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : ""))
if (!(await Filesystem.exists(bin))) { if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract terraform-ls binary")
return return
} }
@ -1786,7 +1683,6 @@ export const TerraformLS: Info = {
await fs.chmod(bin, 0o755).catch(() => {}) await fs.chmod(bin, 0o755).catch(() => {})
} }
log.info(`installed terraform-ls`, { bin })
} }
return { return {
@ -1812,11 +1708,9 @@ export const TexLab: Info = {
if (!bin) { if (!bin) {
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("downloading texlab from GitHub releases")
const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest")
if (!response.ok) { if (!response.ok) {
log.error("Failed to fetch texlab release info")
return return
} }
@ -1826,7 +1720,6 @@ export const TexLab: Info = {
} }
const version = release.tag_name?.replace("v", "") const version = release.tag_name?.replace("v", "")
if (!version) { if (!version) {
log.error("texlab release did not include a version tag")
return return
} }
@ -1841,13 +1734,11 @@ export const TexLab: Info = {
const assets = release.assets ?? [] const assets = release.assets ?? []
const asset = assets.find((a) => a.name === assetName) const asset = assets.find((a) => a.name === assetName)
if (!asset?.browser_download_url) { if (!asset?.browser_download_url) {
log.error(`Could not find asset ${assetName} in texlab release`)
return return
} }
const downloadResponse = await fetch(asset.browser_download_url) const downloadResponse = await fetch(asset.browser_download_url)
if (!downloadResponse.ok) { if (!downloadResponse.ok) {
log.error("Failed to download texlab")
return return
} }
@ -1858,7 +1749,6 @@ export const TexLab: Info = {
const ok = await Archive.extractZip(tempPath, Global.Path.bin) const ok = await Archive.extractZip(tempPath, Global.Path.bin)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract texlab archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -1872,7 +1762,6 @@ export const TexLab: Info = {
bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : ""))
if (!(await Filesystem.exists(bin))) { if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract texlab binary")
return return
} }
@ -1880,7 +1769,6 @@ export const TexLab: Info = {
await fs.chmod(bin, 0o755).catch(() => {}) await fs.chmod(bin, 0o755).catch(() => {})
} }
log.info("installed texlab", { bin })
} }
return { return {
@ -1924,7 +1812,6 @@ export const Gleam: Info = {
async spawn(root) { async spawn(root) {
const gleam = which("gleam") const gleam = which("gleam")
if (!gleam) { if (!gleam) {
log.info("gleam not found, please install gleam first")
return return
} }
return { return {
@ -1945,7 +1832,6 @@ export const Clojure: Info = {
bin = which("clojure-lsp.exe") bin = which("clojure-lsp.exe")
} }
if (!bin) { if (!bin) {
log.info("clojure-lsp not found, please install clojure-lsp first")
return return
} }
return { return {
@ -1973,7 +1859,6 @@ export const Nixd: Info = {
async spawn(root) { async spawn(root) {
const nixd = which("nixd") const nixd = which("nixd")
if (!nixd) { if (!nixd) {
log.info("nixd not found, please install nixd first")
return return
} }
return { return {
@ -1996,11 +1881,9 @@ export const Tinymist: Info = {
if (!bin) { if (!bin) {
if (flags.disableLspDownload) return if (flags.disableLspDownload) return
log.info("downloading tinymist from GitHub releases")
const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest")
if (!response.ok) { if (!response.ok) {
log.error("Failed to fetch tinymist release info")
return return
} }
@ -2032,13 +1915,11 @@ export const Tinymist: Info = {
const assets = release.assets ?? [] const assets = release.assets ?? []
const asset = assets.find((a) => a.name === assetName) const asset = assets.find((a) => a.name === assetName)
if (!asset?.browser_download_url) { if (!asset?.browser_download_url) {
log.error(`Could not find asset ${assetName} in tinymist release`)
return return
} }
const downloadResponse = await fetch(asset.browser_download_url) const downloadResponse = await fetch(asset.browser_download_url)
if (!downloadResponse.ok) { if (!downloadResponse.ok) {
log.error("Failed to download tinymist")
return return
} }
@ -2049,7 +1930,6 @@ export const Tinymist: Info = {
const ok = await Archive.extractZip(tempPath, Global.Path.bin) const ok = await Archive.extractZip(tempPath, Global.Path.bin)
.then(() => true) .then(() => true)
.catch((error) => { .catch((error) => {
log.error("Failed to extract tinymist archive", { error })
return false return false
}) })
if (!ok) return if (!ok) return
@ -2062,7 +1942,6 @@ export const Tinymist: Info = {
bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : "")) bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : ""))
if (!(await Filesystem.exists(bin))) { if (!(await Filesystem.exists(bin))) {
log.error("Failed to extract tinymist binary")
return return
} }
@ -2070,7 +1949,6 @@ export const Tinymist: Info = {
await fs.chmod(bin, 0o755).catch(() => {}) await fs.chmod(bin, 0o755).catch(() => {})
} }
log.info("installed tinymist", { bin })
} }
return { return {
@ -2086,7 +1964,6 @@ export const HLS: Info = {
async spawn(root) { async spawn(root) {
const bin = which("haskell-language-server-wrapper") const bin = which("haskell-language-server-wrapper")
if (!bin) { if (!bin) {
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
return return
} }
return { return {
@ -2104,7 +1981,6 @@ export const JuliaLS: Info = {
async spawn(root) { async spawn(root) {
const julia = which("julia") const julia = which("julia")
if (!julia) { if (!julia) {
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
return return
} }
return { return {

View file

@ -15,7 +15,6 @@ import {
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
@ -33,7 +32,6 @@ import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000 const DEFAULT_TIMEOUT = 30_000
const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ const TolerantListToolsResultSchema = ListToolsResultSchema.extend({
@ -117,7 +115,6 @@ const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")
function remoteURL(key: string, value: string) { function remoteURL(key: string, value: string) {
if (URL.canParse(value)) return new URL(value) if (URL.canParse(value)) return new URL(value)
log.warn("invalid remote mcp url", { key })
} }
function isOutputSchemaValidationError(error: Error) { function isOutputSchemaValidationError(error: Error) {
@ -135,7 +132,6 @@ function listTools(key: string, client: MCPClient, timeout: number) {
Effect.catch((error) => { Effect.catch((error) => {
if (!isOutputSchemaValidationError(error)) return Effect.fail(error) if (!isOutputSchemaValidationError(error)) return Effect.fail(error)
log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error })
return Effect.tryPromise({ return Effect.tryPromise({
try: () => try: () =>
client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { client.request({ method: "tools/list" }, TolerantListToolsResultSchema, {
@ -189,7 +185,6 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
function defs(key: string, client: MCPClient, timeout?: number) { function defs(key: string, client: MCPClient, timeout?: number) {
return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe( return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe(
Effect.catch((err) => { Effect.catch((err) => {
log.error("failed to get tools from client", { key, error: err })
return Effect.succeed(undefined) return Effect.succeed(undefined)
}), }),
) )
@ -204,7 +199,6 @@ function fetchFromClient<T extends { name: string }>(
return Effect.tryPromise({ return Effect.tryPromise({
try: () => listFn(client), try: () => listFn(client),
catch: (e: any) => { catch: (e: any) => {
log.warn(`failed to get ${label}`, { clientName, error: e.message })
return e return e
}, },
}).pipe( }).pipe(
@ -330,9 +324,7 @@ export const layer = Layer.effect(
redirectUri: oauthConfig?.redirectUri, redirectUri: oauthConfig?.redirectUri,
}, },
{ {
onRedirect: async (url) => { onRedirect: async () => {},
log.info("oauth redirect requested", { key, url: url.toString() })
},
}, },
auth, auth,
) )
@ -367,8 +359,6 @@ export const layer = Layer.effect(
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth")) error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
if (isAuthError) { if (isAuthError) {
log.info("mcp server requires authentication", { key, transport: name })
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) { if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
lastStatus = { lastStatus = {
status: "needs_client_registration" as const, status: "needs_client_registration" as const,
@ -396,18 +386,11 @@ export const layer = Layer.effect(
} }
} }
log.debug("transport connection failed", {
key,
transport: name,
url: mcp.url,
error: lastError.message,
})
lastStatus = { status: "failed" as const, error: lastError.message } lastStatus = { status: "failed" as const, error: lastError.message }
return Effect.succeed(undefined) return Effect.succeed(undefined)
}), }),
) )
if (result) { if (result) {
log.info("connected", { key, transport: result.transportName })
return { client: result.client as MCPClient | undefined, status: { status: "connected" } as Status } return { client: result.client as MCPClient | undefined, status: { status: "connected" } as Status }
} }
// If this was an auth error, stop trying other transports // If this was an auth error, stop trying other transports
@ -437,9 +420,6 @@ export const layer = Layer.effect(
...mcp.environment, ...mcp.environment,
}, },
}) })
transport.stderr?.on("data", (chunk: Buffer) => {
log.info(`mcp stderr: ${chunk.toString()}`, { key })
})
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
return yield* connectTransport(transport, connectTimeout).pipe( return yield* connectTransport(transport, connectTimeout).pipe(
@ -449,7 +429,6 @@ export const layer = Layer.effect(
})), })),
Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => { Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => {
const msg = error instanceof Error ? error.message : String(error) const msg = error instanceof Error ? error.message : String(error)
log.error("local mcp startup failed", { key, command: mcp.command, cwd, error: msg })
return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } }) return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } })
}), }),
) )
@ -457,11 +436,9 @@ export const layer = Layer.effect(
const create = Effect.fn("MCP.create")(function* (key: string, mcp: ConfigMCPV1.Info) { const create = Effect.fn("MCP.create")(function* (key: string, mcp: ConfigMCPV1.Info) {
if (mcp.enabled === false) { if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
return DISABLED_RESULT return DISABLED_RESULT
} }
log.info("found", { key, type: mcp.type })
const { client: mcpClient, status } = const { client: mcpClient, status } =
mcp.type === "remote" mcp.type === "remote"
@ -478,7 +455,6 @@ export const layer = Layer.effect(
return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult
} }
log.info("create() successfully created client", { key, toolCount: listed.length })
return { mcpClient, status, defs: listed } satisfies CreateResult return { mcpClient, status, defs: listed } satisfies CreateResult
}) })
const cfgSvc = yield* Config.Service const cfgSvc = yield* Config.Service
@ -510,7 +486,6 @@ export const layer = Layer.effect(
function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) { function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) {
if (!client.getServerCapabilities()?.tools) return if (!client.getServerCapabilities()?.tools) return
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
log.info("tools list changed notification received", { server: name })
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
const listed = await bridge.promise(defs(name, client, timeout)) const listed = await bridge.promise(defs(name, client, timeout))
@ -539,7 +514,6 @@ export const layer = Layer.effect(
([key, mcp]) => ([key, mcp]) =>
Effect.gen(function* () { Effect.gen(function* () {
if (!isMcpConfigured(mcp)) { if (!isMcpConfigured(mcp)) {
log.error("Ignoring MCP config entry without type", { key })
return return
} }
@ -690,7 +664,6 @@ export const layer = Layer.effect(
const listed = s.defs[clientName] const listed = s.defs[clientName]
if (!listed) { if (!listed) {
log.warn("missing cached tools for connected server", { clientName })
return return
} }
@ -745,13 +718,11 @@ export const layer = Layer.effect(
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const client = s.clients[clientName] const client = s.clients[clientName]
if (!client) { if (!client) {
log.warn(`client not found for ${label}`, { clientName })
return undefined return undefined
} }
return yield* Effect.tryPromise({ return yield* Effect.tryPromise({
try: () => fn(client), try: () => fn(client),
catch: (e: any) => { catch: (e: any) => {
log.error(`failed to ${label}`, { clientName, ...meta, error: e?.message })
return e return e
}, },
}).pipe(Effect.orElseSucceed(() => undefined)) }).pipe(Effect.orElseSucceed(() => undefined))
@ -873,7 +844,6 @@ export const layer = Layer.effect(
return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout) return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout)
} }
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: result.oauthState })
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
@ -894,7 +864,6 @@ export const layer = Layer.effect(
}), }),
), ),
Effect.catch(() => { Effect.catch(() => {
log.warn("failed to open browser, user must open URL manually", { mcpName })
return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
}), }),
) )
@ -918,7 +887,6 @@ export const layer = Layer.effect(
const result = yield* Effect.tryPromise({ const result = yield* Effect.tryPromise({
try: () => transport.finishAuth(authorizationCode).then(() => true as const), try: () => transport.finishAuth(authorizationCode).then(() => true as const),
catch: (error) => { catch: (error) => {
log.error("failed to finish oauth", { mcpName, error })
return error return error
}, },
}).pipe(Effect.option) }).pipe(Effect.option)
@ -939,7 +907,6 @@ export const layer = Layer.effect(
yield* auth.remove(mcpName) yield* auth.remove(mcpName)
McpOAuthCallback.cancelPending(mcpName) McpOAuthCallback.cancelPending(mcpName)
pendingOAuthTransports.delete(mcpName) pendingOAuthTransports.delete(mcpName)
log.info("removed oauth credentials", { mcpName })
}) })
const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) { const supportsOAuth = Effect.fn("MCP.supportsOAuth")(function* (mcpName: string) {

View file

@ -1,9 +1,7 @@
import { createConnection } from "net" import { createConnection } from "net"
import { createServer } from "http" import { createServer } from "http"
import * as Log from "@opencode-ai/core/util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
const log = Log.create({ service: "mcp.oauth-callback" })
// Current callback server configuration (may differ from defaults if custom redirectUri is used) // Current callback server configuration (may differ from defaults if custom redirectUri is used)
let currentPort = OAUTH_CALLBACK_PORT let currentPort = OAUTH_CALLBACK_PORT
@ -87,12 +85,10 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
const error = url.searchParams.get("error") const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description") const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
// Enforce state parameter presence // Enforce state parameter presence
if (!state) { if (!state) {
const errorMsg = "Missing required state parameter - potential CSRF attack" const errorMsg = "Missing required state parameter - potential CSRF attack"
log.error("oauth callback missing state parameter", { url: url.toString() })
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg)) res.end(HTML_ERROR(errorMsg))
return return
@ -121,7 +117,6 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
// Validate state parameter // Validate state parameter
if (!pendingAuths.has(state)) { if (!pendingAuths.has(state)) {
const errorMsg = "Invalid or expired state parameter - potential CSRF attack" const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) })
res.writeHead(400, { "Content-Type": "text/html" }) res.writeHead(400, { "Content-Type": "text/html" })
res.end(HTML_ERROR(errorMsg)) res.end(HTML_ERROR(errorMsg))
return return
@ -144,7 +139,6 @@ export async function ensureRunning(redirectUri?: string): Promise<void> {
// If server is running on a different port/path, stop it first // If server is running on a different port/path, stop it first
if (server && (currentPort !== port || currentPath !== path)) { if (server && (currentPort !== port || currentPath !== path)) {
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
await stop() await stop()
} }
@ -152,7 +146,6 @@ export async function ensureRunning(redirectUri?: string): Promise<void> {
const running = await isPortInUse(port) const running = await isPortInUse(port)
if (running) { if (running) {
log.info("oauth callback server already running on another instance", { port })
return return
} }
@ -162,7 +155,6 @@ export async function ensureRunning(redirectUri?: string): Promise<void> {
server = createServer(handleRequest) server = createServer(handleRequest)
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
server!.listen(currentPort, () => { server!.listen(currentPort, () => {
log.info("oauth callback server started", { port: currentPort, path: currentPath })
resolve() resolve()
}) })
server!.on("error", reject) server!.on("error", reject)
@ -214,7 +206,6 @@ export async function stop(): Promise<void> {
if (server) { if (server) {
await new Promise<void>((resolve) => server!.close(() => resolve())) await new Promise<void>((resolve) => server!.close(() => resolve()))
server = undefined server = undefined
log.info("oauth callback server stopped")
} }
for (const [_name, pending] of pendingAuths) { for (const [_name, pending] of pendingAuths) {

View file

@ -7,9 +7,7 @@ import type {
} from "@modelcontextprotocol/sdk/shared/auth.js" } from "@modelcontextprotocol/sdk/shared/auth.js"
import { Effect } from "effect" import { Effect } from "effect"
import { McpAuth } from "./auth" import { McpAuth } from "./auth"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "mcp.oauth" })
const OAUTH_CALLBACK_PORT = 19876 const OAUTH_CALLBACK_PORT = 19876
const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback" const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback"
@ -70,7 +68,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
if (entry?.clientInfo) { if (entry?.clientInfo) {
// Check if client secret has expired // Check if client secret has expired
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
log.info("client secret expired, need to re-register", { mcpName: this.mcpName })
return undefined return undefined
} }
return { return {
@ -96,10 +93,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
this.serverUrl, this.serverUrl,
), ),
) )
log.info("saved dynamically registered client", {
mcpName: this.mcpName,
clientId: info.client_id,
})
} }
async tokens(): Promise<OAuthTokens | undefined> { async tokens(): Promise<OAuthTokens | undefined> {
@ -131,11 +124,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
this.serverUrl, this.serverUrl,
), ),
) )
log.info("saved oauth tokens", { mcpName: this.mcpName })
} }
async redirectToAuthorization(authorizationUrl: URL): Promise<void> { async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
log.info("redirecting to authorization", { mcpName: this.mcpName, url: authorizationUrl.toString() })
await this.callbacks.onRedirect(authorizationUrl) await this.callbacks.onRedirect(authorizationUrl)
} }
@ -173,7 +164,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
} }
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> { async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
log.info("invalidating credentials", { mcpName: this.mcpName, type })
const entry = await Effect.runPromise(this.auth.get(this.mcpName)) const entry = await Effect.runPromise(this.auth.get(this.mcpName))
if (!entry) { if (!entry) {
return return

View file

@ -1,5 +1,4 @@
export { Config } from "@/config/config" export { Config } from "@/config/config"
export { Server } from "./server/server" export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap" export { bootstrap } from "./cli/bootstrap"
export * as Log from "@opencode-ai/core/util/log"
export { Database } from "@opencode-ai/core/database/database" export { Database } from "@opencode-ai/core/database/database"

View file

@ -1,11 +1,8 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import * as path from "path" import * as path from "path"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import * as Log from "@opencode-ai/core/util/log"
import * as Bom from "../util/bom" import * as Bom from "../util/bom"
const log = Log.create({ service: "patch" })
export const PatchSchema = Schema.Struct({ export const PatchSchema = Schema.Struct({
patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }), patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }),
}) })
@ -530,14 +527,14 @@ export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function*
case "add": { case "add": {
yield* fs.writeWithDirs(hunk.path, hunk.contents) yield* fs.writeWithDirs(hunk.path, hunk.contents)
added.push(hunk.path) added.push(hunk.path)
log.info(`Added file: ${hunk.path}`) yield* Effect.logInfo(`Added file: ${hunk.path}`)
break break
} }
case "delete": { case "delete": {
yield* fs.remove(hunk.path) yield* fs.remove(hunk.path)
deleted.push(hunk.path) deleted.push(hunk.path)
log.info(`Deleted file: ${hunk.path}`) yield* Effect.logInfo(`Deleted file: ${hunk.path}`)
break break
} }
@ -549,11 +546,11 @@ export const applyHunksToFiles = Effect.fn("Patch.applyHunksToFiles")(function*
yield* fs.writeWithDirs(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom)) yield* fs.writeWithDirs(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom))
yield* fs.remove(hunk.path) yield* fs.remove(hunk.path)
modified.push(hunk.move_path) modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) yield* Effect.logInfo(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else { } else {
yield* fs.writeWithDirs(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom)) yield* fs.writeWithDirs(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom))
modified.push(hunk.path) modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`) yield* Effect.logInfo(`Updated file: ${hunk.path}`)
} }
break break
} }

View file

@ -1,6 +1,5 @@
import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@opencode-ai/core/util/wildcard" import { Wildcard } from "@opencode-ai/core/util/wildcard"
import { Deferred, Effect, Layer, Context } from "effect" import { Deferred, Effect, Layer, Context } from "effect"
import os from "os" import os from "os"
@ -8,8 +7,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "permission" })
export const Event = { export const Event = {
Asked: EventV2.define({ type: "permission.asked", schema: PermissionV1.Request.fields }), Asked: EventV2.define({ type: "permission.asked", schema: PermissionV1.Request.fields }),
Replied: EventV2.define({ Replied: EventV2.define({
@ -84,7 +81,7 @@ export const layer = Layer.effect(
for (const pattern of request.patterns) { for (const pattern of request.patterns) {
const rule = evaluate(request.permission, pattern, ruleset, approved) const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule }) yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") { if (rule.action === "deny") {
return yield* new PermissionV1.DeniedError({ return yield* new PermissionV1.DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
@ -106,7 +103,7 @@ export const layer = Layer.effect(
always: request.always, always: request.always,
tool: request.tool, tool: request.tool,
} }
log.info("asking", { id, permission: info.permission, patterns: info.patterns }) yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, PermissionV1.RejectedError | PermissionV1.CorrectedError>() const deferred = yield* Deferred.make<void, PermissionV1.RejectedError | PermissionV1.CorrectedError>()
pending.set(id, { info, deferred }) pending.set(id, { info, deferred })

View file

@ -1,11 +1,9 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2" import type { Model } from "@opencode-ai/sdk/v2"
import * as Log from "@opencode-ai/core/util/log"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { createServer } from "http" import { createServer } from "http"
import open from "open" import open from "open"
const log = Log.create({ service: "plugin.digitalocean" })
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82" const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize" const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
@ -188,7 +186,6 @@ async function startOAuthServer(): Promise<void> {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => { oauthServer!.listen(OAUTH_PORT, () => {
log.info("digitalocean oauth server started", { port: OAUTH_PORT })
resolve() resolve()
}) })
oauthServer!.on("error", reject) oauthServer!.on("error", reject)
@ -197,7 +194,7 @@ async function startOAuthServer(): Promise<void> {
function stopOAuthServer() { function stopOAuthServer() {
if (!oauthServer) return if (!oauthServer) return
oauthServer.close(() => log.info("digitalocean oauth server stopped")) oauthServer.close()
oauthServer = undefined oauthServer = undefined
} }
@ -315,11 +312,9 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
path: { id: "digitalocean" }, path: { id: "digitalocean" },
body: { type: "api", key: ctx.auth.key, metadata: updated }, body: { type: "api", key: ctx.auth.key, metadata: updated },
}) })
.catch((err) => log.warn("failed to persist refreshed routers", { error: err })) .catch(() => {})
} else if (result.status === 401 || result.status === 403) { } else if (result.status === 401 || result.status === 403) {
log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status })
} else if (result.status !== 0) { } else if (result.status !== 0) {
log.warn("digitalocean router refresh failed", { status: result.status })
} }
} }
@ -355,7 +350,6 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
const routerResult = await listRouters(tokens.access_token) const routerResult = await listRouters(tokens.access_token)
const routers = routerResult.ok ? routerResult.routers : [] const routers = routerResult.ok ? routerResult.routers : []
if (!routerResult.ok) { if (!routerResult.ok) {
log.warn("digitalocean initial router fetch failed", { status: routerResult.status })
} }
return { return {
type: "success" as const, type: "success" as const,
@ -372,7 +366,6 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
}, },
} }
} catch (err) { } catch (err) {
log.error("digitalocean oauth callback failed", { error: err })
return { type: "failed" as const } return { type: "failed" as const }
} finally { } finally {
stopOAuthServer() stopOAuthServer()

View file

@ -2,12 +2,10 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2" import type { Model } from "@opencode-ai/sdk/v2"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import * as Log from "@opencode-ai/core/util/log"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models" import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session/message-v2"
const log = Log.create({ service: "plugin.copilot" })
const CLIENT_ID = "Ov23li8tweQw6odWQebz" const CLIENT_ID = "Ov23li8tweQw6odWQebz"
const API_VERSION = "2026-06-01" const API_VERSION = "2026-06-01"
@ -87,7 +85,6 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}) })
.catch((error) => { .catch((error) => {
models = {} models = {}
log.error("failed to fetch copilot models", { error })
return Object.fromEntries( return Object.fromEntries(
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]), Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
) )

View file

@ -6,7 +6,6 @@ import type {
WorkspaceAdapter as PluginWorkspaceAdapter, WorkspaceAdapter as PluginWorkspaceAdapter,
} from "@opencode-ai/plugin" } from "@opencode-ai/plugin"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import * as Log from "@opencode-ai/core/util/log"
import { createOpencodeClient } from "@opencode-ai/sdk" import { createOpencodeClient } from "@opencode-ai/sdk"
import { ServerAuth } from "@/server/auth" import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./openai/codex" import { CodexAuthPlugin } from "./openai/codex"
@ -31,7 +30,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { InstallationChannel } from "@opencode-ai/core/installation/version" import { InstallationChannel } from "@opencode-ai/core/installation/version"
const log = Log.create({ service: "plugin" })
type State = { type State = {
hooks: Hooks[] hooks: Hooks[]
@ -163,11 +161,9 @@ export const layer = Layer.effect(
} }
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) { for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
log.info("loading internal plugin", { name: plugin.name })
const init = yield* Effect.tryPromise({ const init = yield* Effect.tryPromise({
try: () => plugin(input), try: () => plugin(input),
catch: (err) => { catch: (err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err })
}, },
}).pipe(Effect.option) }).pipe(Effect.option)
if (init._tag === "Some") hooks.push(init.value) if (init._tag === "Some") hooks.push(init.value)
@ -175,7 +171,6 @@ export const layer = Layer.effect(
const plugins = flags.pure ? [] : (cfg.plugin_origins ?? []) const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
if (flags.pure && cfg.plugin_origins?.length) { if (flags.pure && cfg.plugin_origins?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
} }
if (plugins.length) yield* config.waitForDependencies() if (plugins.length) yield* config.waitForDependencies()
@ -185,10 +180,8 @@ export const layer = Layer.effect(
kind: "server", kind: "server",
report: { report: {
start(candidate) { start(candidate) {
log.info("loading plugin", { path: candidate.plan.spec })
}, },
missing(candidate, _retry, message) { missing(candidate, _retry, message) {
log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
}, },
error(candidate, _retry, stage, error, resolved) { error(candidate, _retry, stage, error, resolved) {
const spec = candidate.plan.spec const spec = candidate.plan.spec
@ -197,24 +190,20 @@ export const layer = Layer.effect(
if (stage === "install") { if (stage === "install") {
const parsed = parsePluginSpecifier(spec) const parsed = parsePluginSpecifier(spec)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`) publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
return return
} }
if (stage === "compatibility") { if (stage === "compatibility") {
log.warn("plugin incompatible", { path: spec, error: message })
publishPluginError(`Plugin ${spec} skipped: ${message}`) publishPluginError(`Plugin ${spec} skipped: ${message}`)
return return
} }
if (stage === "entry") { if (stage === "entry") {
log.error("failed to resolve plugin server entry", { path: spec, error: message })
publishPluginError(`Failed to load plugin ${spec}: ${message}`) publishPluginError(`Failed to load plugin ${spec}: ${message}`)
return return
} }
log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
publishPluginError(`Failed to load plugin ${spec}: ${message}`) publishPluginError(`Failed to load plugin ${spec}: ${message}`)
}, },
}, },
@ -229,7 +218,6 @@ export const layer = Layer.effect(
try: () => applyPlugin(load, input, hooks), try: () => applyPlugin(load, input, hooks),
catch: (err) => { catch: (err) => {
const message = errorMessage(err) const message = errorMessage(err)
log.error("failed to load plugin", { path: load.spec, error: message })
return message return message
}, },
}).pipe( }).pipe(
@ -249,10 +237,11 @@ export const layer = Layer.effect(
for (const hook of hooks) { for (const hook of hooks) {
yield* Effect.tryPromise({ yield* Effect.tryPromise({
try: () => Promise.resolve((hook as any).config?.(cfg)), try: () => Promise.resolve((hook as any).config?.(cfg)),
catch: (err) => { catch: errorMessage,
log.error("plugin config hook failed", { error: err }) }).pipe(
}, Effect.tapError((error) => Effect.logError("plugin config hook failed", { error })),
}).pipe(Effect.ignore) Effect.ignore,
)
} }
const unsubscribe = yield* events.listen((event) => { const unsubscribe = yield* events.listen((event) => {
@ -272,7 +261,6 @@ export const layer = Layer.effect(
Effect.tryPromise({ Effect.tryPromise({
try: () => Promise.resolve(hook.dispose?.()), try: () => Promise.resolve(hook.dispose?.()),
catch: (error) => { catch: (error) => {
log.error("plugin dispose hook failed", { error })
}, },
}).pipe(Effect.ignore), }).pipe(Effect.ignore),
{ discard: true }, { discard: true },

View file

@ -1,5 +1,4 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OAUTH_DUMMY_KEY } from "../../auth" import { OAUTH_DUMMY_KEY } from "../../auth"
import os from "os" import os from "os"
@ -7,7 +6,6 @@ import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http" import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool" import { OpenAIWebSocketPool } from "./ws-pool"
const log = Log.create({ service: "plugin.codex" })
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com" const ISSUER = "https://auth.openai.com"
@ -306,7 +304,6 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
oauthServer!.listen(OAUTH_PORT, () => { oauthServer!.listen(OAUTH_PORT, () => {
log.info("codex oauth server started", { port: OAUTH_PORT })
resolve() resolve()
}) })
oauthServer!.on("error", reject) oauthServer!.on("error", reject)
@ -318,7 +315,6 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
function stopOAuthServer() { function stopOAuthServer() {
if (oauthServer) { if (oauthServer) {
oauthServer.close(() => { oauthServer.close(() => {
log.info("codex oauth server stopped")
}) })
oauthServer = undefined oauthServer = undefined
} }
@ -442,7 +438,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
if (!currentAuth.access || currentAuth.expires < Date.now()) { if (!currentAuth.access || currentAuth.expires < Date.now()) {
if (!refreshPromise) { if (!refreshPromise) {
log.info("refreshing codex access token")
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer) refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
.then(async (tokens) => { .then(async (tokens) => {
const accountId = extractAccountId(tokens) || authWithAccount.accountId const accountId = extractAccountId(tokens) || authWithAccount.accountId

View file

@ -1,12 +1,10 @@
import WebSocket from "ws" import WebSocket from "ws"
import * as Log from "@opencode-ai/core/util/log"
import { ProviderError } from "@/provider/error" import { ProviderError } from "@/provider/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws" import { OpenAIWebSocket } from "./ws"
export const TITLE_HEADER = "x-opencode-title" export const TITLE_HEADER = "x-opencode-title"
const log = Log.create({ service: "plugin.openai.ws" })
export interface CreateWebSocketFetchOptions { export interface CreateWebSocketFetchOptions {
httpFetch?: typeof globalThis.fetch httpFetch?: typeof globalThis.fetch
@ -63,13 +61,11 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
})() })()
if (!body?.stream) return httpFetch(input, httpInit) if (!body?.stream) return httpFetch(input, httpInit)
if (internalHeaders[TITLE_HEADER] === "true") { if (internalHeaders[TITLE_HEADER] === "true") {
log.debug("http fallback", { reason: "title" })
return httpFetch(input, httpInit) return httpFetch(input, httpInit)
} }
const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"] const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"]
if (!sessionID) { if (!sessionID) {
log.debug("http fallback", { reason: "missing_session" })
return httpFetch(input, httpInit) return httpFetch(input, httpInit)
} }
const key = `${sessionID}:conversation` const key = `${sessionID}:conversation`
@ -78,11 +74,9 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
pool.set(key, entry) pool.set(key, entry)
if (entry.fallback) { if (entry.fallback) {
log.debug("http fallback", { key, reason: "fallback_active" })
return httpFetch(input, httpInit) return httpFetch(input, httpInit)
} }
if (entry.busy) { if (entry.busy) {
log.debug("http fallback", { key, reason: "busy" })
return httpFetch(input, httpInit) return httpFetch(input, httpInit)
} }
@ -114,12 +108,10 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
entry.lastUsedAt = Date.now() entry.lastUsedAt = Date.now()
entry.streamFailures = 0 entry.streamFailures = 0
if (event.type !== "response.completed" && event.type !== "response.done") { if (event.type !== "response.completed" && event.type !== "response.done") {
log.warn("websocket terminal failure", { key, type: event.type })
invalidate(entry) invalidate(entry)
} }
}, },
onConnectionInvalid: (error) => { onConnectionInvalid: (error) => {
log.warn("websocket invalidated", { key, error: error.message })
entry.busy = false entry.busy = false
entry.lastUsedAt = Date.now() entry.lastUsedAt = Date.now()
if (!entry.fallback) recordStreamFailure(entry) if (!entry.fallback) recordStreamFailure(entry)
@ -127,7 +119,6 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
resolveFirstEvent(false) resolveFirstEvent(false)
}, },
onAbort: (error) => { onAbort: (error) => {
log.debug("websocket aborted", { key })
entry.busy = false entry.busy = false
entry.lastUsedAt = Date.now() entry.lastUsedAt = Date.now()
entry.streamFailures = 0 entry.streamFailures = 0
@ -137,7 +128,6 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
onRetryableTerminal: async (event) => { onRetryableTerminal: async (event) => {
const error = connectionLimitError(event) const error = connectionLimitError(event)
if (!error) return undefined if (!error) return undefined
log.warn("websocket connection limit reached", { key })
throw error throw error
}, },
}) })
@ -150,7 +140,6 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
}) })
} }
if (!entry.fallback) return response if (!entry.fallback) return response
log.debug("http fallback", { key, reason: "websocket_retries_exhausted" })
return httpFetch(input, httpInit) return httpFetch(input, httpInit)
} catch (error) { } catch (error) {
entry.busy = false entry.busy = false
@ -162,11 +151,6 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
} }
recordStreamFailure(entry) recordStreamFailure(entry)
log.warn("websocket setup failed", {
key,
error: error instanceof Error ? error.message : String(error),
fallback: entry.fallback ? "http" : undefined,
})
invalidate(entry) invalidate(entry)
if (entry.fallback) return httpFetch(input, httpInit) if (entry.fallback) return httpFetch(input, httpInit)
return failedResponse( return failedResponse(
@ -189,14 +173,12 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
if (entry.busy) continue if (entry.busy) continue
if (entry.fallback) continue if (entry.fallback) continue
if (now - entry.lastUsedAt < idleTimeout) continue if (now - entry.lastUsedAt < idleTimeout) continue
log.debug("websocket idle prune", { key })
invalidate(entry) invalidate(entry)
pool.delete(key) pool.delete(key)
} }
} }
function close() { function close() {
log.debug("websocket pool close", { count: pool.size })
clearInterval(pruneTimer) clearInterval(pruneTimer)
for (const entry of pool.values()) invalidate(entry) for (const entry of pool.values()) invalidate(entry)
pool.clear() pool.clear()
@ -206,7 +188,6 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
const key = `${sessionID}:conversation` const key = `${sessionID}:conversation`
const entry = pool.get(key) const entry = pool.get(key)
if (!entry) return if (!entry) return
log.debug("websocket pool remove", { key })
invalidate(entry) invalidate(entry)
pool.delete(key) pool.delete(key)
} }

View file

@ -14,7 +14,6 @@ import {
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
import * as Log from "@opencode-ai/core/util/log"
import { errorData, errorMessage } from "@opencode-ai/tui/util/error" import { errorData, errorMessage } from "@opencode-ai/tui/util/error"
import { isRecord } from "@opencode-ai/tui/util/record" import { isRecord } from "@opencode-ai/tui/util/record"
import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention" import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention"
@ -119,7 +118,6 @@ type RuntimeState = {
dispose_timeout_ms: number dispose_timeout_ms: number
} }
const log = Log.create({ service: "tui.plugin" })
const DISPOSE_TIMEOUT_MS = 5000 const DISPOSE_TIMEOUT_MS = 5000
const KV_KEY = "plugin_enabled" const KV_KEY = "plugin_enabled"
const EMPTY_TUI: TuiPluginModule = { const EMPTY_TUI: TuiPluginModule = {
@ -128,19 +126,16 @@ const EMPTY_TUI: TuiPluginModule = {
function fail(message: string, data: Record<string, unknown>) { function fail(message: string, data: Record<string, unknown>) {
if (!("error" in data)) { if (!("error" in data)) {
log.error(message, data)
console.error(`[tui.plugin] ${message}`, data) console.error(`[tui.plugin] ${message}`, data)
return return
} }
const text = `${message}: ${errorMessage(data.error)}` const text = `${message}: ${errorMessage(data.error)}`
const next = { ...data, error: errorData(data.error) } const next = { ...data, error: errorData(data.error) }
log.error(text, next)
console.error(`[tui.plugin] ${text}`, next) console.error(`[tui.plugin] ${text}`, next)
} }
function warn(message: string, data: Record<string, unknown>) { function warn(message: string, data: Record<string, unknown>) {
log.warn(message, data)
console.warn(`[tui.plugin] ${message}`, data) console.warn(`[tui.plugin] ${message}`, data)
} }
@ -275,15 +270,7 @@ function createThemeInstaller(
await Flock.withLock(`tui-theme:${dest}`, async () => { await Flock.withLock(`tui-theme:${dest}`, async () => {
const save = async () => { const save = async () => {
plugin.themes[name] = info plugin.themes[name] = info
await PluginMeta.setTheme(plugin.id, name, info).catch((error) => { await PluginMeta.setTheme(plugin.id, name, info).catch(() => {})
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
} }
const exists = hasTheme(name) const exists = hasTheme(name)
@ -298,37 +285,26 @@ function createThemeInstaller(
if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
} }
const text = await Filesystem.readText(src).catch((error) => { const text = await Filesystem.readText(src).catch(() => undefined)
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
return
})
if (text === undefined) return if (text === undefined) return
const fail = Symbol() const fail = Symbol()
const data = await Promise.resolve(text) const data = await Promise.resolve(text)
.then((x) => JSON.parse(x)) .then((x) => JSON.parse(x))
.catch((error) => { .catch(() => fail)
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
return fail
})
if (data === fail) return if (data === fail) return
if (!isTheme(data)) { if (!isTheme(data)) {
log.warn("invalid tui plugin theme", { path: spec, theme: src })
return return
} }
if (exists || !(await Filesystem.exists(dest))) { if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => { await Filesystem.write(dest, text).catch(() => {})
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
})
} }
upsertTheme(name, data) upsertTheme(name, data)
await save() await save()
}).catch((error) => { }).catch(() => {})
log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error })
})
} }
} }
@ -701,9 +677,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P
items: list, items: list,
kind: "tui", kind: "tui",
wait: async () => { wait: async () => {
await wait().catch((error) => { await wait().catch(() => {})
log.warn("failed waiting for tui plugin dependencies", { error })
})
}, },
finish: async (loaded, origin, retry) => { finish: async (loaded, origin, retry) => {
const mod = await Promise.resolve() const mod = await Promise.resolve()
@ -774,9 +748,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P
} }
}, },
report: { report: {
start(candidate, retry) { start() {},
log.info("loading tui plugin", { path: candidate.plan.spec, retry })
},
missing(candidate, retry, message) { missing(candidate, retry, message) {
warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message }) warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message })
}, },
@ -809,10 +781,7 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
target: item.target, target: item.target,
id: item.id, id: item.id,
})), })),
).catch((error) => { ).catch(() => undefined)
log.warn("failed to track tui plugins", { error })
return undefined
})
const plugins: PluginEntry[] = [] const plugins: PluginEntry[] = []
let ok = true let ok = true
@ -820,17 +789,6 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
const entry = ready[i] const entry = ready[i]
if (!entry) continue if (!entry) continue
const hit = meta?.[i] const hit = meta?.[i]
if (hit && hit.state !== "same") {
log.info("tui plugin metadata updated", {
path: entry.spec,
retry: entry.retry,
state: hit.state,
source: hit.entry.source,
version: hit.entry.version,
modified: hit.entry.modified,
})
}
const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id) const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {} const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
const plugin: PluginEntry = { const plugin: PluginEntry = {
@ -1129,11 +1087,9 @@ async function load(input: {
const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins()) const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins())
const records = Flag.OPENCODE_PURE ? [] : pluginOrigins const records = Flag.OPENCODE_PURE ? [] : pluginOrigins
if (Flag.OPENCODE_PURE && pluginOrigins.length) { if (Flag.OPENCODE_PURE && pluginOrigins.length) {
log.info("skipping external tui plugins in pure mode", { count: pluginOrigins.length })
} }
for (const item of internalTuiPlugins(flags)) { for (const item of internalTuiPlugins(flags)) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item) const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, { addPluginEntry(next, {

View file

@ -1,10 +1,8 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { OAUTH_DUMMY_KEY } from "../auth" import { OAUTH_DUMMY_KEY } from "../auth"
import { createServer } from "http" import { createServer } from "http"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
const log = Log.create({ service: "plugin.xai" })
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
@ -510,8 +508,6 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
// behavior and crash the entire opencode process. Matches the silent- // behavior and crash the entire opencode process. Matches the silent-
// swallow behavior the Codex plugin gets from its permanent // swallow behavior the Codex plugin gets from its permanent
// `oauthServer!.on("error", reject)`. // `oauthServer!.on("error", reject)`.
server.on("error", (err) => log.warn("xai oauth server error", { error: err }))
log.info("xai oauth server started", { host: OAUTH_HOST, port: OAUTH_PORT })
resolve() resolve()
}) })
oauthServer = server oauthServer = server
@ -522,7 +518,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string }
function stopOAuthServer() { function stopOAuthServer() {
if (oauthServer) { if (oauthServer) {
oauthServer.close(() => log.info("xai oauth server stopped")) oauthServer.close()
oauthServer = undefined oauthServer = undefined
} }
} }
@ -607,7 +603,6 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
if (expiresSoon) { if (expiresSoon) {
if (!refreshPromise) { if (!refreshPromise) {
const refreshToken = currentAuth.refresh const refreshToken = currentAuth.refresh
log.info("refreshing xai access token")
refreshPromise = refreshAccessToken(refreshToken, options) refreshPromise = refreshAccessToken(refreshToken, options)
.then(async (tokens) => { .then(async (tokens) => {
const refreshedExpires = Date.now() + (tokens.expires_in ?? 3600) * 1000 const refreshedExpires = Date.now() + (tokens.expires_in ?? 3600) * 1000
@ -627,7 +622,7 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
expires: refreshedExpires, expires: refreshedExpires,
}, },
}) })
.catch((err) => log.warn("failed to persist refreshed xai tokens", { error: err })) .catch(() => {})
return { access: tokens.access_token, refresh: refreshedRefresh, expires: refreshedExpires } return { access: tokens.access_token, refresh: refreshedRefresh, expires: refreshedExpires }
}) })
.finally(() => { .finally(() => {
@ -688,7 +683,6 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
} }
} catch (err) { } catch (err) {
log.error("xai oauth callback failed", { error: err })
return { type: "failed" as const } return { type: "failed" as const }
} finally { } finally {
stopOAuthServer() stopOAuthServer()
@ -725,7 +719,6 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
} }
} catch (err) { } catch (err) {
log.error("xai device code callback failed", { error: err })
return { type: "failed" as const } return { type: "failed" as const }
} }
}, },

View file

@ -39,7 +39,7 @@ export const layer = Layer.effect(
const run = Effect.gen(function* () { const run = Effect.gen(function* () {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
yield* Effect.logInfo("bootstrapping").pipe(Effect.annotateLogs("directory", ctx.directory)) yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
// everything depends on config so eager load it for nice traces // everything depends on config so eager load it for nice traces
yield* config.get() yield* config.get()
// in 99% of use cases user that is opened opencode at certain directory will // in 99% of use cases user that is opened opencode at certain directory will

View file

@ -90,7 +90,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
) )
const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {
yield* Effect.logInfo("disposing instance").pipe(Effect.annotateLogs("directory", ctx.directory)) yield* Effect.logInfo("disposing instance", { directory: ctx.directory })
yield* Effect.promise(() => runDisposers(ctx.directory)) yield* Effect.promise(() => runDisposers(ctx.directory))
yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })
}) })
@ -113,7 +113,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() } const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry) cache.set(directory, entry)
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
yield* Effect.logInfo("creating instance").pipe(Effect.annotateLogs("directory", directory)) yield* Effect.logInfo("creating instance", { directory: directory })
yield* completeLoad(directory, input, entry) yield* completeLoad(directory, input, entry)
}).pipe(Effect.forkIn(scope, { startImmediately: true })) }).pipe(Effect.forkIn(scope, { startImmediately: true }))
return yield* restore(Deferred.await(entry.deferred)) return yield* restore(Deferred.await(entry.deferred))
@ -129,7 +129,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() } const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry) cache.set(directory, entry)
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
yield* Effect.logInfo("reloading instance").pipe(Effect.annotateLogs("directory", directory)) yield* Effect.logInfo("reloading instance", { directory: directory })
if (previous) { if (previous) {
yield* Deferred.await(previous.deferred).pipe(Effect.ignore) yield* Deferred.await(previous.deferred).pipe(Effect.ignore)
yield* Effect.promise(() => runDisposers(directory)) yield* Effect.promise(() => runDisposers(directory))
@ -169,9 +169,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) { if (Exit.isFailure(exit)) {
yield* Effect.logWarning("instance dispose failed").pipe( yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
Effect.annotateLogs({ key: item[0], cause: exit.cause }),
)
yield* removeEntry(item[0], item[1]) yield* removeEntry(item[0], item[1])
return return
} }

View file

@ -3,7 +3,6 @@ import { Database } from "@opencode-ai/core/database/database"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { which } from "@opencode-ai/core/util/which" import { which } from "@opencode-ai/core/util/which"
@ -22,8 +21,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "project" })
const ProjectVcs = Schema.Literal("git") const ProjectVcs = Schema.Literal("git")
const ProjectIcon = Schema.Struct({ const ProjectIcon = Schema.Struct({
@ -246,13 +243,13 @@ export const layer = Layer.effect(
) )
.pipe( .pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.sync(() => log.warn("project directory persistence failed", { projectID: input.projectID, cause })), Effect.logWarning("project directory persistence failed", { projectID: input.projectID, cause }),
), ),
) )
}) })
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) { const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory }) yield* Effect.logInfo("fromDirectory", { directory })
const data = yield* projectV2.resolve(AbsolutePath.make(directory)) const data = yield* projectV2.resolve(AbsolutePath.make(directory))
const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory

View file

@ -3,11 +3,9 @@ import { formatPatch, structuredPatch } from "diff"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Git } from "@/git" import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "vcs" })
const PATCH_CONTEXT_LINES = 2_147_483_647 const PATCH_CONTEXT_LINES = 2_147_483_647
const MAX_PATCH_BYTES = 10_000_000 const MAX_PATCH_BYTES = 10_000_000
const MAX_TOTAL_PATCH_BYTES = 10_000_000 const MAX_TOTAL_PATCH_BYTES = 10_000_000
@ -107,7 +105,6 @@ const batchPatches = Effect.fnUntraced(function* (
context: options?.context ?? PATCH_CONTEXT_LINES, context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES, maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
}) })
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
return { return {
patches: splitGitPatch(result).reduce((acc, patch, index) => { patches: splitGitPatch(result).reduce((acc, patch, index) => {
@ -139,13 +136,11 @@ const nativePatch = Effect.fnUntraced(function* (
}) })
if (!result.truncated && result.text) return result.text if (!result.truncated && result.text) return result.text
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
return emptyPatch(item.file) return emptyPatch(item.file)
}) })
const totalPatch = (file: string, patch: string, total: number) => { const totalPatch = (file: string, patch: string, total: number) => {
if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false } if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false }
log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES })
return { patch: emptyPatch(file), capped: true } return { patch: emptyPatch(file), capped: true }
} }
@ -325,7 +320,6 @@ export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Serv
concurrency: 2, concurrency: 2,
}) })
const value = { current, root } const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
const unsubscribe = yield* events.listen((event) => { const unsubscribe = yield* events.listen((event) => {
if (event.type !== Watcher.Event.Updated.type || event.location?.directory !== ctx.directory) if (event.type !== Watcher.Event.Updated.type || event.location?.directory !== ctx.directory)
@ -335,7 +329,6 @@ export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Serv
return Effect.gen(function* () { return Effect.gen(function* () {
const next = yield* get() const next = yield* get()
if (next !== value.current) { if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next value.current = next
yield* events.publish(Event.BranchUpdated, { branch: next }) yield* events.publish(Event.BranchUpdated, { branch: next })
} }

View file

@ -4,7 +4,6 @@ import fuzzysort from "fuzzysort"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai" import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "@opencode-ai/core/util/log"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { Hash } from "@opencode-ai/core/util/hash" import { Hash } from "@opencode-ai/core/util/hash"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
@ -32,7 +31,6 @@ import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderError } from "./error" import { ProviderError } from "./error"
const log = Log.create({ service: "provider" })
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
function wrapSSE(res: Response, ms: number, ctl: AbortController) { function wrapSSE(res: Response, ms: number, ctl: AbortController) {
@ -648,7 +646,6 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
}, },
async discoverModels(): Promise<Record<string, Model>> { async discoverModels(): Promise<Record<string, Model>> {
if (!apiKey) { if (!apiKey) {
log.info("gitlab model discovery skipped: no apiKey")
return {} return {}
} }
@ -657,18 +654,9 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const getHeaders = (): Record<string, string> => const getHeaders = (): Record<string, string> =>
auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` } auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
log.info("gitlab model discovery starting", { instanceUrl })
const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory }) const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory })
if (!result.models.length) { if (!result.models.length) {
log.info("gitlab model discovery skipped: no models found", {
project: result.project
? {
id: result.project.id,
path: result.project.pathWithNamespace,
}
: null,
})
return {} return {}
} }
@ -717,13 +705,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
} }
} }
log.info("gitlab model discovery complete", {
count: Object.keys(models).length,
models: Object.keys(models),
})
return models return models
} catch (e) { } catch (e) {
log.warn("gitlab model discovery failed", { error: e })
return {} return {}
} }
}, },
@ -1298,7 +1281,6 @@ export const layer = Layer.effect(
const state = yield* InstanceState.make<State>(() => const state = yield* InstanceState.make<State>(() =>
Effect.gen(function* () { Effect.gen(function* () {
using _ = log.time("state")
const bridge = yield* EffectBridge.make() const bridge = yield* EffectBridge.make()
const cfg = yield* config.get() const cfg = yield* config.get()
const modelsDev = yield* modelsDevSvc.get() const modelsDev = yield* modelsDevSvc.get()
@ -1324,7 +1306,6 @@ export const layer = Layer.effect(
get: (key: string) => env.get(key), get: (key: string) => env.get(key),
} }
log.info("init")
function mergeProvider(providerID: ProviderV2.ID, provider: Partial<Info>) { function mergeProvider(providerID: ProviderV2.ID, provider: Partial<Info>) {
const existing = providers[providerID] const existing = providers[providerID]
@ -1526,7 +1507,6 @@ export const layer = Layer.effect(
if (disabled.has(providerID)) continue if (disabled.has(providerID)) continue
const data = database[providerID] const data = database[providerID]
if (!data) { if (!data) {
log.error("Provider does not exist in model list " + providerID)
continue continue
} }
const result = yield* fn(data) const result = yield* fn(data)
@ -1561,7 +1541,6 @@ export const layer = Layer.effect(
} }
} }
} catch (e) { } catch (e) {
log.warn("state discovery error", { id: "gitlab", error: e })
} }
}) })
} }
@ -1614,7 +1593,6 @@ export const layer = Layer.effect(
continue continue
} }
log.info("found", { providerID })
} }
return { return {
@ -1632,9 +1610,6 @@ export const layer = Layer.effect(
async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) { async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
try { try {
using _ = log.time("getSDK", {
providerID: model.providerID,
})
const provider = s.providers[model.providerID] const provider = s.providers[model.providerID]
const options = { ...provider.options } const options = { ...provider.options }
@ -1752,10 +1727,6 @@ export const layer = Layer.effect(
const bundledLoader = BUNDLED_PROVIDERS[model.api.npm] const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
if (bundledLoader) { if (bundledLoader) {
log.info("using bundled provider", {
providerID: model.providerID,
pkg: model.api.npm,
})
const factory = await bundledLoader() const factory = await bundledLoader()
const loaded = factory({ const loaded = factory({
name: model.providerID, name: model.providerID,
@ -1767,7 +1738,6 @@ export const layer = Layer.effect(
const installedPath = await (async () => { const installedPath = await (async () => {
if (model.api.npm.startsWith("file://")) { if (model.api.npm.startsWith("file://")) {
log.info("loading local provider", { pkg: model.api.npm })
return model.api.npm return model.api.npm
} }
const item = await Npm.add(model.api.npm) const item = await Npm.add(model.api.npm)

View file

@ -1,13 +1,10 @@
import { Deferred, Effect, Layer, Schema, Context } from "effect" import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { QuestionID } from "./schema" import { QuestionID } from "./schema"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "question" })
// Schemas — these are pure data; nothing checks class identity (see PR // Schemas — these are pure data; nothing checks class identity (see PR
// description) so they're plain `Schema.Struct` + type alias. That lets // description) so they're plain `Schema.Struct` + type alias. That lets
// `Question.ask` and other internal sites trust the type contract without a // `Question.ask` and other internal sites trust the type contract without a
@ -159,7 +156,7 @@ export const layer = Layer.effect(
}) { }) {
const pending = (yield* InstanceState.get(state)).pending const pending = (yield* InstanceState.get(state)).pending
const id = QuestionID.ascending() const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length }) yield* Effect.logInfo("asking", { id, questions: input.questions.length })
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>() const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
const info: Request = { const info: Request = {
@ -186,11 +183,11 @@ export const layer = Layer.effect(
const pending = (yield* InstanceState.get(state)).pending const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(input.requestID) const existing = pending.get(input.requestID)
if (!existing) { if (!existing) {
log.warn("reply for unknown request", { requestID: input.requestID }) yield* Effect.logWarning("reply for unknown request", { requestID: input.requestID })
return yield* new NotFoundError({ requestID: input.requestID }) return yield* new NotFoundError({ requestID: input.requestID })
} }
pending.delete(input.requestID) pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers }) yield* Effect.logInfo("replied", { requestID: input.requestID, answers: input.answers })
yield* events.publish(Event.Replied, { yield* events.publish(Event.Replied, {
sessionID: existing.info.sessionID, sessionID: existing.info.sessionID,
requestID: existing.info.id, requestID: existing.info.id,
@ -203,11 +200,11 @@ export const layer = Layer.effect(
const pending = (yield* InstanceState.get(state)).pending const pending = (yield* InstanceState.get(state)).pending
const existing = pending.get(requestID) const existing = pending.get(requestID)
if (!existing) { if (!existing) {
log.warn("reject for unknown request", { requestID }) yield* Effect.logWarning("reject for unknown request", { requestID })
return yield* new NotFoundError({ requestID }) return yield* new NotFoundError({ requestID })
} }
pending.delete(requestID) pending.delete(requestID)
log.info("rejected", { requestID }) yield* Effect.logInfo("rejected", { requestID })
yield* events.publish(Event.Rejected, { yield* events.publish(Event.Rejected, {
sessionID: existing.info.sessionID, sessionID: existing.info.sessionID,
requestID: existing.info.id, requestID: existing.info.id,

View file

@ -104,9 +104,7 @@ function materializeReference(cache: RepositoryCache.Interface, reference: Extra
return cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe( return cache.ensure({ reference: reference.reference, branch: reference.branch, refresh: true }).pipe(
Effect.asVoid, Effect.asVoid,
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference repository").pipe( Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }),
Effect.annotateLogs({ name: reference.name, cause }),
),
), ),
) )
} }

View file

@ -1,11 +1,8 @@
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { InstanceStore } from "@/project/instance-store" import { InstanceStore } from "@/project/instance-store"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect" import { Effect } from "effect"
import { Event } from "./event" import { Event } from "./event"
const log = Log.create({ service: "server" })
export const emitGlobalDisposed = Effect.sync(() => export const emitGlobalDisposed = Effect.sync(() =>
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: "global", directory: "global",
@ -21,13 +18,7 @@ export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.dispos
const store = yield* InstanceStore.Service const store = yield* InstanceStore.Service
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
yield* options?.swallowErrors yield* options?.swallowErrors
? store.disposeAll().pipe( ? store.disposeAll().pipe(Effect.catchCause((cause) => Effect.logWarning("global disposal failed", { cause })))
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("global disposal failed", { cause })
}),
),
)
: store.disposeAll() : store.disposeAll()
yield* emitGlobalDisposed yield* emitGlobalDisposed
}).pipe(Effect.uninterruptible) }).pipe(Effect.uninterruptible)

View file

@ -1,8 +1,5 @@
import * as Log from "@opencode-ai/core/util/log"
import { Bonjour } from "bonjour-service" import { Bonjour } from "bonjour-service"
const log = Log.create({ service: "mdns" })
let bonjour: Bonjour | undefined let bonjour: Bonjour | undefined
let currentPort: number | undefined let currentPort: number | undefined
@ -22,17 +19,10 @@ export function publish(port: number, domain?: string) {
txt: { path: "/" }, txt: { path: "/" },
}) })
service.on("up", () => { service.on("error", () => {})
log.info("mDNS service published", { name, port })
})
service.on("error", (err) => {
log.error("mDNS service error", { error: err })
})
currentPort = port currentPort = port
} catch (err) { } catch {
log.error("mDNS publish failed", { error: err })
if (bonjour) { if (bonjour) {
try { try {
bonjour.destroy() bonjour.destroy()
@ -48,12 +38,9 @@ export function unpublish() {
try { try {
bonjour.unpublishAll() bonjour.unpublishAll()
bonjour.destroy() bonjour.destroy()
} catch (err) { } catch {}
log.error("mDNS unpublish failed", { error: err })
}
bonjour = undefined bonjour = undefined
currentPort = undefined currentPort = undefined
log.info("mDNS service unpublished")
} }
} }

View file

@ -1,6 +1,5 @@
import { Auth } from "@/auth" import { Auth } from "@/auth"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api" import { RootHttpApi } from "../api"
@ -27,8 +26,15 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han
}) })
const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) { const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
const logger = Log.create({ service: ctx.payload.service }) const write =
logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra) ctx.payload.level === "debug"
? Effect.logDebug
: ctx.payload.level === "info"
? Effect.logInfo
: ctx.payload.level === "warn"
? Effect.logWarning
: Effect.logError
yield* write(ctx.payload.message).pipe(Effect.annotateLogs(ctx.payload.extra ?? {}))
return true return true
}) })

View file

@ -2,7 +2,6 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Queue } from "effect" import { Effect, Queue } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http" import { HttpServerResponse } from "effect/unstable/http"
@ -10,8 +9,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Sse from "effect/unstable/encoding/Sse" import * as Sse from "effect/unstable/encoding/Sse"
import { EventApi } from "../groups/event" import { EventApi } from "../groups/event"
const log = Log.create({ service: "server" })
function eventData(data: unknown): Sse.Event { function eventData(data: unknown): Sse.Event {
return { return {
_tag: "Event", _tag: "Event",
@ -68,14 +65,14 @@ function eventResponse(events: EventV2.Interface) {
Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })), Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })),
) )
log.info("event connected") yield* Effect.logInfo("event connected")
return HttpServerResponse.stream( return HttpServerResponse.stream(
Stream.make({ id: eventID(), type: "server.connected", properties: {} }).pipe( Stream.make({ id: eventID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData), Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()), Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText, Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))), Stream.ensuring(Effect.logInfo("event disconnected")),
), ),
{ {
contentType: "text/event-stream", contentType: "text/event-stream",

View file

@ -4,15 +4,12 @@ import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Search } from "@opencode-ai/core/filesystem/search" import { Search } from "@opencode-ai/core/filesystem/search"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Log } from "@opencode-ai/core/util/log"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import path from "path" import path from "path"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api" import { InstanceHttpApi } from "../api"
const log = Log.create({ service: "server.file" })
export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service
@ -42,7 +39,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
// to the ripgrep-backed FileSystem.find when fff is unavailable. // to the ripgrep-backed FileSystem.find when fff is unavailable.
const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie) const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie)
if (fff !== undefined) { if (fff !== undefined) {
log.info("find file", { yield* Effect.logInfo("find file", {
engine: "fff", engine: "fff",
query: ctx.query.query, query: ctx.query.query,
kind, kind,
@ -62,7 +59,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl
}), }),
), ),
)).map((item) => item.path) )).map((item) => item.path)
log.info("find file", { yield* Effect.logInfo("find file", {
engine: "ripgrep", engine: "ripgrep",
query: ctx.query.query, query: ctx.query.query,
kind, kind,

View file

@ -5,7 +5,6 @@ import { EventV2 } from "@opencode-ai/core/event"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Queue, Schema } from "effect" import { Effect, Queue, Schema } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@ -14,8 +13,6 @@ import * as Sse from "effect/unstable/encoding/Sse"
import { RootHttpApi } from "../api" import { RootHttpApi } from "../api"
import { GlobalUpgradeInput } from "../groups/global" import { GlobalUpgradeInput } from "../groups/global"
const log = Log.create({ service: "server" })
function eventData(data: unknown): Sse.Event { function eventData(data: unknown): Sse.Event {
return { return {
_tag: "Event", _tag: "Event",
@ -34,36 +31,38 @@ function parseBody(body: string) {
} }
function eventResponse() { function eventResponse() {
log.info("global event connected") return Effect.gen(function* () {
const events = Stream.callback<GlobalBusEvent>((queue) => { yield* Effect.logInfo("global event connected")
const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event) const events = Stream.callback<GlobalBusEvent>((queue) => {
return Effect.acquireRelease( const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
Effect.sync(() => GlobalBus.on("event", handler)), return Effect.acquireRelease(
() => Effect.sync(() => GlobalBus.off("event", handler)), Effect.sync(() => GlobalBus.on("event", handler)),
() => Effect.sync(() => GlobalBus.off("event", handler)),
)
})
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })),
)
return HttpServerResponse.stream(
Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.logInfo("global event disconnected")),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
) )
}) })
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })),
)
return HttpServerResponse.stream(
Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))),
),
{
contentType: "text/event-stream",
headers: {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
},
},
)
} }
export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) => export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handlers) =>
@ -77,7 +76,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
}) })
const event = Effect.fn("GlobalHttpApi.event")(function* () { const event = Effect.fn("GlobalHttpApi.event")(function* () {
return eventResponse() return yield* eventResponse()
}) })
const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () { const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {

View file

@ -314,9 +314,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe( yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.logError("prompt_async failed").pipe( yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),
)
yield* events.publish(Session.Event.Error, { yield* events.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID, sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(), error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),

View file

@ -15,9 +15,6 @@ import { Effect, Scope } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api" import { InstanceHttpApi } from "../api"
import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync" import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "server.sync" })
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -43,7 +40,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
data: { ...event.data }, data: { ...event.data },
})) }))
const source = payload[0].aggregateID const source = payload[0].aggregateID
log.info("sync replay requested", { yield* Effect.logInfo("sync replay requested", {
sessionID: source, sessionID: source,
events: payload.length, events: payload.length,
first: payload[0]?.seq, first: payload[0]?.seq,
@ -52,7 +49,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
}) })
const ownerID = yield* InstanceState.workspaceID const ownerID = yield* InstanceState.workspaceID
yield* events.replayAll(payload, { ownerID, strictOwner: true }) yield* events.replayAll(payload, { ownerID, strictOwner: true })
log.info("sync replay complete", { yield* Effect.logInfo("sync replay complete", {
sessionID: source, sessionID: source,
events: payload.length, events: payload.length,
first: payload[0]?.seq, first: payload[0]?.seq,
@ -67,10 +64,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
yield* session.setWorkspace({ sessionID: ctx.payload.sessionID, workspaceID }) yield* session.setWorkspace({ sessionID: ctx.payload.sessionID, workspaceID })
log.info("sync session stolen", { yield* Effect.logInfo("sync session stolen", { sessionID: ctx.payload.sessionID, workspaceID })
sessionID: ctx.payload.sessionID,
workspaceID,
})
return { sessionID: ctx.payload.sessionID } return { sessionID: ctx.payload.sessionID }
}) })

View file

@ -1,12 +1,9 @@
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance-context" import type { InstanceContext } from "@/project/instance-context"
import { InstanceStore } from "@/project/instance-store" import { InstanceStore } from "@/project/instance-store"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
const log = Log.create({ service: "server" })
type MarkedInstance = { type MarkedInstance = {
ctx: InstanceContext ctx: InstanceContext
store: InstanceStore.Interface store: InstanceStore.Interface
@ -51,7 +48,7 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
if (!marked) return response if (!marked) return response
disposeAfterResponse.delete(request.source) disposeAfterResponse.delete(request.source)
yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))).pipe( yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), Effect.catchCause((cause) => Effect.logWarning("instance disposal failed", { cause })),
) )
return response return response
}) })

View file

@ -1,10 +1,7 @@
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Effect } from "effect" import { Cause, Effect } from "effect"
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
const log = Log.create({ service: "server" })
// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
effect.pipe( effect.pipe(
@ -20,15 +17,15 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
const error = defect.defect const error = defect.defect
const ref = `err_${crypto.randomUUID().slice(0, 8)}` const ref = `err_${crypto.randomUUID().slice(0, 8)}`
log.error("failed", { ref, error, cause: Cause.pretty(cause) }) return Effect.logError("failed", { ref, error, cause: Cause.pretty(cause) }).pipe(
Effect.as(
return Effect.succeed( HttpServerResponse.jsonUnsafe(
HttpServerResponse.jsonUnsafe( new NamedError.Unknown({
new NamedError.Unknown({ message: "Unexpected server error. Check server logs for details.",
message: "Unexpected server error. Check server logs for details.", ref,
ref, }).toObject(),
}).toObject(), { status: 500 },
{ status: 500 }, ),
), ),
) )
}), }),

View file

@ -1,11 +1,8 @@
import { Effect } from "effect" import { Effect } from "effect"
import { HttpServerResponse } from "effect/unstable/http" import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi" import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Log from "@opencode-ai/core/util/log"
import { InvalidRequestError } from "../errors" import { InvalidRequestError } from "../errors"
const log = Log.create({ service: "server" })
// Effect's Issue formatter recursively dumps the rejected `actual` value with // Effect's Issue formatter recursively dumps the rejected `actual` value with
// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep // no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep
// 4xx responses small and avoid mirroring entire request payloads (which may // 4xx responses small and avoid mirroring entire request payloads (which may
@ -27,16 +24,18 @@ export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaError
export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error, context) => { export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error, context) => {
const reason = truncateReason(error.cause.message) const reason = truncateReason(error.cause.message)
log.warn("schema rejection", { kind: error.kind, reason }) const response = context.endpoint.path.startsWith("/api/")
if (context.endpoint.path.startsWith("/api/")) { ? Effect.fail(
return Effect.fail( new InvalidRequestError({
new InvalidRequestError({ message: reason,
message: reason, kind: error.kind,
kind: error.kind, }),
}), )
) : Effect.succeed(
} HttpServerResponse.jsonUnsafe(
return Effect.succeed( { name: "BadRequest", data: { message: reason, kind: error.kind } },
HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }), { status: 400 },
) ),
)
return Effect.logWarning("schema rejection", { kind: error.kind, reason }).pipe(Effect.andThen(response))
}) })

View file

@ -16,7 +16,7 @@ import { Auth } from "@/auth"
import { BackgroundJob } from "@/background/job" import { BackgroundJob } from "@/background/job"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Command } from "@/command" import { Command } from "@/command"
import * as Observability from "@opencode-ai/core/effect/observability" import * as Observability from "@opencode-ai/core/observability"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep" import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { Format } from "@/format" import { Format } from "@/format"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"

View file

@ -1,7 +1,6 @@
import "./init-projectors" import "./init-projectors"
import { NodeHttpServer } from "@effect/platform-node" import { NodeHttpServer } from "@effect/platform-node"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpRouter, HttpServer } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi" import { OpenApi } from "effect/unstable/httpapi"
@ -17,8 +16,6 @@ import { lazy } from "@/util/lazy"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false globalThis.AI_SDK_LOG_WARNINGS = false
const log = Log.create({ service: "server" })
export type Listener = { export type Listener = {
hostname: string hostname: string
port: number port: number
@ -165,7 +162,9 @@ function setupMdns(opts: ListenOptions, port: number, scope: Scope.Scope) {
yield* Scope.addFinalizer(scope, unpublish) yield* Scope.addFinalizer(scope, unpublish)
return unpublish return unpublish
} }
if (opts.mdns) log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") if (opts.mdns) {
yield* Effect.logWarning("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
return Effect.void return Effect.void
}) })
} }

View file

@ -3,12 +3,10 @@ import { inArray } from "drizzle-orm"
import { EventSequenceTable } from "@opencode-ai/core/event/sql" import { EventSequenceTable } from "@opencode-ai/core/event/sql"
import { Workspace } from "@/control-plane/workspace" import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace" import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect" import { Effect } from "effect"
export const HEADER = "x-opencode-sync" export const HEADER = "x-opencode-sync"
export type State = Record<string, number> export type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(db: Database.Interface["db"], ids?: string[]) { export function load(db: Database.Interface["db"], ids?: string[]) {
return Effect.gen(function* () { return Effect.gen(function* () {
@ -55,14 +53,8 @@ export function parse(headers: Headers): State | undefined {
export function wait(workspaceID: WorkspaceV2.ID, state: State, signal?: AbortSignal) { export function wait(workspaceID: WorkspaceV2.ID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () { return Effect.gen(function* () {
log.info("waiting for state", { yield* Effect.logInfo("waiting for state", { workspaceID, state })
workspaceID,
state,
})
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
log.info("state fully synced", { yield* Effect.logInfo("state fully synced", { workspaceID, state })
workspaceID,
state,
})
}) })
} }

View file

@ -5,7 +5,6 @@ import { SessionID, MessageID, PartID } from "./schema"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Token } from "@/util/token" import { Token } from "@/util/token"
import { Log } from "@opencode-ai/core/util/log"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
@ -26,8 +25,6 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { buildPrompt } from "@opencode-ai/core/session/compaction" import { buildPrompt } from "@opencode-ai/core/session/compaction"
const log = Log.create({ service: "session.compaction" })
export const Event = { export const Event = {
Compacted: EventV2.define({ Compacted: EventV2.define({
type: "session.compacted", type: "session.compacted",
@ -237,7 +234,9 @@ export const layer = Layer.effect(
estimate, estimate,
}) })
if (split) keep = split if (split) keep = split
else if (!keep) log.info("tail fallback", { budget, size, total }) else if (!keep) {
yield* Effect.logInfo("tail fallback", { budget, size, total })
}
break break
} }
@ -253,7 +252,7 @@ export const layer = Layer.effect(
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) { const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get() const cfg = yield* config.get()
if (!cfg.compaction?.prune) return if (!cfg.compaction?.prune) return
log.info("pruning") yield* Effect.logInfo("pruning")
const msgs = yield* session const msgs = yield* session
.messages({ sessionID: input.sessionID }) .messages({ sessionID: input.sessionID })
@ -284,7 +283,7 @@ export const layer = Layer.effect(
} }
} }
log.info("found", { pruned, total }) yield* Effect.logInfo("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) { if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) { for (const part of toPrune) {
if (part.state.status === "completed") { if (part.state.status === "completed") {
@ -292,7 +291,7 @@ export const layer = Layer.effect(
yield* session.updatePart(part) yield* session.updatePart(part)
} }
} }
log.info("pruned", { count: toPrune.length }) yield* Effect.logInfo("pruned", { count: toPrune.length })
} }
}) })

View file

@ -2,7 +2,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionV1 } from "@opencode-ai/core/v1/session"
import { serviceUse } from "@opencode-ai/core/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Log } from "@opencode-ai/core/util/log"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import * as Stream from "effect/Stream" import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai"
@ -29,7 +28,6 @@ import { LLMAISDK } from "./llm/ai-sdk"
import { LLMNativeRuntime } from "./llm/native-runtime" import { LLMNativeRuntime } from "./llm/native-runtime"
import { LLMRequestPrep } from "./llm/request" import { LLMRequestPrep } from "./llm/request"
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
export type StreamInput = { export type StreamInput = {
@ -83,17 +81,13 @@ const live: Layer.Layer<
const flags = yield* RuntimeFlags.Service const flags = yield* RuntimeFlags.Service
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
const l = log yield* Effect.logInfo("stream", {
.clone()
.tag("providerID", input.model.providerID)
.tag("modelID", input.model.id)
.tag("session.id", input.sessionID)
.tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name)
.tag("mode", input.agent.mode)
l.info("stream", {
modelID: input.model.id,
providerID: input.model.providerID, providerID: input.model.providerID,
modelID: input.model.id,
"session.id": input.sessionID,
small: (input.small ?? false).toString(),
agent: input.agent.name,
mode: input.agent.mode,
}) })
const [language, cfg, item, info] = yield* Effect.all( const [language, cfg, item, info] = yield* Effect.all(
@ -245,36 +239,38 @@ const live: Layer.Layer<
abort: input.abort, abort: input.abort,
}) })
if (native.type === "supported") { if (native.type === "supported") {
yield* Effect.logInfo("llm runtime selected").pipe( yield* Effect.logInfo("llm runtime selected", {
Effect.annotateLogs({ "llm.runtime": "native",
"llm.runtime": "native", "llm.provider": input.model.providerID,
"llm.provider": input.model.providerID, "llm.model": input.model.id,
"llm.model": input.model.id, })
}),
)
return { return {
type: "native" as const, type: "native" as const,
stream: native.stream, stream: native.stream,
} }
} }
yield* Effect.logInfo("llm runtime selected").pipe( yield* Effect.logInfo("llm runtime selected", {
Effect.annotateLogs({
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
"llm.native_unsupported_reason": native.reason,
}),
)
l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason })
}
yield* Effect.logInfo("llm runtime selected").pipe(
Effect.annotateLogs({
"llm.runtime": "ai-sdk", "llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID, "llm.provider": input.model.providerID,
"llm.model": input.model.id, "llm.model": input.model.id,
}), "llm.native_unsupported_reason": native.reason,
) })
yield* Effect.logInfo("native runtime unavailable; falling back to ai-sdk", {
providerID: input.model.providerID,
modelID: input.model.id,
"session.id": input.sessionID,
small: (input.small ?? false).toString(),
agent: input.agent.name,
mode: input.agent.mode,
reason: native.reason,
})
}
yield* Effect.logInfo("llm runtime selected", {
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
})
// Default runtime path: AI SDK owns provider execution and tool dispatch; // Default runtime path: AI SDK owns provider execution and tool dispatch;
// LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor. // LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor.
return { return {
@ -282,18 +278,9 @@ const live: Layer.Layer<
result: streamText({ result: streamText({
// Copilot returns the authoritative billed amount only in provider-specific response fields. // Copilot returns the authoritative billed amount only in provider-specific response fields.
includeRawChunks: input.model.providerID.includes("github-copilot"), includeRawChunks: input.model.providerID.includes("github-copilot"),
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) { async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase() const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && prepared.tools[lower]) { if (lower !== failed.toolCall.toolName && prepared.tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return { return {
...failed.toolCall, ...failed.toolCall,
toolName: lower, toolName: lower,

View file

@ -37,7 +37,6 @@ import { isMedia } from "@/util/media"
import type { SystemError } from "bun" import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error { interface FetchDecompressionError extends Error {
@ -431,7 +430,7 @@ export function toModelMessages(
model: Provider.Model, model: Provider.Model,
options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
): Promise<ModelMessage[]> { ): Promise<ModelMessage[]> {
return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) return Effect.runPromise(toModelMessagesEffect(input, model, options))
} }
export const page = Effect.fn("MessageV2.page")(function* (input: { export const page = Effect.fn("MessageV2.page")(function* (input: {

View file

@ -20,7 +20,6 @@ import { SessionSummary } from "./summary"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { Question } from "@/question" import { Question } from "@/question"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { Log } from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
@ -34,8 +33,6 @@ import { toolFileSourceFromUri, Usage, type LLMEvent } from "@opencode-ai/llm"
import { ToolOutput } from "@opencode-ai/core/tool-output" import { ToolOutput } from "@opencode-ai/core/tool-output"
const DOOM_LOOP_THRESHOLD = 3 const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })
export type Result = "compact" | "stop" | "continue" export type Result = "compact" | "stop" | "continue"
export interface Handle { export interface Handle {
@ -131,7 +128,6 @@ export const layer = Layer.effect(
} }
const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary const mirrorAssistant = flags.experimentalEventSystem && !input.assistantMessage.summary
let aborted = false let aborted = false
const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id)
const parse = (e: unknown) => const parse = (e: unknown) =>
MessageV2.fromError(e, { MessageV2.fromError(e, {
@ -918,7 +914,12 @@ export const layer = Layer.effect(
}) })
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined }) yield* Effect.logError("process", {
"session.id": input.sessionID,
messageID: input.assistantMessage.id,
error: errorMessage(e),
stack: e instanceof Error ? e.stack : undefined,
})
const error = parse(e) const error = parse(e)
yield* flushV2Fragments() yield* flushV2Fragments()
if (SessionV1.ContextOverflowError.isInstance(error)) { if (SessionV1.ContextOverflowError.isInstance(error)) {
@ -956,7 +957,10 @@ export const layer = Layer.effect(
}) })
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
slog.info("process") yield* Effect.logInfo("process", {
"session.id": input.sessionID,
messageID: input.assistantMessage.id,
})
ctx.needsCompaction = false ctx.needsCompaction = false
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true

View file

@ -4,7 +4,6 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import os from "os" import os from "os"
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Log } from "@opencode-ai/core/util/log"
import { SessionRevert } from "./revert" import { SessionRevert } from "./revert"
import { Session } from "./session" import { Session } from "./session"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
@ -43,7 +42,6 @@ import { Image } from "@/image/image"
import { decodeDataUrl } from "@/util/data-url" import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { TaskTool, type TaskPromptOps } from "@/tool/task" import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state" import { SessionRunState } from "./run-state"
@ -80,9 +78,6 @@ IMPORTANT:
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.` const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
const log = Log.create({ service: "session.prompt" })
const elog = EffectLogger.create({ service: "session.prompt" })
function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
// cleanup() marks abandoned tool_use blocks this way after retries/aborts. // cleanup() marks abandoned tool_use blocks this way after retries/aborts.
// They are not pending work and must not trigger an assistant-prefill request. // They are not pending work and must not trigger an assistant-prefill request.
@ -141,7 +136,7 @@ export const layer = Layer.effect(
}) })
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
yield* elog.info("cancel", { sessionID }) yield* Effect.logInfo("cancel", { "session.id": sessionID })
yield* state.cancel(sessionID) yield* state.cancel(sessionID)
}) })
@ -282,7 +277,7 @@ export const layer = Layer.effect(
const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
yield* sessions yield* sessions
.setTitle({ sessionID: input.session.id, title: t }) .setTitle({ sessionID: input.session.id, title: t })
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) }))) .pipe(Effect.catchCause((cause) => Effect.logError("failed to generate title", { error: Cause.squash(cause) })))
}) })
const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
@ -384,8 +379,11 @@ export const layer = Layer.effect(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
const defect = Cause.squash(cause) const defect = Cause.squash(cause)
error = defect instanceof Error ? defect : new Error(String(defect)) error = defect instanceof Error ? defect : new Error(String(defect))
log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) return Effect.logError("subtask execution failed", {
return Effect.void error,
agent: task.agent,
description: task.description,
})
}), }),
Effect.onInterrupt(() => Effect.onInterrupt(() =>
Effect.gen(function* () { Effect.gen(function* () {
@ -761,7 +759,7 @@ export const layer = Layer.effect(
if (part.type === "file") { if (part.type === "file") {
if (part.source?.type === "resource") { if (part.source?.type === "resource") {
const { clientName, uri } = part.source const { clientName, uri } = part.source
log.info("mcp resource", { clientName, uri, mime: part.mime }) yield* Effect.logInfo("mcp resource", { clientName, uri, mime: part.mime })
const pieces: Draft<SessionV1.Part>[] = [ const pieces: Draft<SessionV1.Part>[] = [
{ {
messageID: info.id, messageID: info.id,
@ -799,7 +797,7 @@ export const layer = Layer.effect(
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID }) pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
} else { } else {
const error = Cause.squash(exit.cause) const error = Cause.squash(exit.cause)
log.error("failed to read MCP resource", { error, clientName, uri }) yield* Effect.logError("failed to read MCP resource", { error, clientName, uri })
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
pieces.push({ pieces.push({
messageID: info.id, messageID: info.id,
@ -835,7 +833,7 @@ export const layer = Layer.effect(
} }
break break
case "file:": { case "file:": {
log.info("file", { mime: part.mime }) yield* Effect.logInfo("file", { mime: part.mime })
const filepath = fileURLToPath(part.url) const filepath = fileURLToPath(part.url)
const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime const mime = (yield* fsys.isDir(filepath)) ? "application/x-directory" : part.mime
@ -918,7 +916,7 @@ export const layer = Layer.effect(
} }
} else { } else {
const error = Cause.squash(exit.cause) const error = Cause.squash(exit.cause)
log.error("failed to read file", { error }) yield* Effect.logError("failed to read file", { error, filepath })
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
yield* events.publish(Session.Event.Error, { yield* events.publish(Session.Event.Error, {
sessionID: input.sessionID, sessionID: input.sessionID,
@ -940,7 +938,7 @@ export const layer = Layer.effect(
const exit = yield* execRead(args).pipe(Effect.exit) const exit = yield* execRead(args).pipe(Effect.exit)
if (Exit.isFailure(exit)) { if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause) const error = Cause.squash(exit.cause)
log.error("failed to read directory", { error }) yield* Effect.logError("failed to read directory", { error, filepath })
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
yield* events.publish(Session.Event.Error, { yield* events.publish(Session.Event.Error, {
sessionID: input.sessionID, sessionID: input.sessionID,
@ -1065,7 +1063,7 @@ export const layer = Layer.effect(
const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" }) const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" })
if (Exit.isFailure(parsed)) { if (Exit.isFailure(parsed)) {
log.error("invalid user message before save", { yield* Effect.logError("invalid user message before save", {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: info.id, messageID: info.id,
agent: info.agent, agent: info.agent,
@ -1073,10 +1071,10 @@ export const layer = Layer.effect(
cause: Cause.pretty(parsed.cause), cause: Cause.pretty(parsed.cause),
}) })
} }
parts.forEach((part, index) => { for (const [index, part] of parts.entries()) {
const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" }) const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })
if (Exit.isSuccess(p)) return if (Exit.isSuccess(p)) continue
log.error("invalid user part before save", { yield* Effect.logError("invalid user part before save", {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: info.id, messageID: info.id,
partID: part.id, partID: part.id,
@ -1085,7 +1083,7 @@ export const layer = Layer.effect(
cause: Cause.pretty(p.cause), cause: Cause.pretty(p.cause),
part, part,
}) })
}) }
yield* sessions.updateMessage(info) yield* sessions.updateMessage(info)
for (const part of parts) yield* sessions.updatePart(part) for (const part of parts) yield* sessions.updatePart(part)
@ -1217,14 +1215,13 @@ export const layer = Layer.effect(
const runLoop: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts> = Effect.fn("SessionPrompt.run")( const runLoop: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts> = Effect.fn("SessionPrompt.run")(
function* (sessionID: SessionID) { function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const slog = elog.with({ sessionID })
let structured: unknown let structured: unknown
let step = 0 let step = 0
const session = yield* sessions.get(sessionID).pipe(Effect.orDie) const session = yield* sessions.get(sessionID).pipe(Effect.orDie)
while (true) { while (true) {
yield* status.set(sessionID, { type: "busy" }) yield* status.set(sessionID, { type: "busy" })
yield* slog.info("loop", { step }) yield* Effect.logInfo("loop", { "session.id": sessionID, step })
let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe( let msgs = yield* MessageV2.filterCompactedEffect(sessionID).pipe(
Effect.provideService(Database.Service, database), Effect.provideService(Database.Service, database),
@ -1255,13 +1252,14 @@ export const layer = Layer.effect(
(part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part), (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
) )
if (orphan) { if (orphan) {
yield* slog.warn("loop exit with orphaned interrupted tool", { yield* Effect.logWarning("loop exit with orphaned interrupted tool", {
"session.id": sessionID,
messageID: lastAssistant.id, messageID: lastAssistant.id,
tool: orphan.tool, tool: orphan.tool,
callID: orphan.callID, callID: orphan.callID,
}) })
} }
yield* slog.info("exiting loop") yield* Effect.logInfo("exiting loop", { "session.id": sessionID })
break break
} }
@ -1486,7 +1484,11 @@ export const layer = Layer.effect(
}) })
const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) { const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent }) yield* Effect.logInfo("command", {
"session.id": input.sessionID,
command: input.command,
agent: input.agent,
})
const cmd = yield* commands.get(input.command) const cmd = yield* commands.get(input.command)
if (!cmd) { if (!cmd) {
const available = (yield* commands.list()).map((c) => c.name) const available = (yield* commands.list()).map((c) => c.name)

View file

@ -3,15 +3,12 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { Snapshot } from "../snapshot" import { Snapshot } from "../snapshot"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { Log } from "@opencode-ai/core/util/log"
import { Session } from "./session" import { Session } from "./session"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
import { SessionRunState } from "./run-state" import { SessionRunState } from "./run-state"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
const log = Log.create({ service: "session.revert" })
export const RevertInput = Schema.Struct({ export const RevertInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
messageID: MessageID, messageID: MessageID,
@ -90,7 +87,7 @@ export const layer = Layer.effect(
}) })
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) { const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input) yield* Effect.logInfo("unreverting", { sessionID: input.sessionID })
yield* state.assertNotBusy(input.sessionID) yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
if (!session.revert) return session if (!session.revert) return session

View file

@ -28,7 +28,6 @@ import { or } from "drizzle-orm"
import type { SQL } from "drizzle-orm" import type { SQL } from "drizzle-orm"
import { PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { PartTable, SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Log } from "@opencode-ai/core/util/log"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import type { InstanceContext } from "../project/instance-context" import type { InstanceContext } from "../project/instance-context"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
@ -46,7 +45,6 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
const log = Log.create({ service: "session" })
const runtime = makeRuntime(Database.Service, Database.defaultLayer) const runtime = makeRuntime(Database.Service, Database.defaultLayer)
const parentTitlePrefix = "New session - " const parentTitlePrefix = "New session - "
@ -573,7 +571,7 @@ export const layer: Layer.Layer<
updated: Date.now(), updated: Date.now(),
}, },
} }
log.info("created", result) yield* Effect.logInfo("created", result)
yield* events.publish(SessionV1.Event.Created, { sessionID: result.id, info: result }) yield* events.publish(SessionV1.Event.Created, { sessionID: result.id, info: result })
@ -664,8 +662,8 @@ export const layer: Layer.Layer<
yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session }) yield* events.publish(SessionV1.Event.Deleted, { sessionID, info: session })
yield* events.remove(sessionID) yield* events.remove(sessionID)
} catch (e) { } catch (error) {
log.error(e) yield* Effect.logError("failed to remove session", { sessionID, error })
} }
}) })

View file

@ -17,13 +17,10 @@ import { MessageV2 } from "./message-v2"
import { Session } from "./session" import { Session } from "./session"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { PartID } from "./schema" import { PartID } from "./schema"
import { Log } from "@opencode-ai/core/util/log"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
const log = Log.create({ service: "session.tools" })
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
agent: Agent.Info agent: Agent.Info
model: Provider.Model model: Provider.Model
@ -33,7 +30,6 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
messages: SessionV1.WithParts[] messages: SessionV1.WithParts[]
promptOps: TaskPromptOps promptOps: TaskPromptOps
}) { }) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {} const tools: Record<string, AITool> = {}
const run = yield* EffectBridge.make() const run = yield* EffectBridge.make()
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service

View file

@ -13,13 +13,11 @@ import type { SessionID } from "@/session/schema"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import * as Log from "@opencode-ai/core/util/log"
import { SessionShareTable } from "@opencode-ai/core/share/sql" import { SessionShareTable } from "@opencode-ai/core/share/sql"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "share-next" })
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
export type Api = { export type Api = {
@ -141,9 +139,7 @@ export const layer = Layer.effect(
yield* flush(sessionID).pipe( yield* flush(sessionID).pipe(
Effect.delay(1000), Effect.delay(1000),
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.sync(() => { Effect.logError("share flush failed", { sessionID: sessionID, cause: cause }),
log.error("share flush failed", { sessionID, cause })
}),
), ),
Effect.forkIn(s.scope), Effect.forkIn(s.scope),
) )
@ -175,7 +171,7 @@ export const layer = Layer.effect(
if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void
return fn(event.data as EventV2.Data<D>).pipe( return fn(event.data as EventV2.Data<D>).pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.sync(() => log.error("share subscriber failed", { type: def.type, cause })), Effect.logError("share subscriber failed", { type: def.type, cause: cause }),
), ),
) )
}) })
@ -267,12 +263,12 @@ export const layer = Layer.effect(
) )
if (res.status >= 400) { if (res.status >= 400) {
log.warn("failed to sync share", { sessionID, shareID: share.id, status: res.status }) yield* Effect.logWarning("failed to sync share", { sessionID: sessionID, shareID: share.id, status: res.status })
} }
}) })
const full = Effect.fn("ShareNext.full")(function* (sessionID: SessionID) { const full = Effect.fn("ShareNext.full")(function* (sessionID: SessionID) {
log.info("full sync", { sessionID }) yield* Effect.logInfo("full sync", { sessionID: sessionID })
const info = yield* session.get(sessionID) const info = yield* session.get(sessionID)
const diffs = yield* session.diff(sessionID) const diffs = yield* session.diff(sessionID)
const messages = yield* session.messages({ sessionID }) const messages = yield* session.messages({ sessionID })
@ -309,7 +305,7 @@ export const layer = Layer.effect(
const create = Effect.fn("ShareNext.create")(function* (sessionID: SessionID) { const create = Effect.fn("ShareNext.create")(function* (sessionID: SessionID) {
if (disabled) return { id: "", url: "", secret: "" } if (disabled) return { id: "", url: "", secret: "" }
log.info("creating share", { sessionID }) yield* Effect.logInfo("creating share", { sessionID: sessionID })
const req = yield* request() const req = yield* request()
const result = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.create}`).pipe( const result = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.create}`).pipe(
HttpClientRequest.setHeaders(req.headers), HttpClientRequest.setHeaders(req.headers),
@ -330,9 +326,7 @@ export const layer = Layer.effect(
s.shared.set(sessionID, result) s.shared.set(sessionID, result)
yield* full(sessionID).pipe( yield* full(sessionID).pipe(
Effect.catchCause((cause) => Effect.catchCause((cause) =>
Effect.sync(() => { Effect.logError("share full sync failed", { sessionID: sessionID, cause: cause }),
log.error("share full sync failed", { sessionID, cause })
}),
), ),
Effect.forkIn(s.scope), Effect.forkIn(s.scope),
) )
@ -341,7 +335,7 @@ export const layer = Layer.effect(
const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) { const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) {
if (disabled) return if (disabled) return
log.info("removing share", { sessionID }) yield* Effect.logInfo("removing share", { sessionID: sessionID })
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const share = yield* getCached(sessionID) const share = yield* getCached(sessionID)
if (!share) { if (!share) {

View file

@ -4,7 +4,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
const skillConcurrency = 4 const skillConcurrency = 4
const fileConcurrency = 8 const fileConcurrency = 8
@ -27,7 +26,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sk
export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | HttpClient.HttpClient> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const path = yield* Path.Path const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
@ -42,10 +40,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))), Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
Effect.as(true), Effect.as(true),
Effect.catch((err) => Effect.catch((err) =>
Effect.sync(() => { Effect.logError("failed to download", { url: url, error: err }).pipe(Effect.as(false)),
log.error("failed to download", { url, err })
return false
}),
), ),
) )
}) })
@ -55,29 +50,27 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
const index = new URL("index.json", base).href const index = new URL("index.json", base).href
const host = base.slice(0, -1) const host = base.slice(0, -1)
log.info("fetching index", { url: index }) yield* Effect.logInfo("fetching index", { url: index })
const data = yield* HttpClientRequest.get(index).pipe( const data = yield* HttpClientRequest.get(index).pipe(
HttpClientRequest.acceptJson, HttpClientRequest.acceptJson,
http.execute, http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((err) => Effect.catch((err) =>
Effect.sync(() => { Effect.logError("failed to fetch index", { url: index, error: err }).pipe(Effect.as(null)),
log.error("failed to fetch index", { url: index, err })
return null
}),
), ),
) )
if (!data) return [] if (!data) return []
const list = data.skills.filter((skill) => { const missing = data.skills.filter((skill) => !skill.files.includes("SKILL.md"))
if (!skill.files.includes("SKILL.md")) { yield* Effect.forEach(
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name }) missing,
return false (skill) =>
} Effect.logWarning("skill entry missing SKILL.md", { url: index, skill: skill.name }),
return true { discard: true },
}) )
const list = data.skills.filter((skill) => skill.files.includes("SKILL.md"))
const dirs = yield* Effect.forEach( const dirs = yield* Effect.forEach(
list, list,

View file

@ -14,11 +14,9 @@ import { FrontmatterError } from "@opencode-ai/core/v1/config/error"
import { ConfigMarkdown } from "@/config/markdown" import { ConfigMarkdown } from "@/config/markdown"
import { RuntimeFlags } from "@/effect/runtime-flags" import { RuntimeFlags } from "@/effect/runtime-flags"
import { Glob } from "@opencode-ai/core/util/glob" import { Glob } from "@opencode-ai/core/util/glob"
import * as Log from "@opencode-ai/core/util/log"
import { Discovery } from "./discovery" import { Discovery } from "./discovery"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
const log = Log.create({ service: "skill" })
const CLAUDE_EXTERNAL_DIR = ".claude" const CLAUDE_EXTERNAL_DIR = ".claude"
const AGENTS_EXTERNAL_DIR = ".agents" const AGENTS_EXTERNAL_DIR = ".agents"
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
@ -113,7 +111,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev
const message = FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}` const message = FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse skill ${match}`
const { Session } = yield* Effect.promise(() => import("@/session/session")) const { Session } = yield* Effect.promise(() => import("@/session/session"))
yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load skill", { skill: match, err }) yield* Effect.logError("failed to load skill", { skill: match, error: err })
return undefined return undefined
}), }),
), ),
@ -124,11 +122,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev
if (!isSkillFrontmatter(md.data)) return if (!isSkillFrontmatter(md.data)) return
if (state.skills[md.data.name]) { if (state.skills[md.data.name]) {
log.warn("duplicate skill name", { yield* Effect.logWarning("duplicate skill name", { name: md.data.name, existing: state.skills[md.data.name].location, duplicate: match })
name: md.data.name,
existing: state.skills[md.data.name].location,
duplicate: match,
})
} }
state.dirs.add(path.dirname(match)) state.dirs.add(path.dirname(match))
@ -159,8 +153,7 @@ const scan = Effect.fnUntraced(function* (
}).pipe( }).pipe(
Effect.catch((error) => { Effect.catch((error) => {
if (!opts?.scope) return Effect.die(error) if (!opts?.scope) return Effect.die(error)
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) return Effect.logError(`failed to scan ${opts.scope} skills`, { dir: root, error: error }).pipe(Effect.as([] as string[]))
return Effect.succeed([] as string[])
}), }),
) )
@ -212,7 +205,7 @@ const discoverSkills = Effect.fnUntraced(function* (
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
if (!(yield* fsys.isDir(dir))) { if (!(yield* fsys.isDir(dir))) {
log.warn("skill path not found", { path: dir }) yield* Effect.logWarning("skill path not found", { path: dir })
continue continue
} }
@ -242,7 +235,7 @@ const loadSkills = Effect.fnUntraced(function* (
discard: true, discard: true,
}) })
log.info("init", { count: Object.keys(state.skills).length }) yield* Effect.logInfo("init", { count: Object.keys(state.skills).length })
}) })
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}

Some files were not shown because too many files have changed in this diff Show more