From 54105335f39ff39c2e4a230ee4b7143fc1ae10e4 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Mar 2026 15:32:25 +0200 Subject: [PATCH 01/20] fix(cli): cap file content at 256 KB in Snapshot.diffFull() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When diffFull() reads file contents via git show, files exceeding 256 KB (e.g. .heapsnapshot JSON) are now treated like binary files — before/after are replaced with empty strings. This prevents multi-GB strings from accumulating in downstream consumers (storage, SSE, TUI, VS Code, sharing). --- packages/opencode/src/snapshot/index.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 9aade734eee8..465a6a8ccc87 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,10 +34,11 @@ export namespace Snapshot { .then(() => true) .catch(() => false) if (!exists) return - const result = await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` - .quiet() - .cwd(Instance.directory) - .nothrow() + const result = + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` + .quiet() + .cwd(Instance.directory) + .nothrow() if (result.exitCode !== 0) { log.warn("cleanup failed", { exitCode: result.exitCode, @@ -240,12 +241,13 @@ export namespace Snapshot { .quiet() .nothrow() .text() + const oversized = before.length > 256 * 1024 || after.length > 256 * 1024 const added = isBinaryFile ? 0 : parseInt(additions) const deleted = isBinaryFile ? 0 : parseInt(deletions) result.push({ file, - before, - after, + before: oversized ? "" : before, + after: oversized ? "" : after, additions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, status: status.get(file) ?? "modified", From 7f57983c14a07405dc487594e8a432ec2c11a1fe Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Mar 2026 15:32:33 +0200 Subject: [PATCH 02/20] fix(cli): strip before/after from TUI session_diff store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense-in-depth: destructure away before/after content from FileDiff objects at both TUI store entry points (SSE handler + full sync). The sidebar only reads file, additions, deletions — carrying full file content in the Solid store is unnecessary and risks memory bloat. --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 7d0d8f62ab53..eaa1a9daa80e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -53,7 +53,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: Snapshot.FileDiff[] + [sessionID: string]: Omit[] } todo: { [sessionID: string]: Todo[] @@ -199,7 +199,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break case "session.diff": - setStore("session_diff", event.properties.sessionID, event.properties.diff) + setStore( + "session_diff", + event.properties.sessionID, + event.properties.diff.map(({ before: _, after: __, ...rest }) => rest), + ) break case "session.deleted": { @@ -485,7 +489,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ for (const message of messages.data!) { draft.part[message.info.id] = message.parts } - draft.session_diff[sessionID] = diff.data ?? [] + draft.session_diff[sessionID] = (diff.data ?? []).map(({ before: _, after: __, ...rest }) => rest) }), ) fullSyncedSessions.add(sessionID) From 59d1bbb6afa5d46fb00839c4278a8497b62561d9 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Mar 2026 16:56:19 +0200 Subject: [PATCH 03/20] perf(cli): use git cat-file -s to pre-check size before reading file content Avoids allocating multi-MB strings in the JS heap for oversized files. Previously the full content was read then discarded; now the object size is checked first via cat-file -s and the git show is skipped entirely when either side exceeds 256 KB. --- packages/opencode/src/snapshot/index.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 465a6a8ccc87..8ec11418c46d 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -229,25 +229,31 @@ export namespace Snapshot { if (!line) continue const [additions, deletions, file] = line.split("\t") const isBinaryFile = additions === "-" && deletions === "-" - const before = isBinaryFile + const oversized = + !isBinaryFile && + ((parseInt(await $`git --git-dir ${git} cat-file -s ${from}:${file}`.quiet().nothrow().text()) || 0) > + 256 * 1024 || + (parseInt(await $`git --git-dir ${git} cat-file -s ${to}:${file}`.quiet().nothrow().text()) || 0) > + 256 * 1024) + const skip = isBinaryFile || oversized + const before = skip ? "" : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` .quiet() .nothrow() .text() - const after = isBinaryFile + const after = skip ? "" : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}` .quiet() .nothrow() .text() - const oversized = before.length > 256 * 1024 || after.length > 256 * 1024 const added = isBinaryFile ? 0 : parseInt(additions) const deleted = isBinaryFile ? 0 : parseInt(deletions) result.push({ file, - before: oversized ? "" : before, - after: oversized ? "" : after, + before, + after, additions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, status: status.get(file) ?? "modified", From 24517648bd8a23808bd87b038d9828c0743a6cba Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Mar 2026 17:11:37 +0200 Subject: [PATCH 04/20] fix(cli): scrub oversized diffs from stored session_diff on read Existing sessions may have multi-GB before/after strings persisted in session_diff JSON files. The read path in Summary.diff() now checks each entry against the 256 KB cap and replaces oversized content with empty strings, then rewrites the file so subsequent loads are fast. This follows the existing unquoteGitPath migration pattern. --- packages/opencode/src/session/summary.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f724067f6654..eaaa06555c6c 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -121,15 +121,19 @@ export namespace SessionSummary { }), async (input) => { const diffs = await Storage.read(["session_diff", input.sessionID]).catch(() => []) + const limit = 256 * 1024 const next = diffs.map((item) => { const file = unquoteGitPath(item.file) - if (file === item.file) return item + const oversized = item.before.length > limit || item.after.length > limit + if (file === item.file && !oversized) return item return { ...item, file, + before: oversized ? "" : item.before, + after: oversized ? "" : item.after, } }) - const changed = next.some((item, i) => item.file !== diffs[i]?.file) + const changed = next.some((item, i) => item !== diffs[i]) if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {}) return next }, From d7f73a162e80ab52ef125819aff81aee56615be5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Mar 2026 17:16:38 +0200 Subject: [PATCH 05/20] fix(cli): use byte length for size check --- packages/opencode/src/session/summary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index eaaa06555c6c..7abd7c37349f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -124,7 +124,7 @@ export namespace SessionSummary { const limit = 256 * 1024 const next = diffs.map((item) => { const file = unquoteGitPath(item.file) - const oversized = item.before.length > limit || item.after.length > limit + const oversized = Buffer.byteLength(item.before) > limit || Buffer.byteLength(item.after) > limit if (file === item.file && !oversized) return item return { ...item, From 1a07ad1a56cd548fe8ed6ca84cdd378d693cc395 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Mar 2026 17:33:05 +0200 Subject: [PATCH 06/20] fix(cli): evict per-session data from TUI store on navigation The Solid store accumulated messages, parts, diffs, todos, status, and permissions for every session visited during a TUI lifetime. Navigating away via /new or the session list never freed the old session's data. Add an evict() function that deletes all per-session entries from the store maps and clears the fullSyncedSessions cache. Wire it into: - A createEffect in app.tsx that fires when the route changes away from a session (on() tracks prev vs current sessionID) - The session.deleted SSE handler, which previously only removed the session list entry but left orphaned per-session data --- packages/opencode/src/cli/cmd/tui/app.tsx | 11 +++++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 30 ++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a0236bd4569d..2fdc41b3934c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -268,6 +268,17 @@ function App() { }) // kilocode_change end + // kilocode_change start — evict per-session data from store when navigating away + createEffect( + on( + () => (route.data.type === "session" ? route.data.sessionID : undefined), + (current, prev) => { + if (prev && prev !== current) sync.session.evict(prev) + }, + ), + ) + // kilocode_change end + // Update terminal window title based on current route and session createEffect(() => { if (!terminalTitleEnabled() || Flag.KILO_DISABLE_TERMINAL_TITLE) return diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eaa1a9daa80e..858d0e46448f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -107,12 +107,32 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() + const fullSyncedSessions = new Set() + async function syncWorkspaces() { const result = await sdk.client.experimental.workspace.list().catch(() => undefined) if (!result?.data) return setStore("workspaceList", reconcile(result.data)) } + function evict(sessionID: string) { + setStore( + produce((draft) => { + const messages = draft.message[sessionID] + if (messages) { + for (const msg of messages) delete draft.part[msg.id] + } + delete draft.message[sessionID] + delete draft.session_diff[sessionID] + delete draft.session_status[sessionID] + delete draft.todo[sessionID] + delete draft.permission[sessionID] + delete draft.question[sessionID] + }), + ) + fullSyncedSessions.delete(sessionID) + } + sdk.event.listen((e) => { const event = e.details switch (event.type) { @@ -207,15 +227,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break case "session.deleted": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) - if (result.found) { + const sid = event.properties.info.id + const match = Binary.search(store.session, sid, (s) => s.id) + if (match.found) { setStore( "session", produce((draft) => { - draft.splice(result.index, 1) + draft.splice(match.index, 1) }), ) } + evict(sid) break } case "session.updated": { @@ -445,7 +467,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ bootstrap() }) - const fullSyncedSessions = new Set() const result = { data: store, set: setStore, @@ -494,6 +515,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) fullSyncedSessions.add(sessionID) }, + evict, }, workspace: { get(workspaceID: string) { From a3c8340401b3b5ec92f66095c4d941a38adc52e4 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Mar 2026 17:40:22 +0200 Subject: [PATCH 07/20] fix(cli): keep permission and question on evict --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 858d0e46448f..c6d05710bee2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -126,8 +126,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ delete draft.session_diff[sessionID] delete draft.session_status[sessionID] delete draft.todo[sessionID] - delete draft.permission[sessionID] - delete draft.question[sessionID] }), ) fullSyncedSessions.delete(sessionID) From 6878ddb03dadf8d4e12e2741e07763d3f116caff Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 25 Mar 2026 17:52:05 +0200 Subject: [PATCH 08/20] fix(cli): strip summary.diffs from messages in TUI store User messages carry summary.diffs with full before/after file content (the same giant strings as session_diff). The TUI never reads this field. Strip it at both entry points (SSE handler + full sync) to prevent multi-MB strings from accumulating in the Solid store. --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index c6d05710bee2..6af322ed80f4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -131,6 +131,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ fullSyncedSessions.delete(sessionID) } + // Strip summary.diffs from user messages — the TUI never reads them + // and they can carry multi-MB before/after file content strings. + function strip(msg: Message): Message { + if (msg.role !== "user" || !msg.summary?.diffs) return msg + const { summary, ...rest } = msg + return { ...rest, summary: { ...summary, diffs: [] } } as Message + } + sdk.event.listen((e) => { const event = e.details switch (event.type) { @@ -259,30 +267,31 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { - const messages = store.message[event.properties.info.sessionID] + const info = strip(event.properties.info) + const messages = store.message[info.sessionID] if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) + setStore("message", info.sessionID, [info]) break } - const result = Binary.search(messages, event.properties.info.id, (m) => m.id) + const result = Binary.search(messages, info.id, (m) => m.id) if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + setStore("message", info.sessionID, result.index, reconcile(info)) break } setStore( "message", - event.properties.info.sessionID, + info.sessionID, produce((draft) => { - draft.splice(result.index, 0, event.properties.info) + draft.splice(result.index, 0, info) }), ) - const updated = store.message[event.properties.info.sessionID] + const updated = store.message[info.sessionID] if (updated.length > 100) { const oldest = updated[0] batch(() => { setStore( "message", - event.properties.info.sessionID, + info.sessionID, produce((draft) => { draft.shift() }), @@ -504,7 +513,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) + draft.message[sessionID] = messages.data!.map((x) => strip(x.info)) for (const message of messages.data!) { draft.part[message.info.id] = message.parts } From 6819ee75809588a49fb8e926e00642a70221ddf5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 10:28:20 +0200 Subject: [PATCH 09/20] fix(cli): restart worker on /new to reclaim native memory Bun's JSC does not return freed native heap pages to the OS within a single Worker lifetime. After large sessions, the only way to reclaim that 2-3 GB of native allocator retention is to terminate the worker and spawn a fresh one. Workaround for https://github.com/oven-sh/bun/issues/28318 - Add getter-indirection layer so fetch/events transparently follow worker replacement without rebuilding the TUI - Add rejectAll() to RPC client to fail in-flight calls on termination - Add rebindable event source that re-registers handlers on new client - Wire /new command to fire-and-forget restart; sync layer re-bootstraps via server.instance.disposed event from the new worker - Guard against re-entry and skip in external server mode --- packages/opencode/src/cli/cmd/tui/app.tsx | 7 ++ .../opencode/src/cli/cmd/tui/context/sdk.tsx | 2 + packages/opencode/src/cli/cmd/tui/thread.ts | 90 +++++++++++++++++-- packages/opencode/src/util/rpc.ts | 18 ++-- 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2fdc41b3934c..6c50beedb261 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -117,6 +117,7 @@ export function tui(input: { fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource + restart?: () => Promise // kilocode_change — worker restart callback for /new }) { // promise to prevent immediate exit return new Promise(async (resolve) => { @@ -152,6 +153,7 @@ export function tui(input: { fetch={input.fetch} headers={input.headers} events={input.events} + restart={input.restart} > @@ -428,6 +430,11 @@ function App() { initialPrompt: currentPrompt, }) dialog.clear() + // kilocode_change start — restart worker to reclaim native/JSC memory. + // Fire-and-forget; the sync layer will re-bootstrap when the new worker + // emits server.instance.disposed. + sdk.restart?.() + // kilocode_change end }, }, { diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 260452fc1d8d..a152f7cb9039 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -16,6 +16,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource + restart?: () => Promise // kilocode_change — worker restart callback }) => { const abort = new AbortController() let workspaceID: string | undefined @@ -112,6 +113,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ directory: props.directory, event: emitter, fetch: props.fetch ?? fetch, + restart: props.restart, // kilocode_change — expose worker restart setWorkspace(next?: string) { if (workspaceID === next) return workspaceID = next diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 659c189d693f..687f2b3decea 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -24,11 +24,12 @@ declare global { type RpcClient = ReturnType> -function createWorkerFetch(client: RpcClient): typeof fetch { +// kilocode_change start — getter-indirection so fetch/events follow worker restarts +function createWorkerFetch(getter: () => RpcClient): typeof fetch { const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const request = new Request(input, init) const body = request.body ? await request.text() : undefined - const result = await client.call("fetch", { + const result = await getter().call("fetch", { url: request.url, method: request.method, headers: Object.fromEntries(request.headers.entries()), @@ -42,14 +43,36 @@ function createWorkerFetch(client: RpcClient): typeof fetch { return fn as typeof fetch } -function createEventSource(client: RpcClient): EventSource { +function createEventSource(getter: () => RpcClient): EventSource & { rebind(): void } { + // Handlers registered via on() persist across worker restarts. + // Call rebind() after replacing the RPC client so they re-attach. + const handlers = new Set<(event: Event) => void>() + let unsubs: (() => void)[] = [] + + function rebind() { + for (const u of unsubs) u() + unsubs = [] + const cur = getter() + for (const h of handlers) { + unsubs.push(cur.on("event", h)) + } + } + return { - on: (handler) => client.on("event", handler), + on: (handler) => { + handlers.add(handler) + unsubs.push(getter().on("event", handler)) + return () => { + handlers.delete(handler) + } + }, setWorkspace: (workspaceID) => { - void client.call("setWorkspace", { workspaceID }) + void getter().call("setWorkspace", { workspaceID }) }, + rebind, } } +// kilocode_change end async function target() { if (typeof KILO_WORKER_PATH !== "undefined") return KILO_WORKER_PATH @@ -147,7 +170,10 @@ export const TuiThreadCommand = cmd({ } const cwd = Filesystem.resolve(process.cwd()) - const worker = new Worker(file, { + // kilocode_change start — mutable worker/client so /new can recycle the worker + // to reclaim native memory held by Bun's JSC allocator. + // See https://github.com/oven-sh/bun/issues/28318 + let worker = new Worker(file, { env: Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ), @@ -156,7 +182,9 @@ export const TuiThreadCommand = cmd({ Log.Default.error(e) } - const client = Rpc.client(worker) + let client = Rpc.client(worker) + const getClient = () => client + // kilocode_change end const error = (e: unknown) => { Log.Default.error(e) } @@ -185,6 +213,46 @@ export const TuiThreadCommand = cmd({ }) worker.terminate() } + + // kilocode_change start — restart the worker to reclaim native/JSC memory. + // Bun's JSC does not return freed native heap pages to the OS within a + // single Worker lifetime, so the only way to reclaim that memory after + // large sessions is to terminate the worker and spawn a fresh one. + let restarting = false + let events: ReturnType | undefined + const restart = async () => { + if (restarting || stopped || external) return + restarting = true + try { + Log.Default.info("restarting worker to reclaim memory") + // 1. Reject any in-flight RPC calls so callers don't hang forever. + const reason = new Error("worker restarting") + client.rejectAll(reason) + // 2. Graceful shutdown of the old worker (flushes event stream, disposes instances). + await withTimeout(client.call("shutdown", undefined), 5000).catch((err) => { + Log.Default.warn("worker shutdown during restart failed", { + error: err instanceof Error ? err.message : String(err), + }) + }) + worker.terminate() + // 3. Spawn fresh worker + RPC client. + worker = new Worker(file, { + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }) + worker.onerror = (e) => { + Log.Default.error(e) + } + client = Rpc.client(worker) + // 4. Re-attach event handlers to the new client so the TUI receives + // events (including server.instance.disposed which triggers bootstrap). + events?.rebind() + } finally { + restarting = false + } + } + // kilocode_change end // kilocode_change start - graceful shutdown on external signals // The worker's postMessage for the RPC result may never be delivered // after shutdown because the worker's event loop drains. Send the @@ -265,6 +333,8 @@ export const TuiThreadCommand = cmd({ network.port !== 0 || network.hostname !== "127.0.0.1" + // kilocode_change start — assign events so restart() can call rebind() + events = external ? undefined : createEventSource(getClient) const transport = external ? { url: (await client.call("server", network)).url, @@ -273,9 +343,10 @@ export const TuiThreadCommand = cmd({ } : { url: "http://kilo.internal", - fetch: createWorkerFetch(client), - events: createEventSource(client), + fetch: createWorkerFetch(getClient), + events, } + // kilocode_change end setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch(() => {}) @@ -307,6 +378,7 @@ export const TuiThreadCommand = cmd({ directory: cwd, fetch: transport.fetch, events: transport.events, + restart: external ? undefined : restart, // kilocode_change — worker restart for /new args: { continue: args.continue, sessionID: args.session, diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index ebd8be40e455..30bb8049555a 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -21,15 +21,15 @@ export namespace Rpc { postMessage: (data: string) => void | null onmessage: ((this: Worker, ev: MessageEvent) => any) | null }) { - const pending = new Map void>() + const pending = new Map void; reject: (error: any) => void }>() const listeners = new Map void>>() let id = 0 target.onmessage = async (evt) => { const parsed = JSON.parse(evt.data) if (parsed.type === "rpc.result") { - const resolve = pending.get(parsed.id) - if (resolve) { - resolve(parsed.result) + const entry = pending.get(parsed.id) + if (entry) { + entry.resolve(parsed.result) pending.delete(parsed.id) } } @@ -45,8 +45,8 @@ export namespace Rpc { return { call(method: Method, input: Parameters[0]): Promise> { const requestId = id++ - return new Promise((resolve) => { - pending.set(requestId, resolve) + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) }) }, @@ -61,6 +61,12 @@ export namespace Rpc { handlers!.delete(handler) } }, + rejectAll(reason: Error) { + for (const entry of pending.values()) { + entry.reject(reason) + } + pending.clear() + }, } } } From 10e3b12b1359ea1793cab24abfa2e776425f9b6e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 10:46:09 +0200 Subject: [PATCH 10/20] fix(cli): evict child sessions recursively --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 6af322ed80f4..ba22f813cfd4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -116,6 +116,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } function evict(sessionID: string) { + // Collect child session IDs so we can evict them too. + const children = store.session.filter((s) => s.parentID === sessionID).map((s) => s.id) setStore( produce((draft) => { const messages = draft.message[sessionID] @@ -129,6 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) fullSyncedSessions.delete(sessionID) + for (const child of children) evict(child) } // Strip summary.diffs from user messages — the TUI never reads them From 1a0536daf334d79fa6bf52cceb18e0ab82ad7218 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 10:46:23 +0200 Subject: [PATCH 11/20] fix(cli): detach RPC listener on cleanup --- packages/opencode/src/cli/cmd/tui/thread.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 687f2b3decea..eba84c1f34d2 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -61,9 +61,13 @@ function createEventSource(getter: () => RpcClient): EventSource & { rebind(): v return { on: (handler) => { handlers.add(handler) - unsubs.push(getter().on("event", handler)) + const unsub = getter().on("event", handler) + unsubs.push(unsub) return () => { handlers.delete(handler) + unsub() + const idx = unsubs.indexOf(unsub) + if (idx !== -1) unsubs.splice(idx, 1) } }, setWorkspace: (workspaceID) => { From a7d37318cb1f6f0bef165ad9f0d9066d5594a187 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 10:46:45 +0200 Subject: [PATCH 12/20] fix(cli): replay workspace after restart --- packages/opencode/src/cli/cmd/tui/thread.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index eba84c1f34d2..d4b5a4673a15 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -48,6 +48,7 @@ function createEventSource(getter: () => RpcClient): EventSource & { rebind(): v // Call rebind() after replacing the RPC client so they re-attach. const handlers = new Set<(event: Event) => void>() let unsubs: (() => void)[] = [] + let workspace: string | undefined function rebind() { for (const u of unsubs) u() @@ -56,6 +57,11 @@ function createEventSource(getter: () => RpcClient): EventSource & { rebind(): v for (const h of handlers) { unsubs.push(cur.on("event", h)) } + // Replay last workspace so the new worker receives events for the + // correct workspace instead of falling back to the default. + if (workspace !== undefined) { + void cur.call("setWorkspace", { workspaceID: workspace }) + } } return { @@ -70,8 +76,9 @@ function createEventSource(getter: () => RpcClient): EventSource & { rebind(): v if (idx !== -1) unsubs.splice(idx, 1) } }, - setWorkspace: (workspaceID) => { - void getter().call("setWorkspace", { workspaceID }) + setWorkspace: (id) => { + workspace = id + void getter().call("setWorkspace", { workspaceID: id }) }, rebind, } From 5e38d95da42b72de25d66ea54622baef1fc8e489 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 10:47:05 +0200 Subject: [PATCH 13/20] chore: update source-links.md --- packages/kilo-docs/source-links.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 0ff621242af8..2d459a262c56 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -61,6 +61,8 @@ - +- + - - From 1596cae38357d7ba37a3352c680354c3f48dd931 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 11:28:14 +0200 Subject: [PATCH 14/20] fix(cli): use subprocess instead of Worker thread for actual memory reclamation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun Workers are threads within the same OS process — terminate() frees the JSC context but mimalloc retains every page process-wide, so the previous Worker-restart approach had zero effect on RSS. Switch to Bun.spawn() with IPC which creates a separate child process. Killing that process returns all its native memory to the OS. The IPC relay pattern means the RPC client persists across subprocess restarts — event listeners, fetch proxy, and SDK all continue working without getter-indirection or rebinding. Workaround for https://github.com/oven-sh/bun/issues/28318 --- packages/opencode/src/cli/cmd/tui/thread.ts | 145 +++++++++----------- packages/opencode/src/cli/cmd/tui/worker.ts | 8 +- packages/opencode/src/util/rpc.ts | 45 ++++-- 3 files changed, 100 insertions(+), 98 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index d4b5a4673a15..736d249e32c7 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -24,12 +24,11 @@ declare global { type RpcClient = ReturnType> -// kilocode_change start — getter-indirection so fetch/events follow worker restarts -function createWorkerFetch(getter: () => RpcClient): typeof fetch { +function createWorkerFetch(client: RpcClient): typeof fetch { const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise => { const request = new Request(input, init) const body = request.body ? await request.text() : undefined - const result = await getter().call("fetch", { + const result = await client.call("fetch", { url: request.url, method: request.method, headers: Object.fromEntries(request.headers.entries()), @@ -43,47 +42,14 @@ function createWorkerFetch(getter: () => RpcClient): typeof fetch { return fn as typeof fetch } -function createEventSource(getter: () => RpcClient): EventSource & { rebind(): void } { - // Handlers registered via on() persist across worker restarts. - // Call rebind() after replacing the RPC client so they re-attach. - const handlers = new Set<(event: Event) => void>() - let unsubs: (() => void)[] = [] - let workspace: string | undefined - - function rebind() { - for (const u of unsubs) u() - unsubs = [] - const cur = getter() - for (const h of handlers) { - unsubs.push(cur.on("event", h)) - } - // Replay last workspace so the new worker receives events for the - // correct workspace instead of falling back to the default. - if (workspace !== undefined) { - void cur.call("setWorkspace", { workspaceID: workspace }) - } - } - +function createEventSource(client: RpcClient): EventSource { return { - on: (handler) => { - handlers.add(handler) - const unsub = getter().on("event", handler) - unsubs.push(unsub) - return () => { - handlers.delete(handler) - unsub() - const idx = unsubs.indexOf(unsub) - if (idx !== -1) unsubs.splice(idx, 1) - } - }, - setWorkspace: (id) => { - workspace = id - void getter().call("setWorkspace", { workspaceID: id }) + on: (handler) => client.on("event", handler), + setWorkspace: (workspaceID) => { + void client.call("setWorkspace", { workspaceID }) }, - rebind, } } -// kilocode_change end async function target() { if (typeof KILO_WORKER_PATH !== "undefined") return KILO_WORKER_PATH @@ -181,21 +147,46 @@ export const TuiThreadCommand = cmd({ } const cwd = Filesystem.resolve(process.cwd()) - // kilocode_change start — mutable worker/client so /new can recycle the worker - // to reclaim native memory held by Bun's JSC allocator. + // kilocode_change start — use a child process instead of a Worker thread so + // that killing and respawning actually returns native memory to the OS. + // Bun Workers are threads within the same process — terminate() frees the + // JSC context but mimalloc retains every page process-wide. // See https://github.com/oven-sh/bun/issues/28318 - let worker = new Worker(file, { - env: Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ), - }) - worker.onerror = (e) => { - Log.Default.error(e) - } + const resolved = file instanceof URL ? fileURLToPath(file) : file + const env = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ) + // Forward runtime flags (e.g. --conditions=browser) + app flags. + const execArgs = process.execArgv ?? [] + const appArgs = process.argv.includes("--print-logs") ? ["--print-logs"] : [] + + // IPC relay: messages from the child are forwarded to whatever handler + // Rpc.client() installs. The relay persists across subprocess restarts + // so we never need to re-create the RPC client or rebind event listeners. + let relay: ((data: string) => void) | undefined + const spawn = () => + Bun.spawn({ + cmd: [process.execPath, ...execArgs, resolved, ...appArgs], + cwd, + env, + ipc(message) { + relay?.(message as string) + }, + stdio: ["ignore", "ignore", "ignore"], + windowsHide: true, + }) + + let child = spawn() - let client = Rpc.client(worker) - const getClient = () => client + const target_: Rpc.Target = { + send: (data) => child.send(data), + receive: (handler) => { + relay = handler + }, + } + const client = Rpc.client(target_) // kilocode_change end + const error = (e: unknown) => { Log.Default.error(e) } @@ -222,43 +213,36 @@ export const TuiThreadCommand = cmd({ error: error instanceof Error ? error.message : String(error), }) }) - worker.terminate() + child.kill() } - // kilocode_change start — restart the worker to reclaim native/JSC memory. - // Bun's JSC does not return freed native heap pages to the OS within a - // single Worker lifetime, so the only way to reclaim that memory after - // large sessions is to terminate the worker and spawn a fresh one. + // kilocode_change start — restart the subprocess to reclaim native/JSC memory. + // Killing the child process returns all its memory to the OS because it is + // a separate address space, unlike Worker threads which share the parent's + // allocator. let restarting = false - let events: ReturnType | undefined const restart = async () => { if (restarting || stopped || external) return restarting = true try { - Log.Default.info("restarting worker to reclaim memory") - // 1. Reject any in-flight RPC calls so callers don't hang forever. - const reason = new Error("worker restarting") - client.rejectAll(reason) - // 2. Graceful shutdown of the old worker (flushes event stream, disposes instances). + Log.Default.info("restarting worker subprocess to reclaim memory") + // 1. Graceful shutdown — let worker flush state to disk. await withTimeout(client.call("shutdown", undefined), 5000).catch((err) => { Log.Default.warn("worker shutdown during restart failed", { error: err instanceof Error ? err.message : String(err), }) }) - worker.terminate() - // 3. Spawn fresh worker + RPC client. - worker = new Worker(file, { - env: Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ), + // 2. Kill old process and wait for it to exit. + child.kill() + await withTimeout(child.exited, 5000).catch(() => { + child.kill(9) // SIGKILL fallback }) - worker.onerror = (e) => { - Log.Default.error(e) - } - client = Rpc.client(worker) - // 4. Re-attach event handlers to the new client so the TUI receives - // events (including server.instance.disposed which triggers bootstrap). - events?.rebind() + // 3. Reject any in-flight RPC calls so callers don't hang. + client.rejectAll(new Error("worker restarted")) + // 4. Spawn fresh subprocess. The relay + RPC client persist — event + // listeners stay registered and new calls automatically route to + // the new child through the mutable `child` closure. + child = spawn() } finally { restarting = false } @@ -344,8 +328,6 @@ export const TuiThreadCommand = cmd({ network.port !== 0 || network.hostname !== "127.0.0.1" - // kilocode_change start — assign events so restart() can call rebind() - events = external ? undefined : createEventSource(getClient) const transport = external ? { url: (await client.call("server", network)).url, @@ -354,10 +336,9 @@ export const TuiThreadCommand = cmd({ } : { url: "http://kilo.internal", - fetch: createWorkerFetch(getClient), - events, + fetch: createWorkerFetch(client), + events: createEventSource(client), } - // kilocode_change end setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch(() => {}) @@ -389,7 +370,7 @@ export const TuiThreadCommand = cmd({ directory: cwd, fetch: transport.fetch, events: transport.events, - restart: external ? undefined : restart, // kilocode_change — worker restart for /new + restart: external ? undefined : restart, // kilocode_change — subprocess restart for /new args: { continue: args.continue, sessionID: args.session, diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 3bf9d5a8bcb1..9e4d6d0180c7 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -144,10 +144,10 @@ export const rpc = { if (eventStream.abort) eventStream.abort.abort() await Instance.disposeAll() if (server) server.stop(true) - // Clear the Rpc message channel so the worker's event loop can drain and - // exit naturally. Without this, the active onmessage handle keeps the - // worker alive even after all async work is done. - onmessage = null + // Clear the Rpc message channel so the event loop can drain and exit + // naturally. In subprocess mode there is no global onmessage — the + // parent kills the process after this RPC response is sent. + if (typeof onmessage !== "undefined") onmessage = null }, } diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index 30bb8049555a..2a99ca5ccf54 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -3,29 +3,50 @@ export namespace Rpc { [method: string]: (input: any) => any } + // kilocode_change start — support both Worker (postMessage) and subprocess (process.send) IPC. + // Auto-detect at module load: if process.send exists we are a Bun child process. + const ipc = typeof process !== "undefined" && typeof process.send === "function" + export function listen(rpc: Definition) { - onmessage = async (evt) => { - const parsed = JSON.parse(evt.data) + const send = ipc ? (data: string) => process.send!(data) : (data: string) => postMessage(data) + + const handle = async (data: string) => { + const parsed = JSON.parse(data) if (parsed.type === "rpc.request") { const result = await rpc[parsed.method](parsed.input) - postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) + send(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) } } + + if (ipc) { + process.on("message", (msg: unknown) => handle(msg as string)) + } else { + onmessage = async (evt) => handle(evt.data) + } } export function emit(event: string, data: unknown) { - postMessage(JSON.stringify({ type: "rpc.event", event, data })) + const msg = JSON.stringify({ type: "rpc.event", event, data }) + if (ipc) { + process.send!(msg) + } else { + postMessage(msg) + } + } + + /** Generic send/receive target — works for both Worker and subprocess. */ + export type Target = { + send(data: string): void + receive(handler: (data: string) => void): void } + // kilocode_change end - export function client(target: { - postMessage: (data: string) => void | null - onmessage: ((this: Worker, ev: MessageEvent) => any) | null - }) { + export function client(target: Target) { const pending = new Map void; reject: (error: any) => void }>() const listeners = new Map void>>() let id = 0 - target.onmessage = async (evt) => { - const parsed = JSON.parse(evt.data) + target.receive((data) => { + const parsed = JSON.parse(data) if (parsed.type === "rpc.result") { const entry = pending.get(parsed.id) if (entry) { @@ -41,13 +62,13 @@ export namespace Rpc { } } } - } + }) return { call(method: Method, input: Parameters[0]): Promise> { const requestId = id++ return new Promise((resolve, reject) => { pending.set(requestId, { resolve, reject }) - target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) + target.send(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) }) }, on(event: string, handler: (data: Data) => void) { From 5d611d6da6fa6d8db8467a2f727eb98536e8c15d Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 11:35:29 +0200 Subject: [PATCH 15/20] revert: remove /new worker/subprocess restart The subprocess restart approach did not solve the underlying Bun native memory retention issue (oven-sh/bun#28318). Remove all restart plumbing from rpc.ts, thread.ts, worker.ts, app.tsx, and sdk.tsx to keep the diff clean. The diff-size and store-eviction fixes remain. --- packages/kilo-docs/source-links.md | 4 +- packages/opencode/src/cli/cmd/tui/app.tsx | 7 -- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 2 - packages/opencode/src/cli/cmd/tui/thread.ts | 82 ++----------------- packages/opencode/src/cli/cmd/tui/worker.ts | 8 +- packages/opencode/src/util/rpc.ts | 63 ++++---------- 6 files changed, 32 insertions(+), 134 deletions(-) diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 2d459a262c56..0ff621242af8 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -61,8 +61,6 @@ - -- - - - diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 6c50beedb261..2fdc41b3934c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -117,7 +117,6 @@ export function tui(input: { fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource - restart?: () => Promise // kilocode_change — worker restart callback for /new }) { // promise to prevent immediate exit return new Promise(async (resolve) => { @@ -153,7 +152,6 @@ export function tui(input: { fetch={input.fetch} headers={input.headers} events={input.events} - restart={input.restart} > @@ -430,11 +428,6 @@ function App() { initialPrompt: currentPrompt, }) dialog.clear() - // kilocode_change start — restart worker to reclaim native/JSC memory. - // Fire-and-forget; the sync layer will re-bootstrap when the new worker - // emits server.instance.disposed. - sdk.restart?.() - // kilocode_change end }, }, { diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index a152f7cb9039..260452fc1d8d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -16,7 +16,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ fetch?: typeof fetch headers?: RequestInit["headers"] events?: EventSource - restart?: () => Promise // kilocode_change — worker restart callback }) => { const abort = new AbortController() let workspaceID: string | undefined @@ -113,7 +112,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ directory: props.directory, event: emitter, fetch: props.fetch ?? fetch, - restart: props.restart, // kilocode_change — expose worker restart setWorkspace(next?: string) { if (workspaceID === next) return workspaceID = next diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 736d249e32c7..659c189d693f 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -147,46 +147,16 @@ export const TuiThreadCommand = cmd({ } const cwd = Filesystem.resolve(process.cwd()) - // kilocode_change start — use a child process instead of a Worker thread so - // that killing and respawning actually returns native memory to the OS. - // Bun Workers are threads within the same process — terminate() frees the - // JSC context but mimalloc retains every page process-wide. - // See https://github.com/oven-sh/bun/issues/28318 - const resolved = file instanceof URL ? fileURLToPath(file) : file - const env = Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), - ) - // Forward runtime flags (e.g. --conditions=browser) + app flags. - const execArgs = process.execArgv ?? [] - const appArgs = process.argv.includes("--print-logs") ? ["--print-logs"] : [] - - // IPC relay: messages from the child are forwarded to whatever handler - // Rpc.client() installs. The relay persists across subprocess restarts - // so we never need to re-create the RPC client or rebind event listeners. - let relay: ((data: string) => void) | undefined - const spawn = () => - Bun.spawn({ - cmd: [process.execPath, ...execArgs, resolved, ...appArgs], - cwd, - env, - ipc(message) { - relay?.(message as string) - }, - stdio: ["ignore", "ignore", "ignore"], - windowsHide: true, - }) - - let child = spawn() - - const target_: Rpc.Target = { - send: (data) => child.send(data), - receive: (handler) => { - relay = handler - }, + const worker = new Worker(file, { + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }) + worker.onerror = (e) => { + Log.Default.error(e) } - const client = Rpc.client(target_) - // kilocode_change end + const client = Rpc.client(worker) const error = (e: unknown) => { Log.Default.error(e) } @@ -213,41 +183,8 @@ export const TuiThreadCommand = cmd({ error: error instanceof Error ? error.message : String(error), }) }) - child.kill() + worker.terminate() } - - // kilocode_change start — restart the subprocess to reclaim native/JSC memory. - // Killing the child process returns all its memory to the OS because it is - // a separate address space, unlike Worker threads which share the parent's - // allocator. - let restarting = false - const restart = async () => { - if (restarting || stopped || external) return - restarting = true - try { - Log.Default.info("restarting worker subprocess to reclaim memory") - // 1. Graceful shutdown — let worker flush state to disk. - await withTimeout(client.call("shutdown", undefined), 5000).catch((err) => { - Log.Default.warn("worker shutdown during restart failed", { - error: err instanceof Error ? err.message : String(err), - }) - }) - // 2. Kill old process and wait for it to exit. - child.kill() - await withTimeout(child.exited, 5000).catch(() => { - child.kill(9) // SIGKILL fallback - }) - // 3. Reject any in-flight RPC calls so callers don't hang. - client.rejectAll(new Error("worker restarted")) - // 4. Spawn fresh subprocess. The relay + RPC client persist — event - // listeners stay registered and new calls automatically route to - // the new child through the mutable `child` closure. - child = spawn() - } finally { - restarting = false - } - } - // kilocode_change end // kilocode_change start - graceful shutdown on external signals // The worker's postMessage for the RPC result may never be delivered // after shutdown because the worker's event loop drains. Send the @@ -370,7 +307,6 @@ export const TuiThreadCommand = cmd({ directory: cwd, fetch: transport.fetch, events: transport.events, - restart: external ? undefined : restart, // kilocode_change — subprocess restart for /new args: { continue: args.continue, sessionID: args.session, diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 9e4d6d0180c7..3bf9d5a8bcb1 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -144,10 +144,10 @@ export const rpc = { if (eventStream.abort) eventStream.abort.abort() await Instance.disposeAll() if (server) server.stop(true) - // Clear the Rpc message channel so the event loop can drain and exit - // naturally. In subprocess mode there is no global onmessage — the - // parent kills the process after this RPC response is sent. - if (typeof onmessage !== "undefined") onmessage = null + // Clear the Rpc message channel so the worker's event loop can drain and + // exit naturally. Without this, the active onmessage handle keeps the + // worker alive even after all async work is done. + onmessage = null }, } diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index 2a99ca5ccf54..ebd8be40e455 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -3,54 +3,33 @@ export namespace Rpc { [method: string]: (input: any) => any } - // kilocode_change start — support both Worker (postMessage) and subprocess (process.send) IPC. - // Auto-detect at module load: if process.send exists we are a Bun child process. - const ipc = typeof process !== "undefined" && typeof process.send === "function" - export function listen(rpc: Definition) { - const send = ipc ? (data: string) => process.send!(data) : (data: string) => postMessage(data) - - const handle = async (data: string) => { - const parsed = JSON.parse(data) + onmessage = async (evt) => { + const parsed = JSON.parse(evt.data) if (parsed.type === "rpc.request") { const result = await rpc[parsed.method](parsed.input) - send(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) + postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) } } - - if (ipc) { - process.on("message", (msg: unknown) => handle(msg as string)) - } else { - onmessage = async (evt) => handle(evt.data) - } } export function emit(event: string, data: unknown) { - const msg = JSON.stringify({ type: "rpc.event", event, data }) - if (ipc) { - process.send!(msg) - } else { - postMessage(msg) - } + postMessage(JSON.stringify({ type: "rpc.event", event, data })) } - /** Generic send/receive target — works for both Worker and subprocess. */ - export type Target = { - send(data: string): void - receive(handler: (data: string) => void): void - } - // kilocode_change end - - export function client(target: Target) { - const pending = new Map void; reject: (error: any) => void }>() + export function client(target: { + postMessage: (data: string) => void | null + onmessage: ((this: Worker, ev: MessageEvent) => any) | null + }) { + const pending = new Map void>() const listeners = new Map void>>() let id = 0 - target.receive((data) => { - const parsed = JSON.parse(data) + target.onmessage = async (evt) => { + const parsed = JSON.parse(evt.data) if (parsed.type === "rpc.result") { - const entry = pending.get(parsed.id) - if (entry) { - entry.resolve(parsed.result) + const resolve = pending.get(parsed.id) + if (resolve) { + resolve(parsed.result) pending.delete(parsed.id) } } @@ -62,13 +41,13 @@ export namespace Rpc { } } } - }) + } return { call(method: Method, input: Parameters[0]): Promise> { const requestId = id++ - return new Promise((resolve, reject) => { - pending.set(requestId, { resolve, reject }) - target.send(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) + return new Promise((resolve) => { + pending.set(requestId, resolve) + target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) }) }, on(event: string, handler: (data: Data) => void) { @@ -82,12 +61,6 @@ export namespace Rpc { handlers!.delete(handler) } }, - rejectAll(reason: Error) { - for (const entry of pending.values()) { - entry.reject(reason) - } - pending.clear() - }, } } } From 3ed648d56671a8a7526b2703ec19bffa98ff07f8 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 13:51:24 +0200 Subject: [PATCH 16/20] refactor(cli): extract max diff size constant --- packages/opencode/src/snapshot/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 8ec11418c46d..6acfb362cdd5 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -14,6 +14,7 @@ export namespace Snapshot { const log = Log.create({ service: "snapshot" }) const hour = 60 * 60 * 1000 const prune = "7.days" + const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change export function init() { Scheduler.register({ @@ -232,9 +233,9 @@ export namespace Snapshot { const oversized = !isBinaryFile && ((parseInt(await $`git --git-dir ${git} cat-file -s ${from}:${file}`.quiet().nothrow().text()) || 0) > - 256 * 1024 || + MAX_DIFF_SIZE || (parseInt(await $`git --git-dir ${git} cat-file -s ${to}:${file}`.quiet().nothrow().text()) || 0) > - 256 * 1024) + MAX_DIFF_SIZE) const skip = isBinaryFile || oversized const before = skip ? "" From bfbfbb967cae37f206414fe7641a7598b9aba00d Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 13:52:48 +0200 Subject: [PATCH 17/20] chore(cli): add kilocode_change markers --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index ba22f813cfd4..61dfc2bf219b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -53,7 +53,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: Omit[] + [sessionID: string]: Omit[] // kilocode_change } todo: { [sessionID: string]: Todo[] @@ -107,7 +107,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() - const fullSyncedSessions = new Set() + const fullSyncedSessions = new Set() // kilocode_change async function syncWorkspaces() { const result = await sdk.client.experimental.workspace.list().catch(() => undefined) @@ -115,6 +115,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("workspaceList", reconcile(result.data)) } + // kilocode_change start function evict(sessionID: string) { // Collect child session IDs so we can evict them too. const children = store.session.filter((s) => s.parentID === sessionID).map((s) => s.id) @@ -141,6 +142,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const { summary, ...rest } = msg return { ...rest, summary: { ...summary, diffs: [] } } as Message } + // kilocode_change end sdk.event.listen((e) => { const event = e.details @@ -235,6 +237,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) break + // kilocode_change start case "session.deleted": { const sid = event.properties.info.id const match = Binary.search(store.session, sid, (s) => s.id) @@ -249,6 +252,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ evict(sid) break } + // kilocode_change end case "session.updated": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (result.found) { @@ -270,7 +274,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { - const info = strip(event.properties.info) + const info = strip(event.properties.info) // kilocode_change const messages = store.message[info.sessionID] if (!messages) { setStore("message", info.sessionID, [info]) @@ -516,7 +520,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => strip(x.info)) + draft.message[sessionID] = messages.data!.map((x) => strip(x.info)) // kilocode_change for (const message of messages.data!) { draft.part[message.info.id] = message.parts } @@ -525,7 +529,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) fullSyncedSessions.add(sessionID) }, - evict, + evict, // kilocode_change }, workspace: { get(workspaceID: string) { From 721b3d06305cab4415d207ec1ce238f9b5464819 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 13:59:08 +0200 Subject: [PATCH 18/20] chore(cli): add missing kilocode_change markers --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 4 +++- packages/opencode/src/session/summary.ts | 2 ++ packages/opencode/src/snapshot/index.ts | 2 ++ packages/opencode/src/tool/task.ts | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 61dfc2bf219b..6cd08a062c7d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -273,8 +273,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + // kilocode_change start case "message.updated": { - const info = strip(event.properties.info) // kilocode_change + const info = strip(event.properties.info) const messages = store.message[info.sessionID] if (!messages) { setStore("message", info.sessionID, [info]) @@ -313,6 +314,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } break } + // kilocode_change end case "message.removed": { const messages = store.message[event.properties.sessionID] const result = Binary.search(messages, event.properties.messageID, (m) => m.id) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 7abd7c37349f..752bd2096c2e 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -121,6 +121,7 @@ export namespace SessionSummary { }), async (input) => { const diffs = await Storage.read(["session_diff", input.sessionID]).catch(() => []) + // kilocode_change start — scrub oversized diffs from stored session_diff const limit = 256 * 1024 const next = diffs.map((item) => { const file = unquoteGitPath(item.file) @@ -135,6 +136,7 @@ export namespace SessionSummary { }) const changed = next.some((item, i) => item !== diffs[i]) if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {}) + // kilocode_change end return next }, ) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 6acfb362cdd5..a28019b3d48d 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -230,6 +230,7 @@ export namespace Snapshot { if (!line) continue const [additions, deletions, file] = line.split("\t") const isBinaryFile = additions === "-" && deletions === "-" + // kilocode_change start const oversized = !isBinaryFile && ((parseInt(await $`git --git-dir ${git} cat-file -s ${from}:${file}`.quiet().nothrow().text()) || 0) > @@ -237,6 +238,7 @@ export namespace Snapshot { (parseInt(await $`git --git-dir ${git} cat-file -s ${to}:${file}`.quiet().nothrow().text()) || 0) > MAX_DIFF_SIZE) const skip = isBinaryFile || oversized + // kilocode_change end const before = skip ? "" : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index a76eb9f756b5..7b7eafdcb84e 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -61,7 +61,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) - const allowsTask = agent.permission.some((rule) => rule.permission === "task" && rule.action === "allow") + const allowsTask = agent.permission.some((rule) => rule.permission === "task" && rule.action === "allow") // kilocode_change const session = await iife(async () => { if (params.task_id) { From 664ceecc32fb47b18d1c3aab02fb273c50a0c39f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 14:06:16 +0200 Subject: [PATCH 19/20] refactor(cli): reuse MAX_DIFF_SIZE constant --- packages/opencode/src/session/summary.ts | 5 +++-- packages/opencode/src/snapshot/index.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 752bd2096c2e..288e5f22c2b3 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -122,10 +122,11 @@ export namespace SessionSummary { async (input) => { const diffs = await Storage.read(["session_diff", input.sessionID]).catch(() => []) // kilocode_change start — scrub oversized diffs from stored session_diff - const limit = 256 * 1024 const next = diffs.map((item) => { const file = unquoteGitPath(item.file) - const oversized = Buffer.byteLength(item.before) > limit || Buffer.byteLength(item.after) > limit + const oversized = + Buffer.byteLength(item.before) > Snapshot.MAX_DIFF_SIZE || + Buffer.byteLength(item.after) > Snapshot.MAX_DIFF_SIZE if (file === item.file && !oversized) return item return { ...item, diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index a28019b3d48d..f2a70db91154 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -14,7 +14,7 @@ export namespace Snapshot { const log = Log.create({ service: "snapshot" }) const hour = 60 * 60 * 1000 const prune = "7.days" - const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change + export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change export function init() { Scheduler.register({ From 1cb4b5c8c018616576dcbad22d2185980af8fb67 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 26 Mar 2026 14:11:54 +0200 Subject: [PATCH 20/20] refactor(cli): clean up code quality issues --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 3 +- packages/opencode/src/config/config.ts | 18 ++++++------ .../opencode/src/kilocode/plan-followup.ts | 29 +++++++------------ 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 6cd08a062c7d..e7049fcbe4e6 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -139,8 +139,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ // and they can carry multi-MB before/after file content strings. function strip(msg: Message): Message { if (msg.role !== "user" || !msg.summary?.diffs) return msg - const { summary, ...rest } = msg - return { ...rest, summary: { ...summary, diffs: [] } } as Message + return { ...msg, summary: { ...msg.summary, diffs: [] } } as Message } // kilocode_change end diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 61479758974e..eff231452e0b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1342,16 +1342,15 @@ export namespace Config { const files = GLOBAL_CONFIG_FILES.map((file) => path.join(Global.Path.config, file)) // also check legacy TOML config — its presence means existing user const legacy = path.join(Global.Path.config, "config") - const existing: string[] = [] - for (const file of files) { - if (existsSync(file)) existing.push(file) - } + const existing = files.filter((file) => existsSync(file)) const hasLegacy = existsSync(legacy) // no global config → new user, they'll get the new bash:ask default if (existing.length === 0 && !hasLegacy) return // check if any config file already has an explicit bash permission for (const file of existing) { - const text = await Bun.file(file).text() + const text = await Bun.file(file) + .text() + .catch(() => "") const data = parseJsonc(text) ?? {} if (data.permission?.bash) return } @@ -1372,11 +1371,12 @@ export namespace Config { formattingOptions: { insertSpaces: true, tabSize: 2 }, }) await Bun.write(target, applyEdits(text, edits)) - } else { - const data = parseJsonc(text) ?? {} - const merged = { ...data, permission: { ...data.permission, bash: "allow" } } - await Bun.write(target, JSON.stringify(merged, null, 2)) + log.info("migrated bash permission to allow for existing user", { path: target }) + return } + const data = parseJsonc(text) ?? {} + const merged = { ...data, permission: { ...data.permission, bash: "allow" } } + await Bun.write(target, JSON.stringify(merged, null, 2)) log.info("migrated bash permission to allow for existing user", { path: target }) } // kilocode_change end diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index cc6408b66558..caf2df67db3e 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -12,7 +12,6 @@ import { LLM } from "@/session/llm" import { MessageV2 } from "@/session/message-v2" import { Todo } from "@/session/todo" import { Log } from "@/util/log" -import fs from "fs/promises" import path from "path" function toText(item: MessageV2.WithParts): string { @@ -115,20 +114,20 @@ export namespace PlanFollowup { export const ANSWER_NEW_SESSION = "Start new session" export const ANSWER_CONTINUE = "Continue here" - function resolveVariant(input: { value: string | undefined; model: Provider.Model | undefined }) { - if (!input.value) return undefined - if (!input.model?.variants?.[input.value]) return undefined - return input.value + function resolveVariant(value: string | undefined, model: Provider.Model | undefined) { + if (!value) return undefined + if (!model?.variants?.[value]) return undefined + return value } async function resolveCodeModel(input: Pick) { const state = Flag.KILO_CLIENT === "cli" - ? await fs - .readFile(path.join(Global.Path.state, "model.json"), "utf-8") + ? await Bun.file(path.join(Global.Path.state, "model.json")) + .text() .then( - (item) => - JSON.parse(item) as { + (raw) => + JSON.parse(raw) as { model?: Record variant?: Record }, @@ -142,10 +141,7 @@ export namespace PlanFollowup { const key = `${saved.providerID}/${saved.modelID}` return { model: saved, - variant: resolveVariant({ - value: state?.variant?.[key], - model: full, - }), + variant: resolveVariant(state?.variant?.[key], full), } } } @@ -156,12 +152,7 @@ export namespace PlanFollowup { if (full) { return { model: agent.model, - variant: agent.variant - ? resolveVariant({ - value: agent.variant, - model: full, - }) - : undefined, + variant: resolveVariant(agent.variant, full), } } }