You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
core: global location watcher subscribes to all of $HOME — 2M+ file home dirs starve the event loop (~900% CPU, multi-GB memory), /api/model 503 #55937
opencode: v2.0.30 (also reproduced on v2.0.21; both installed via npm @opencode/cli)
OS: macOS 26.6.2 (Tahoe, Build 25G83), Apple Silicon M4 (arm64)
Zero user plugins, zero custom providers, brand-new config (fully reproducible with a pristine HOME)
Symptoms
opencode serve boots fine, then the server process pins 300–900% CPU and memory keeps climbing to several GB (%mem ~20% and rising). It never recovers.
GET /api/model hangs until the location-plugin activation timeout and returns 503 "Location plugins did not finish activating" — hence the TUI shows "No items available" in the model picker and "No provider selected".
On v2.0.21 the blowup is deferred to the first chat message: as soon as a message is sent, the server child process hits ~587% CPU + multiple GB RSS, and the TUI logs Timed out connecting repeatedly, then shows "Connection lost… Reconnecting".
TUI↔server SSE repeatedly logs Event stream stalled → "Restarting service…" loops, even with all proxy env vars unset.
Debug-log evidence (v2.0.30, --log-level debug)
The server's location watcher subscribes to the entire home directory and filesystem root:
This machine's $HOME contains 2,130,792 files (iCloud Drive sync + years of dev projects). After the watcher subscribes, CPU explodes (sampled while at ~900% CPU: all hot samples burn inside JIT-compiled JS frames, no native symbol names available).
Decisive A/B experiment
Run
$HOME file count
Server CPU
GET /api/model
Real home
2,130,792
909% peak → ~330% sustained, memory climbing
503, activation timeout
Fake empty home (env HOME=/tmp/oc-home opencode serve)
0
0.0–0.2%
200 OK in 165 ms, full free-model list returned
# reproduce (A: broken)
opencode serve &
curl -u "opencode:$PW" http://127.0.0.1:4096/api/model # 503 after activation timeout, CPU pinned# reproduce (B: healthy)
env HOME=$(mktemp -d) opencode serve &
curl -u "opencode:$PW" http://127.0.0.1:4096/api/model # 200 OK in 165 ms
Same failure shape (CPU-heavy work starving the shared event loop → HTTP/SSE stall), but completely different trigger surface: this reproduces with zero user plugins — no 30×3 native-plugin fixture needed. The trigger is the global location watcher subscribing to $HOME// on machines where those trees are huge. Likely same root (event-loop starvation), different entry point.
Expected fix / ask
Don't recursively watch the whole $HOME (and /) by default, or
provide an escape hatch (env var / config) to disable or scope the global location watcher, or
make the initial watch scan incremental/async so it cannot starve the shared event loop.
Happy to provide sample call-stack dumps or any further data points.
Thanks for the detailed report and the A/B comparison. I tried to reproduce this on Linux with @opencode/cli@2.0.30: a HOME with 500k files and a loop that kept creating files deep inside it while opencode serve --log-level debug ran. The debug log showed the same watches you posted (/ and $HOME as type=entries, ~/.config/opencode as type=directory). The server stayed healthy, though: around 10–20% CPU, and GET /api/model returned 200 in a few milliseconds.
Those entries watches aren't recursive. Each is a single-directory fs.watch that only cares about a few config file names. On Linux that's an inotify watch on just that directory, so changes deeper in $HOME never reach OpenCode. On macOS, I believe the runtime uses FSEvents for directory watches, which reports changes from the whole subtree. If so, every change under $HOME or / (such as iCloud Drive syncing) would reach OpenCode only to be discarded. That fits what you're seeing, but I haven't confirmed it.
Confirming it needs a maintainer with a Mac, so I've left this for them. Related issues: #55272 (same event-loop starvation, triggered by plugins), #53811 and #54114 (watcher CPU loops on macOS/skill dirs), #45611 (skip watching filesystem roots). If you can share a sample of the server process taken while CPU is pinned, that would help show whether the time is spent handling fs.watch events.
Thanks for the quick reproduction attempt and the platform-semantics hypothesis. New experiments on macOS narrow this down further — and partly correct my own initial framing: the watcher events themselves are NOT the trigger. The trigger is the activation pass started by GET /api/model (and presumably any request that awaits location activation).
Controlled experiment (v2.0.30, real ~2.1M-file $HOME, fresh boot each time)
Step
CPU
Notes
opencode serve, idle, no requests
0.0%
watcher subscribed to /, /Users/chuan etc. per debug log — cheap
touch 200 new files deep in $HOME
0.3%
watcher event flood is NOT the problem
GET /api/model
917% CPU
pinned across 3 samples
second GET /api/model
hangs 20s+, HTTP 000
event loop fully starved, server unresponsive
same requests with HOME=$(mktemp -d)
0.0% CPU, 200 OK in 165 ms
full model list returned
Memory: RSS climbs to ~4.5 GB (%mem 17.8 and rising) during the pinned phase.
sample evidence: a self-recursive JS frame
Captured while CPU was ~900% (sample <pid> 4). Bun strips symbols so frames show as offsets, but the tree contains the same code offset nested inside itself at many depths — a JS function recursively calling itself:
All hot samples (1500+/1526) sit under the main thread inside JIT frames (in <unknown binary>), consistent with a single runaway synchronous recursion rather than GC or I/O wait. The recursion depth varies between captures, which fits "enumerate/scan a huge tree synchronously" — and the multi-GB RSS growth suggests per-node allocations along the way.
Updated picture
Linux repro (your 500k-file HOME staying healthy at 10–20% CPU) vs macOS pinning at ~900% on a 2.1M-file HOME: consistent with a macOS-specific cost in the activation scan (FSEvents-backed fs semantics in Bun, or a platform-specific enumeration path), but the trigger is the activation pass, not watch-event delivery.
Your follow-up rules out the watch-event flood hypothesis. I traced current dev, but haven't reproduced your macOS failure or identified the hot function. The ancestor config walk checks fixed paths; the built-in skill scans target config/compatibility skill directories. I haven't found evidence that activation enumerates all of HOME.
There is a built-in JS CPU profiler in v2.0.30: send SIGPROF to the server PID before the first /api/model request:
kill -PROF <server-pid># Once the log says "CPU profile started", immediately repeat your /api/model request.
After ten seconds it writes cpu-<pid>-*.cpuprofile in the OpenCode log directory. Could you inspect it locally and share just the hottest function names, source module URLs (with personal paths redacted), and their sample percentages? No workspace contents or full profile are needed. This should help distinguish recursive JS work from the native frames in sample.
Also, does the request still fail with OPENCODE_FILEWATCHER_DISABLE=1 set when launching the server, keeping HOME and the requested location directory unchanged? That existing switch disables watcher subscriptions; comparing it with the profile would narrow the activation path without adding another setting.
everything else — all remaining frames are JS in file:///$bunfs/root/chunk-*.js
Ancestor chains:
create ← L[chunk-kemvbb8p.js:3] ← (anonymous)[chunk-kemvbb8p.js:17] ← runInAsyncScope[node:async_hooks:202] ← evaluate / runLoop[chunk-kemvbb8p.js:17] ← afterScheduled / runTasks[chunk-kemvbb8p.js:3] ← (root). So the hot native create is invoked from the Effect runtime scheduler chunk.
Correction to my previous comment: my earlier sample-based "runaway JS recursion" reading was an over-interpretation of trampolined frames. The cpuprofile shows the repeating deepest path is the Effect runtime loop itself — ~effect/Effect/evaluate → runLoop → evaluate → E → uu, 6 repeats at depth 40, with self time ≈0.00% each. No user-level JS recursion is burning CPU; ~99% of sampled self time sits in two native frames.
Caveat worth being explicit about: 81 samples over 10.07s means exactly one thread was busy for the whole window, while the process reports 800–900% CPU. So the profile appears to cover the main JS thread only; the remaining ~800% is on threads the profiler isn't sampling.
Ignoring the profile, same run measured: CPU 824%, RSS 3.1 GB, GET /api/model → 503 after 11.6s.
2. OPENCODE_FILEWATCHER_DISABLE=1 — switch verified effective, hang unchanged
I first confirmed the switch is actually honored rather than silently ignored: without it and with --log-level debug, the log emits watcher subscribe … lines; with it set, zero watcher log lines appear. Yet:
env
idle CPU
CPU during /api/model
RSS
result
real $HOME
0.0%
866%
3.5 GB
503 after 9.7s
real $HOME + OPENCODE_FILEWATCHER_DISABLE=1
0.0%
769–866%
3.5–4.8 GB
503 / 30s timeout (HTTP 000)
So watcher subscriptions are excluded as the trigger — consistent with the event-flood test (idle 0.0%, touch of 200 files → 0.3%).
3. A/B narrowing the trigger surface (relevant to "no evidence activation enumerates all of $HOME")
$HOME
config
data dir
CPU
/api/model
real (2,130,792 files)
real
real
800–900%
503
fresh temp $HOME
copied verbatim from real .config/opencode (same plugin list, accounts, skills)
none
0.9%
200, 146 ms, 8404 B
fresh temp $HOME
copied verbatim
copied real .local/share/opencode (sessions, storage, opencode.db; 11k files)
0.1%
200, 859 ms, 106 KB
Not plugins, not skills config, not sessions/storage/DB. Identical config + data inside an empty $HOME is fast; the real $HOME pins. So something outside .config/opencode and .local/share/opencode is doing the work.
4. Log evidence of what gets subscribed (run 5a8d12cf, v2.0.30)
At boot only location services booted (durationMs=66). The subscriptions appear ~10.5 s later — i.e. lazily at activation, inside the pinned window:
type=entries subscriptions on / and on /Users/chuan land exactly in the pinned window, and /Users/chuan alone is 2.13M entries on this machine.
One tension I'd rather flag than paper over: with OPENCODE_FILEWATCHER_DISABLE=1 those subscriptions are absent and the hang still reproduces. So either the entries enumeration runs on a path not gated by that switch, or a second code path walks $HOME independently of the watcher.
Ask / next
If you can map chunk-kemvbb8p.js → the mangled L that calls the native create, that would identify the allocation site directly.
I can attach the path-redacted .cpuprofile, or run an instrumented build if you point me at one.
I can also bisect which $HOME subtree triggers this (.cache 466k / Library 1.57M / Projects 713k files) if that's useful to you.
Repro on this machine (paths redacted): real $HOME, opencode serve --print-logs --log-level debug from any directory → kill -PROF <server-pid>, wait for CPU profile started, then GET /api/model with the printed password → 503 and event loop starved; same binary with a fresh $HOME containing copies of the real config → 200 in 146 ms.
Root cause found: a symlink cycle reachable through the skill roots — not $HOME size, and not the file watcher
First, a correction of my two earlier comments. Both of my earlier hypotheses are now falsified by direct A/B experiments on the same machine. The real trigger is a self-referencing symlink cycle inside a skill directory, and the runaway happens during skill discovery.
The symlinked skill target contains a symlink back into itself, so walking …/.claude/skills/pandaai-factor-online never terminates.
Decisive A/B on the real home (macOS, opencode v2.0.30)
Only variable changed: the two cycle symlinks above.
#
state
CPU
RSS
GET /api/model
R2
cycle symlinks present
817.8 %
3918 MB
000 (25 s timeout)
R1
cycle symlinks removed
1.8 %
499 MB
200 OK, 0.82 s, 106,942 B → 122 models
Everything else identical: same $HOME (2,130,792 files), same opencode.json, same storage dirs, same watcher configuration. With the cycle gone, v2.0.30 is fully usable on this machine, model list included. So home-directory size is not the cause.
Falsified earlier claims
earlier claim
experiment
result
2M+ files in $HOME starve the event loop
2.5 M synthetic zero-byte files under a fake $HOME
200 OK, 0.0 % CPU
the global watcher scanning $HOME is the trigger
OPENCODE_FILEWATCHER_DISABLE=1, confirmed with --log-level debug (watcher subscribe lines → 0)
unchanged: 866 % CPU / 503
duplicate skill names collide
2 roots × 11 identical skill names, no cycle
200 OK, 0.5 % CPU
the skill's content is the trigger
same content, cycle symlinks stripped, mounted in 2 roots
200 OK, 0.1 % CPU
a plain intra-directory cycle is enough
1 root, <skill>/.claude/skills -> ..
200 OK
symlinked skill entries are enough
2 roots, symlinked entries pointing at a cycle-free directory
200 OK, 0.0 % CPU
plugins are involved
plugin: [] in opencode.json throughout
—
Minimum conditions I could reproduce
a skill reachable from a skill root whose target tree contains a symlink pointing back into that target tree, and
that skill being visible from ≥ 2 skill roots.
In my environment ~/.claude/skills, ~/.agents/skills and ~/.mirasim/skills are all symlinks resolving to the same directory, so the same skill gets registered three times — and only then does the scan run away. A single live root holding the exact same cycle did not reproduce, which points at the cross-root de-duplication/merge step rather than at a plain recursive directory walk.
Minimal repro
TGT=/tmp/skill-cycle; mkdir -p "$TGT"printf -- '---\nname: skill-x\ndescription: x\n---\n'>"$TGT/SKILL.md"
mkdir -p "$TGT/.claude/skills"
ln -s "$TGT""$TGT/.claude/skills/skill-x"# cycle
mkdir -p ~/.claude/skills ~/.mirasim/skills
ln -s "$TGT"~/.claude/skills/skill-x
ln -s "$TGT"~/.mirasim/skills/skill-x
opencode serve # then GET /api/model → CPU pegs ~800 %, request times out
Where the cycle came from (third party, not opencode)
scripts/install.py shipped with the pandaai-factor-online skill. Its install_project() symlinks the skill into <directory>/.claude/skills/<NAME> pointing at the repo root SRC; when invoked as install.py projectfrom inside the repo it falls back to Path.cwd(), i.e. directory == SRC, producing <SRC>/.claude/skills/<NAME> -> <SRC>. There is no guard against directory == SRC.
Suggestion
Whatever creates the layout, a directory shape should not be able to take opencode down. Hardening I'd suggest on the opencode side:
Do not follow symlinks while walking skill/discovery roots — or, if following is intentional, keep a visited set of realpaths plus a depth cap so a cycle degrades to "skill skipped".
Apply the same protection in whichever step merges/de-duplicates a skill seen from several roots.
Add a node-visit budget or timeout to the discovery path, so a pathological layout yields a warning instead of a pegged event loop.
Happy to run an instrumented build for any of these, and to re-attach the cpuprofile if useful.
path=/ is the filesystem root. The /private/var/folders/... chain is the ancestor chain of the agent scratch directory (see #55006) — that location alone pulls in /, /private, /private/var, /private/var/folders, /private/var/folders/m6, .../k0xt0jw57f7831mf7j2q4tqw0000gn, .../T and .../T/opencode.
Two backends appear in the log: backend=node (3122 events) and backend=fs-events (1629). The node backend is the fallback used for the ancestor paths FSEvents cannot cover — which is exactly the set that turns a file change anywhere on the volume into an event storm inside the server.
Reproduced symptom on this machine: ripgrep invoked with cwd=/Users/cb (688 occurrences in the log) together with --glob=*.py — i.e. globbing the entire home directory — and a single find "$HOME" -xdev -type f -not -path "$HOME/Library/*" did not finish within 180 s.
Cost before mitigation on this machine: 90 MB free RAM, a 13.6 GB compressor, 95 % of a 12 GB swapfile in use, load averages 6.71 / 7.85 / 9.11.
Mitigation available today — watcher.ignore does work for the root walk. With these patterns configured, the /Users and /Users/cb subscriptions drop to 0 and only explicitly-scoped directories remain watched:
watcher.ignore does not filter the explicit per-skill-directory subscriptions (~/.claude/skills, ~/.agents/skills, and the same pair inside each project-like directory opencode discovers) — those use a different code path. They are individually cheap, but the ignore list alone cannot reduce the subscription set to zero.
The expensive part is the re-subscribe cascade, not the steady-state watch set. In one log file I count 4466 watcher stopped and 4754 watcher started events. A change under one watched skills directory tears down and recrees the watchers for all of them, which is what produces the CPU spike during build churn.
A fix that refuses to subscribe to a path that is an ancestor of the workspace root (or of the scratch dir) would remove the need for a user-maintained ignore list entirely.
Activity
opencode-agent commentedon Oct 16, 2026
Thanks for the detailed report and the A/B comparison. I tried to reproduce this on Linux with
@opencode/cli@2.0.30: aHOMEwith 500k files and a loop that kept creating files deep inside it whileopencode serve --log-level debugran. The debug log showed the same watches you posted (/and$HOMEastype=entries,~/.config/opencodeastype=directory). The server stayed healthy, though: around 10–20% CPU, andGET /api/modelreturned 200 in a few milliseconds.Those
entrieswatches aren't recursive. Each is a single-directoryfs.watchthat only cares about a few config file names. On Linux that's an inotify watch on just that directory, so changes deeper in$HOMEnever reach OpenCode. On macOS, I believe the runtime uses FSEvents for directory watches, which reports changes from the whole subtree. If so, every change under$HOMEor/(such as iCloud Drive syncing) would reach OpenCode only to be discarded. That fits what you're seeing, but I haven't confirmed it.Confirming it needs a maintainer with a Mac, so I've left this for them. Related issues: #55272 (same event-loop starvation, triggered by plugins), #53811 and #54114 (watcher CPU loops on macOS/skill dirs), #45611 (skip watching filesystem roots). If you can share a
sampleof the server process taken while CPU is pinned, that would help show whether the time is spent handlingfs.watchevents.CHANGGELY commentedon Oct 16, 2026
Thanks for the quick reproduction attempt and the platform-semantics hypothesis. New experiments on macOS narrow this down further — and partly correct my own initial framing: the watcher events themselves are NOT the trigger. The trigger is the activation pass started by
GET /api/model(and presumably any request that awaits location activation).Controlled experiment (v2.0.30, real ~2.1M-file
$HOME, fresh boot each time)opencode serve, idle, no requests/,/Users/chuanetc. per debug log — cheaptouch200 new files deep in$HOMEGET /api/modelGET /api/modelHOME=$(mktemp -d)Memory: RSS climbs to ~4.5 GB (
%mem17.8 and rising) during the pinned phase.sampleevidence: a self-recursive JS frameCaptured while CPU was ~900% (
sample <pid> 4). Bun strips symbols so frames show as offsets, but the tree contains the same code offset nested inside itself at many depths — a JS function recursively calling itself:All hot samples (1500+/1526) sit under the main thread inside JIT frames (
in <unknown binary>), consistent with a single runaway synchronous recursion rather than GC or I/O wait. The recursion depth varies between captures, which fits "enumerate/scan a huge tree synchronously" — and the multi-GB RSS growth suggests per-node allocations along the way.Updated picture
Ask (updated)
$HOME//— make it incremental/async or scope it.Full
sampleoutput available on request. Happy to test a fix build.Dante-dan commentedon Oct 16, 2026
Your follow-up rules out the watch-event flood hypothesis. I traced current
dev, but haven't reproduced your macOS failure or identified the hot function. The ancestor config walk checks fixed paths; the built-in skill scans target config/compatibility skill directories. I haven't found evidence that activation enumerates all of HOME.There is a built-in JS CPU profiler in v2.0.30: send
SIGPROFto the server PID before the first/api/modelrequest:After ten seconds it writes
cpu-<pid>-*.cpuprofilein the OpenCode log directory. Could you inspect it locally and share just the hottest function names, source module URLs (with personal paths redacted), and their sample percentages? No workspace contents or full profile are needed. This should help distinguish recursive JS work from the native frames insample.Also, does the request still fail with
OPENCODE_FILEWATCHER_DISABLE=1set when launching the server, keeping HOME and the requested location directory unchanged? That existing switch disables watcher subscriptions; comparing it with the profile would narrow the activation path without adding another setting.CHANGGELY commentedon Oct 16, 2026
Ran both of your asks. Results below — and one of them corrects an inference I made in my previous comment.
1.
kill -PROFcpuprofile (v2.0.30, window 10.07s, 237 nodes, 81 samples)Self time, all frames above 0.05%:
createSocketnode:net:787dlopenvaluesparsefile:///$bunfs/root/chunk-*.jsAncestor chains:
create←L[chunk-kemvbb8p.js:3]←(anonymous)[chunk-kemvbb8p.js:17]←runInAsyncScope[node:async_hooks:202]←evaluate/runLoop[chunk-kemvbb8p.js:17]←afterScheduled/runTasks[chunk-kemvbb8p.js:3]←(root). So the hot nativecreateis invoked from the Effect runtime scheduler chunk.Socket[node:net:787]←Socket[node:_http_server:789]←onServerConnection[node:_http_server:573](root-level child).Root's children:
processTicksAndRejections,afterScheduled,onServerConnection,onNodeHTTPRequest.Correction to my previous comment: my earlier
sample-based "runaway JS recursion" reading was an over-interpretation of trampolined frames. The cpuprofile shows the repeating deepest path is the Effect runtime loop itself —~effect/Effect/evaluate→runLoop→evaluate→E→uu, 6 repeats at depth 40, with self time ≈0.00% each. No user-level JS recursion is burning CPU; ~99% of sampled self time sits in two native frames.Caveat worth being explicit about: 81 samples over 10.07s means exactly one thread was busy for the whole window, while the process reports 800–900% CPU. So the profile appears to cover the main JS thread only; the remaining ~800% is on threads the profiler isn't sampling.
Ignoring the profile, same run measured: CPU 824%, RSS 3.1 GB,
GET /api/model→ 503 after 11.6s.2.
OPENCODE_FILEWATCHER_DISABLE=1— switch verified effective, hang unchangedI first confirmed the switch is actually honored rather than silently ignored: without it and with
--log-level debug, the log emitswatcher subscribe …lines; with it set, zero watcher log lines appear. Yet:/api/model$HOME$HOME+OPENCODE_FILEWATCHER_DISABLE=1So watcher subscriptions are excluded as the trigger — consistent with the event-flood test (idle 0.0%,
touchof 200 files → 0.3%).3. A/B narrowing the trigger surface (relevant to "no evidence activation enumerates all of $HOME")
$HOME/api/model$HOME.config/opencode(same plugin list, accounts, skills)$HOME.local/share/opencode(sessions, storage,opencode.db; 11k files)Not plugins, not skills config, not sessions/storage/DB. Identical config + data inside an empty
$HOMEis fast; the real$HOMEpins. So something outside.config/opencodeand.local/share/opencodeis doing the work.4. Log evidence of what gets subscribed (run
5a8d12cf, v2.0.30)At boot only
location services booted(durationMs=66). The subscriptions appear ~10.5 s later — i.e. lazily at activation, inside the pinned window:type=entriessubscriptions on/and on/Users/chuanland exactly in the pinned window, and/Users/chuanalone is 2.13M entries on this machine.One tension I'd rather flag than paper over: with
OPENCODE_FILEWATCHER_DISABLE=1those subscriptions are absent and the hang still reproduces. So either the entries enumeration runs on a path not gated by that switch, or a second code path walks$HOMEindependently of the watcher.Ask / next
chunk-kemvbb8p.js→ the mangledLthat calls the nativecreate, that would identify the allocation site directly..cpuprofile, or run an instrumented build if you point me at one.$HOMEsubtree triggers this (.cache466k /Library1.57M /Projects713k files) if that's useful to you.Repro on this machine (paths redacted): real
$HOME,opencode serve --print-logs --log-level debugfrom any directory →kill -PROF <server-pid>, wait forCPU profile started, thenGET /api/modelwith the printed password → 503 and event loop starved; same binary with a fresh$HOMEcontaining copies of the real config → 200 in 146 ms.CHANGGELY commentedon Oct 16, 2026
Root cause found: a symlink cycle reachable through the skill roots — not
$HOMEsize, and not the file watcherFirst, a correction of my two earlier comments. Both of my earlier hypotheses are now falsified by direct A/B experiments on the same machine. The real trigger is a self-referencing symlink cycle inside a skill directory, and the runaway happens during skill discovery.
The smoking gun
The symlinked skill target contains a symlink back into itself, so walking
…/.claude/skills/pandaai-factor-onlinenever terminates.Decisive A/B on the real home (macOS, opencode v2.0.30)
Only variable changed: the two cycle symlinks above.
GET /api/model000(25 s timeout)Everything else identical: same
$HOME(2,130,792 files), sameopencode.json, same storage dirs, same watcher configuration. With the cycle gone, v2.0.30 is fully usable on this machine, model list included. So home-directory size is not the cause.Falsified earlier claims
$HOMEstarve the event loop$HOME200 OK, 0.0 % CPU$HOMEis the triggerOPENCODE_FILEWATCHER_DISABLE=1, confirmed with--log-level debug(watcher subscribelines → 0)200 OK, 0.5 % CPU200 OK, 0.1 % CPU<skill>/.claude/skills -> ..200 OK200 OK, 0.0 % CPUplugin: []inopencode.jsonthroughoutMinimum conditions I could reproduce
In my environment
~/.claude/skills,~/.agents/skillsand~/.mirasim/skillsare all symlinks resolving to the same directory, so the same skill gets registered three times — and only then does the scan run away. A single live root holding the exact same cycle did not reproduce, which points at the cross-root de-duplication/merge step rather than at a plain recursive directory walk.Minimal repro
Where the cycle came from (third party, not opencode)
scripts/install.pyshipped with thepandaai-factor-onlineskill. Itsinstall_project()symlinks the skill into<directory>/.claude/skills/<NAME>pointing at the repo rootSRC; when invoked asinstall.py projectfrom inside the repo it falls back toPath.cwd(), i.e.directory == SRC, producing<SRC>/.claude/skills/<NAME> -> <SRC>. There is no guard againstdirectory == SRC.Suggestion
Whatever creates the layout, a directory shape should not be able to take opencode down. Hardening I'd suggest on the opencode side:
realpaths plus a depth cap so a cycle degrades to "skill skipped".Happy to run an instrumented build for any of these, and to re-attach the
cpuprofileif useful.CerisonAutomation commentedon Oct 22, 2026
The watcher subscribes to every ancestor directory of a discovered location — all the way up to
/.opencode 2.1.2 (Homebrew), macOS 27.0.1, M4 Max / 36 GB.
Distinct
watcher startedpaths captured from the server log:path=/is the filesystem root. The/private/var/folders/...chain is the ancestor chain of the agent scratch directory (see #55006) — that location alone pulls in/,/private,/private/var,/private/var/folders,/private/var/folders/m6,.../k0xt0jw57f7831mf7j2q4tqw0000gn,.../Tand.../T/opencode.Two backends appear in the log:
backend=node(3122 events) andbackend=fs-events(1629). Thenodebackend is the fallback used for the ancestor paths FSEvents cannot cover — which is exactly the set that turns a file change anywhere on the volume into an event storm inside the server.Reproduced symptom on this machine:
ripgrepinvoked withcwd=/Users/cb(688 occurrences in the log) together with--glob=*.py— i.e. globbing the entire home directory — and a singlefind "$HOME" -xdev -type f -not -path "$HOME/Library/*"did not finish within 180 s.Cost before mitigation on this machine: 90 MB free RAM, a 13.6 GB compressor, 95 % of a 12 GB swapfile in use, load averages 6.71 / 7.85 / 9.11.
Mitigation available today —
watcher.ignoredoes work for the root walk. With these patterns configured, the/Usersand/Users/cbsubscriptions drop to 0 and only explicitly-scoped directories remain watched:Two caveats worth knowing when reproducing:
watcher.ignoredoes not filter the explicit per-skill-directory subscriptions (~/.claude/skills,~/.agents/skills, and the same pair inside each project-like directory opencode discovers) — those use a different code path. They are individually cheap, but the ignore list alone cannot reduce the subscription set to zero.watcher stoppedand 4754watcher startedevents. A change under one watched skills directory tears down and recrees the watchers for all of them, which is what produces the CPU spike during build churn.A fix that refuses to subscribe to a path that is an ancestor of the workspace root (or of the scratch dir) would remove the need for a user-maintained ignore list entirely.