Summary
dashboard-service.sh start reaps any claude-smart dashboard on port 3001 whose x-claude-smart-plugin-root header doesn't match the caller's PLUGIN_ROOT, then tries to bind a new one. When two callers race in the same second — common during multi-host installs, multi-host SessionStart hooks, and pytest e2e test runs — the loser hits EADDRINUSE, the script exits, and nothing schedules a retry. The dashboard is then dead until a user runs dashboard-service.sh start manually.
The bug is broader than the install path: any process that calls dashboard-service.sh start with a different PLUGIN_ROOT triggers the same race. The five callers observed in the wild:
npx claude-smart install --host X (one per host)
npx claude-smart update --host X (one per host)
- Each host's
SessionStart hook (Claude Code, Codex, OpenCode — each plugin dir registers independently)
- Pytest e2e tests with a fixture
PLUGIN_ROOT (writes to its own log under /private/var/folders/.../home/.claude-smart/, so the reap is invisible from ~/.claude-smart/dashboard.log)
- Stale leftover plugin caches (e.g., the 0.2.45 Claude Code cache from a prior install was on disk and held its own
dashboard-service.sh — workaround is rm -rf ~/.claude/plugins/cache/reflexioai/claude-smart/<old-version>)
Reproduction (deterministic on this machine, but the underlying issue is cross-platform)
Trigger A: multi-host install back-to-back
npx -y claude-smart@0.2.49 install --host claude-code
npx -y claude-smart@0.2.49 install --host codex
npx -y claude-smart@0.2.49 install --host opencode
Each install writes its own plugin copy to a separate directory, then ends with dashboard-service.sh start against the just-installed PLUGIN_ROOT. The four PLUGIN_ROOT paths on this machine were:
/Users/derrickhan/.npm/_npx/66a6b70155dfac7e/node_modules/claude-smart (npx cache for the claude-code install)
/Users/derrickhan/.claude/plugins/cache/reflexioai/claude-smart/0.2.49 (Claude Code cache)
/Users/derrickhan/.codex/plugins/cache/reflexioai/claude-smart/0.2.49 (codex cache)
/Users/derrickhan/.claude-smart/opencode/claude-smart/plugin (opencode local package)
Trigger B: SessionStart hooks firing in the same second
The hook.log shows three SessionStart events at 23:51:20 PDT (one claude-code resume, two opencode) within ~57 seconds. Each host's SessionStart hook calls dashboard-service.sh start with its own PLUGIN_ROOT. The same race that the install path triggers also fires from the hooks alone — no install required. The hook call is the same code path as the install tail-end, so the symptoms are identical.
Trigger C: pytest e2e test against a fixture PLUGIN_ROOT
A pytest e2e test was running with a temp install path (/private/var/folders/.../home/.claude-smart/opencode/claude-smart/plugin). The test calls dashboard-service.sh start with the temp PLUGIN_ROOT, the script reaps the real running dashboard, and the test takes over port 3001. The test's reap is invisible from ~/.claude-smart/dashboard.log because the test writes to its own dashboard.log under the temp dir.
Observed dashboard log (verbatim, Trigger A)
~/.claude-smart/dashboard.log shows nine dashboard starts in roughly four minutes, each switching PLUGIN_ROOT. The middle attempts "succeed" (Next.js reports Ready in Nms) but the script's marker_responds reap keeps killing them because their x-claude-smart-plugin-root header doesn't match the install's PLUGIN_ROOT. The last attempt loses the bind:
> next start -p 3001 -H 127.0.0.1
▲ Next.js 16.2.6
- Local: http://127.0.0.1:3001
- Network: http://127.0.0.1:3001
✓ Ready in 69ms
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
/Users/derrickhan/.npm/_npx/66a6b70155dfac7e/node_modules/claude-smart/plugin
✓ Ready in 95ms
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
/Users/derrickhan/.claude/plugins/cache/reflexioai/claude-smart/0.2.49
✓ Ready in 89ms
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
/Users/derrickhan/.claude/plugins/cache/reflexioai/claude-smart/0.2.49
✓ Ready in 131ms
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
/Users/derrickhan/.claude-smart/opencode/claude-smart/plugin
[claude-smart] dashboard: stale claude-smart dashboard on port 3001; restarting from
/Users/derrickhan/.claude-smart/opencode/claude-smart/plugin
✓ Ready in 130ms
⨯ Failed to start server
Error: listen EADDRINUSE: address already in use 127.0.0.1:3001
at <unknown> (Error: listen EADDRINUSE: address already in use 127.0.0.1:3001) {
code: 'EADDRINUSE', errno: -48, syscall: 'listen', address: '127.0.0.1', port: 3001
}
After the last line, ~/.claude-smart/dashboard.pid still points at a process that no longer exists, port 3001 is free, and dashboard-service.sh start is never re-invoked by the hook or the install script. The dashboard is dead.
Root cause
plugin/scripts/dashboard-service.sh start case (lines 173–204) is a "check then spawn" pattern with no PID-file lock and no EADDRINUSE retry. The relevant flow:
if is_our_dashboard_running; then ... exit 0; fi # line 184
if marker_responds; then
stop_dashboard_listener # line 187 — reap
fi
if port_occupied; then ...; exit 0; fi # line 189 — skip
... # line 230ish — spawn next start
Two compounding gaps:
-
start has no EADDRINUSE retry. When the next start spawn at the bottom hits EADDRINUSE (because the reap above didn't fully release the port, or because two callers raced), start exits and never schedules a retry. The dashboard stays down until a user runs start manually.
-
is_our_dashboard_running is keyed on PLUGIN_ROOT identity (dashboard-service.sh:142 → dashboard_matches_current_root at line 116). Any caller with a different PLUGIN_ROOT sees the running dashboard as "not ours", drops into the reap path at line 187, and kills it. This is correct for a single-user single-install world but wrong for the actual world: the same user can have multiple plugin paths active simultaneously, and any one of them will reap the others.
backend-service.sh has the same shape (is_our_backend_running at line 148, reap_port_listeners at line 176) but in this repro it didn't crash — the reflexio CLI's services start is naturally more idempotent because the new uvicorn exits cleanly when its reflexio peer is already running, and the pid file + log are in ~/.claude-smart/ so the script does converge. The dashboard doesn't have that luck.
Suggested fix direction
The minimum fix that covers all five callers is a PID-file flock around the check-spawn window so two start invocations serialize:
exec 9>"$PID_FILE.lock"
flock -n 9 || { emit_ok; exit 0; } # someone else is starting; that's fine
... existing is_our_dashboard_running / stop_dashboard_listener / port_occupied / spawn ...
This works because:
Smaller follow-up that helps but doesn't close the bug:
Workaround applied (not a fix)
rm -rf ~/.claude/plugins/cache/reflexioai/claude-smart/0.2.45
This removes one stale leftover plugin cache so its dashboard-service.sh is no longer callable. Reduces the surface area but other PLUGIN_ROOT callers remain.
Environment
- claude-smart 0.2.49 (just released)
- macOS (darwin), Apple Silicon
- Node v22.22.2, uv-managed Python 3.12 venv
- Reproduced on a single user account with all three hosts installed in sequence
Cross-references
Summary
dashboard-service.sh startreaps any claude-smart dashboard on port 3001 whosex-claude-smart-plugin-rootheader doesn't match the caller'sPLUGIN_ROOT, then tries to bind a new one. When two callers race in the same second — common during multi-host installs, multi-host SessionStart hooks, and pytest e2e test runs — the loser hitsEADDRINUSE, the script exits, and nothing schedules a retry. The dashboard is then dead until a user runsdashboard-service.sh startmanually.The bug is broader than the install path: any process that calls
dashboard-service.sh startwith a differentPLUGIN_ROOTtriggers the same race. The five callers observed in the wild:npx claude-smart install --host X(one per host)npx claude-smart update --host X(one per host)SessionStarthook (Claude Code, Codex, OpenCode — each plugin dir registers independently)PLUGIN_ROOT(writes to its own log under/private/var/folders/.../home/.claude-smart/, so the reap is invisible from~/.claude-smart/dashboard.log)dashboard-service.sh— workaround isrm -rf ~/.claude/plugins/cache/reflexioai/claude-smart/<old-version>)Reproduction (deterministic on this machine, but the underlying issue is cross-platform)
Trigger A: multi-host install back-to-back
Each
installwrites its own plugin copy to a separate directory, then ends withdashboard-service.sh startagainst the just-installedPLUGIN_ROOT. The fourPLUGIN_ROOTpaths on this machine were:/Users/derrickhan/.npm/_npx/66a6b70155dfac7e/node_modules/claude-smart(npx cache for the claude-code install)/Users/derrickhan/.claude/plugins/cache/reflexioai/claude-smart/0.2.49(Claude Code cache)/Users/derrickhan/.codex/plugins/cache/reflexioai/claude-smart/0.2.49(codex cache)/Users/derrickhan/.claude-smart/opencode/claude-smart/plugin(opencode local package)Trigger B: SessionStart hooks firing in the same second
The hook.log shows three SessionStart events at 23:51:20 PDT (one claude-code resume, two opencode) within ~57 seconds. Each host's
SessionStarthook callsdashboard-service.sh startwith its ownPLUGIN_ROOT. The same race that the install path triggers also fires from the hooks alone — no install required. The hook call is the same code path as the install tail-end, so the symptoms are identical.Trigger C: pytest e2e test against a fixture
PLUGIN_ROOTA pytest e2e test was running with a temp install path (
/private/var/folders/.../home/.claude-smart/opencode/claude-smart/plugin). The test callsdashboard-service.sh startwith the tempPLUGIN_ROOT, the script reaps the real running dashboard, and the test takes over port 3001. The test's reap is invisible from~/.claude-smart/dashboard.logbecause the test writes to its owndashboard.logunder the temp dir.Observed dashboard log (verbatim, Trigger A)
~/.claude-smart/dashboard.logshows nine dashboard starts in roughly four minutes, each switchingPLUGIN_ROOT. The middle attempts "succeed" (Next.js reportsReady in Nms) but the script'smarker_respondsreap keeps killing them because theirx-claude-smart-plugin-rootheader doesn't match the install'sPLUGIN_ROOT. The last attempt loses the bind:After the last line,
~/.claude-smart/dashboard.pidstill points at a process that no longer exists, port 3001 is free, anddashboard-service.sh startis never re-invoked by the hook or the install script. The dashboard is dead.Root cause
plugin/scripts/dashboard-service.shstartcase (lines 173–204) is a "check then spawn" pattern with no PID-file lock and no EADDRINUSE retry. The relevant flow:Two compounding gaps:
starthas no EADDRINUSE retry. When thenext startspawn at the bottom hitsEADDRINUSE(because the reap above didn't fully release the port, or because two callers raced),startexits and never schedules a retry. The dashboard stays down until a user runsstartmanually.is_our_dashboard_runningis keyed onPLUGIN_ROOTidentity (dashboard-service.sh:142→dashboard_matches_current_rootat line 116). Any caller with a differentPLUGIN_ROOTsees the running dashboard as "not ours", drops into the reap path at line 187, and kills it. This is correct for a single-user single-install world but wrong for the actual world: the same user can have multiple plugin paths active simultaneously, and any one of them will reap the others.backend-service.shhas the same shape (is_our_backend_runningat line 148,reap_port_listenersat line 176) but in this repro it didn't crash — the reflexio CLI'sservices startis naturally more idempotent because the newuvicornexits cleanly when itsreflexiopeer is already running, and the pid file + log are in~/.claude-smart/so the script does converge. The dashboard doesn't have that luck.Suggested fix direction
The minimum fix that covers all five callers is a PID-file
flockaround the check-spawn window so twostartinvocations serialize:This works because:
startcallers serialize through the lock. The first one wins, binds 3001, releases the lock. The second one acquires the lock, sees the now-correctly-PLUGIN_ROOT-matching listener, and exits 0 (no-op).backend-service.sh(Stale backend holding port blocks restart -> dashboard 502 (start doesn't reap own unhealthy listener; lsof-gated reap no-ops on Windows) #108 is the dead-but-listening case on Windows; the sameflockwould close both).Smaller follow-up that helps but doesn't close the bug:
dashboard-service.sh start(and ideally tobackend-service.sh start). Three retries at 1/2/5 s with a freshport_occupiedcheck between each. Reduces the failure rate but doesn't eliminate the race; the second caller would still reap the first caller's listener when it eventually binds.dashboard-service.sh startat the end ofclaude-smart installif a dashboard is already running on port 3001, regardless ofPLUGIN_ROOTmatch. Closes caller refactor: scope user profiles to project instead of session #1 and refactor(cite): rank+fingerprint citation ids + uninstall subcommand #2 only; github workflow integration test #3 (SessionStart hook) and chore: bump reflexio to 0.2.15, raise node engines to >=20.9.0 #4 (test) still race.Workaround applied (not a fix)
This removes one stale leftover plugin cache so its
dashboard-service.shis no longer callable. Reduces the surface area but otherPLUGIN_ROOTcallers remain.Environment
Cross-references
Stale backend holding port blocks restart -> dashboard 502 (start doesn't reap own unhealthy listener; lsof-gated reap no-ops on Windows). Same underlying "start is fragile" weakness, different angle (single dead-but-listening wedge vs. multi-caller race). Theflockidea above would close both.