summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/effect/logger.ts73
-rw-r--r--packages/core/src/effect/memo-map.ts3
-rw-r--r--packages/core/src/effect/observability.ts107
-rw-r--r--packages/core/src/effect/runtime.ts21
-rw-r--r--packages/core/src/flag/flag.ts107
-rw-r--r--packages/core/src/global.ts41
-rw-r--r--packages/core/src/installation/version.ts8
-rw-r--r--packages/core/src/util/log.ts185
-rw-r--r--packages/core/src/util/opencode-process.ts24
9 files changed, 553 insertions, 16 deletions
diff --git a/packages/core/src/effect/logger.ts b/packages/core/src/effect/logger.ts
new file mode 100644
index 000000000..69f9631e0
--- /dev/null
+++ b/packages/core/src/effect/logger.ts
@@ -0,0 +1,73 @@
+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 }),
+})
diff --git a/packages/core/src/effect/memo-map.ts b/packages/core/src/effect/memo-map.ts
new file mode 100644
index 000000000..c797dbf42
--- /dev/null
+++ b/packages/core/src/effect/memo-map.ts
@@ -0,0 +1,3 @@
+import { Layer } from "effect"
+
+export const memoMap = Layer.makeMemoMapUnsafe()
diff --git a/packages/core/src/effect/observability.ts b/packages/core/src/effect/observability.ts
new file mode 100644
index 000000000..0203079ab
--- /dev/null
+++ b/packages/core/src/effect/observability.ts
@@ -0,0 +1,107 @@
+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 }
diff --git a/packages/core/src/effect/runtime.ts b/packages/core/src/effect/runtime.ts
new file mode 100644
index 000000000..e4f682709
--- /dev/null
+++ b/packages/core/src/effect/runtime.ts
@@ -0,0 +1,21 @@
+import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
+import { memoMap } from "./memo-map"
+import { Observability } from "./observability"
+
+export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
+ let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+ const getRuntime = () =>
+ (rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer<I, E>, {
+ memoMap,
+ }))
+
+ return {
+ runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
+ runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
+ getRuntime().runPromiseExit(service.use(fn), options),
+ runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
+ getRuntime().runPromise(service.use(fn), options),
+ runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
+ runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
+ }
+}
diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts
new file mode 100644
index 000000000..72c8931f5
--- /dev/null
+++ b/packages/core/src/flag/flag.ts
@@ -0,0 +1,107 @@
+import { Config } from "effect"
+
+function truthy(key: string) {
+ const value = process.env[key]?.toLowerCase()
+ return value === "true" || value === "1"
+}
+
+function falsy(key: string) {
+ const value = process.env[key]?.toLowerCase()
+ return value === "false" || value === "0"
+}
+
+function number(key: string) {
+ const value = process.env[key]
+ if (!value) return undefined
+ const parsed = Number(value)
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
+}
+
+const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
+const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
+const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
+ OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
+const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
+
+export const Flag = {
+ OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
+ OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
+
+ OPENCODE_AUTO_SHARE: truthy("OPENCODE_AUTO_SHARE"),
+ OPENCODE_AUTO_HEAP_SNAPSHOT: truthy("OPENCODE_AUTO_HEAP_SNAPSHOT"),
+ OPENCODE_GIT_BASH_PATH: process.env["OPENCODE_GIT_BASH_PATH"],
+ OPENCODE_CONFIG: process.env["OPENCODE_CONFIG"],
+ OPENCODE_CONFIG_CONTENT: process.env["OPENCODE_CONFIG_CONTENT"],
+ OPENCODE_DISABLE_AUTOUPDATE: truthy("OPENCODE_DISABLE_AUTOUPDATE"),
+ OPENCODE_ALWAYS_NOTIFY_UPDATE: truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE"),
+ OPENCODE_DISABLE_PRUNE: truthy("OPENCODE_DISABLE_PRUNE"),
+ OPENCODE_DISABLE_TERMINAL_TITLE: truthy("OPENCODE_DISABLE_TERMINAL_TITLE"),
+ OPENCODE_SHOW_TTFD: truthy("OPENCODE_SHOW_TTFD"),
+ OPENCODE_PERMISSION: process.env["OPENCODE_PERMISSION"],
+ OPENCODE_DISABLE_DEFAULT_PLUGINS: truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
+ OPENCODE_DISABLE_LSP_DOWNLOAD: truthy("OPENCODE_DISABLE_LSP_DOWNLOAD"),
+ OPENCODE_ENABLE_EXPERIMENTAL_MODELS: truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
+ OPENCODE_DISABLE_AUTOCOMPACT: truthy("OPENCODE_DISABLE_AUTOCOMPACT"),
+ OPENCODE_DISABLE_MODELS_FETCH: truthy("OPENCODE_DISABLE_MODELS_FETCH"),
+ OPENCODE_DISABLE_MOUSE: truthy("OPENCODE_DISABLE_MOUSE"),
+ OPENCODE_DISABLE_CLAUDE_CODE,
+ OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
+ OPENCODE_DISABLE_CLAUDE_CODE_SKILLS,
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
+ OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
+ OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
+ OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
+ OPENCODE_ENABLE_QUESTION_TOOL: truthy("OPENCODE_ENABLE_QUESTION_TOOL"),
+
+ // Experimental
+ OPENCODE_EXPERIMENTAL,
+ OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
+ Config.withDefault(false),
+ ),
+ OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe(
+ Config.withDefault(false),
+ ),
+ OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
+ OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
+ copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"),
+ OPENCODE_ENABLE_EXA: truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA"),
+ OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
+ OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
+ OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
+ OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
+ OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
+ OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
+ OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
+ OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
+ OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
+ OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
+ OPENCODE_DB: process.env["OPENCODE_DB"],
+ OPENCODE_DISABLE_CHANNEL_DB: truthy("OPENCODE_DISABLE_CHANNEL_DB"),
+ OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"),
+ OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
+
+ OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
+ OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
+ OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
+
+ // Evaluated at access time (not module load) because tests, the CLI, and
+ // external tooling set these env vars at runtime.
+ get OPENCODE_DISABLE_PROJECT_CONFIG() {
+ return truthy("OPENCODE_DISABLE_PROJECT_CONFIG")
+ },
+ get OPENCODE_TUI_CONFIG() {
+ return process.env["OPENCODE_TUI_CONFIG"]
+ },
+ get OPENCODE_CONFIG_DIR() {
+ return process.env["OPENCODE_CONFIG_DIR"]
+ },
+ get OPENCODE_PURE() {
+ return truthy("OPENCODE_PURE")
+ },
+ get OPENCODE_PLUGIN_META_FILE() {
+ return process.env["OPENCODE_PLUGIN_META_FILE"]
+ },
+ get OPENCODE_CLIENT() {
+ return process.env["OPENCODE_CLIENT"] ?? "cli"
+ },
+}
diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts
index 538cc091b..bf605618f 100644
--- a/packages/core/src/global.ts
+++ b/packages/core/src/global.ts
@@ -3,6 +3,24 @@ import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
+const app = "opencode"
+const data = path.join(xdgData!, app)
+const cache = path.join(xdgCache!, app)
+const config = path.join(xdgConfig!, app)
+const state = path.join(xdgState!, app)
+
+export const Path = {
+ get home() {
+ return process.env.OPENCODE_TEST_HOME ?? os.homedir()
+ },
+ data,
+ bin: path.join(cache, "bin"),
+ log: path.join(data, "log"),
+ cache,
+ config,
+ state,
+}
+
export namespace Global {
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
@@ -19,23 +37,14 @@ export namespace Global {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const app = "opencode"
- const home = process.env.OPENCODE_TEST_HOME ?? os.homedir()
- const data = path.join(xdgData!, app)
- const cache = path.join(xdgCache!, app)
- const cfg = path.join(xdgConfig!, app)
- const state = path.join(xdgState!, app)
- const bin = path.join(cache, "bin")
- const log = path.join(data, "log")
-
return Service.of({
- home,
- data,
- cache,
- config: cfg,
- state,
- bin,
- log,
+ home: Path.home,
+ data: Path.data,
+ cache: Path.cache,
+ config: Path.config,
+ state: Path.state,
+ bin: Path.bin,
+ log: Path.log,
})
}),
)
diff --git a/packages/core/src/installation/version.ts b/packages/core/src/installation/version.ts
new file mode 100644
index 000000000..25d9cd99a
--- /dev/null
+++ b/packages/core/src/installation/version.ts
@@ -0,0 +1,8 @@
+declare global {
+ const OPENCODE_VERSION: string
+ const OPENCODE_CHANNEL: string
+}
+
+export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
+export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
+export const InstallationLocal = InstallationChannel === "local"
diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts
new file mode 100644
index 000000000..a61c15f7a
--- /dev/null
+++ b/packages/core/src/util/log.ts
@@ -0,0 +1,185 @@
+import path from "path"
+import fs from "fs/promises"
+import { createWriteStream } from "fs"
+import * as Global from "../global"
+import z from "zod"
+import { Glob } from "./glob"
+
+export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
+export type Level = z.infer<typeof Level>
+
+const levelPriority: Record<Level, number> = {
+ DEBUG: 0,
+ INFO: 1,
+ WARN: 2,
+ ERROR: 3,
+}
+const keep = 10
+
+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
+}
+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",
+ )
+ await fs.truncate(logpath).catch(() => {})
+ 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
+}
diff --git a/packages/core/src/util/opencode-process.ts b/packages/core/src/util/opencode-process.ts
new file mode 100644
index 000000000..f59270ad2
--- /dev/null
+++ b/packages/core/src/util/opencode-process.ts
@@ -0,0 +1,24 @@
+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
+}