Severity: High — degrades the whole machine, silently, over normal multi-day use.
Environment
- LifeOS 6.0.5
- bun 1.3.8
- macOS 14.6.1 (Intel), launchd/GUI session
Summary
Over several days of normal use, PostToolUse / UserPromptSubmit hook processes
(bun ~/.claude/hooks/ToolActivityTracker.hook.ts and
~/.claude/hooks/SatisfactionCapture.hook.ts) fail to exit and actively burn CPU
for days, accumulating one leaked, spinning process at a time. On my machine this
reached 11 stuck hook processes totalling ~900 CPU-hours, driving the 1-minute
load average to 180 (healthy is ~4–8). Killing the 11 processes immediately dropped
load back toward normal.
Crucially, these hooks have timeout set in settings.json (5s for
ToolActivityTracker, 20s for SatisfactionCapture) — yet the leaked processes had
elapsed times of 3–10 days. So whatever the timeout is meant to do, it is not
terminating the process.
Evidence
ps -axo pid,etime,time,command for the leaked hooks (representative rows):
ETIME CPUTIME COMMAND
08-22:17:35 5826:41 (~97h) bun ~/.claude/hooks/ToolActivityTracker.hook.ts
10-09:54:38 7433:19 (~124h) bun ~/.claude/hooks/ToolActivityTracker.hook.ts
03-11:17:29 2051:46 (~34h) bun ~/.claude/hooks/SatisfactionCapture.hook.ts
...11 such processes, ~900 CPU-hours total...
CPU-time / elapsed ≈ 45% sustained CPU each — i.e. they are busy-spinning, not
idling on a blocked await (a hung await would sit near 0% CPU). Load average timeline
across the fix:
before kill: load average: 180.03 150.82 147.79
after kill 11: load average: 34.39 106.46 130.86 (1-min avg falling)
What I ruled out (via source review)
The obvious suspect — the event-sourced work-registry write path
(bumpLastToolActivity → writeRegistry → foldToSnapshot, hooks/lib/work-events.ts) —
appears clean:
foldToSnapshot() is straight-line: acquireLock() returns on contention (no
spin-wait), single heartbeat write, replay, tmp+rename, release. No loop, no
setInterval.
parseEventLines() is a bounded for loop that always advances (no non-advancing
parse loop on malformed input).
work-events.jsonl is only ~491K (compaction is working); no stuck *.lock dir.
So the spin is not in the work-registry fold.
ToolActivityTracker.hook.ts itself also looks correct in isolation: 2s stdin-read
timeout, wrapped in try/catch, ends in process.exit(0).
Confirmed secondary bug — duplicate hook registration
settings.json registers two hooks twice on UserPromptSubmit:
DUPLICATE x2: ~/.claude/hooks/PromptProcessing.hook.ts
DUPLICATE x2: ~/.claude/hooks/SatisfactionCapture.hook.ts
This doubles invocations (and therefore doubles the leak rate) on every prompt. It is
independently wrong and worth fixing regardless of the leak root cause.
Leading hypotheses (not yet definitively proven)
-
Timeout does not reap the process. The timeout in settings.json is set but
the leaked processes lived for days — so either the hook timeout doesn't SIGKILL the
bun process, or it kills only a direct child and orphans others. Once orphaned
(reparented away from Claude Code), nothing reaps them.
-
Orphaned bun process busy-spins on its stdin pipe. Both leaking hooks attach
process.stdin.on('data', …). When a hook is orphaned mid-run, the stdin pipe can
enter a half-open/broken state; a readable stream on a broken fd can spin the event
loop (data/readable in a tight loop) at high CPU without ever reaching
process.exit(0). This matches the ~45%-CPU-forever signature and the fact that
both hooks (very different bodies) leak identically — the common factor is stdin +
orphaning, not the hook body.
-
Self-amplifying via inference(). SatisfactionCapture calls
inference() (LIFEOS/TOOLS/Inference.ts) on every prompt, which spawns
claude --print — a heavy, slow subprocess (its own comment notes contention "for
the same claude --print slot"). Under load these easily exceed the 20s hook timeout,
which increases orphaning; the orphans raise load, which slows the next inference,
which times out sooner… a feedback loop. Inference.ts on timeout does
proc.kill('SIGTERM') on the direct child only (not the process group, no
SIGKILL escalation), so a child that ignores SIGTERM or has grandchildren survives.
Reproduction / how to confirm
- Use a session heavily for a while (many tool calls / prompts) across days.
- Periodically:
ps -axo pid,etime,time,command | grep 'hooks/.*\.hook\.ts' — watch
for hook processes with elapsed/CPU-time far exceeding their configured timeout.
- Confirm they are spinning (high CPU) rather than blocked:
top -pid <pid>.
Suggested fixes (for maintainers to consider)
- Reap reliably: ensure hook processes are hard-killed (SIGKILL) when they exceed
their timeout, and kill the whole process group (spawn hooks/inference with a new
session/detached + kill(-pgid) on timeout) so spawned claude --print children
don't orphan.
- Guard the hook body with a self-timeout + hard exit: wrap each hook's
main() in
a top-level setTimeout(() => process.exit(0), N) (unref'd) as a belt-and-braces
self-terminate, independent of Claude Code's timeout.
- Harden stdin reads: after resolving
readStdin (timer or end), explicitly
process.stdin.destroy() / pause() and remove listeners so a broken pipe can't spin
the loop; consider process.stdin.unref().
- De-duplicate the
settings.json UserPromptSubmit registrations for
PromptProcessing and SatisfactionCapture.
- Reconsider spawning
claude --print synchronously from a per-prompt hook — it's
the heaviest, most timeout-prone thing in the hot path and the most likely to orphan.
Workaround (until fixed)
# kill runaway hook processes with > ~10 min of accumulated CPU (a hook should take ms)
ps -axo pid,time,command | awk '/bun .*\.hook\.ts/ {
split($2,t,":"); s=t[1]*60; if (length(t)==3) s=t[1]*3600+t[2]*60;
if (s>600) print $1
}' | xargs -r kill -9
Severity: High — degrades the whole machine, silently, over normal multi-day use.
Environment
Summary
Over several days of normal use, PostToolUse / UserPromptSubmit hook processes
(
bun ~/.claude/hooks/ToolActivityTracker.hook.tsand~/.claude/hooks/SatisfactionCapture.hook.ts) fail to exit and actively burn CPUfor days, accumulating one leaked, spinning process at a time. On my machine this
reached 11 stuck hook processes totalling ~900 CPU-hours, driving the 1-minute
load average to 180 (healthy is ~4–8). Killing the 11 processes immediately dropped
load back toward normal.
Crucially, these hooks have
timeoutset insettings.json(5s forToolActivityTracker, 20s for SatisfactionCapture) — yet the leaked processes had
elapsed times of 3–10 days. So whatever the timeout is meant to do, it is not
terminating the process.
Evidence
ps -axo pid,etime,time,commandfor the leaked hooks (representative rows):CPU-time / elapsed ≈ 45% sustained CPU each — i.e. they are busy-spinning, not
idling on a blocked
await(a hung await would sit near 0% CPU). Load average timelineacross the fix:
What I ruled out (via source review)
The obvious suspect — the event-sourced work-registry write path
(
bumpLastToolActivity → writeRegistry → foldToSnapshot,hooks/lib/work-events.ts) —appears clean:
foldToSnapshot()is straight-line:acquireLock()returns on contention (nospin-wait), single heartbeat write, replay, tmp+rename, release. No loop, no
setInterval.parseEventLines()is a boundedforloop that always advances (no non-advancingparse loop on malformed input).
work-events.jsonlis only ~491K (compaction is working); no stuck*.lockdir.So the spin is not in the work-registry fold.
ToolActivityTracker.hook.tsitself also looks correct in isolation: 2s stdin-readtimeout, wrapped in try/catch, ends in
process.exit(0).Confirmed secondary bug — duplicate hook registration
settings.jsonregisters two hooks twice onUserPromptSubmit:This doubles invocations (and therefore doubles the leak rate) on every prompt. It is
independently wrong and worth fixing regardless of the leak root cause.
Leading hypotheses (not yet definitively proven)
Timeout does not reap the process. The
timeoutinsettings.jsonis set butthe leaked processes lived for days — so either the hook timeout doesn't SIGKILL the
bun process, or it kills only a direct child and orphans others. Once orphaned
(reparented away from Claude Code), nothing reaps them.
Orphaned bun process busy-spins on its stdin pipe. Both leaking hooks attach
process.stdin.on('data', …). When a hook is orphaned mid-run, the stdin pipe canenter a half-open/broken state; a readable stream on a broken fd can spin the event
loop (
data/readablein a tight loop) at high CPU without ever reachingprocess.exit(0). This matches the ~45%-CPU-forever signature and the fact thatboth hooks (very different bodies) leak identically — the common factor is stdin +
orphaning, not the hook body.
Self-amplifying via
inference().SatisfactionCapturecallsinference()(LIFEOS/TOOLS/Inference.ts) on every prompt, which spawnsclaude --print— a heavy, slow subprocess (its own comment notes contention "forthe same claude --print slot"). Under load these easily exceed the 20s hook timeout,
which increases orphaning; the orphans raise load, which slows the next inference,
which times out sooner… a feedback loop.
Inference.tson timeout doesproc.kill('SIGTERM')on the direct child only (not the process group, noSIGKILL escalation), so a child that ignores SIGTERM or has grandchildren survives.
Reproduction / how to confirm
ps -axo pid,etime,time,command | grep 'hooks/.*\.hook\.ts'— watchfor hook processes with elapsed/CPU-time far exceeding their configured
timeout.top -pid <pid>.Suggested fixes (for maintainers to consider)
their timeout, and kill the whole process group (spawn hooks/inference with a new
session/
detached+kill(-pgid)on timeout) so spawnedclaude --printchildrendon't orphan.
main()ina top-level
setTimeout(() => process.exit(0), N)(unref'd) as a belt-and-bracesself-terminate, independent of Claude Code's timeout.
readStdin(timer orend), explicitlyprocess.stdin.destroy()/pause()and remove listeners so a broken pipe can't spinthe loop; consider
process.stdin.unref().settings.jsonUserPromptSubmitregistrations forPromptProcessingandSatisfactionCapture.claude --printsynchronously from a per-prompt hook — it'sthe heaviest, most timeout-prone thing in the hot path and the most likely to orphan.
Workaround (until fixed)