{/* The lab's route exists only on the dev server and in Beta. */}
diff --git a/packages/gui-extensions/src/debug/contract.ts b/packages/gui-extensions/src/debug/contract.ts
index 7c364c74cbd..108dc2b53c8 100644
--- a/packages/gui-extensions/src/debug/contract.ts
+++ b/packages/gui-extensions/src/debug/contract.ts
@@ -23,3 +23,22 @@ export const HeapSnapshots = Ipc.define({
},
},
})
+
+/**
+ * Memory diagnostics, offered only in debug builds (the dev server and Beta) and off until turned on: a JSONL memory
+ * log in the desktop logs. The debug bar's toggle turns them on and remembers it; `OPENCODE_DESKTOP_MEMORY_LOG=`
+ * turns them on for one launch, and the toggle cannot turn that off.
+ */
+export const MemoryDiagnostics = Ipc.define({
+ id: "debug.memory",
+ state: Schema.Struct({
+ /** Whether diagnostics are running. */
+ on: Schema.Boolean,
+ /** Whether the environment turned them on. */
+ env: Schema.Boolean,
+ }),
+ methods: {
+ /** Turns the stored toggle on or off. */
+ set: { input: Schema.Boolean },
+ },
+})
diff --git a/packages/gui-extensions/src/debug/i18n/en.ts b/packages/gui-extensions/src/debug/i18n/en.ts
index 120cdc5ed61..73e79cb143e 100644
--- a/packages/gui-extensions/src/debug/i18n/en.ts
+++ b/packages/gui-extensions/src/debug/i18n/en.ts
@@ -37,6 +37,11 @@ export default {
"mem.label": "MEM",
"mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.",
"mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.",
+ "memory.label": "MEMLOG",
+ "memory.tip":
+ "Memory diagnostics: log app memory to the desktop logs every 30 seconds. Stays on across restarts until turned off; the bar stays open while it runs.",
+ "memory.tipEnv": "Memory diagnostics are on for this launch: OPENCODE_DESKTOP_MEMORY_LOG is set.",
+ "memory.env": "ENV",
"motion.label": "KNOBS",
"motion.title": "Knobs",
"motion.tip": "Tune live. Changes persist on this device.",
diff --git a/packages/gui-extensions/src/debug/index.ts b/packages/gui-extensions/src/debug/index.ts
index ed4e073bfe1..c5178559cbd 100644
--- a/packages/gui-extensions/src/debug/index.ts
+++ b/packages/gui-extensions/src/debug/index.ts
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Extension, Store } from "../sdk"
-import { HeapSnapshots } from "./contract"
+import { HeapSnapshots, MemoryDiagnostics } from "./contract"
import en from "./i18n/en"
export default Extension.define({
@@ -11,8 +11,10 @@ export default Extension.define({
visible: false,
motion: false,
}),
+ // Whether the debug bar turned memory diagnostics on. Read only in debug builds.
+ memory: Store.main(Schema.Boolean, false),
},
- provides: { heap: HeapSnapshots },
+ provides: { heap: HeapSnapshots, memory: MemoryDiagnostics },
i18n: {
en,
am: () => import("./i18n/am"),
diff --git a/packages/gui-extensions/src/debug/main.ts b/packages/gui-extensions/src/debug/main.ts
index 76024baf889..e80f4d8ab8a 100644
--- a/packages/gui-extensions/src/debug/main.ts
+++ b/packages/gui-extensions/src/debug/main.ts
@@ -4,9 +4,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { getHeapSpaceStatistics, getHeapStatistics, writeHeapSnapshot } from "node:v8"
import type { MainSetup } from "../sdk/main"
-import { HeapSnapshots } from "./contract"
+import { HeapSnapshots, MemoryDiagnostics } from "./contract"
import type definition from "./index"
import { summarize, traceConfig } from "./memory-infra"
+import { startMemoryLog } from "./memory-log"
+import { debugBuild, memoryLogInterval } from "./options"
import { captureServer } from "./server-heap"
/** A desktop process the manifest lists: the file written, or why there is none. */
@@ -50,6 +52,9 @@ const pageStatsScript = `(() => {
}
})()`
+/** Stops the running memory diagnostics, while they run. */
+type Running = { stop?: () => void }
+
const setup: MainSetup = (ctx) => {
const queue: Queue = { tail: Promise.resolve() }
@@ -148,6 +153,36 @@ const setup: MainSetup = (ctx) => {
return run
},
})
+
+ if (!debugBuild(ctx.build)) return
+
+ // The environment turns diagnostics on for this launch; otherwise the debug bar's stored toggle does.
+ const envInterval = memoryLogInterval(process.env.OPENCODE_DESKTOP_MEMORY_LOG)
+ const stored = ctx.stores.memory
+ const running: Running = {}
+
+ const sync = () => {
+ const on = envInterval !== undefined || stored.value
+
+ if (on && !running.stop) running.stop = startMemoryLog(envInterval ?? 30_000, ctx.log)
+
+ if (on || !running.stop) return
+ running.stop()
+ running.stop = undefined
+ }
+
+ // Started before the Ipc is provided, so the first state windows receive already says whether diagnostics run.
+ sync()
+ ctx.scope.addFinalizer(() => running.stop?.())
+
+ const provider = ctx.provide(MemoryDiagnostics, {
+ state: () => ({ on: running.stop !== undefined, env: envInterval !== undefined }),
+ set: (enabled) => {
+ stored.set(enabled)
+ sync()
+ provider.changed()
+ },
+ })
}
// Chromium's own breakdown of every process's memory: what a heap snapshot cannot show (images, layout, compositor
diff --git a/packages/gui-extensions/src/debug/memory-log.ts b/packages/gui-extensions/src/debug/memory-log.ts
new file mode 100644
index 00000000000..e939d3ac05f
--- /dev/null
+++ b/packages/gui-extensions/src/debug/memory-log.ts
@@ -0,0 +1,80 @@
+import { appendFile, mkdir } from "node:fs/promises"
+import path from "node:path"
+import { app, webContents } from "electron"
+import { Schema } from "effect"
+import type { Log } from "../sdk/main"
+import { memoryRecord, RendererMemory, type RendererRecord } from "./memory-record"
+
+// Runs only while diagnostics are on; no preload hooks, observers, or store subscriptions.
+const probe = `(() => {
+ const memory = performance.memory;
+ return {
+ jsHeap: memory ? { used: memory.usedJSHeapSize, total: memory.totalJSHeapSize, limit: memory.jsHeapSizeLimit } : null,
+ domElements: document.getElementsByTagName('*').length
+ };
+})()`
+
+/** Appends a sample every `interval` ms to a new JSONL file in the desktop logs, which exported debug logs include. */
+export function startMemoryLog(interval: number, log: Log) {
+ const file = path.join(
+ app.getPath("userData"),
+ "logs",
+ `memory-${new Date().toISOString().replace(/[:.]/g, "")}.jsonl`,
+ )
+
+ // Without the launch switch, Chromium quantizes renderer heap sizes and refreshes them rarely.
+ const precise = app.commandLine.hasSwitch("enable-precise-memory-info")
+ const pending = new WeakSet()
+ let stopped = false
+ let timer: ReturnType | undefined
+
+ const sample = async () => {
+ const metrics = app.getAppMetrics()
+
+ const renderers = await Promise.all(
+ webContents.getAllWebContents().map(async (contents): Promise => {
+ const identity = { id: contents.id, pid: contents.getOSProcessId(), type: contents.getType() }
+
+ if (pending.has(contents)) return { ...identity, error: "Previous renderer sample is still pending" }
+ pending.add(contents)
+ let deadline: ReturnType | undefined
+ // A hung renderer must not stop native-memory logging or accumulate evaluation requests.
+ const reading = contents.executeJavaScript(probe).then(Schema.decodeUnknownSync(RendererMemory))
+ void reading.finally(() => pending.delete(contents)).catch(() => undefined)
+
+ return Promise.race([
+ reading,
+ new Promise((_, reject) => {
+ deadline = setTimeout(() => reject(new Error("Renderer sample timed out")), 2000)
+ }),
+ ])
+ .then(
+ (sample) => ({ ...identity, sample }),
+ (error: Error) => ({ ...identity, error: error.message }),
+ )
+ .finally(() => clearTimeout(deadline))
+ }),
+ )
+
+ if (stopped) return
+ await mkdir(path.dirname(file), { recursive: true })
+ await appendFile(file, `${JSON.stringify(memoryRecord(metrics, renderers, precise))}\n`, { mode: 0o600 })
+ }
+
+ const tick = () => {
+ void sample()
+ .catch((error: Error) => log.write("error", "memory diagnostics sample failed", { error: error.message }))
+ .finally(() => {
+ if (!stopped) timer = setTimeout(tick, interval)
+ })
+ }
+
+ log.write("info", "memory diagnostics started", { file, intervalMs: interval, precise })
+ timer = setTimeout(tick, interval)
+
+ return () => {
+ stopped = true
+ clearTimeout(timer)
+ log.write("info", "memory diagnostics stopped", { file })
+ }
+}
diff --git a/packages/gui-extensions/src/debug/memory-record.test.ts b/packages/gui-extensions/src/debug/memory-record.test.ts
new file mode 100644
index 00000000000..cb4f19f302f
--- /dev/null
+++ b/packages/gui-extensions/src/debug/memory-record.test.ts
@@ -0,0 +1,44 @@
+import { afterEach, expect, setSystemTime, test } from "bun:test"
+import { memoryRecord } from "./memory-record"
+
+afterEach(() => setSystemTime())
+
+test("JSONL correlates renderer heaps and OS memory by pid, with explicit units and unavailable values", () => {
+ setSystemTime(new Date("2026-10-23T00:00:00Z"))
+
+ const record = memoryRecord(
+ [
+ {
+ pid: 42,
+ type: "Tab",
+ creationTime: 1,
+ cpu: { percentCPUUsage: 0, cumulativeCPUUsage: 0, idleWakeupsPerSecond: 0 },
+ memory: { workingSetSize: 2048, peakWorkingSetSize: 4096 },
+ },
+ ],
+ [
+ { id: 7, pid: 42, type: "window", sample: { jsHeap: { used: 1024, total: 4096, limit: 8192 }, domElements: 15 } },
+ { id: 8, pid: 43, type: "window", error: "Renderer sample timed out" },
+ ],
+ true,
+ )
+
+ expect(JSON.parse(`${JSON.stringify(record)}\n`)).toEqual({
+ timestamp: "2026-10-23T00:00:00.000Z",
+ mainPid: process.pid,
+ nativeMemoryUnit: "KiB",
+ jsHeapUnit: "bytes",
+ preciseJsHeap: true,
+ processes: [{ type: "Tab", pid: 42, workingSetSize: 2048, privateBytes: null }],
+ renderers: [
+ {
+ id: 7,
+ pid: 42,
+ type: "window",
+ sample: { jsHeap: { used: 1024, total: 4096, limit: 8192 }, domElements: 15 },
+ nativeMemory: { workingSetSize: 2048, peakWorkingSetSize: 4096 },
+ },
+ { id: 8, pid: 43, type: "window", error: "Renderer sample timed out", nativeMemory: null },
+ ],
+ })
+})
diff --git a/packages/gui-extensions/src/debug/memory-record.ts b/packages/gui-extensions/src/debug/memory-record.ts
new file mode 100644
index 00000000000..34e4f83392f
--- /dev/null
+++ b/packages/gui-extensions/src/debug/memory-record.ts
@@ -0,0 +1,36 @@
+import type { ProcessMetric } from "electron"
+import { Schema } from "effect"
+
+export const RendererMemory = Schema.Struct({
+ jsHeap: Schema.NullOr(Schema.Struct({ used: Schema.Number, total: Schema.Number, limit: Schema.Number })),
+ domElements: Schema.Number,
+})
+
+export type RendererRecord = {
+ id: number
+ pid: number
+ type: string
+ sample?: typeof RendererMemory.Type
+ error?: string
+}
+
+export function memoryRecord(metrics: ProcessMetric[], renderers: RendererRecord[], preciseJsHeap: boolean) {
+ return {
+ timestamp: new Date().toISOString(),
+ mainPid: process.pid,
+ nativeMemoryUnit: "KiB",
+ jsHeapUnit: "bytes",
+ preciseJsHeap,
+ processes: metrics.map((metric) => ({
+ type: metric.type,
+ pid: metric.pid,
+ workingSetSize: metric.memory.workingSetSize,
+ // Electron exposes privateBytes only on Windows. Null means unavailable, not zero.
+ privateBytes: metric.memory.privateBytes ?? null,
+ })),
+ renderers: renderers.map((renderer) => ({
+ ...renderer,
+ nativeMemory: metrics.find((metric) => metric.pid === renderer.pid)?.memory ?? null,
+ })),
+ }
+}
diff --git a/packages/gui-extensions/src/debug/options.test.ts b/packages/gui-extensions/src/debug/options.test.ts
new file mode 100644
index 00000000000..ddd97f2b371
--- /dev/null
+++ b/packages/gui-extensions/src/debug/options.test.ts
@@ -0,0 +1,28 @@
+import { expect, test } from "bun:test"
+import { debugBuild, memoryLogInterval } from "./options"
+
+test.each([
+ [{ packaged: false, channel: "dev" }, true],
+ [{ packaged: false, channel: "prod" }, true],
+ [{ packaged: true, channel: "beta" }, true],
+ [{ packaged: true, channel: "dev" }, false],
+ [{ packaged: true, channel: "prod" }, false],
+] as const)("%o is a debug build: %s", (build, expected) => {
+ expect(debugBuild(build)).toBe(expected)
+})
+
+test.each([
+ [undefined, undefined],
+ ["", undefined],
+ ["0", undefined],
+ ["-1", undefined],
+ ["NaN", undefined],
+ ["Infinity", undefined],
+ ["0.5", undefined],
+ ["2147484", undefined],
+ ["1", 1000],
+ ["2.5", 2500],
+ ["60", 60000],
+] as const)("memory interval %s is bounded", (value, expected) => {
+ expect(memoryLogInterval(value)).toBe(expected)
+})
diff --git a/packages/gui-extensions/src/debug/options.ts b/packages/gui-extensions/src/debug/options.ts
new file mode 100644
index 00000000000..e69ab2a9f32
--- /dev/null
+++ b/packages/gui-extensions/src/debug/options.ts
@@ -0,0 +1,15 @@
+/**
+ * Builds with the debug tools: the dev server and Beta, as for the debug bar's Motion tools. Stable builds never run
+ * memory diagnostics or open a debugging port, whatever the environment says.
+ */
+export function debugBuild(build: { readonly packaged: boolean; readonly channel: string }) {
+ return !build.packaged || build.channel === "beta"
+}
+
+/** Invalid values disable diagnostics rather than turning a bad interval into a hot timer. */
+export function memoryLogInterval(value: string | undefined) {
+ if (!value || !/^\d+(\.\d+)?$/.test(value)) return undefined
+ const seconds = Number(value)
+
+ return seconds >= 1 && seconds <= 2_147_483 ? seconds * 1000 : undefined
+}
diff --git a/packages/gui-extensions/src/debug/renderer.tsx b/packages/gui-extensions/src/debug/renderer.tsx
index bce1938f925..9f40a7a638c 100644
--- a/packages/gui-extensions/src/debug/renderer.tsx
+++ b/packages/gui-extensions/src/debug/renderer.tsx
@@ -19,6 +19,37 @@ const setup: Setup = (ctx) => {
})
}
+ const hide = () => {
+ bar.update((draft) => {
+ draft.visible = false
+ })
+ }
+
+ // Offered by main only in debug builds. The bar stays open while diagnostics run, so they are never silently on.
+ const memory = ctx.uses.memory
+
+ const memoryState = () => {
+ const live = memory()
+
+ return live.status === "active" ? live.value.state() : undefined
+ }
+
+ const memoryControl = () => {
+ const live = memory()
+ const state = memoryState()
+
+ if (live.status !== "active" || !state) return
+
+ return {
+ on: state.on,
+ env: state.env,
+ toggle: () => {
+ // The environment's choice lasts for the launch.
+ if (!state.env) void live.value.set(!state.on)
+ },
+ }
+ }
+
const toggleMotion = () => {
bar.update((draft) => {
draft.motion = !draft.motion
@@ -78,11 +109,12 @@ const setup: Setup = (ctx) => {
at: "window.bottom",
render: () => (
<>
-
+