diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts
index 12852d7709..384dab4331 100644
--- a/packages/cli/src/commands/commands.ts
+++ b/packages/cli/src/commands/commands.ts
@@ -1,6 +1,5 @@
-import { Argument, Command, Flag } from "effect/unstable/cli"
+import { Argument, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
-import { GlobalFlags } from "./global-flags"
declare const OPENCODE_CLI_NAME: string | undefined
@@ -343,4 +342,4 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
],
})
-export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
+export const Commands = Root
diff --git a/packages/cli/src/commands/global-flags.ts b/packages/cli/src/commands/global-flags.ts
deleted file mode 100644
index 5b19816357..0000000000
--- a/packages/cli/src/commands/global-flags.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-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
index fac1fad7a4..9c7bcf2751 100644
--- a/packages/cli/src/cpu-profile.ts
+++ b/packages/cli/src/cpu-profile.ts
@@ -1,10 +1,36 @@
export * as CpuProfile from "./cpu-profile"
-import { Effect, FileSystem } from "effect"
+import { Global } from "@opencode-ai/util/global"
+import { Effect, FileSystem, Queue } from "effect"
import { Session } from "node:inspector"
import path from "node:path"
-export function run(file: string, effect: Effect.Effect) {
+export const listen = Effect.gen(function* () {
+ const global = yield* Global.Service
+ if (process.platform === "win32") return
+ const signals = yield* Queue.dropping(1)
+ yield* Effect.acquireRelease(
+ Effect.sync(() => {
+ const handler = () => Queue.offerUnsafe(signals, undefined)
+ process.on("SIGPROF", handler)
+ return handler
+ }),
+ (handler) => Effect.sync(() => process.off("SIGPROF", handler)),
+ )
+ yield* Effect.gen(function* () {
+ yield* Queue.take(signals)
+ const file = path.join(
+ global.log,
+ `cpu-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.cpuprofile`,
+ )
+ yield* run(file, Effect.sleep("10 seconds")).pipe(
+ Effect.catchCause((cause) => Effect.logError("Failed to capture CPU profile", { path: file, cause })),
+ )
+ yield* Queue.poll(signals)
+ }).pipe(Effect.forever, Effect.forkScoped({ startImmediately: true }))
+})
+
+function run(file: string, effect: Effect.Effect) {
const target = path.resolve(file)
return Effect.acquireUseRelease(
Effect.gen(function* () {
diff --git a/packages/cli/src/framework/runtime.ts b/packages/cli/src/framework/runtime.ts
index 89b57ab0d0..63104a6dc9 100644
--- a/packages/cli/src/framework/runtime.ts
+++ b/packages/cli/src/framework/runtime.ts
@@ -1,13 +1,10 @@
-import { Effect, FileSystem, Option, Scope } from "effect"
+import { Effect, FileSystem, 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
@@ -90,21 +87,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray): Provided
Command.withHandler((input) =>
Effect.gen(function* () {
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
- }),
- ),
- )
+ return yield* module.default(input)
}),
),
)
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 2f2be4dd69..f344f45057 100755
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -13,6 +13,7 @@ import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/util/npm"
import { Heap } from "./heap"
+import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -61,6 +62,7 @@ const Handlers = Runtime.handlers(Commands, {
Effect.gen(function* () {
yield* Heap.listen
+ yield* CpuProfile.listen
const runFork = Effect.runForkWith(yield* Effect.context())
const uncaughtException = (cause: Error, origin: "uncaughtException" | "unhandledRejection") => {
runFork(Effect.logError("uncaught exception", { cause, origin }))
diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts
index 9eeebfb164..5b459777e4 100644
--- a/packages/cli/src/services/service-config.ts
+++ b/packages/cli/src/services/service-config.ts
@@ -110,7 +110,6 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
...selfCommand(),
"serve",
"--service",
- ...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
],
}
})
diff --git a/packages/cli/test/cpu-profile.test.ts b/packages/cli/test/cpu-profile.test.ts
new file mode 100644
index 0000000000..e64951193f
--- /dev/null
+++ b/packages/cli/test/cpu-profile.test.ts
@@ -0,0 +1,18 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { Global } from "@opencode-ai/util/global"
+import { expect, test } from "bun:test"
+import { Effect } from "effect"
+import { CpuProfile } from "../src/cpu-profile"
+
+test("subscribes and unsubscribes SIGPROF with the CLI scope", async () => {
+ const listeners = process.listenerCount("SIGPROF")
+ await Effect.runPromise(
+ Effect.scoped(
+ Effect.gen(function* () {
+ yield* CpuProfile.listen
+ expect(process.listenerCount("SIGPROF")).toBe(listeners + (process.platform === "win32" ? 0 : 1))
+ }),
+ ).pipe(Effect.provideService(Global.Service, Global.make()), Effect.provide(NodeFileSystem.layer)),
+ )
+ expect(process.listenerCount("SIGPROF")).toBe(listeners)
+})
diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts
index a669e1f5b3..0582417734 100644
--- a/packages/cli/test/service.test.ts
+++ b/packages/cli/test/service.test.ts
@@ -19,29 +19,6 @@ 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 {
diff --git a/packages/core/src/plugin/skill/opencode.md b/packages/core/src/plugin/skill/opencode.md
index c363b51816..c30035ad1a 100644
--- a/packages/core/src/plugin/skill/opencode.md
+++ b/packages/core/src/plugin/skill/opencode.md
@@ -236,6 +236,24 @@ problem belongs to the client, the shared server, or one project.
- Redact API keys, authorization headers, prompts, file contents, and other
sensitive data before sharing diagnostics.
+### CPU profiles
+
+On Linux and macOS, send `SIGPROF` to a running OpenCode process to capture its
+CPU activity. Get the background server PID from the health endpoint, then send
+the signal:
+
+```sh
+opencode2 api get /api/health
+kill -SIGPROF
+```
+
+One signal starts a ten-second profile and stops it automatically. OpenCode
+writes the result to its log directory as
+`cpu--.cpuprofile` and logs the complete path. Additional
+`SIGPROF` signals are ignored while a profile is active. Signal-triggered CPU
+profiles are unavailable on Windows. There is no CPU profile CLI flag or
+environment variable.
+
See the [full troubleshooting guide](https://opencode.ai/v2/docs/troubleshooting)
for service lifecycle commands, API inspection, log locations, explicit server
connections, issue-reporting details, and local development paths.