fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk - #361
fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk#361timkjr wants to merge 4 commits into
Conversation
|
Thanks for this, and for the shell-layer analysis, which is careful and correct. Writing Codeman's exporter into the user's The question: create-time-only, or sticky? ( Two ways out: (a) Keep sticky semantics server-side: persist the PUT action as a I lean toward (a), because "turn the chip on and nothing happens" is a bad experience and the disk mechanism did not have it. Your call if you see a reason (b) is better. Then, whichever way that goes: 1. Rebase onto master's CLI registry ( 2. 3. The in-terminal footer print-through is dropped ( 4. The background subshell inherits stdout, so a host reading to EOF still waits for curl ( Smaller: the docs and registries did not move with the code. I would settle the sticky question first, then the rebase, the footer and the redirection are each small once the shape is agreed, and the rest are one-liners. The tests you wrote are real ones, so this is worth a round trip rather than a rewrite. |
…never disk
Codeman's plan-usage chip wrote a statusLine.command into the case's
.claude/settings.local.json to receive Claude Code's rate_limits blob.
That file-based statusLine took precedence over the user's own
global/project statusline for ANY `claude` run in that directory,
including entirely outside Codeman, with no disclosure in the App
Settings UI (labeled only as a header-display toggle) and no way to
remove it once written (the removal code path was unreachable dead
code — nothing ever called it with false).
Replace the disk write with an EPHEMERAL `claude --settings
'{"statusLine":{...}}'` CLI flag, resolved fresh at spawn time
(resolveStatusLineCliCommand in hooks-config.ts) and merged with
effort/ultracode into one --settings object (buildClaudeSettingsFlag
in tmux-manager.ts, since Claude Code accepts only one --settings
flag). Never touches disk, so a plain `claude` run outside Codeman is
untouched. Self-healing: any legacy disk-written exporter from an
older build is stripped the first time a session starts in that
workspace again. Still respects a user's own hand-authored statusLine
(skips the flag entirely rather than overriding it).
Mid-fix bug found and fixed: the exporter's command legitimately
depends on $CODEMAN_SESSION_ID/$CODEMAN_API_URL/$CODEMAN_HOOK_SECRET_FILE
and an internal $INPUT, all meant to be expanded only when Claude Code
itself executes the statusline, using the pane's tmux-setenv'd
environment. Passing that text through --settings routed it through
execSync's own implicit /bin/sh -c first (tmux respawn-pane's
`bash -c "..."` wrapper) — POSIX double quotes don't suppress $
expansion, so those vars got expanded prematurely against the
server's own environment (unset there), producing malformed JSON that
printed as literal error text in the statusline. Fixed by writing the
exporter as a real, shared script file (ensureStatusLineExporterScript,
marker-versioned so stale copies self-heal) and passing only its bare
path via --settings — nothing for any intermediate shell to mangle.
Verified against a real Claude CLI on an isolated tmux socket, and via
direct execSync reproduction of the exact nested wrapping
createSession/respawnPane use.
A hard "never inject, even ephemerally" kill-switch was added and then
removed in the same pass: with the disk-leak fixed, disabling
injection only cost the plan-usage telemetry the feature exists to
provide, for no remaining benefit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GyMnFWnUzc41TDeHg9juW
…ping it Now that the exporter no longer lives in a fixed per-case file, it can compose with the user's actual configured statusline rather than just backing off when one is found. findEffectiveUserStatusLineCommand() walks Claude Code's own settings precedence for a workspace: project-local .claude/settings.local.json > project-shared .claude/settings.json > the user's global ~/.claude/settings.json. A legacy Codeman-marked entry left behind in the project's own settings.local.json is never treated as a real user command — it's skipped and precedence continues to the next layer. The shared exporter script (bumped to a V2 marker so stale copies self-heal) now fires the telemetry POST in a background subshell — its own stdout/stderr discarded so nothing leaks into the visible statusline, and confirmed non-blocking (~4ms, even against an unreachable endpoint) — then, if the pane's environment carries CODEMAN_USER_STATUSLINE_CMD, feeds it the same stdin blob and relays its stdout as ours. Otherwise it falls back to the plain "codeman" marker as before. The discovered command is threaded to the pane via `tmux setenv CODEMAN_USER_STATUSLINE_CMD` (_configureStatusLineUserCommand) rather than embedded in the spawn command line, for the same premature-shell-expansion reason as the parent commit: tmux stores a setenv value verbatim and never re-parses it, so once shellescape()d for that one command, the command's own $/quotes survive untouched into the pane's environment. Verified live via direct shell execution of the generated script (both branches: fallback and user-command wrapping) before deploy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GyMnFWnUzc41TDeHg9juW
…OF fix Responds to Ark0N's review round on the ephemeral-CLI-flag statusline injection rework: - Rebase-detail fixes: registry-gated telemetry eligibility via getCli(mode)?.capabilities.statusLineTelemetry instead of a hardcoded mode === 'claude' check, using the capability flag master's CLI-registry refactor already declares for exactly this purpose. - Design question settled: sticky (a). Rather than persisting the toggle as a new field and threading it through every session-creation path (cron, Ralph Loop API, quick-start), eliminated the per-session field entirely. readPlanUsageTelemetryEnabled() (hooks-config.ts) reads the existing showPlanUsageLimits setting fresh from settings.json at every claude create/respawn (TmuxManager.createSession/respawnPane) - no per-session state to survive a restart, and it applies uniformly to every creation path for free, since they all flow through the same TmuxManager methods. This required fixing a real bug found along the way: showPlanUsageLimits was not actually round-tripping through settings.json on save - settings-ui.js explicitly excluded it from the PUT body as a pure per-device display key. It now flows through normally (both true and false); the load-side per-device merge behavior is unchanged. Removed entirely as a result: the statusLineTelemetry field from CreateSessionSchema/SettingsUpdateSchema, CreateSessionOptions/ RespawnPaneOptions, Session._statusLineTelemetry (this is what makes the restart-persistence bug moot rather than patched), and the frontend send sites. - Footer print-through restored: the no-user-statusline branch of the exporter script now runs the telemetry POST in the foreground so its own stdout becomes the in-terminal footer, falling back to a plain "codeman" marker only on curl failure. - Background-subshell EOF fix: the wrap-a-real-statusline branch closes stdin too, not just stdout/stderr (`>/dev/null 2>&1 </dev/null &`) - the un-redirected subshell process itself, not curl, was what held a reader-to-EOF's pipe open for however long curl took to finish. Added curl --max-time 5 so a hung (not just refused) Codeman cannot wedge the render. Tests: real-shell-execution tests for the footer/EOF fixes (fake curl stand-in on PATH, real sh subprocess spawns, real elapsed-time measurements - verified non-vacuous against a hand-reconstructed old-style script), unit tests for readPlanUsageTelemetryEnabled. Adapted two existing tests whose payloads referenced the removed field. Fixed during independent code review: a stray indentation break and a test exercising the wrong (legacy) exporter code path. Docs synced: CLAUDE.md, docs/usage-limits-display-plan.md (old disk-based section marked superseded, kept for history), docs/architecture-invariants.md. Full suite green: 352 files, 6780 passed, 12 skipped, 0 failed. tsc/lint/format:check/frontend-syntax all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7aff633 to
d5b75af
Compare
|
Thanks for the detailed breakdown — went with (a), sticky. Turned out simpler than expected: rather than persisting the toggle as its own field and threading it through cron/the Ralph API/quick-start, I reused Rebased onto master's CLI-registry refactor (#347): #3 and #4 are both fixed — foreground POST with real print-through in the no-wrap branch, Docs are synced (CLAUDE.md, usage-limits-display-plan.md, architecture-invariants.md). Full suite's green: 352 files, 6780 tests, 0 failed. |
planUsageChipEnabled() (settings-ui.js) shows the header chip and the App Settings checkbox as already ON whenever showPlanUsageLimits has never been set — a discoverability default from 1.9.3. readPlanUsageTelemetryEnabled() (hooks-config.ts) deliberately treats an absent key as "no telemetry" — a privacy default, pinned by its own unit tests (never POST usage data without an explicit persisted yes). Nothing reconciled those two independent guesses, so a fresh install showed a checked box that silently collected nothing until the user opened Settings and hit Save at least once. Verified live: an install that had never touched this setting had no showPlanUsageLimits key in settings.json at all, and its running Claude process's argv carried no --settings flag — zero telemetry ever collected despite the chip rendering as enabled. GET /api/settings now persists the resolved default (true) the first time the key is truly absent — not explicit false — so "chip visible" and "telemetry collected" become the same fact. readPlanUsageTelemetryEnabled's own absent-means-false contract is untouched; after this runs once the key is never absent again, so that branch stays correct in isolation while being unreachable in practice for any install that has ever called this route. An explicit false set afterward is respected forever. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Found and fixed a follow-up gap in this one, verified live rather than just in the diff. The sticky-collection design you signed off on works correctly — Fix: Added round-trip route tests for all four cases (absent + file missing entirely, absent + file exists with other keys, explicit |
Summary
The plan-usage chip's statusLine exporter was written into the case's
.claude/settings.local.json. That file-basedstatusLinetook precedence over the user's own global/project statusline for anyclaudeinvocation in that directory — including entirely outside Codeman — with no disclosure in the App Settings UI (labeled only as a header-display toggle) and no way to remove it once written (the removal code path was dead code: nothing ever calledapplyStatusLineConfig(path, false)).Fixes this by injecting the exporter as an ephemeral
claude --settings '{"statusLine":{...}}'CLI flag, resolved fresh at spawn time and merged witheffort/ultracodeinto one--settingsobject (Claude Code accepts only one--settingsflag per invocation). Never touches disk, so a plainclauderun outside Codeman is completely untouched. Self-healing: a legacy disk-written exporter from an older build is stripped the first time a session starts in that workspace again. Still respects a user's own hand-authored statusline — it's never overridden.Bonus: since the exporter no longer lives in a fixed file, it can now wrap the user's own real statusline instead of just backing off.
findEffectiveUserStatusLineCommand()walks Claude Code's actual settings precedence (projectsettings.local.json→ projectsettings.json→ user~/.claude/settings.json) to find whatever's really configured, and the shared exporter script fires the telemetry POST in a background subshell (confirmed non-blocking, ~4ms, even against an unreachable endpoint) then execs the user's real command with the same stdin — so the user keeps seeing their own statusline, and the plan-usage chip still gets its data.A subtler bug found mid-fix
The exporter's command legitimately depends on
$CODEMAN_SESSION_ID/$CODEMAN_API_URL/$CODEMAN_HOOK_SECRET_FILEand an internal$INPUT, all meant to be expanded only when Claude Code itself executes the statusline, using the pane'stmux setenv'd environment. An early version of this fix passed that command as literal text through--settings, which routed it throughexecSync's own implicit/bin/sh -c(tmuxrespawn-pane'sbash -c "..."wrapper) before Claude Code ever saw it — POSIX double quotes don't suppress$expansion, so those vars got expanded prematurely against the server process's own environment (unset there), producing malformed JSON that printed as literal error text in the statusline.Fixed by writing the exporter as a real, shared script file (
ensureStatusLineExporterScript, marker-versioned so stale copies self-heal) and passing only its bare path via--settings— a path has no shell metacharacters for any intermediate shell layer to mangle.Test plan
npm test(full CI gate) green throughouttest/hooks-config.test.ts(resolveStatusLineCliCommand,findEffectiveUserStatusLineCommand),test/statusline-cli-flag.test.ts(real-shell-execution based, not hand-rolled unescaping)execSyncreproduction of the exact nested shell wrappingcreateSession/respawnPaneuse, with an arg-echoing stand-in forclaude, before trusting the fix