diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts
index a54896c8de..693d2a48e5 100644
--- a/packages/cli/src/commands/commands.ts
+++ b/packages/cli/src/commands/commands.ts
@@ -1,5 +1,6 @@
-import { Argument, Flag } from "effect/unstable/cli"
+import { Argument, Command, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
+import { GlobalFlags } from "./global-flags"
declare const OPENCODE_CLI_NAME: string | undefined
@@ -26,7 +27,7 @@ const PermissionParams = {
),
}
-export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
+const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
...ServerParams,
@@ -277,3 +278,5 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
],
})
+
+export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
diff --git a/packages/cli/src/commands/global-flags.ts b/packages/cli/src/commands/global-flags.ts
new file mode 100644
index 0000000000..5b19816357
--- /dev/null
+++ b/packages/cli/src/commands/global-flags.ts
@@ -0,0 +1,12 @@
+export * as GlobalFlags from "./global-flags"
+
+import { Flag, GlobalFlag } from "effect/unstable/cli"
+
+export const CpuProfile = GlobalFlag.setting("cpu-profile")({
+ flag: Flag.string("cpu-profile").pipe(
+ Flag.withDescription("Write a CPU profile to this path when the process stops"),
+ Flag.optional,
+ ),
+})
+
+export const all = [CpuProfile] as const
diff --git a/packages/cli/src/cpu-profile.ts b/packages/cli/src/cpu-profile.ts
new file mode 100644
index 0000000000..fac1fad7a4
--- /dev/null
+++ b/packages/cli/src/cpu-profile.ts
@@ -0,0 +1,45 @@
+export * as CpuProfile from "./cpu-profile"
+
+import { Effect, FileSystem } from "effect"
+import { Session } from "node:inspector"
+import path from "node:path"
+
+export function run(file: string, effect: Effect.Effect) {
+ const target = path.resolve(file)
+ return Effect.acquireUseRelease(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ yield* fs.makeDirectory(path.dirname(target), { recursive: true })
+ const session = new Session()
+ session.connect()
+ yield* command(session, "Profiler.enable")
+ yield* command(session, "Profiler.start")
+ yield* Effect.logInfo("CPU profile started", { path: target })
+ return session
+ }),
+ () => effect,
+ (session) =>
+ Effect.tryPromise(
+ () =>
+ new Promise((resolve, reject) => {
+ session.post("Profiler.stop", (error, result) => {
+ session.disconnect()
+ if (error) return reject(error)
+ Bun.write(target, JSON.stringify(result.profile)).then(() => resolve(), reject)
+ })
+ }),
+ ).pipe(
+ Effect.andThen(Effect.logInfo("CPU profile written", { path: target })),
+ Effect.catchCause((cause) => Effect.logError("Failed to write CPU profile", { path: target, cause })),
+ ),
+ )
+}
+
+function command(session: Session, method: "Profiler.enable" | "Profiler.start") {
+ return Effect.tryPromise(
+ () =>
+ new Promise((resolve, reject) => {
+ session.post(method, (error) => (error ? reject(error) : resolve()))
+ }),
+ )
+}
diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts
index a2ad9e8cbf..51f720ef5e 100644
--- a/packages/cli/src/framework/runtime.ts
+++ b/packages/cli/src/framework/runtime.ts
@@ -1,10 +1,13 @@
-import { Effect, FileSystem, Scope } from "effect"
+import { Effect, FileSystem, Option, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm"
+import { GlobalFlags } from "../commands/global-flags"
+import { CpuProfile } from "../cpu-profile"
+import path from "node:path"
export type Input =
Value extends Spec.Node
@@ -86,7 +89,20 @@ function provide(node: Spec.Any, handlers: ReadonlyArray): Provided
? node.spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
- yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
+ const module = yield* Effect.promise(handler.load)
+ const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile)
+ if (!cpuProfile) return yield* module.default(input)
+ const target = path.resolve(cpuProfile)
+ const previous = process.env.OPENCODE_CPU_PROFILE
+ process.env.OPENCODE_CPU_PROFILE = target
+ return yield* (node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input)).pipe(
+ Effect.ensuring(
+ Effect.sync(() => {
+ if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
+ else process.env.OPENCODE_CPU_PROFILE = previous
+ }),
+ ),
+ )
}),
),
)
diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts
index 3c8dbba226..53d9a0ccc1 100644
--- a/packages/cli/src/services/service-config.ts
+++ b/packages/cli/src/services/service-config.ts
@@ -104,7 +104,12 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
- command: [...selfCommand(), "serve", "--service"],
+ command: [
+ ...selfCommand(),
+ "serve",
+ "--service",
+ ...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
+ ],
}
})
diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts
index 62b92bdd8f..704d8773b1 100644
--- a/packages/cli/test/service.test.ts
+++ b/packages/cli/test/service.test.ts
@@ -19,6 +19,29 @@ test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
+test("managed service forwards the CPU profile path to the server", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-"))
+ const profile = path.join(root, "server.cpuprofile")
+ try {
+ const previous = process.env.OPENCODE_CPU_PROFILE
+ process.env.OPENCODE_CPU_PROFILE = profile
+ try {
+ const options = await Effect.runPromise(
+ ServiceConfig.options().pipe(
+ Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
+ Effect.provide(NodeFileSystem.layer),
+ ),
+ )
+ expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile])
+ } finally {
+ if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
+ else process.env.OPENCODE_CPU_PROFILE = previous
+ }
+ } finally {
+ await fs.rm(root, { recursive: true, force: true })
+ }
+})
+
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {