What happened?
Supacode becomes progressively memory-heavy and sluggish during multi-day use with several long-running agent TUIs across worktrees. The underlying shells remain healthy; restarting only Supacode immediately recovers most of the memory and CPU while the same zmx-backed OMP processes continue running.
I expected deselected worktrees to have bounded background cost. Instead, each touched worktree retains its WorktreeTerminalState, GhosttySurfaceView, renderer, graphics resources, and terminal state until the worktree is pruned. Occlusion stops display-link drawing, but Ghostty still runs updateFrame for terminal wakeups while the surface is hidden.
Live restart A/B
Measured on Supacode 0.10.6 after approximately 47 hours of app uptime:
| Signal |
Before restart |
Fresh restart |
After reopening old sessions |
| Supacode physical footprint |
~1.6 GB (1.8 GB peak) |
~381–403 MB |
~693–735 MB |
| Supacode CPU |
~91–157% |
~10–21% |
~18–35% |
| Renderer activity |
Two renderer threads ~60% and ~56% active |
One moderately active; others idle |
Six renderers; two idle and four processing wakeups |
| Main thread |
~96.5% idle |
Responsive |
Mostly idle |
The important zmx/OMP processes survived the restart. One 21-hour OMP process retained the same PID and was still using approximately 1.1 GB RSS afterward. This isolates the pathological state to the old Supacode/Ghostty surfaces rather than the agent process alone.
Before restart, Supacode's default malloc zone held approximately 753.7 MB allocated. After restart and reopening the sessions it held approximately 140.4 MB. /usr/bin/leaks found only 160 bytes, so this looks like reachable per-surface/aged terminal-renderer accumulation rather than a conventional unreachable leak.
Current source behavior matches the observation:
WorktreeTerminalManager.state(for:) retains a state per touched worktree (WorktreeTerminalManager.swift:478-507).
- Selecting another worktree only calls
setAllSurfacesOccluded() on the previous state (WorktreeTerminalManager.swift:410-415).
- States and surfaces are released only by prune (
WorktreeTerminalManager.swift:622-659).
- Ghostty stops the display link for invisible surfaces (
renderer/generic.zig:1053-1063) but renderer wakeups still execute updateFrame before drawFrame is skipped (renderer/Thread.zig:513-532, 596-617).
The previously reported Ghostty non-standard PageList leak does not match this profile: the bundled source contains the fix at PageList.zig:3167-3170, and this process was malloc/graphics-heavy rather than dominated by unbounded VM_ALLOCATE regions.
Steps to reproduce
- Open several Supacode worktrees and start long-running TUI coding agents in them.
- Leave the sessions running and switch among the worktrees over one or more days.
- Observe Supacode's physical footprint and per-renderer CPU with
vmmap -summary <pid>, top -pid <pid>, and /usr/bin/sample <pid> 3.
- Quit Supacode normally without terminating terminal sessions, then relaunch it.
- Confirm the zmx/agent PIDs survived while Supacode's memory and renderer CPU dropped substantially.
- Reopen the old worktrees. Resource use rises with reattached surfaces but remains well below the aged pre-restart process.
Proposed solution
Hibernate terminal surfaces belonging to inactive worktrees after an inactivity grace period.
- Enable hibernation by default, with a setting allowing users to opt out.
- Preserve the
WorktreeTerminalState layout and surface UUIDs, but detach and destroy the inactive Ghostty surfaces/renderers without killing their zmx sessions.
- Cancel pending hibernation when a worktree is selected again. Recreate its surfaces and reattach using the existing zmx session IDs.
- Let zmx restore the terminal snapshot. zmx already retains terminal state and scrollback through
libghostty-vt and serializes it during reattachment (ThirdParty/zmx/src/main.zig:934-968).
- Keep explicit close, prune, and "terminate sessions" behavior unchanged; those paths must still kill sessions where they do today.
- Handle selection/hibernate races on
@MainActor so a newly selected surface cannot be torn down by an expired task.
Synchronize the history limit
Ghostty and zmx both currently default to 10,000,000 bytes, but the values are independent:
- Ghostty: configurable
scrollback-limit (Config.zig:1368-1387).
- zmx: hardcoded
DaemonConfig.max_scrollback = 10_000_000 (ThirdParty/zmx/src/main.zig:472-474).
Capture the effective Ghostty limit per session and use the same value for:
- The original Ghostty surface.
- The zmx daemon's
max_scrollback.
- The recreated surface after wake.
This avoids truncating user configurations above 10 MB and avoids zmx retaining unnecessary history for configurations below 10 MB. It also preserves the existing rule that a running surface keeps the limit it was created with.
Acceptance criteria
- Inactive worktree surfaces release their Ghostty renderers and graphics resources after the grace period.
- Their shell/agent and zmx session remain alive.
- Selecting the worktree restores the screen, alternate-screen state, cursor, and no more than the configured history limit.
- Hibernation is enabled by default and can be disabled in Settings.
- Repeated hibernate/wake cycles do not duplicate tabs, surfaces, callbacks, or zmx clients.
- Closing/pruning a hibernated worktree still removes the zmx session.
- Focused lifecycle, race, restoration, and lower/default/higher history-limit tests cover the behavior.
Alternatives considered
- Restart Supacode periodically: effective but disruptive and hides the retained-state problem.
- Only coalesce hidden Ghostty
updateFrame calls: likely reduces CPU but does not release per-surface renderer, Metal, and terminal allocations.
- Hard-cap retained worktree states: bounds memory but loses the explicit lifecycle and restoration semantics that zmx already supports.
Supacode version and build
0.10.6 (1783877721)
macOS version
macOS 26.5.1 (25F80)
System locale
en_GB
Mac hardware
Apple M5 Pro, 48 GB
Are you planning to fix this yourself?
Before submitting
What happened?
Supacode becomes progressively memory-heavy and sluggish during multi-day use with several long-running agent TUIs across worktrees. The underlying shells remain healthy; restarting only Supacode immediately recovers most of the memory and CPU while the same zmx-backed OMP processes continue running.
I expected deselected worktrees to have bounded background cost. Instead, each touched worktree retains its
WorktreeTerminalState,GhosttySurfaceView, renderer, graphics resources, and terminal state until the worktree is pruned. Occlusion stops display-link drawing, but Ghostty still runsupdateFramefor terminal wakeups while the surface is hidden.Live restart A/B
Measured on Supacode 0.10.6 after approximately 47 hours of app uptime:
The important zmx/OMP processes survived the restart. One 21-hour OMP process retained the same PID and was still using approximately 1.1 GB RSS afterward. This isolates the pathological state to the old Supacode/Ghostty surfaces rather than the agent process alone.
Before restart, Supacode's default malloc zone held approximately 753.7 MB allocated. After restart and reopening the sessions it held approximately 140.4 MB.
/usr/bin/leaksfound only 160 bytes, so this looks like reachable per-surface/aged terminal-renderer accumulation rather than a conventional unreachable leak.Current source behavior matches the observation:
WorktreeTerminalManager.state(for:)retains a state per touched worktree (WorktreeTerminalManager.swift:478-507).setAllSurfacesOccluded()on the previous state (WorktreeTerminalManager.swift:410-415).WorktreeTerminalManager.swift:622-659).renderer/generic.zig:1053-1063) but renderer wakeups still executeupdateFramebeforedrawFrameis skipped (renderer/Thread.zig:513-532,596-617).The previously reported Ghostty non-standard PageList leak does not match this profile: the bundled source contains the fix at
PageList.zig:3167-3170, and this process was malloc/graphics-heavy rather than dominated by unboundedVM_ALLOCATEregions.Steps to reproduce
vmmap -summary <pid>,top -pid <pid>, and/usr/bin/sample <pid> 3.Proposed solution
Hibernate terminal surfaces belonging to inactive worktrees after an inactivity grace period.
WorktreeTerminalStatelayout and surface UUIDs, but detach and destroy the inactive Ghostty surfaces/renderers without killing their zmx sessions.libghostty-vtand serializes it during reattachment (ThirdParty/zmx/src/main.zig:934-968).@MainActorso a newly selected surface cannot be torn down by an expired task.Synchronize the history limit
Ghostty and zmx both currently default to 10,000,000 bytes, but the values are independent:
scrollback-limit(Config.zig:1368-1387).DaemonConfig.max_scrollback = 10_000_000(ThirdParty/zmx/src/main.zig:472-474).Capture the effective Ghostty limit per session and use the same value for:
max_scrollback.This avoids truncating user configurations above 10 MB and avoids zmx retaining unnecessary history for configurations below 10 MB. It also preserves the existing rule that a running surface keeps the limit it was created with.
Acceptance criteria
Alternatives considered
updateFramecalls: likely reduces CPU but does not release per-surface renderer, Metal, and terminal allocations.Supacode version and build
0.10.6 (1783877721)
macOS version
macOS 26.5.1 (25F80)
System locale
en_GB
Mac hardware
Apple M5 Pro, 48 GB
Are you planning to fix this yourself?
Before submitting