Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5410533
fix(cli): cap file content at 256 KB in Snapshot.diffFull()
alex-alecu Mar 25, 2026
7f57983
fix(cli): strip before/after from TUI session_diff store
alex-alecu Mar 25, 2026
59d1bbb
perf(cli): use git cat-file -s to pre-check size before reading file …
alex-alecu Mar 25, 2026
2451764
fix(cli): scrub oversized diffs from stored session_diff on read
alex-alecu Mar 25, 2026
d7f73a1
fix(cli): use byte length for size check
alex-alecu Mar 25, 2026
1a07ad1
fix(cli): evict per-session data from TUI store on navigation
alex-alecu Mar 25, 2026
a3c8340
fix(cli): keep permission and question on evict
alex-alecu Mar 25, 2026
6878ddb
fix(cli): strip summary.diffs from messages in TUI store
alex-alecu Mar 25, 2026
1c4918c
Merge branch 'main' into fix/session-diff-memory-leak
alex-alecu Mar 25, 2026
6819ee7
fix(cli): restart worker on /new to reclaim native memory
alex-alecu Mar 26, 2026
10e3b12
fix(cli): evict child sessions recursively
alex-alecu Mar 26, 2026
1a0536d
fix(cli): detach RPC listener on cleanup
alex-alecu Mar 26, 2026
a7d3731
fix(cli): replay workspace after restart
alex-alecu Mar 26, 2026
5e38d95
chore: update source-links.md
alex-alecu Mar 26, 2026
1596cae
fix(cli): use subprocess instead of Worker thread for actual memory r…
alex-alecu Mar 26, 2026
5d611d6
revert: remove /new worker/subprocess restart
alex-alecu Mar 26, 2026
8b676f8
Merge branch 'main' into fix/session-diff-memory-leak
alex-alecu Mar 26, 2026
3ed648d
refactor(cli): extract max diff size constant
alex-alecu Mar 26, 2026
bfbfbb9
chore(cli): add kilocode_change markers
alex-alecu Mar 26, 2026
721b3d0
chore(cli): add missing kilocode_change markers
alex-alecu Mar 26, 2026
664ceec
refactor(cli): reuse MAX_DIFF_SIZE constant
alex-alecu Mar 26, 2026
1cb4b5c
refactor(cli): clean up code quality issues
alex-alecu Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,17 @@ function App() {
})
// kilocode_change end

// kilocode_change start — evict per-session data from store when navigating away
createEffect(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR added createEffect in app.tsx that calls evict(prev) when navigating away from a session, intended to clean up memory. But it evicts ALL child sessions' data including pending permissions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a3c8340 doesn't solve the problem when navigating to or from subagent views while there is a pending permission request or question.

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
Expand Down
40 changes: 33 additions & 7 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: Snapshot.FileDiff[]
[sessionID: string]: Omit<Snapshot.FileDiff, "before" | "after">[]
Comment thread
alex-alecu marked this conversation as resolved.
Resolved
}
todo: {
[sessionID: string]: Todo[]
Expand Down Expand Up @@ -107,12 +107,32 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({

const sdk = useSDK()

const fullSyncedSessions = new Set<string>()

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) {
Comment thread
alex-alecu marked this conversation as resolved.
Resolved
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]
Comment thread
alex-alecu marked this conversation as resolved.
Resolved
delete draft.question[sessionID]
}),
)
fullSyncedSessions.delete(sessionID)
}

sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
Expand Down Expand Up @@ -199,19 +219,25 @@ 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": {
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": {
Expand Down Expand Up @@ -441,7 +467,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
bootstrap()
})

const fullSyncedSessions = new Set<string>()
const result = {
data: store,
set: setStore,
Expand Down Expand Up @@ -485,11 +510,12 @@ 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)
},
evict,
},
workspace: {
get(workspaceID: string) {
Expand Down
8 changes: 6 additions & 2 deletions packages/opencode/src/session/summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,19 @@ export namespace SessionSummary {
}),
async (input) => {
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Existing oversized session_diff files still load fully before scrub

Storage.read() deserializes the whole JSON blob here, so a previously persisted multi-GB before/after payload is already in memory before the limit check runs. That means the "clean on first load" migration still hits the same first-load memory spike/OOM this PR is trying to prevent; only later reads benefit after the rewrite succeeds.

const limit = 256 * 1024

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you not use the constant here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 664ceec. Exported the constant from Snapshot and reused it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 664ceec (reuses Snapshot.MAX_DIFF_SIZE) and 1cb4b5c (broader cleanup: replaced fs.readFile with Bun.file, simplified resolveVariant params, removed destructuring in strip(), added .catch() to config read, replaced push loop with .filter(), removed if/else).

const next = diffs.map((item) => {
const file = unquoteGitPath(item.file)
if (file === item.file) return item
const oversized = Buffer.byteLength(item.before) > limit || Buffer.byteLength(item.after) > 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
},
Expand Down
20 changes: 14 additions & 6 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -228,13 +229,20 @@ export namespace Snapshot {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile
const oversized =
Comment thread
alex-alecu marked this conversation as resolved.
Resolved
!isBinaryFile &&
((parseInt(await $`git --git-dir ${git} cat-file -s ${from}:${file}`.quiet().nothrow().text()) || 0) >
256 * 1024 ||
Comment thread
alex-alecu marked this conversation as resolved.
Resolved
(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()
Expand Down
Loading