Skip to content

feat(daemon): opt-in shared-mode install, status/recovery UX, benchmarks and acceptance gates (#19) - #50

Merged
axisrow merged 9 commits into
mainfrom
ao/ccstatusline-22/daemon-install-status-19
Sep 30, 2026
Merged

axisrow merged 9 commits into
mainfrom
ao/ccstatusline-22/daemon-install-status-19

Conversation

@axisrow

@axisrow axisrow commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #19

Daemon delivery step 4: opt-in installation, status/recovery UX, benchmarks with honest acceptance numbers. Builds on #16 (transport), #17 (lifecycle), #18 (providers/caches); branch base = d79f0b4 (latest main).

What's here

Opt-in installation (shared mode)

  • enableSharedMode() / disableSharedMode() in src/utils/claude-settings.ts: switch the Claude Code statusLine to the IPC client wrapper (sh <install>/client/ccstatusline-ipc) and back. The previous one-shot command is remembered in ccstatusline's own settings (daemonSharedMode.previousStatusLine, incl. null for "there was no status line") and restored verbatim; enable is idempotent; corrupt/unreadable settings are refused on both sides; Windows refuses (documented in docs/WINDOWS.md).
  • ccstatusline daemon install|uninstall drive the switch. Nothing on the render path ever starts a daemon: the wrapper fails with empty stdout + exit 1 when it is down; starting one is only ever daemon start|install. install rolls the switch back if the daemon start fails and this run did the switch.
  • client/ccstatusline-ipc ships in the npm package (files += client/).

Status/recovery UX

  • daemon status surfaces protocol/build identity, pid, uptime, started-at, last render time, active renders and aggregate request counters from /v1/health — identity and timings, never secrets (no token in the payload; pinned by test).
  • Verified upgrade restart via daemon restart when a running daemon reports incompatible.

Benchmarks

scripts/benchmark-render.py daemon-bench: one-shot vs shared, same fixtures and wave pattern; shared aggregate CPU covers the daemon and every client (reaped children, getrusage(RUSAGE_CHILDREN)). Cold/warm × small/large × slow-command, 1/10/36/50 sessions, plus a deterministic recovery proof gated in the exit code. Results: docs/daemon-19-results.json (full JSON), docs/daemon-19-bench.md (generated tables), docs/daemon-19-verification.md (method + honesty).

Acceptance gates (epic checklist) — measured, not assumed

Apple M5 (10 cores), macOS 26, Node runtime, CCSL_FORK=1, width 120, load gate 14 (machine not idle; per-scenario loadavg stamped).

Gate Target Measured Result
Aggregate-CPU reduction, warmed 36-session workload, daemon + clients included ≥50% 180.0 → 34.7 ms CPU/render = 80.7% (38.88 → 7.50 CPU-s) ✅ PASS
Output equality one-shot vs shared unchanged output exactly one distinct hash per scenario in every passing pair; all comparison rows output_hashes_equal=true ✅ PASS
Recovery fail clean + byte-identical re-render SIGKILL → 50/50 clients failed rc≠0 with empty stdout; restart; 0 mismatches; recovery gate PASS (enforced in exit code) ✅ PASS
Shared mode opt-in with tested return to one-shot both directions tested round-trip, verbatim restore, idempotent enable, corrupt-config/Windows refusals — all pinned by tests ✅ PASS

Warm rows (CPU ms/render, shared vs one-shot): s1 16.9/118.1 (−85.7%), s10 27.2/170.1 (−84.0%), s36 34.7/180.0 (−80.7%, gate), s50 37.5/178.9 (−79.0%), s50-large 46.8/215.7 (−78.3%). Aggregate RSS at 50 warm sessions: 3.5 GB (one-shot) vs 0.56 GB (shared).

Measured limitation (kept visible): s50-shared-warm-small-slow fails reproducibly — 50 synchronous sessions × 0.4 s custom command is a ~6–7 s render queue, and the client's bounded ~8 s retry ladder does not guarantee a slot, so some clients give up with 503 busy. That is the designed backpressure boundary (a real repaint retries next tick); the run exits 1 and the row is documented, not tuned away. Details in docs/daemon-19-verification.md.

Verification

  • bun test: 2732 passed / 0 failed (173 files), single full run.
  • bun run lint: clean (tsc --noEmit + ESLint --max-warnings=0).
  • Harness --self-test PASS.
  • Live CLI verification in an isolated environment (fake config dirs, real dist build): 19/19 — piped render starts no daemon; install → status → wrapper render → stop → fail-clean → uninstall → verbatim restore → honest no-op; no token in status output.

Notable fixes found by the acceptance run

  • Client 503 handling: flat 8×50 ms retries lost status lines under 10+ session bursts; now an exponential ladder (~8 s budget) with the payload buffered once so retries re-send identical stdin; signal traps exit instead of resuming the loop.
  • Review follow-ups (all threads resolved): corrupt-settings refusal, restore-before-clear, uninstall refusal semantics, install rollback, recovery invariants enforced in the exit code.

🤖 Generated with Claude Code

axisrow and others added 2 commits September 29, 2026 21:27
- enableSharedMode/disableSharedMode in claude-settings.ts: switch the
  Claude Code statusLine to the IPC client wrapper, remember the previous
  one-shot command in ccstatusline settings, restore it verbatim on return
- daemon install / daemon uninstall subcommands drive the switch; the
  daemon is started only by these explicit commands, never from the
  render path (the wrapper fails clean with empty stdout when it is down)
- daemon status now reports uptime, last render time, active renders and
  aggregate counters from /v1/health — identity and timings, no secrets
- ship client/ccstatusline-ipc in the npm package (files += client/)

Part of #19

Co-Authored-By: Claude Code <noreply@anthropic.com>
#19)

- daemon-shared-mode.test.ts: enable/disable round-trip, previous-command
  memory, idempotent enable, unreadable-settings refusal, Windows refusal,
  wrapper resolution and command classification
- lifecycle status test: uptime/counters surfaced, no token in the payload
- benchmark-render.py: daemon-bench compares one-shot vs shared (cold/warm,
  1/10/36/50 sessions, small/large, slow-command), records daemon health
  counters, and proves recovery: SIGKILL, dead-daemon wave must fail clean
  (rc!=0, empty stdout), restart, byte-identical re-renders. Aggregate CPU
  covers daemon + all clients (reaped children)
- docs: USAGE.md daemon section, WINDOWS.md one-shot scope note
- fix: isExecutableAvailable passes explicit env to execSync (bun ignores
  runtime process.env mutations otherwise)

Part of #19

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review: PR #50 — daemon opt-in install, status/recovery UX, benchmarks

Verdict: changes requested. The overall shape is good: the opt-in gate is real (nothing on the render path starts a daemon), the enable/disable round-trip is well tested with verbatim restore, isKnownCommand/classifyInstallation integration with #48 is consistent, and the bench harness honestly splits cold/warm and folds daemon + clients into aggregate CPU via reaped children. All findings below are on failure paths — but one of them silently destroys user config, so I'm asking for changes.

Findings

  1. [medium] enableSharedMode overwrites a corrupt settings.json with defaults — src/utils/claude-settings.ts:601. loadSettings() returns in-memory defaults on invalid JSON (codebase convention: "file left unchanged"), and saveSettings(configSettings) then silently rewrites the user's ccstatusline settings.json with defaults plus the daemonSharedMode marker — the whole user config is lost. The same input class right next to it (unreadable Claude settings) is correctly refused. Fix: use loadSettingsFrom/check the load error and refuse, mirroring the Claude-settings refusal.

  2. [low] disableSharedMode clears the restore memory before restoring the statusLine — src/utils/claude-settings.ts:643. If saveClaudeSettings(claude) fails after saveSettings(rest), the daemonSharedMode marker is already gone, the statusLine still points at the wrapper, and a retry of daemon uninstall answers "shared mode was not enabled" — no tool-driven way back. Reversing the order (restore Claude settings first, then clear the memory) makes the failure path safe: a stale marker just repeats an idempotent restore.

  3. [low] daemon uninstall with unreadable Claude settings prints a false "off" and darkens the status line — src/daemon/lifecycle.ts:762. When the reason is "could not read Claude settings; refusing to modify", the restore did not happen and the statusLine still holds the wrapper, yet the message is "shared mode off (nothing to restore: ...)" and the daemon is stopped — the status line goes quiet while being told it is off. Distinguish "not enabled" (correct case) from "restore refused", and skip the daemon stop when the restore was refused.

  4. [low] daemon install: a daemon-start failure does not say the statusLine was already switched — src/daemon/lifecycle.ts:752. If enableSharedMode succeeded and ensureDaemon throws, the catch prints only ccstatusline daemon install: <error> and exits 1, while the statusLine already points at the wrapper (currently dark). At minimum extend the message ("statusLine switched; run 'ccstatusline daemon start' or 'daemon uninstall'"); better, roll the switch back when the start fails.

  5. [low] The bench recovery proof does not gate — scripts/benchmark-render.py:388. clients_that_still_succeeded, failed_clients_stdout_empty and output_mismatches are only reported; cmd_daemon_bench's exit code depends solely on run errors and the CPU gate. Since the scenario is billed as a "deterministic recovery proof", a client succeeding against the dead daemon or a hash mismatch should produce failed=1 / exit != 0 (or a dedicated flag), otherwise DAEMON-BENCH.md can say PASS with a violated invariant.

Checked and sound

  • isSharedModeCommand / classifyInstallation: the wrapper is recognized as ours; self-managed classification is consistent with #48 behavior.
  • getDaemonClientWrapperPath resolves correctly for a dev checkout (src/utils to repo root) and the npm dist layout (--format=esm keeps import.meta.url working; package root is within the 5-level walk), and client/ is added to package files.
  • Status fields (uptimeSeconds, lastRenderMs, activeRenders, counters) match /v1/health in server.ts; no token in the payload and a test pins that.
  • Windows refusals: assertIpcPlatform plus the win32 check in enableSharedMode back the WINDOWS.md claims.
  • Bench bookkeeping: one-shot/shared label pairing lines up, the recovery scenario compares warm hashes against post-restart re-renders correctly, and the RUSAGE_CHILDREN accounting is fair to both sides.

Comment thread src/utils/claude-settings.ts Outdated
Comment thread src/utils/claude-settings.ts
Comment thread src/daemon/lifecycle.ts
Comment thread src/daemon/lifecycle.ts Outdated
Comment thread scripts/benchmark-render.py
axisrow and others added 2 commits September 30, 2026 10:25
Review fixes on PR #50: enableSharedMode refuses a corrupt ccstatusline
settings.json instead of overwriting it with defaults; disableSharedMode
restores the status line before clearing the restore memory, so a failed
restore write can be retried; 'daemon uninstall' distinguishes a refused
restore from 'not enabled' (no daemon stop, exit 1 on refusal); 'daemon
install' rolls the switch back when the daemon start fails and this run
did the switch, so the user is not stranded on a dark wrapper status line.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…#19)

client/ccstatusline-ipc: a 503 busy answer (render queue momentarily full
under a burst of repaints across sessions) is now retried up to 8 times
with a 50ms backoff — the same thing the next Claude Code repaint tick
would do. The payload is buffered to a temp file once so retries re-send
identical stdin; stdout still stays empty on any final failure. Without
this, 10+ concurrent sessions with distinct payloads lost status lines
whenever their repaints collided (maxInFlight guard answers busy).

benchmark-render.py: the recovery scenario is now a gated proof — a client
succeeding against the dead daemon, a non-empty stdout on the dead wave,
or any post-restart output mismatch fails the whole run (exit != 0), not
just the report.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@axisrow

axisrow commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Addressed all five findings in 45f4f56 + f659990 (threads resolved with per-finding replies):

  1. [medium] corrupt settings overwrite — enableSharedMode/disableSharedMode now load via loadSettingsFrom() and refuse on a non-null loadError; nothing is written on a rejected config. Pinned by two new tests (nothing written to either settings file).
  2. restore-before-clear — disableSharedMode writes the Claude status line first, clears the marker afterwards; a failed restore leaves the memory intact and is retryable.
  3. uninstall refusal — refusal is now stderr + exit 1 ("the status line still points at the shared-mode client"), and the daemon is only stopped after a successful restore; nothing goes dark.
  4. install start-failure — when this run did the switch, install rolls it back and reports the restore plus the original error (attached as cause); when shared mode was already active, the switch is kept (a rollback would erase the remembered one-shot command) and the message names the options.
  5. recovery gate — recovery invariants now fail the whole bench run (exit ≠ 0): any client succeeding against the dead daemon, non-empty stdout on the dead wave, or any post-restart output mismatch. recovery_gate recorded in daemon-bench.json, printed, and shown as PASS/FAIL in DAEMON-BENCH.md.

Bonus, found while running the acceptance benchmark: with 10+ concurrent sessions the render queue (MAX_IN_FLIGHT_RENDERS = 4) answers 503 busy to colliding repaint waves, and the client gave up — real sessions lost status lines. client/ccstatusline-ipc now retries 503 busy up to 8×50ms (payload buffered once so retries re-send identical stdin); a 12-session burst passes with 72/72 renders and busy=13 retries all succeeding.

bun test: 2732 passed / 0 failed. bun run lint: clean. Acceptance benchmark re-run in progress; results will be committed to docs/ and the gate table updated.

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review: PR #50 (update) — fix commits 45f4f56 + f659990

Verdict: approved. Both fix commits address every finding from the previous review (5360702453), and the new client retry code is sound. Nothing blocking remains.

Previous findings — all resolved

  1. [medium, fixed] enableSharedMode now loads via loadSettingsFrom and refuses on a non-null loadError (claude-settings.ts:601-606) instead of saving in-memory defaults over a corrupt settings.json; disableSharedMode refuses the same way. Both refusals are pinned by new tests, including the assertion that the corrupt file is left byte-identical.
  2. [low, fixed] disableSharedMode restores the Claude statusLine before clearing the daemonSharedMode marker (claude-settings.ts:655-660), so a failed restore write leaves a retryable state instead of stranding the wrapper command.
  3. [low, fixed] daemon uninstall now distinguishes not-enabled from refused: a refusal prints to stderr and exits 1, and the daemon is stopped only when the restore actually happened (lifecycle.ts:772-789) — the dark-status-line-while-saying-off case is gone.
  4. [low, fixed] daemon install rolls the switch back via disableSharedMode when this run did the switch and ensureDaemon fails; when shared mode was already active it keeps the memory and says the status line still points at the client (lifecycle.ts:746-766). The original error is preserved as cause.

New code: client 503 retry — checked

The retry (client/ccstatusline-ipc:91-113) fires only on curl rc 22 with HTTP 503, which the server really emits when renderJobs.size >= maxInFlight (server.ts:421). It is bounded (8 retries, 50ms backoff; only fast 503s retry, a stalled response still hits --max-time and dies), the empty-stdout-on-failure contract is untouched (dead daemon = curl rc 7, no retry), and the bench recovery proof is unaffected. Temp files come from mktemp (0600), hold only the piped payload and the rendered line, and the token still travels only through the curl config on stdin. The recovery gate (benchmark-render.py:616-631) now enforces still_succeeded=0, empty stdout and zero mismatches in the exit code — previous finding 5 is closed.

Non-blocking notes

  • client/ccstatusline-ipc:88 — the EXIT HUP INT TERM trap removes the temp files but does not exit; on SIGTERM during the backoff sleep POSIX sh resumes the retry loop instead of dying. Bounded in practice (curl --max-time), cosmetic.
  • The 503-then-success retry path has no automated coverage in src/daemon/tests/client.test.ts (the file was not touched); the bench exercises only the dead-daemon path. A mock 503-then-200 case there would pin the new behavior.

Comment thread client/ccstatusline-ipc Outdated
…#19)

HUP/INT/TERM handlers now exit with the conventional 128+signal code so a
signal during the backoff sleep cannot resume the retry cycle; temp-file
cleanup still happens through the EXIT trap.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review: PR #50 (update) — fix commit cb64c72

Verdict: approved. This commit closes the one open inline note from the previous review (5360853079): the signal traps now exit instead of returning into the retry loop.

Checked: trap cleanup EXIT runs on the exit from each signal handler, so the temp files are still removed on HUP/INT/TERM; the exit codes 129/130/143 are the conventional 128+signal values; both variables are assigned before the traps are installed, so cleanup is safe under set -u. No other files changed since the approved revision — all five original findings and the client 503 retry review from the previous round still stand.

Still open and still non-blocking: the 503-then-success retry path has no automated coverage in src/daemon/tests/client.test.ts; worth a follow-up, not a gate for this PR.

The flat 8x50ms budget (~0.4s) was shorter than the time a 50-session
burst needs to drain the render queue (4 in-flight x render time), so
every retry still saw busy and the client gave up: s50 shared-warm waves
failed with 8x503 in stderr. Now 14 attempts on a 50ms-1s ladder (~8s
budget) — enough for dozens of concurrent large renders or slow custom
commands, while dead-daemon and bad-request failures still exit
immediately on the first attempt.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review: PR #50 (update) — fix commit f15e1d8

Verdict: approved. The fixed 50ms backoff became a growing ladder (0.05, 0.1, 0.25, 0.5, then 1s) with the retry cap raised to 13.

Checked: only curl rc 22 with HTTP 503 retries, so the empty-stdout-on-failure contract is untouched — a dead daemon still fails fast with rc 7 and no retry, which keeps the bench recovery proof valid. The case statement is POSIX-correct and total wall time stays bounded (about 6.8s of sleeps plus fast 503 responses; a stalled response still dies at curl --max-time). No other files changed since the approved revision cb64c72.

Two non-blocking notes: the comment says an 8s total budget while the sleep ladder sums to about 6.8s across 13 retries (cosmetic); and the 503-then-success retry path still has no automated coverage in src/daemon/tests/client.test.ts — a mock 503-then-200 case remains a worthwhile follow-up.

Full 1/10/36/50-session one-shot vs shared run: 80.7% aggregate-CPU
reduction on the warmed 36-session gate (target >=50%), output hashes
equal in every passing pair, recovery proof PASS (52/52 clients fail
clean on the dead daemon, byte-identical re-render after restart).
s50-shared-warm-small-slow recorded as a reproducible measured
limitation (busy-queue boundary), not tuned away.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review: PR #50 (update) — docs commit 7d2df32

Verdict: changes requested. The measured substance is solid and honestly reported — the 80.7% gate PASS on s36-warm-small matches the JSON, the s50-shared-warm-small-slow failure is recorded as failed (the 503 tail in the JSON confirms the documented queue-exhaustion story), recovery_gate is PASS with correct counts in the generated MD. But this commit is acceptance evidence, and it contains one wrong measured number plus a generator wart that visibly corrupts the committed table. Both are two-minute fixes.

Findings

  1. [low, factual] The verification doc claims 52/52 clients failed the dead-daemon wave; the measured number is 50/50 — docs/daemon-19-verification.md:22. The results JSON and the generated DAEMON-BENCH.md both say 50 of 50 (50 sessions in the wave), and the doc itself promises measured numbers only. Fix the count to 50/50.

  2. [low, cosmetic] write_daemon_summary does not strip newlines from a failed run error, so the traceback breaks the markdown table — scripts/benchmark-render.py:676. The committed docs/daemon-19-bench.md line 34-36 shows the s50-shared-warm-small-slow traceback spilling out of its table row (pipes are escaped, newlines are not). Add a newline-to-space replacement next to the pipe escape.

Everything else in the docs commit checks out against the JSON: gate numbers, per-scenario table values, recovery section, and the honest failure narrative (serial render at MAX_IN_FLIGHT_RENDERS = 4, retry ladder bounded, failure kept visible with exit 1).

Comment thread docs/daemon-19-verification.md Outdated
Comment thread scripts/benchmark-render.py Outdated
axisrow and others added 2 commits September 30, 2026 11:10
19/19 checks against the real dist build in an isolated environment:
piped render starts no daemon; install/status/wrapper-render/
fail-clean/uninstall/verbatim-restore all behave as documented; status
output never contains the auth token.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…ows (#19)

The verification doc claimed 52/52 dead-daemon failures; the wave has 50
sessions and the results JSON says 50 of 50. write_daemon_summary now
flattens newlines in failed-run errors so tracebacks stay inside the
markdown table row; the committed daemon-19-bench.md row is repaired to
match what the fixed generator emits.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@axisrow

axisrow commented Sep 30, 2026

Copy link
Copy Markdown
Owner Author

Both findings fixed in ac4a4e3 (threads resolved with replies):

  1. Recovery count corrected to 50/50 in docs/daemon-19-verification.md — the 52 I wrote was the daemon health request counter (requests: 52), not the client count; the wave has 50 sessions. The PR description carried the same error and is corrected.
  2. write_daemon_summary now flattens newlines in failed-run errors before the pipe escape, so tracebacks stay inside the markdown table row; the committed docs/daemon-19-bench.md row is repaired to match the fixed generator's output.

Live CLI verification also landed in this PR (b2921ce): 19/19 checks against the real dist build in an isolated environment — piped render starts no daemon, install → status → wrapper render → stop → fail-clean → uninstall → verbatim restore → honest no-op, no token in status output. Recorded in docs/daemon-19-verification.md.

@axisrow axisrow left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review: PR #50 (update) — commits b2921ce + ac4a4e3

Verdict: approved. Both findings from the previous review (5361047049) are fixed.

Checked: the recovery row in docs/daemon-19-verification.md now says 50/50, matching the results JSON and the generated table; write_daemon_summary strips newlines before the pipe escape (benchmark-render.py:676), and the regenerated daemon-19-bench.md FAILED row is a single well-formed table line. The new live CLI verification paragraph (19/19 opt-in gating checks on an isolated fake config) is consistent with the rest of the evidence. No other code changed since the approved revision f15e1d8.

Standing non-blocking note, unchanged: the 503-then-success retry path still lacks automated coverage in src/daemon/tests/client.test.ts — fine as a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Daemon: opt-in installation, status/recovery UX, benchmarks and acceptance gates

1 participant