fix(statusline): sticky telemetry collection, footer print-through, EOF 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>
This commit is contained in:
timkjr
2026-09-07 20:37:29 -05:00
co-authored by Claude Sonnet 5
parent e15e8e43e8
commit d5b75af628
17 changed files with 288 additions and 143 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve DISPLAY ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs the two call sites that must never disagree (the App Settings checkbox, the chip's visibility). The SAME persisted setting also doubles as the server-side telemetry COLLECTION switch — `readPlanUsageTelemetryEnabled()` (hooks-config.ts) reads it fresh from `settings.json` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), so it applies uniformly to every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) with no per-session state and no per-request field — a Codeman restart cannot silently kill it (there is nothing per-session to lose). Claude data comes from Codeman's marked `statusLine.command` exporter (injected as an EPHEMERAL `claude --settings` CLI flag, never written to disk — see `resolveStatusLineCliCommand`), which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine (it WRAPS it instead — `findEffectiveUserStatusLineCommand`), and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
+1 -1
View File
@@ -90,7 +90,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
### Plan-usage chip (statusLine telemetry) ### Plan-usage chip (statusLine telemetry)
**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through**. Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox, chip visibility, and Claude create-time exporter flag. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`. **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, `|| echo codeman` on failure; backgrounded — `>/dev/null 2>&1 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`.
### Cron jobs ### Cron jobs
+12 -10
View File
@@ -2,6 +2,8 @@
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet. > **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
> >
> **2026-09-07 rework — the "Injection lifecycle" section below (disk-write reconcile via `applyStatusLineConfig`) is SUPERSEDED and describes the OLD mechanism, kept for history.** That disk write let a Codeman-marked `statusLine.command` in `.claude/settings.local.json` take precedence over the user's own global/project statusline for ANY `claude` run in that directory — including entirely outside Codeman — with no disclosure and no way to undo it (real bug, found 2026-08-31). The exporter is now injected as an EPHEMERAL `claude --settings` CLI flag at spawn (`resolveStatusLineCliCommand`/`ensureStatusLineExporterScript`, hooks-config.ts) — never written to disk — and it WRAPS the user's own real statusline (`findEffectiveUserStatusLineCommand`) rather than replacing it. `showPlanUsageLimits` now doubles as the telemetry COLLECTION switch too: `readPlanUsageTelemetryEnabled()` reads it fresh from `settings.json` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), so it applies uniformly across every claude-creation path — interactive Run, cron, the Ralph Loop API, quick-start — with no per-session state (a Codeman restart cannot silently kill it) and no per-request field on the wire at all.
>
> Two surfaces from one `statusLine` callback: > Two surfaces from one `statusLine` callback:
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red. > - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
> - **In-terminal statusline footer** — the **current session's** status: `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%`. > - **In-terminal statusline footer** — the **current session's** status: `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%`.
@@ -110,33 +112,33 @@ Fixed path (sessionId in the **body**, not the URL) so the auth exemption is an
2. **Fresh load / reconnect:** server stores the latest in `plan-usage-latest.ts`; `getLightState()` includes it as `planUsage`; the per-connection **init snapshot** replays it; `handleInit` paints the chip immediately (authoritative over localStorage). Null until the first telemetry of the process. 2. **Fresh load / reconnect:** server stores the latest in `plan-usage-latest.ts`; `getLightState()` includes it as `planUsage`; the per-connection **init snapshot** replays it; `handleInit` paints the chip immediately (authoritative over localStorage). Null until the first telemetry of the process.
3. **Offline / cross-restart:** `restorePlanUsageChip()` reads `localStorage` on load (12h freshness guard). 3. **Offline / cross-restart:** `restorePlanUsageChip()` reads `localStorage` on load (12h freshness guard).
### 5. Injection lifecycle — works for *any* user, never self-destructs ### 5. Injection lifecycle (SUPERSEDED 2026-09-07 — see header note; kept for history)
The setting `showPlanUsageLimits` is **synced** (in `settings.json`, not a per-device `displayKey`). The setting `showPlanUsageLimits` is **synced** (in `settings.json`, not a per-device `displayKey`).
- **On toggle** (`PUT /api/settings`, `system-routes.ts`): reconcile the exporter across **all active Claude sessions' working dirs** — inject on enable, remove on disable. Server-side and authoritative, so existing sessions get the footer + feed the chip *immediately*, no new session needed, no dependency on a client's synced localStorage. - ~~**On toggle** (`PUT /api/settings`, `system-routes.ts`): reconcile the exporter across **all active Claude sessions' working dirs** — inject on enable, remove on disable.~~ There is nothing to (re)inject into an already-running session under the new CLI-flag mechanism — the NEXT respawn (a Ralph cycle, `/clear`, a PTY-exit restart) already reads the setting fresh.
- **On session create** (`session-routes.ts`): **ADD-ONLY** — inject when `statusLineTelemetry` is true; **never remove**. Sessions in a repo share one `settings.local.json`, so a single create-with-false (e.g. a client whose synced setting hadn't loaded) must not yank the statusLine out from under other live sessions. Removal happens only via the explicit toggle. - ~~**On session create** (`session-routes.ts`): **ADD-ONLY** — inject when `statusLineTelemetry` is true; **never remove**.~~ There is no `statusLineTelemetry` request field anymore. `TmuxManager.createSession`/`respawnPane` read `readPlanUsageTelemetryEnabled()` fresh at spawn instead, uniformly across every claude-creation path.
- `applyStatusLineConfig()` is **`isOurs`-guarded** (matches `/api/status-telemetry`), so a user's own hand-authored statusLine is never touched, and it **updates an out-of-date ours-command** so fixes (e.g. `-k`) propagate. **No `CASES_DIR` gate** — runs for linked cases / real repos (where sessions actually run), mirroring `updateCaseModel`. - ~~`applyStatusLineConfig()` is **`isOurs`-guarded**~~ — `applyStatusLineConfig` still exists but only for the SELF-HEAL path now (`resolveStatusLineCliCommand` strips a legacy disk-written exporter the first time a session starts in a workspace an older Codeman build touched).
## Codeman-specific considerations ## Codeman-specific considerations
1. **Account-global limits.** The 5h/7d pools are shared across all sessions on the account → one shared header chip (freshest sample wins), not a per-tab bar. 1. **Account-global limits.** The 5h/7d pools are shared across all sessions on the account → one shared header chip (freshest sample wins), not a per-tab bar.
2. **The footer is owned, by necessity.** A statusLine command always replaces Claude's default footer. Since `rate_limits` *only* arrives via statusLine, we reconstruct a useful **session-status** footer (model · tokens · ctx %) from the same payload rather than showing the limits there. 2. **The footer is owned, by necessity.** A statusLine command always replaces Claude's default footer. Since `rate_limits` *only* arrives via statusLine, we reconstruct a useful **session-status** footer (model · tokens · ctx %) from the same payload rather than showing the limits there.
3. **`isOurs`-guarded.** Never removes/overwrites a user's own statusLine on disable; only manages the Codeman exporter. 3. **Never overwrites, now WRAPS.** The exporter composes with a user's own real statusline (`findEffectiveUserStatusLineCommand`) rather than replacing it; `applyStatusLineConfig`'s `isOurs`-guard now only backs the legacy self-heal removal path.
4. **Security envelope unchanged.** The exporter runs arbitrary shell every render — same trust model as the hook curls (localhost + `$CODEMAN_HOOK_SECRET_FILE`); reuses the hook-secret gate. 4. **Security envelope unchanged.** The exporter runs arbitrary shell every render — same trust model as the hook curls (localhost + `$CODEMAN_HOOK_SECRET_FILE`); reuses the hook-secret gate.
5. **Claude-only.** OpenCode/Codex emit no `rate_limits` JSON; injection is gated to `mode === 'claude'`. 5. **Claude-only, registry-gated.** Injection is gated on `getCli(mode)?.capabilities.statusLineTelemetry` (currently `true` only for claude) rather than a hardcoded `mode === 'claude'` string.
6. **Future — auto-resume synergy.** Live percentages would let `SessionAutoOps` pre-arm *before* the wall instead of reacting to the stall footer. Not built. 6. **Future — auto-resume synergy.** Live percentages would let `SessionAutoOps` pre-arm *before* the wall instead of reacting to the stall footer. Not built.
## Files shipped ## Files shipped
- `src/usage-telemetry.ts` — pure parse/format (`parseStatusTelemetry`, `parseSessionStatus`, `formatSessionStatusText`, `telemetrySignature`) + `test/usage-telemetry.test.ts`. - `src/usage-telemetry.ts` — pure parse/format (`parseStatusTelemetry`, `parseSessionStatus`, `formatSessionStatusText`, `telemetrySignature`) + `test/usage-telemetry.test.ts`.
- `src/hooks-config.ts` — `generateStatusLineCommand()` (`curl -sk`), `applyStatusLineConfig()` (add/update/remove, `isOurs`-guarded). - `src/hooks-config.ts` — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` (ephemeral CLI-flag injection, never disk), `findEffectiveUserStatusLineCommand()` (wrap the user's real statusline), `readPlanUsageTelemetryEnabled()` (fresh global-setting read), `applyStatusLineConfig()` (legacy self-heal removal only now).
- `src/session-cli-registry-bridge.ts` — merges the exporter path into the SAME `--settings` JSON object as effort/ultracode (Claude Code accepts only one `--settings` flag per invocation).
- `src/web/routes/status-telemetry-routes.ts` — `POST /api/status-telemetry`. - `src/web/routes/status-telemetry-routes.ts` — `POST /api/status-telemetry`.
- `src/web/plan-usage-latest.ts` — process-wide last-known store for init replay. - `src/web/plan-usage-latest.ts` — process-wide last-known store for init replay.
- `src/web/schemas.ts` — `StatusTelemetrySchema` + `showPlanUsageLimits` + create-payload `statusLineTelemetry`. - `src/web/schemas.ts` — `StatusTelemetrySchema` + `showPlanUsageLimits` (no separate create-payload or action field anymore).
- `src/web/middleware/auth.ts` — exemption extended to `/api/status-telemetry`. - `src/web/middleware/auth.ts` — exemption extended to `/api/status-telemetry`.
- `src/web/routes/session-routes.ts` — add-only create-time injection. - `src/tmux-manager.ts` — `createSession`/`respawnPane` read `readPlanUsageTelemetryEnabled()` fresh at spawn.
- `src/web/routes/system-routes.ts` — settings-toggle reconcile.
- `src/web/server.ts` — `getLightState().planUsage` (init snapshot). - `src/web/server.ts` — `getLightState().planUsage` (init snapshot).
- `src/web/sse-events.ts` + `constants.js` — `session:statusTelemetry`. - `src/web/sse-events.ts` + `constants.js` — `session:statusTelemetry`.
- Frontend: `app.js` (`_onSessionStatusTelemetry`, `updatePlanUsageChip`, `restorePlanUsageChip`, `handleInit`), `settings-ui.js` (toggle + `applyHeaderVisibilitySettings`), `index.html` (chip + toggle row), `styles.css` (chip + colors), `session-ui.js` (create payload). - Frontend: `app.js` (`_onSessionStatusTelemetry`, `updatePlanUsageChip`, `restorePlanUsageChip`, `handleInit`), `settings-ui.js` (toggle + `applyHeaderVisibilitySettings`), `index.html` (chip + toggle row), `styles.css` (chip + colors), `session-ui.js` (create payload).
+61 -27
View File
@@ -39,6 +39,7 @@ import { fileURLToPath } from 'node:url';
import type { HookEventType } from './types.js'; import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js'; import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js'; import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
/** /**
* Serializes read-modify-write access to a `settings.local.json` path. Every * Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -912,32 +913,41 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
* whenever the script content changes so `ensureStatusLineExporterScript`'s * whenever the script content changes so `ensureStatusLineExporterScript`'s
* content comparison rewrites stale copies on next use. * content comparison rewrites stale copies on next use.
*/ */
const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V2'; const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V3';
function statusLineExporterScriptContent(): string { function statusLineExporterScriptContent(): string {
// The telemetry POST runs in a BACKGROUND subshell so it can never add // Where the telemetry POST runs depends on who owns the footer. When the pane's
// latency to a render, and its own stdout/stderr are discarded so they // env carries CODEMAN_USER_STATUSLINE_CMD (set via tmux setenv by TmuxManager
// never leak into the visible statusline. If the pane's env carries // when findEffectiveUserStatusLineCommand found the user's own REAL statusLine —
// CODEMAN_USER_STATUSLINE_CMD (set via tmux setenv by TmuxManager when // see that function's doc comment), the user's command owns the footer, so the
// findEffectiveUserStatusLineCommand found the user's own REAL statusLine — // POST runs in a BACKGROUND subshell with stdin/stdout/stderr all closed
// see that function's doc comment), the SAME stdin blob is fed to it and // (`>/dev/null 2>&1 </dev/null &`) — closing stdout/stderr keeps it from adding
// ITS stdout becomes ours, so the user keeps seeing their own statusline // latency or leaking into the visible statusline, and closing stdin too is what
// untouched. Absent that, fall back to the plain "codeman" marker. // lets a host reading this script's own stdout to EOF (`sh script | cat`) see
// that EOF promptly: without it the backgrounded curl keeps the pipe's write end
// open until IT exits, so the reader blocks for however long curl takes (measured
// ~5s with a stand-in) instead of the ~9ms it takes once stdin is closed too.
// Absent a user statusline, NOTHING else will print the footer, so the POST runs
// in the FOREGROUND and ITS OWN stdout becomes the footer — `/api/status-telemetry`
// returns formatSessionStatusText(...) (model/tokens/context %) precisely so this
// can happen — falling back to the plain "codeman" marker only if curl itself
// fails (`|| echo codeman`, refused/unreachable Codeman). `--max-time` bounds a
// HUNG (not just refused) Codeman so it cannot wedge the render indefinitely.
const post =
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
`curl -sk --max-time 5 -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @-`;
return ( return (
`#!/bin/sh\n` + `#!/bin/sh\n` +
`# ${STATUSLINE_EXPORTER_SCRIPT_MARKER} — auto-generated by Codeman; safe to delete, regenerated on demand.\n` + `# ${STATUSLINE_EXPORTER_SCRIPT_MARKER} — auto-generated by Codeman; safe to delete, regenerated on demand.\n` +
`INPUT=$(cat 2>/dev/null || echo '{}')\n` + `INPUT=$(cat 2>/dev/null || echo '{}')\n` +
`(\n` +
` printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- >/dev/null 2>&1\n` +
`) &\n` +
`if [ -n "$CODEMAN_USER_STATUSLINE_CMD" ]; then\n` + `if [ -n "$CODEMAN_USER_STATUSLINE_CMD" ]; then\n` +
` ( ${post} ) >/dev/null 2>&1 </dev/null &\n` +
` printf '%s' "$INPUT" | sh -c "$CODEMAN_USER_STATUSLINE_CMD"\n` + ` printf '%s' "$INPUT" | sh -c "$CODEMAN_USER_STATUSLINE_CMD"\n` +
`else\n` + `else\n` +
` echo codeman\n` + ` ${post} 2>/dev/null || echo codeman\n` +
`fi\n` `fi\n`
); );
} }
@@ -1019,13 +1029,34 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
return scriptPath; return scriptPath;
} }
/**
* Whether plan-usage telemetry collection is CURRENTLY wanted — read FRESH
* from the persisted `showPlanUsageLimits` setting on every call, never
* cached and never per-session. Reusing that setting rather than inventing a
* second persisted flag: it's the SAME boolean the App Settings chip checkbox
* already writes (see `planUsageChipEnabled()` in settings-ui.js).
*
* This is what lets the on/off decision survive a Codeman restart (there is
* no per-session state to lose — see the now-removed `Session._statusLineTelemetry`,
* which WAS such a per-session field and went stale on every restart) and
* apply uniformly across every claude session-creation path — interactive
* create, cron, the Ralph Loop API, quick-start — with none of them needing
* to thread a request-time flag through: they all already construct a
* session via TmuxManager.createSession/respawnPane, which reads this at
* spawn time.
*/
export async function readPlanUsageTelemetryEnabled(): Promise<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
return settings.showPlanUsageLimits === true;
}
/** /**
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings` * Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
* CLI flag for this one process (see buildClaudeSettingsFlag in * CLI flag for this one process (see buildSpawnCommandFromRegistry in
* tmux-manager.ts) — never written to disk. This supersedes the old * session-cli-registry-bridge.ts) — never written to disk. This supersedes
* applyStatusLineConfig(path, true) disk-write: a file-based statusLine * the old applyStatusLineConfig(path, true) disk-write: a file-based
* leaked into any plain `claude` run in that directory outside Codeman * statusLine leaked into any plain `claude` run in that directory outside
* entirely (it took precedence over the user's own global/project * Codeman entirely (it took precedence over the user's own global/project
* statusline with no disclosure and no way to remove it — found live * statusline with no disclosure and no way to remove it — found live
* 2026-08-31). * 2026-08-31).
* *
@@ -1033,14 +1064,17 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
* exporter into this workspace's settings.local.json, it is stripped here * exporter into this workspace's settings.local.json, it is stripped here
* (isOurs-guarded, same as applyStatusLineConfig's removal branch) so every * (isOurs-guarded, same as applyStatusLineConfig's removal branch) so every
* workspace migrates off the disk-based mechanism the first time a session * workspace migrates off the disk-based mechanism the first time a session
* starts there again — no manual cleanup required. * starts there again — no manual cleanup required. This self-heal runs
* regardless of `telemetryEnabled`, so a legacy leftover is cleaned up even
* while the setting is currently off.
* *
* Returns undefined when telemetry wasn't requested, or when the workspace * Returns undefined when telemetry isn't currently enabled (see
* already has its OWN hand-configured statusLine (never override a real one). * readPlanUsageTelemetryEnabled), or when the workspace already has its OWN
* hand-configured statusLine (never override a real one).
*/ */
export async function resolveStatusLineCliCommand( export async function resolveStatusLineCliCommand(
casePath: string, casePath: string,
telemetryRequested: boolean telemetryEnabled: boolean
): Promise<string | undefined> { ): Promise<string | undefined> {
const settingsPath = join(casePath, '.claude', 'settings.local.json'); const settingsPath = join(casePath, '.claude', 'settings.local.json');
let userHasOwnStatusLine = false; let userHasOwnStatusLine = false;
@@ -1059,7 +1093,7 @@ export async function resolveStatusLineCliCommand(
// Malformed — leave it alone, same guard applyStatusLineConfig itself uses. // Malformed — leave it alone, same guard applyStatusLineConfig itself uses.
} }
} }
if (!telemetryRequested || userHasOwnStatusLine) return undefined; if (!telemetryEnabled || userHasOwnStatusLine) return undefined;
return ensureStatusLineExporterScript(); return ensureStatusLineExporterScript();
} }
-9
View File
@@ -90,13 +90,6 @@ export interface CreateSessionOptions {
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel; effort?: EffortLevel;
/**
* Claude-only: request the plan-usage statusLine exporter for this session,
* injected as an EPHEMERAL `--settings` CLI flag (never written to disk — see
* generateStatusLineCommand/resolveStatusLineCliCommand). Skipped when the
* workspace already has its own hand-configured statusLine.
*/
statusLineTelemetry?: boolean;
/** tmux history-limit (scrollback lines) allocated when this session is created. */ /** tmux history-limit (scrollback lines) allocated when this session is created. */
historyLimit?: number; historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */ /** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -132,8 +125,6 @@ export interface RespawnPaneOptions {
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel; effort?: EffortLevel;
/** Preserved across respawns — see CreateSessionOptions.statusLineTelemetry. */
statusLineTelemetry?: boolean;
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */ /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
historyLimit?: number; historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */ /** Remote execution metadata for local tmux sessions wrapping SSH */
-10
View File
@@ -577,11 +577,6 @@ export class Session extends EventEmitter {
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined; private _effort: EffortLevel | undefined;
// Claude-only: request the plan-usage statusLine exporter for this session,
// injected as an ephemeral `--settings` CLI flag at spawn (never written to
// disk). Preserved across respawns like _effort above.
private _statusLineTelemetry: boolean | undefined;
// tmux history-limit (scrollback lines) allocated when this session's pane is created. // tmux history-limit (scrollback lines) allocated when this session's pane is created.
private readonly _tmuxHistoryLimit: number; private readonly _tmuxHistoryLimit: number;
@@ -678,8 +673,6 @@ export class Session extends EventEmitter {
envOverrides?: Record<string, string>; envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel; effort?: EffortLevel;
/** Claude-only: request the plan-usage statusLine exporter (ephemeral --settings flag, never disk-written) */
statusLineTelemetry?: boolean;
/** tmux history-limit (scrollback lines) allocated when this session's pane is created. */ /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */
tmuxHistoryLimit?: number; tmuxHistoryLimit?: number;
/** Restored per-session attachment history. May include server-private external paths. */ /** Restored per-session attachment history. May include server-private external paths. */
@@ -833,7 +826,6 @@ export class Session extends EventEmitter {
if (config.effort && isEffortLevel(config.effort)) { if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort; this._effort = config.effort;
} }
this._statusLineTelemetry = config.statusLineTelemetry;
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote; this._remote = config.remote;
this._docker = config.docker; this._docker = config.docker;
@@ -1754,7 +1746,6 @@ export class Session extends EventEmitter {
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort, effort: this._effort,
statusLineTelemetry: this._statusLineTelemetry,
historyLimit: this._tmuxHistoryLimit, historyLimit: this._tmuxHistoryLimit,
remote: this._remote, remote: this._remote,
docker: this._docker, docker: this._docker,
@@ -2070,7 +2061,6 @@ export class Session extends EventEmitter {
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides, envOverrides: this._envOverrides,
effort: this._effort, effort: this._effort,
statusLineTelemetry: this._statusLineTelemetry,
historyLimit: this._tmuxHistoryLimit, historyLimit: this._tmuxHistoryLimit,
remote: this._remote, remote: this._remote,
docker: this._docker, docker: this._docker,
+13 -11
View File
@@ -67,7 +67,11 @@ import {
legacyConfigForMode, legacyConfigForMode,
} from './session-cli-registry-bridge.js'; } from './session-cli-registry-bridge.js';
import type { CliEntry } from './config/cli-registry/types.js'; import type { CliEntry } from './config/cli-registry/types.js';
import { resolveStatusLineCliCommand, findEffectiveUserStatusLineCommand } from './hooks-config.js'; import {
resolveStatusLineCliCommand,
readPlanUsageTelemetryEnabled,
findEffectiveUserStatusLineCommand,
} from './hooks-config.js';
import { import {
buildSshConnectionArgs, buildSshConnectionArgs,
defaultRemoteCommandForMode, defaultRemoteCommandForMode,
@@ -1756,7 +1760,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort, effort,
statusLineTelemetry,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote, remote,
docker, docker,
@@ -1815,13 +1818,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// Claude-only, local spawns only (remote/docker have their own separate // Registry-gated (capabilities.statusLineTelemetry — claude only today), local
// command builders — out of scope here). Also self-heals: strips any // spawns only (remote/docker have their own separate command builders — out of
// legacy disk-written exporter from an older Codeman build the first // scope here). Also self-heals: strips any legacy disk-written exporter from an
// time a session starts in that workspace again. // older Codeman build the first time a session starts in that workspace again.
const statusLineCommand = const statusLineCommand =
mode === 'claude' && !remote && !docker getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) ? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined; : undefined;
// The user's own REAL statusLine, if any (walked via Claude Code's own // The user's own REAL statusLine, if any (walked via Claude Code's own
// settings precedence) — exported below so the shared exporter script // settings precedence) — exported below so the shared exporter script
@@ -2070,7 +2073,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
resumeSessionId, resumeSessionId,
envOverrides, envOverrides,
effort, effort,
statusLineTelemetry,
remote, remote,
docker, docker,
name, name,
@@ -2088,8 +2090,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// See createSession()'s identical resolution for rationale. // See createSession()'s identical resolution for rationale.
const statusLineCommand = const statusLineCommand =
mode === 'claude' && !remote && !docker getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) ? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined; : undefined;
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
-7
View File
@@ -1062,13 +1062,6 @@ Object.assign(CodemanApp.prototype, {
...(hasEnvOverrides ? { envOverrides } : {}), ...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}), ...(effort ? { effort } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}), ...(modelOverride !== undefined ? { modelOverride } : {}),
// Plan-usage statusLine exporter (App Settings → Display). The server
// ADDS our exporter on create when true; when false it intentionally
// leaves any existing exporter in place (a per-repo settings.local.json
// is shared by sibling sessions, so create-with-false must not yank it
// — see the comment in session-routes create). Disabling the setting
// removes it via the App Settings toggle path (system-routes), not here.
statusLineTelemetry: this.planUsageChipEnabled(globalSettings),
}) })
}).then(r => r.json()) }).then(r => r.json())
); );
+22 -16
View File
@@ -2271,22 +2271,26 @@ Object.assign(CodemanApp.prototype, {
// Save to server (includes notification prefs for cross-browser persistence). // Save to server (includes notification prefs for cross-browser persistence).
// Strip device-specific DISPLAY keys so they never sync across devices — // Strip device-specific DISPLAY keys so they never sync across devices —
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits // localEcho/cjk/extendedKeyboard/skin are per-platform.
// is per-device too (desktop can show the usage chip while mobile stays hidden).
// webglRendererEnabled is per-device as well (renderer choice is GPU-specific, // webglRendererEnabled is per-device as well (renderer choice is GPU-specific,
// and syncing would leak mobile's hidden-checkbox false onto desktop); it's // and syncing would leak mobile's hidden-checkbox false onto desktop); it's
// also absent from SettingsUpdateSchema, which is .strict() — sending it // also absent from SettingsUpdateSchema, which is .strict() — sending it
// would 400 the whole settings PUT. // would 400 the whole settings PUT.
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on // showPlanUsageLimits is the ONE exception to "per-device keys never sync":
// ENABLE only, so a device with the chip OFF never strips the exporter that // its DISPLAY stays per-device (loadAppSettingsFromServer only seeds it into
// another device's chip depends on — see system-routes settings handler). // localStorage when a device has no value yet — same as every other display
// key), but it ALSO doubles as the server-side plan-usage telemetry
// COLLECTION switch (readPlanUsageTelemetryEnabled in hooks-config.ts, read
// fresh at every claude session create/respawn), so unlike the others it
// MUST flow through in `serverSettings` below on every save — including
// OFF, which used to be un-sendable under the old one-way "ENABLE only"
// action field this replaces.
const { const {
localEchoEnabled: _leo, localEchoEnabled: _leo,
cjkInputEnabled: _cjk, cjkInputEnabled: _cjk,
extendedKeyboardBar: _ekb, extendedKeyboardBar: _ekb,
skin: _skin, skin: _skin,
language: _language, language: _language,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb, showAttachmentsButton: _ahb,
showFileViewerButton: _fvb, showFileViewerButton: _fvb,
webglRendererEnabled: _wgl, webglRendererEnabled: _wgl,
@@ -2316,7 +2320,6 @@ Object.assign(CodemanApp.prototype, {
try { try {
const res = await this._apiPut('/api/settings', { const res = await this._apiPut('/api/settings', {
...serverSettings, ...serverSettings,
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
notificationPreferences: notifPrefsToSave, notificationPreferences: notifPrefsToSave,
voiceSettings, voiceSettings,
}); });
@@ -2579,10 +2582,11 @@ Object.assign(CodemanApp.prototype, {
// Resolved per-device state of the plan-usage chip. Desktop defaults ON, // Resolved per-device state of the plan-usage chip. Desktop defaults ON,
// handhelds default OFF (the mobile block in getDefaultSettings() sets false, // handhelds default OFF (the mobile block in getDefaultSettings() sets false,
// and the mobile-header-buttons-policy guard depends on that staying false). // and the mobile-header-buttons-policy guard depends on that staying false).
// Single source of truth for THREE call sites that must never disagree: the // Single source of truth for the two call sites that must never disagree:
// App Settings checkbox, the chip's visibility, and the statusLineTelemetry // the App Settings checkbox and the chip's visibility. Telemetry COLLECTION
// flag sent on session create. A chip shown without telemetry renders "—" // no longer has a THIRD client-side call site here at all — the server reads
// forever, which is exactly the drift this helper prevents. // this same persisted setting directly (readPlanUsageTelemetryEnabled in
// hooks-config.ts), fresh, at every claude session create/respawn.
planUsageChipEnabled(settings = null) { planUsageChipEnabled(settings = null) {
const s = settings ?? this.loadAppSettingsFromStorage(); const s = settings ?? this.loadAppSettingsFromStorage();
return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true; return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true;
@@ -3074,11 +3078,13 @@ Object.assign(CodemanApp.prototype, {
'sessionLineageLines', 'sessionLineageLines',
]); ]);
// The plan-usage chip is a PER-DEVICE display setting (desktop default ON, // The plan-usage chip is a PER-DEVICE display setting (desktop default ON,
// handheld default OFF): desktop can show it while mobile stays hidden. It // handheld default OFF): desktop can show it while mobile stays hidden. Drop
// used to sync, so an older server.json may still carry a value — drop it // the server's stored value here so it is NEVER seeded into a device that
// so the server value is NEVER // didn't explicitly enable it — even though this SAME setting also drives
// seeded into a device that didn't explicitly enable it (collection is handled // server-side telemetry collection now (readPlanUsageTelemetryEnabled in
// separately via the statusLineTelemetry action, not this display flag). // hooks-config.ts), that's a read the server does directly from settings.json
// at spawn time; it has nothing to do with what gets merged into THIS
// device's local display preference.
delete appSettings.showPlanUsageLimits; delete appSettings.showPlanUsageLimits;
// Merge settings: non-display keys always sync from server, // Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value // display keys only seed from server when localStorage has no value
+13 -13
View File
@@ -947,18 +947,19 @@ export function registerSessionRoutes(
await updateCaseModel(workingDir, body.modelOverride || null); await updateCaseModel(workingDir, body.modelOverride || null);
} }
// Plan-usage telemetry request (App Settings → header chip). NO LONGER a // Plan-usage telemetry (App Settings → header chip): no request-time field
// disk write here — a settings.local.json statusLine took precedence over // here anymore, and NO disk write — a settings.local.json statusLine used
// the user's own global/project statusLine for ANY `claude` run in that // to take precedence over the user's own global/project statusLine for ANY
// directory, including entirely outside Codeman, with no disclosure and no // `claude` run in that directory, including entirely outside Codeman, with
// way to undo it (real bug, found 2026-08-31). The request now flows // no disclosure and no way to undo it (real bug, found 2026-08-31).
// through as an ordinary session field (statusLineTelemetryRequested below) // TmuxManager.createSession reads the persisted `showPlanUsageLimits`
// and Session/TmuxManager resolve it into an EPHEMERAL `claude --settings` // setting FRESH at spawn (readPlanUsageTelemetryEnabled in hooks-config.ts)
// CLI flag at actual spawn time (resolveStatusLineCliCommand in // and resolves it into an EPHEMERAL `claude --settings` CLI flag — never
// hooks-config.ts) — never written to disk, so a plain `claude` run outside // written to disk, so a plain `claude` run outside Codeman is untouched —
// Codeman is untouched. That resolution also self-heals: it strips any // and applies uniformly to every claude creation path (this route, cron,
// legacy disk-written exporter an older Codeman build left behind. // the Ralph Loop API, quick-start), not just this one. That resolution
const statusLineTelemetryRequested = body.statusLineTelemetry === true; // also self-heals: it strips any legacy disk-written exporter an older
// Codeman build left behind.
// Hooks for the workspace this session runs in (install vs refresh-only is the // Hooks for the workspace this session runs in (install vs refresh-only is the
// `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote // `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote
@@ -1092,7 +1093,6 @@ export function registerSessionRoutes(
resumeSessionId: validatedResumeId, resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort, effort: body.effort,
statusLineTelemetry: statusLineTelemetryRequested,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote, remote,
owner, owner,
+21 -19
View File
@@ -990,11 +990,9 @@ export function registerSystemRoutes(
} catch { } catch {
/* ignore */ /* ignore */
} }
// statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored // acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
// settings) — strip them before persisting so settings.json stays clean. // it before persisting so settings.json stays clean.
// statusLineTelemetry is an action field only (see below); it must never const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
// land in settingsToStore.
const { statusLineTelemetry: _statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings;
const merged = { ...existing, ...settingsToStore }; const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
@@ -1007,7 +1005,7 @@ export function registerSystemRoutes(
// Service toggles resolve from `merged` (existing + incoming), NEVER from the // Service toggles resolve from `merged` (existing + incoming), NEVER from the
// raw request body. A PARTIAL PUT omits keys it does not intend to change, and // raw request body. A PARTIAL PUT omits keys it does not intend to change, and
// reading the body directly turned every omission into "apply the default": // reading the body directly turned every omission into "apply the default":
// a body of just `{statusLineTelemetry:true}` would START the subagent watcher // a body of just `{showPlanUsageLimits:true}` would START the subagent watcher
// (`?? true`) and STOP the workflow + image watchers (`?? false`), silently // (`?? true`) and STOP the workflow + image watchers (`?? false`), silently
// undoing the user's persisted config. Reading `merged` makes any PUT reconcile // undoing the user's persisted config. Reading `merged` makes any PUT reconcile
// services to the effective stored settings instead, which also self-heals // services to the effective stored settings instead, which also self-heals
@@ -1033,19 +1031,23 @@ export function registerSystemRoutes(
} }
}); });
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js). // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js),
// Telemetry COLLECTION was previously server-side and enable-sticky here — // but `showPlanUsageLimits` ALSO doubles as the telemetry COLLECTION switch,
// toggling the chip ON re-injected a statusLine.command into every ACTIVE // persisted here in settingsToStore like any other setting (no special-casing
// Claude session's settings.local.json so live % started flowing without a // needed — see readPlanUsageTelemetryEnabled's doc comment in hooks-config.ts).
// new session. That disk write is exactly the bug fixed 2026-08-31 (it took // Telemetry COLLECTION used to be a SEPARATE, action-only, sticky mechanism
// precedence over the user's own statusline for ANY `claude` run in that // here: toggling the chip ON re-injected a statusLine.command into every
// directory, including outside Codeman, with no way to undo it). Telemetry // ACTIVE Claude session's settings.local.json so live % started flowing
// is now requested per-session at CREATE/RESPAWN time only (statusLineTelemetry // without a new session. That disk write was the bug fixed 2026-08-31 (it
// threaded through cron/ralph-loop/quick-start/interactive-create, see those // took precedence over the user's own statusline for ANY `claude` run in
// route handlers), resolved into an ephemeral `--settings` CLI flag — fixed at // that directory, including outside Codeman, with no way to undo it).
// spawn, so there is nothing to (re)inject into an ALREADY-RUNNING session // Collection is now decided by TmuxManager.createSession/respawnPane reading
// here, unlike the old disk mechanism. The action field above is received and // `showPlanUsageLimits` FRESH from settings.json at spawn time — no
// discarded; flipping the chip ON only affects sessions created from now on. // per-session field, no per-request threading through cron/Ralph-loop/
// quick-start/interactive-create (they all reach the same read), and no
// (re)injection into an already-running session needed here: the NEXT
// respawn (a Ralph cycle, `/clear`, a PTY-exit restart) already picks up
// whatever this PUT just persisted.
// Handle tunnel toggle dynamically // Handle tunnel toggle dynamically
if ('tunnelEnabled' in settings) { if ('tunnelEnabled' in settings) {
+4 -8
View File
@@ -524,8 +524,6 @@ export const CreateSessionSchema = z.object({
effort: effortLevelSchema, effort: effortLevelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */ /** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(), modelOverride: z.string().max(50).optional(),
/** Inject the Claude statusLine source for the shared plan-usage chip. Claude sessions only; Codex is host-polled. */
statusLineTelemetry: z.boolean().optional(),
openCodeConfig: OpenCodeConfigSchema, openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema, codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema, geminiConfig: GeminiConfigSchema,
@@ -1289,13 +1287,11 @@ export const SettingsUpdateSchema = z
showFileBrowser: z.boolean().optional(), showFileBrowser: z.boolean().optional(),
showSubagents: z.boolean().optional(), showSubagents: z.boolean().optional(),
showMultiMonitorButton: z.boolean().optional(), showMultiMonitorButton: z.boolean().optional(),
// Doubles as the plan-usage telemetry COLLECTION switch, read fresh from
// disk by readPlanUsageTelemetryEnabled() (hooks-config.ts) at every claude
// session create/respawn — not just the chip's DISPLAY preference. See that
// function's doc comment for why one persisted field serves both.
showPlanUsageLimits: z.boolean().optional(), showPlanUsageLimits: z.boolean().optional(),
// Action field (NOT persisted as a setting): when true, (re)injects the
// plan-usage statusLine exporter into active Claude sessions so live usage %
// starts flowing. Sent on ENABLE only — the chip's DISPLAY is per-device
// (client-side), but telemetry COLLECTION is server-side, so the per-device
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
statusLineTelemetry: z.boolean().optional(),
showRedrawButton: z.boolean().optional(), showRedrawButton: z.boolean().optional(),
// Input // Input
gestureControlEnabled: z.boolean().optional(), gestureControlEnabled: z.boolean().optional(),
+4 -1
View File
@@ -1450,7 +1450,10 @@ export class WebServer extends EventEmitter {
// PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It // PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It
// used to be server-revealed from a synced setting, but that leaked the desktop // used to be server-revealed from a synced setting, but that leaked the desktop
// choice onto mobile — display is now per-device only (like the response viewer). // choice onto mobile — display is now per-device only (like the response viewer).
// Telemetry collection stays server-side via the statusLineTelemetry action. // Telemetry collection stays server-side, reading `showPlanUsageLimits` fresh
// from settings.json at every claude session create/respawn (see
// readPlanUsageTelemetryEnabled in hooks-config.ts) — the same setting this
// display-visibility check reads, doing double duty.
// Detached single-session ("solo") window: inject the target session id so // Detached single-session ("solo") window: inject the target session id so
// the client can enter solo mode even if a (network-first) service worker // the client can enter solo mode even if a (network-first) service worker
// later serves a cached shell. The client primarily detects solo mode from // later serves a cached shell. The client primarily detects solo mode from
+122
View File
@@ -7,6 +7,7 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { import {
chmodSync,
closeSync, closeSync,
existsSync, existsSync,
openSync, openSync,
@@ -18,6 +19,7 @@ import {
statSync, statSync,
} from 'node:fs'; } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { tmpdir, homedir } from 'node:os'; import { tmpdir, homedir } from 'node:os';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { import {
@@ -28,6 +30,7 @@ import {
generateHooksConfig, generateHooksConfig,
generateStatusLineCommand, generateStatusLineCommand,
generateSubagentStopGuardScript, generateSubagentStopGuardScript,
readPlanUsageTelemetryEnabled,
refreshStaleCodemanHooks, refreshStaleCodemanHooks,
resolveStatusLineCliCommand, resolveStatusLineCliCommand,
settingsWriteBlocker, settingsWriteBlocker,
@@ -1319,6 +1322,48 @@ describe('Hook Config Generation - Extended', () => {
}); });
}); });
describe('readPlanUsageTelemetryEnabled', () => {
const backup = existsSync(SETTINGS_PATH) ? readFileSync(SETTINGS_PATH, 'utf-8') : null;
afterEach(() => {
if (backup !== null) {
writeFileSync(SETTINGS_PATH, backup);
} else {
rmSync(SETTINGS_PATH, { force: true });
}
});
it('reads true fresh from the persisted showPlanUsageLimits setting', async () => {
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: true }));
expect(await readPlanUsageTelemetryEnabled()).toBe(true);
});
it('reads false when the setting is explicitly false', async () => {
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: false }));
expect(await readPlanUsageTelemetryEnabled()).toBe(false);
});
it('defaults to false when the setting is absent or the file is missing', async () => {
rmSync(SETTINGS_PATH, { force: true });
expect(await readPlanUsageTelemetryEnabled()).toBe(false);
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ someOtherSetting: true }));
expect(await readPlanUsageTelemetryEnabled()).toBe(false);
});
it('never caches — a change on disk is visible on the very next call', async () => {
mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: false }));
expect(await readPlanUsageTelemetryEnabled()).toBe(false);
writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: true }));
expect(await readPlanUsageTelemetryEnabled()).toBe(true);
});
});
describe('resolveStatusLineCliCommand', () => { describe('resolveStatusLineCliCommand', () => {
const testDir = join(tmpdir(), 'codeman-statusline-cli-test-' + Date.now()); const testDir = join(tmpdir(), 'codeman-statusline-cli-test-' + Date.now());
@@ -1389,6 +1434,83 @@ describe('resolveStatusLineCliCommand', () => {
}); });
}); });
describe('statusline exporter script (real shell execution)', () => {
const testDir = join(tmpdir(), 'codeman-statusline-script-exec-test-' + Date.now());
const binDir = join(tmpdir(), 'codeman-statusline-script-exec-bin-' + Date.now());
beforeEach(() => {
mkdirSync(testDir, { recursive: true });
mkdirSync(binDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
rmSync(binDir, { recursive: true, force: true });
});
// A stand-in for the real `curl` binary, placed FIRST on PATH — same technique
// the exporter's own review used ("an arg-echoing stand-in"). It ignores every
// arg curl would have received; only its own scripted behavior matters here.
function writeFakeCurl(script: string): void {
const curlPath = join(binDir, 'curl');
writeFileSync(curlPath, `#!/bin/sh\n${script}\n`);
chmodSync(curlPath, 0o755);
}
function runExporter(
env: Record<string, string>
): Promise<{ code: number | null; stdout: string; durationMs: number }> {
return resolveStatusLineCliCommand(testDir, true).then(
(scriptPath) =>
new Promise((resolve, reject) => {
const start = Date.now();
const child = spawn('sh', [scriptPath!], {
env: { ...env, PATH: `${binDir}:${process.env.PATH}` },
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.on('error', reject);
child.on('close', (code) => resolve({ code, stdout, durationMs: Date.now() - start }));
child.stdin.end('{}');
})
);
}
const baseEnv = {
CODEMAN_SESSION_ID: 'x',
CODEMAN_API_URL: 'http://127.0.0.1:1',
CODEMAN_HOOK_SECRET_FILE: '/dev/null',
};
it('no-user-statusline branch: the POST runs in the foreground and its OWN stdout becomes the footer', async () => {
writeFakeCurl(`echo 'model: opus | 42% used'`);
const result = await runExporter(baseEnv);
expect(result.stdout.trim()).toBe('model: opus | 42% used');
});
it('no-user-statusline branch: falls back to the plain "codeman" marker when curl fails', async () => {
writeFakeCurl(`exit 1`);
const result = await runExporter(baseEnv);
expect(result.stdout.trim()).toBe('codeman');
});
it('wrap branch: never blocks a reader-to-EOF on a slow/hung curl (background subshell closes stdin too)', async () => {
writeFakeCurl(`sleep 3`);
const result = await runExporter({ ...baseEnv, CODEMAN_USER_STATUSLINE_CMD: 'echo my-own-statusline' });
expect(result.stdout.trim()).toBe('my-own-statusline');
expect(result.durationMs).toBeLessThan(1000);
}, 10000);
it('curl is bounded with --max-time so a HUNG (not just refused) Codeman cannot wedge the render', async () => {
const scriptPath = await resolveStatusLineCliCommand(testDir, true);
expect(readFileSync(scriptPath!, 'utf-8')).toContain('--max-time');
});
});
describe('findEffectiveUserStatusLineCommand', () => { describe('findEffectiveUserStatusLineCommand', () => {
const testDir = join(tmpdir(), 'codeman-statusline-precedence-test-' + Date.now()); const testDir = join(tmpdir(), 'codeman-statusline-precedence-test-' + Date.now());
const userSettingsPath = join(homedir(), '.claude', 'settings.json'); const userSettingsPath = join(homedir(), '.claude', 'settings.json');
@@ -156,7 +156,7 @@ describe('POST /api/sessions workspace hooks', () => {
const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json'); const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json');
const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null; const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
const res = await createSession({ name: 'hooks-no-dir', mode: 'claude', statusLineTelemetry: true }); const res = await createSession({ name: 'hooks-no-dir', mode: 'claude' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null; const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
@@ -166,8 +166,9 @@ describe('POST /api/sessions workspace hooks', () => {
it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => { it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => {
// A claude-mode attachRemoteSession create overwrites workingDir with // A claude-mode attachRemoteSession create overwrites workingDir with
// `user@host:session` — locally a RELATIVE path, so a mkdir would create it // `user@host:session` — locally a RELATIVE path, so a mkdir would create it
// as a junk directory under the server cwd. statusLineTelemetry rides along: // as a junk directory under the server cwd. The statusLine exporter rides
// applyStatusLineConfig mkdirs the same way and used to run for remote attaches. // along: applyStatusLineConfig mkdirs the same way and used to run for
// remote attaches.
// SAFETY (2026-08-29): write straight to `getDataDir()` — `test/setup.ts` // SAFETY (2026-08-29): write straight to `getDataDir()` — `test/setup.ts`
// already sandboxes the data dir for the whole file (temp HOME, inherited // already sandboxes the data dir for the whole file (temp HOME, inherited
// CODEMAN_DATA_DIR stripped; same convention as the docker-hosts fixtures // CODEMAN_DATA_DIR stripped; same convention as the docker-hosts fixtures
@@ -188,7 +189,6 @@ describe('POST /api/sessions workspace hooks', () => {
const res = await createSession({ const res = await createSession({
name: 'hooks-remote', name: 'hooks-remote',
mode: 'claude', mode: 'claude',
statusLineTelemetry: true,
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' }, attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
@@ -4,7 +4,7 @@
* The three service toggles (subagent watcher, workflow-run watcher, image * The three service toggles (subagent watcher, workflow-run watcher, image
* watcher) used to read the RAW REQUEST BODY with `??` defaults, so any key the * watcher) used to read the RAW REQUEST BODY with `??` defaults, so any key the
* caller omitted was treated as "apply the default". A body of just * caller omitted was treated as "apply the default". A body of just
* `{statusLineTelemetry:true}` therefore STARTED the subagent watcher (`?? true`) * `{showPlanUsageLimits:true}` therefore STARTED the subagent watcher (`?? true`)
* and STOPPED the workflow + image watchers (`?? false`), silently undoing the * and STOPPED the workflow + image watchers (`?? false`), silently undoing the
* persisted config. Nothing triggered it in practice only because every shipped * persisted config. Nothing triggered it in practice only because every shipped
* client sends a full settings payload rebuilt from the DOM. * client sends a full settings payload rebuilt from the DOM.
@@ -91,8 +91,8 @@ describe('PUT /api/settings — partial body must not reset service toggles', ()
const res = await harness.app.inject({ const res = await harness.app.inject({
method: 'PUT', method: 'PUT',
url: '/api/settings', url: '/api/settings',
// Action-only body: the exact shape that used to flip all three watchers. // Minimal single-key body: the exact shape that used to flip all three watchers.
payload: { statusLineTelemetry: true }, payload: { showPlanUsageLimits: true },
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
+7 -3
View File
@@ -78,9 +78,13 @@ describe('buildSpawnCommand statusLineCommand (claude mode)', () => {
expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: tricky } }); expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: tricky } });
}); });
it('round-trips the REAL exporter command unmodified (generateStatusLineCommand)', async () => { it('round-trips the REAL exporter script path unmodified (ensureStatusLineExporterScript)', async () => {
const { generateStatusLineCommand } = await import('../src/hooks-config.js'); // What resolveStatusLineCliCommand actually hands to buildSpawnCommand at spawn
const real = generateStatusLineCommand(); // time today is a bare script PATH (see that function's doc comment for why —
// never the raw curl command generateStatusLineCommand() builds, which only
// backs the legacy disk-write applyStatusLineConfig path now).
const { ensureStatusLineExporterScript } = await import('../src/hooks-config.js');
const real = await ensureStatusLineExporterScript();
const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: real }); const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: real });
expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: real } }); expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: real } });
}); });