diff --git a/CLAUDE.md b/CLAUDE.md index ed423971..67e4855c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) -**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`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 43705d0b..2821aedb 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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** (`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 **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: > - **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%`. @@ -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. 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`). -- **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 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. -- `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`. +- ~~**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**.~~ 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**~~ — `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 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. -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. -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. ## Files shipped - `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/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/routes/session-routes.ts` — add-only create-time injection. -- `src/web/routes/system-routes.ts` — settings-toggle reconcile. +- `src/tmux-manager.ts` — `createSession`/`respawnPane` read `readPlanUsageTelemetryEnabled()` fresh at spawn. - `src/web/server.ts` — `getLightState().planUsage` (init snapshot). - `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). diff --git a/src/hooks-config.ts b/src/hooks-config.ts index d8a8a38b..e637a3cb 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -31,7 +31,7 @@ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -39,6 +39,7 @@ import { fileURLToPath } from 'node:url'; import type { HookEventType } from './types.js'; import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.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 @@ -906,6 +907,196 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean): }); } +/** + * Version-agnostic marker embedded as a comment in the generated exporter + * SCRIPT (see ensureStatusLineExporterScript) — bump the numeric suffix + * whenever the script content changes so `ensureStatusLineExporterScript`'s + * content comparison rewrites stale copies on next use. + */ +const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V3'; + +function statusLineExporterScriptContent(): string { + // Where the telemetry POST runs depends on who owns the footer. When the pane's + // env carries CODEMAN_USER_STATUSLINE_CMD (set via tmux setenv by TmuxManager + // when findEffectiveUserStatusLineCommand found the user's own REAL statusLine — + // see that function's doc comment), the user's command owns the footer, so the + // POST runs in a BACKGROUND subshell with stdin/stdout/stderr all closed + // (`>/dev/null 2>&1 /dev/null)" ` + + `--data @-`; + return ( + `#!/bin/sh\n` + + `# ${STATUSLINE_EXPORTER_SCRIPT_MARKER} — auto-generated by Codeman; safe to delete, regenerated on demand.\n` + + `INPUT=$(cat 2>/dev/null || echo '{}')\n` + + `if [ -n "$CODEMAN_USER_STATUSLINE_CMD" ]; then\n` + + ` ( ${post} ) >/dev/null 2>&1 /dev/null || echo codeman\n` + + `fi\n` + ); +} + +async function readStatusLineCommandFromFile(settingsPath: string): Promise { + if (!existsSync(settingsPath)) return undefined; + try { + const parsed = JSON.parse(await readFile(settingsPath, 'utf-8')); + const current = parsed.statusLine as { command?: unknown } | undefined; + return current && typeof current.command === 'string' ? current.command : undefined; + } catch { + return undefined; // Malformed — treat as absent, same posture as applyStatusLineConfig. + } +} + +/** + * Walk Claude Code's OWN settings precedence for `workingDir` to find whatever + * statusLine command is ACTUALLY effective there right now: project-local + * `.claude/settings.local.json` > project-shared `.claude/settings.json` > + * the user's global `~/.claude/settings.json`. Returns undefined when none of + * the three configures one. + * + * A legacy Codeman-marked entry in the project's OWN settings.local.json + * (written by an older build's disk-based mechanism) is never treated as a + * real user command — resolveStatusLineCliCommand strips it before this ever + * runs, so ordinarily this function never even sees one; the marker check + * here is a second, defensive guard in case something else wrote a copy in + * between, and precedence simply continues to the next layer instead of + * stopping on it. + */ +export async function findEffectiveUserStatusLineCommand(workingDir: string): Promise { + const projectLocal = await readStatusLineCommandFromFile(join(workingDir, '.claude', 'settings.local.json')); + if (projectLocal && !projectLocal.includes(STATUSLINE_MARKER)) return projectLocal; + + const projectShared = await readStatusLineCommandFromFile(join(workingDir, '.claude', 'settings.json')); + if (projectShared) return projectShared; + + return readStatusLineCommandFromFile(join(homedir(), '.claude', 'settings.json')); +} + +/** + * Write (or refresh) the SHARED, single exporter script every claude session + * points its ephemeral --settings statusLine flag at, and return its absolute + * path. Idempotent: only rewrites when the marker-versioned content differs. + * + * This is the fix for a real bug found live 2026-08-31: the exporter's + * command string legitimately depends on `$CODEMAN_SESSION_ID`, + * `$CODEMAN_API_URL`, `$CODEMAN_HOOK_SECRET_FILE`, and its own internal + * `$INPUT` — all meant to be expanded ONLY when Claude Code itself finally + * executes the statusLine command, using the PANE's tmux-setenv'd + * environment. Passing that command as literal TEXT through + * `--settings '...'` routes it through this server's OWN spawn-time shell + * layers first (tmux respawn-pane's `bash -c "..."`, itself invoked via + * execSync's implicit `/bin/sh -c`) — and POSIX double quotes do NOT + * suppress `$` expansion, so those vars got expanded there and then, against + * the SERVER process's environment (where they are unset), producing a + * mangled curl call that posted malformed JSON and printed the server's raw + * error response as the statusline text itself. A bare file PATH has no `$`, + * quotes, or pipes for any of those intermediate shells to mangle — the + * script's own content (containing the real `$VAR`s) is never touched by a + * shell until Claude Code executes the file itself, at which point the + * pane's real environment is in scope. This mirrors the existing #208 fix in + * tmux-manager.ts (never embed a literal `$SHELL` meant for later + * expansion — resolve it, or in this case reference a file, instead). + */ +export async function ensureStatusLineExporterScript(): Promise { + const scriptPath = dataPath('statusline-exporter.sh'); + const desired = statusLineExporterScriptContent(); + let current: string | null = null; + try { + current = await readFile(scriptPath, 'utf-8'); + } catch { + // Doesn't exist yet. + } + if (current !== desired) { + await writeFile(scriptPath, desired); + await chmod(scriptPath, 0o755); + } + 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 { + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {}); + return settings.showPlanUsageLimits === true; +} + +/** + * Resolve the statusLine command to pass as an EPHEMERAL `claude --settings` + * CLI flag for this one process (see buildSpawnCommandFromRegistry in + * session-cli-registry-bridge.ts) — never written to disk. This supersedes + * the old applyStatusLineConfig(path, true) disk-write: a file-based + * statusLine leaked into any plain `claude` run in that directory outside + * Codeman entirely (it took precedence over the user's own global/project + * statusline with no disclosure and no way to remove it — found live + * 2026-08-31). + * + * Also self-heals: if an OLDER Codeman build already wrote its marked + * exporter into this workspace's settings.local.json, it is stripped here + * (isOurs-guarded, same as applyStatusLineConfig's removal branch) so every + * workspace migrates off the disk-based mechanism the first time a session + * 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 isn't currently enabled (see + * readPlanUsageTelemetryEnabled), or when the workspace already has its OWN + * hand-configured statusLine (never override a real one). + */ +export async function resolveStatusLineCliCommand( + casePath: string, + telemetryEnabled: boolean +): Promise { + const settingsPath = join(casePath, '.claude', 'settings.local.json'); + let userHasOwnStatusLine = false; + if (existsSync(settingsPath)) { + try { + const existing = JSON.parse(await readFile(settingsPath, 'utf-8')); + const current = existing.statusLine as { command?: unknown } | undefined; + if (current && typeof current.command === 'string') { + if (current.command.includes(STATUSLINE_MARKER)) { + await applyStatusLineConfig(casePath, false); // strip legacy disk-written exporter + } else { + userHasOwnStatusLine = true; + } + } + } catch { + // Malformed — leave it alone, same guard applyStatusLineConfig itself uses. + } + } + if (!telemetryEnabled || userHasOwnStatusLine) return undefined; + return ensureStatusLineExporterScript(); +} + // ─── Agent skill injection ─────────────────────────────────────────────────── /** diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index d49e2447..e11e1449 100644 --- a/src/session-cli-registry-bridge.ts +++ b/src/session-cli-registry-bridge.ts @@ -56,6 +56,14 @@ export interface SpawnBridgeOptions { effort?: EffortLevel; sessionName?: string; claudeCliVersion?: string | null; + /** + * Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the + * exporter. Claude only. Rides the SAME `--settings` JSON object as `effortSettingsJson` + * (see buildSpawnCommandFromRegistry): Claude Code accepts only one `--settings` flag + * per invocation, so the two must be merged before reaching the argv engine rather than + * rendered as two independent params. + */ + statusLineCommand?: string; } /** @@ -186,8 +194,23 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri // than re-deriving the ultracode special case) keeps the EFFORT_LEVELS allowlist and the // settings-JSON shape single-sourced in session-cli-builder.ts. const [effortFlag, effortValue] = buildEffortCliArgs(options.effort); - if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue; - else if (effortFlag === '--effort') engineValues.effortLevel = effortValue; + if (effortFlag === '--effort') { + engineValues.effortLevel = effortValue; + } + + // Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in + // hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude + // Code accepts only one `--settings` flag per invocation — rendering them as two independent + // params would let the second one silently win. Claude-only in practice (statusLineCommand + // is resolved claude-mode-only upstream), but this merge is mode-agnostic. + if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) { + const settingsObj: Record = + effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}; + if (options.statusLineCommand) { + settingsObj.statusLine = { type: 'command', command: options.statusLineCommand }; + } + engineValues.effortSettingsJson = JSON.stringify(settingsObj); + } // Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes // the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9fdd2bda..751b3c69 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -67,6 +67,11 @@ import { legacyConfigForMode, } from './session-cli-registry-bridge.js'; import type { CliEntry } from './config/cli-registry/types.js'; +import { + resolveStatusLineCliCommand, + readPlanUsageTelemetryEnabled, + findEffectiveUserStatusLineCommand, +} from './hooks-config.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -661,6 +666,8 @@ export function buildSpawnCommand(options: { ompConfig?: OmpConfig; resumeSessionId?: string; effort?: EffortLevel; + /** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */ + statusLineCommand?: string; /** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */ sessionName?: string; /** @@ -1704,6 +1711,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * Export the user's own REAL statusLine command (found by + * findEffectiveUserStatusLineCommand) via tmux setenv, so the shared + * exporter script (statusLineExporterScriptContent in hooks-config.ts) can + * wrap it. Via setenv rather than embedding it in the spawn command line: + * tmux stores a setenv value verbatim and never re-parses it as shell + * syntax, so once safely escaped for THIS one command, the command's own + * `$`/quotes survive untouched into the claude process's environment — the + * same reasoning that made the exporter script itself necessary (see + * ensureStatusLineExporterScript's doc comment). Only this ONE line needs + * shellescape(); the stored value itself is opaque to tmux from then on. + */ + private _configureStatusLineUserCommand(muxName: string, command: string | undefined): void { + if (!command) return; + try { + execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} CODEMAN_USER_STATUSLINE_CMD ${shellescape(command)}`, { + timeout: EXEC_TIMEOUT_MS, + stdio: 'ignore', + }); + } catch { + // Non-critical — the exporter just falls back to the plain "codeman" marker. + } + } + /** * Creates a new tmux session wrapping Claude CLI or a shell. * In test mode: creates an in-memory session only (no real tmux session). @@ -1787,6 +1818,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); + // Registry-gated (capabilities.statusLineTelemetry — claude only today), local + // spawns only (remote/docker have their own separate command builders — out of + // scope here). Also self-heals: strips any legacy disk-written exporter from an + // older Codeman build the first time a session starts in that workspace again. + const statusLineCommand = + getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker + ? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled()) + : undefined; + // The user's own REAL statusLine, if any (walked via Claude Code's own + // settings precedence) — exported below so the shared exporter script + // can wrap it. Only worth discovering when we're actually injecting. + const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; + const baseCmd = buildSpawnCommand({ mode, sessionId, @@ -1803,6 +1847,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { ompConfig, resumeSessionId, effort, + statusLineCommand, sessionName: name, }); @@ -1865,6 +1910,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode, legacyConfigForMode(mode, options as unknown as Record) ); + this._configureStatusLineUserCommand(muxName, userStatusLineCommand); // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. @@ -2042,6 +2088,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); + // See createSession()'s identical resolution for rationale. + const statusLineCommand = + getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker + ? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled()) + : undefined; + const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; + const baseCmd = buildSpawnCommand({ mode, sessionId, @@ -2058,6 +2111,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { ompConfig, resumeSessionId, effort, + statusLineCommand, sessionName: name, }); const config = niceConfig || DEFAULT_NICE_CONFIG; @@ -2077,6 +2131,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode, legacyConfigForMode(mode, options as unknown as Record) ); + this._configureStatusLineUserCommand(muxName, userStatusLineCommand); // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 26ed4390..92debff2 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1062,13 +1062,6 @@ Object.assign(CodemanApp.prototype, { ...(hasEnvOverrides ? { envOverrides } : {}), ...(effort ? { effort } : {}), ...(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()) ); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 75e088a6..4f892be8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2271,22 +2271,26 @@ Object.assign(CodemanApp.prototype, { // Save to server (includes notification prefs for cross-browser persistence). // Strip device-specific DISPLAY keys so they never sync across devices — - // localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits - // is per-device too (desktop can show the usage chip while mobile stays hidden). + // localEcho/cjk/extendedKeyboard/skin are per-platform. // webglRendererEnabled is per-device as well (renderer choice is GPU-specific, // and syncing would leak mobile's hidden-checkbox false onto desktop); it's // also absent from SettingsUpdateSchema, which is .strict() — sending it // would 400 the whole settings PUT. - // Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on - // ENABLE only, so a device with the chip OFF never strips the exporter that - // another device's chip depends on — see system-routes settings handler). + // showPlanUsageLimits is the ONE exception to "per-device keys never sync": + // its DISPLAY stays per-device (loadAppSettingsFromServer only seeds it into + // 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 { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, language: _language, - showPlanUsageLimits: _pul, showAttachmentsButton: _ahb, showFileViewerButton: _fvb, webglRendererEnabled: _wgl, @@ -2316,7 +2320,6 @@ Object.assign(CodemanApp.prototype, { try { const res = await this._apiPut('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/api/settings', { ...serverSettings, - ...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}), notificationPreferences: notifPrefsToSave, voiceSettings, }); @@ -2579,10 +2582,11 @@ Object.assign(CodemanApp.prototype, { // Resolved per-device state of the plan-usage chip. Desktop defaults ON, // handhelds default OFF (the mobile block in getDefaultSettings() sets 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 - // App Settings checkbox, the chip's visibility, and the statusLineTelemetry - // flag sent on session create. A chip shown without telemetry renders "—" - // forever, which is exactly the drift this helper prevents. + // Single source of truth for the two call sites that must never disagree: + // the App Settings checkbox and the chip's visibility. Telemetry COLLECTION + // no longer has a THIRD client-side call site here at all — the server reads + // this same persisted setting directly (readPlanUsageTelemetryEnabled in + // hooks-config.ts), fresh, at every claude session create/respawn. planUsageChipEnabled(settings = null) { const s = settings ?? this.loadAppSettingsFromStorage(); return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true; @@ -3074,11 +3078,13 @@ Object.assign(CodemanApp.prototype, { 'sessionLineageLines', ]); // 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 - // used to sync, so an older server.json may still carry a value — drop it - // so the server value is NEVER - // seeded into a device that didn't explicitly enable it (collection is handled - // separately via the statusLineTelemetry action, not this display flag). + // handheld default OFF): desktop can show it while mobile stays hidden. Drop + // the server's stored value here so it is NEVER seeded into a device that + // didn't explicitly enable it — even though this SAME setting also drives + // server-side telemetry collection now (readPlanUsageTelemetryEnabled in + // 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; // Merge settings: non-display keys always sync from server, // display keys only seed from server when localStorage has no value diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9ea80060..48fd7328 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -94,7 +94,6 @@ import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, - applyStatusLineConfig, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, @@ -948,27 +947,19 @@ export function registerSessionRoutes( await updateCaseModel(workingDir, body.modelOverride || null); } - // Plan-usage statusLine exporter (App Settings → Display → "Plan Usage - // Limits"). Claude-only; runs for ANY working dir (linked cases / real repos, - // where most sessions live), mirroring updateCaseModel above. - // - // ADD-ONLY: we never remove on create. 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 yet) must NOT yank the statusLine out from - // under other live sessions in that repo — that breaks their footer + the - // chip's data feed for everyone. The exporter is benign when the chip is off - // (the footer just shows session status). isOurs-guarded so a user's own - // statusLine is never touched. - // - // Same guard as the hooks call below (499d355): never for a remote attach - // (workingDir is a user@host:session pseudo-path — the mkdir inside - // applyStatusLineConfig would create it as a junk local dir), and only when - // the caller named a workingDir — the process-cwd fallback is $HOME under - // installer-created services, and a statusLine materializing in - // ~/.claude/settings.local.json was never asked for. - if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) { - await applyStatusLineConfig(workingDir, true); - } + // Plan-usage telemetry (App Settings → header chip): no request-time field + // here anymore, and NO disk write — a settings.local.json statusLine used + // to 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). + // TmuxManager.createSession reads the persisted `showPlanUsageLimits` + // setting FRESH at spawn (readPlanUsageTelemetryEnabled in hooks-config.ts) + // and resolves it into an EPHEMERAL `claude --settings` CLI flag — never + // written to disk, so a plain `claude` run outside Codeman is untouched — + // and applies uniformly to every claude creation path (this route, cron, + // the Ralph Loop API, quick-start), not just this one. That resolution + // 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 // `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index f409e911..93c45ded 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -5,7 +5,6 @@ */ import { FastifyInstance } from 'fastify'; -import { getCli } from '../../config/cli-registry/registry.js'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, readdirSync } from 'node:fs'; @@ -33,7 +32,6 @@ import { import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { workflowRunWatcher } from '../../workflow-run-watcher.js'; -import { applyStatusLineConfig } from '../../hooks-config.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { buildAwayDigest, @@ -938,7 +936,47 @@ export function registerSystemRoutes( // ========== Settings ========== app.get('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/api/settings', async () => { - return readJsonConfig(SETTINGS_PATH, 'settings', {}); + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings', {}); + + // Plan-usage chip default reconciliation (PR #361 follow-up): the client's + // own default resolution (planUsageChipEnabled() in settings-ui.js) shows + // the header chip and the App Settings checkbox as already ON whenever this + // key has never been set — a discoverability default from 1.9.3, unrelated + // to consent. Meanwhile readPlanUsageTelemetryEnabled() (hooks-config.ts) + // deliberately treats an absent key as "no telemetry" (privacy: never POST + // usage data without an explicit persisted yes, pinned by its own unit + // tests). Nothing ever reconciled those two independent guesses, so a + // fresh install showed a checked box that silently did 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, and its running Claude process's argv carried no + // --settings flag at all, i.e. zero telemetry ever collected. + // + // Resolve it ONCE, here, the first time anything reads settings: if the + // key is truly ABSENT (never explicit true or false), persist the same + // desktop-default-ON resolution the client already shows, so "chip visible" + // and "telemetry collected" become the same fact instead of two defaults + // that happen to disagree. 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 (its + // unit tests keep passing unmodified) while being unreachable in practice + // for any install that has ever called this route. An explicit false the + // user sets afterward is respected forever; this only fires on true absence. + if (!('showPlanUsageLimits' in settings)) { + settings.showPlanUsageLimits = true; + try { + const dir = dirname(SETTINGS_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + await fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)); + } catch { + // Best-effort: the resolved default still reaches this response even + // if the write fails, so the caller sees consistent data either way. + } + } + + return settings; }); app.put('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/api/settings', async (req) => { @@ -992,9 +1030,9 @@ export function registerSystemRoutes( } catch { /* ignore */ } - // statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored - // settings) — strip them before persisting so settings.json stays clean. - const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings; + // acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip + // it before persisting so settings.json stays clean. + const { acknowledgeUnauthTunnel, ...settingsToStore } = settings; const merged = { ...existing, ...settingsToStore }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); @@ -1007,7 +1045,7 @@ export function registerSystemRoutes( // 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 // 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 // undoing the user's persisted config. Reading `merged` makes any PUT reconcile // services to the effective stored settings instead, which also self-heals @@ -1033,22 +1071,23 @@ export function registerSystemRoutes( } }); - // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js). - // Telemetry COLLECTION is server-side and enable-sticky — when a client turns - // the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter - // into every ACTIVE Claude session's working dir so the live % starts flowing - // immediately (no new session needed). We deliberately never auto-REMOVE here: - // the exporter is benign/print-through and a per-repo settings.local.json is - // shared by sibling sessions, so one device's "off" must not yank the exporter - // another device's chip depends on. Each dir handled once. - if (statusLineTelemetry === true) { - const dirs = new Set(); - for (const session of ctx.sessions.values()) { - if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir) - dirs.add(session.workingDir); - } - await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {}))); - } + // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js), + // but `showPlanUsageLimits` ALSO doubles as the telemetry COLLECTION switch, + // persisted here in settingsToStore like any other setting (no special-casing + // needed — see readPlanUsageTelemetryEnabled's doc comment in hooks-config.ts). + // Telemetry COLLECTION used to be a SEPARATE, action-only, sticky mechanism + // here: toggling the chip ON re-injected a statusLine.command into every + // ACTIVE Claude session's settings.local.json so live % started flowing + // without a new session. That disk write was the bug fixed 2026-08-31 (it + // took precedence over the user's own statusline for ANY `claude` run in + // that directory, including outside Codeman, with no way to undo it). + // Collection is now decided by TmuxManager.createSession/respawnPane reading + // `showPlanUsageLimits` FRESH from settings.json at spawn time — no + // 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 if ('tunnelEnabled' in settings) { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 3ae465c2..df9028c5 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -524,8 +524,6 @@ export const CreateSessionSchema = z.object({ effort: effortLevelSchema, /** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */ 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, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, @@ -1289,13 +1287,11 @@ export const SettingsUpdateSchema = z showFileBrowser: z.boolean().optional(), showSubagents: 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(), - // 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(), // Input gestureControlEnabled: z.boolean().optional(), diff --git a/src/web/server.ts b/src/web/server.ts index b554ada5..e84c626c 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1450,7 +1450,10 @@ export class WebServer extends EventEmitter { // PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It // 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). - // 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 // 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 diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 774774b3..1048486a 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -6,17 +6,33 @@ */ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; -import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import { + chmodSync, + closeSync, + existsSync, + openSync, + readFileSync, + writeFileSync, + mkdirSync, + rmSync, + symlinkSync, + statSync, +} from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import { tmpdir, homedir } from 'node:os'; import { spawn } from 'node:child_process'; import { applyStatusLineConfig, ensureCodemanHooks, + findEffectiveUserStatusLineCommand, generateBackgroundWakeScript, generateHooksConfig, + generateStatusLineCommand, generateSubagentStopGuardScript, + readPlanUsageTelemetryEnabled, refreshStaleCodemanHooks, + resolveStatusLineCliCommand, settingsWriteBlocker, stripCaseEnvKeys, updateCaseEnvVars, @@ -1305,3 +1321,264 @@ describe('Hook Config Generation - Extended', () => { expect(stopHooks[0].hooks[0].command).toContain('stop'); }); }); + +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', () => { + const testDir = join(tmpdir(), 'codeman-statusline-cli-test-' + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('returns undefined when telemetry was not requested', async () => { + expect(await resolveStatusLineCliCommand(testDir, false)).toBeUndefined(); + }); + + it('returns a bare exporter SCRIPT PATH (never the inline command) when requested', async () => { + // A bare path has no `$`, quotes, or pipes for any intermediate shell + // layer to mangle — see ensureStatusLineExporterScript's doc comment for + // the real bug this guards against. + const cmd = await resolveStatusLineCliCommand(testDir, true); + expect(cmd).toBeDefined(); + expect(cmd).not.toContain('$'); + expect(cmd).not.toContain("'"); + expect(cmd).toMatch(/^\/.*statusline-exporter\.sh$/); + expect(existsSync(cmd!)).toBe(true); + const stat = statSync(cmd!); + expect(stat.mode & 0o111).not.toBe(0); // executable + expect(readFileSync(cmd!, 'utf-8')).toContain('CODEMAN_STATUSLINE_EXPORTER_V'); + }); + + it('never overrides a real, hand-authored statusLine', async () => { + const claudeDir = join(testDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.local.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo my-own-prompt' } }, null, 2) + ); + + expect(await resolveStatusLineCliCommand(testDir, true)).toBeUndefined(); + + // The user's own config is untouched — this is a read-only decision, not a write. + const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); + expect(parsed.statusLine.command).toBe('echo my-own-prompt'); + }); + + it('self-heals: strips a legacy disk-written exporter from an older Codeman build', async () => { + // Simulate a workspace touched by the pre-fix applyStatusLineConfig(dir, true). + await applyStatusLineConfig(testDir, true); + const settingsPath = join(testDir, '.claude', 'settings.local.json'); + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeDefined(); + + const cmd = await resolveStatusLineCliCommand(testDir, true); + + // Cleaned off disk... + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined(); + // ...and telemetry still flows, via the ephemeral CLI flag instead. + expect(cmd).toMatch(/statusline-exporter\.sh$/); + }); + + it('does not resurrect the legacy exporter when telemetry is off during cleanup', async () => { + await applyStatusLineConfig(testDir, true); + const settingsPath = join(testDir, '.claude', 'settings.local.json'); + + const cmd = await resolveStatusLineCliCommand(testDir, false); + + expect(cmd).toBeUndefined(); + expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined(); + }); +}); + +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 + ): 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', () => { + const testDir = join(tmpdir(), 'codeman-statusline-precedence-test-' + Date.now()); + const userSettingsPath = join(homedir(), '.claude', 'settings.json'); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + rmSync(userSettingsPath, { force: true }); // don't leak into other tests sharing this HOME + }); + + it('returns undefined when nothing is configured anywhere', async () => { + expect(await findEffectiveUserStatusLineCommand(testDir)).toBeUndefined(); + }); + + it('finds the user global ~/.claude/settings.json when nothing else is set', async () => { + const userClaudeDir = join(homedir(), '.claude'); + mkdirSync(userClaudeDir, { recursive: true }); + writeFileSync( + join(userClaudeDir, 'settings.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo user-global' } }) + ); + + expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo user-global'); + }); + + it('project-SHARED settings.json wins over user-global', async () => { + const userClaudeDir = join(homedir(), '.claude'); + mkdirSync(userClaudeDir, { recursive: true }); + writeFileSync( + join(userClaudeDir, 'settings.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo user-global' } }) + ); + const projectClaudeDir = join(testDir, '.claude'); + mkdirSync(projectClaudeDir, { recursive: true }); + writeFileSync( + join(projectClaudeDir, 'settings.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } }) + ); + + expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-shared'); + }); + + it('project-LOCAL settings.local.json wins over everything', async () => { + const projectClaudeDir = join(testDir, '.claude'); + mkdirSync(projectClaudeDir, { recursive: true }); + writeFileSync( + join(projectClaudeDir, 'settings.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } }) + ); + writeFileSync( + join(projectClaudeDir, 'settings.local.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo project-local' } }) + ); + + expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-local'); + }); + + it('skips a legacy Codeman-marked entry in project settings.local.json and falls through', async () => { + await applyStatusLineConfig(testDir, true); // simulates a pre-fix disk-written exporter + const projectClaudeDir = join(testDir, '.claude'); + writeFileSync( + join(projectClaudeDir, 'settings.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } }) + ); + + expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-shared'); + }); +}); diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts index 82c55820..523db4e0 100644 --- a/test/routes/session-routes-workspace-hooks.test.ts +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -156,7 +156,7 @@ describe('POST /api/sessions workspace hooks', () => { const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json'); 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); 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 () => { // A claude-mode attachRemoteSession create overwrites workingDir with // `user@host:session` — locally a RELATIVE path, so a mkdir would create it - // as a junk directory under the server cwd. statusLineTelemetry rides along: - // applyStatusLineConfig mkdirs the same way and used to run for remote attaches. + // as a junk directory under the server cwd. The statusLine exporter rides + // along: applyStatusLineConfig mkdirs the same way and used to run for + // remote attaches. // SAFETY (2026-08-29): write straight to `getDataDir()` — `test/setup.ts` // already sandboxes the data dir for the whole file (temp HOME, inherited // 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({ name: 'hooks-remote', mode: 'claude', - statusLineTelemetry: true, attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' }, }); expect(res.statusCode).toBe(200); diff --git a/test/routes/system-routes-settings-get-plan-usage-default.test.ts b/test/routes/system-routes-settings-get-plan-usage-default.test.ts new file mode 100644 index 00000000..56221a08 --- /dev/null +++ b/test/routes/system-routes-settings-get-plan-usage-default.test.ts @@ -0,0 +1,108 @@ +/** + * @fileoverview GET /api/settings must reconcile `showPlanUsageLimits` the + * first time it is ever read, closing the gap left by PR #361. + * + * planUsageChipEnabled() (settings-ui.js) shows the header chip and the App + * Settings checkbox as already ON whenever this key 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. 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. + * + * These tests pin the fix: GET /api/settings persists the resolved default + * ONCE when the key is truly absent, and never overwrites an explicit value + * either way afterward. + * + * Uses app.inject() — no real HTTP ports needed. Port: N/A. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; + +// vi.mock factories are hoisted above module-level consts, so the mutable +// persisted-settings fixture has to be built inside vi.hoisted(). +const { state } = vi.hoisted(() => ({ + // Mutated per-test to control what "disk" holds before the GET. + state: { persisted: {} as Record, exists: true }, +})); + +vi.mock('node:fs/promises', () => ({ + default: { + readFile: vi.fn(async () => { + if (!state.exists) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return JSON.stringify(state.persisted); + }), + writeFile: vi.fn(async (_path: string, content: string) => { + state.persisted = JSON.parse(content); + state.exists = true; + }), + }, +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) }; +}); + +describe('GET /api/settings — showPlanUsageLimits default reconciliation', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + it('persists the resolved default (true) the first time the key is absent', async () => { + state.persisted = { someOtherSetting: true }; + state.exists = true; + + const res = await harness.app.inject({ method: 'GET', url: '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/api/settings' }); + + expect(res.statusCode).toBe(200); + expect(res.json().showPlanUsageLimits).toBe(true); + // Reconciliation actually reached disk, not just the response. + expect(state.persisted.showPlanUsageLimits).toBe(true); + }); + + it('reconciles even when settings.json does not exist at all', async () => { + state.exists = false; + + const res = await harness.app.inject({ method: 'GET', url: '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/api/settings' }); + + expect(res.statusCode).toBe(200); + expect(res.json().showPlanUsageLimits).toBe(true); + expect(state.persisted.showPlanUsageLimits).toBe(true); + }); + + it('never overwrites an explicit false', async () => { + state.persisted = { showPlanUsageLimits: false }; + state.exists = true; + + const res = await harness.app.inject({ method: 'GET', url: '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/api/settings' }); + + expect(res.statusCode).toBe(200); + expect(res.json().showPlanUsageLimits).toBe(false); + // Untouched — reconciliation must not have written anything. + expect(state.persisted.showPlanUsageLimits).toBe(false); + }); + + it('never rewrites an explicit true', async () => { + state.persisted = { showPlanUsageLimits: true }; + state.exists = true; + + const res = await harness.app.inject({ method: 'GET', url: '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/api/settings' }); + + expect(res.statusCode).toBe(200); + expect(res.json().showPlanUsageLimits).toBe(true); + }); +}); diff --git a/test/routes/system-routes-settings-partial-put.test.ts b/test/routes/system-routes-settings-partial-put.test.ts index 56134313..bc38b150 100644 --- a/test/routes/system-routes-settings-partial-put.test.ts +++ b/test/routes/system-routes-settings-partial-put.test.ts @@ -4,7 +4,7 @@ * The three service toggles (subagent watcher, workflow-run watcher, image * 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 - * `{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 * persisted config. Nothing triggered it in practice only because every shipped * 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({ method: 'PUT', url: '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/api/settings', - // Action-only body: the exact shape that used to flip all three watchers. - payload: { statusLineTelemetry: true }, + // Minimal single-key body: the exact shape that used to flip all three watchers. + payload: { showPlanUsageLimits: true }, }); expect(res.statusCode).toBe(200); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 3873f2f5..be690ce1 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -411,21 +411,43 @@ describe('system-routes', () => { // ========== GET /api/settings ========== describe('GET /api/settings', () => { - it('returns empty object when settings file does not exist', async () => { + it('reconciles showPlanUsageLimits to true when the settings file does not exist', async () => { + // The chip/checkbox default to ON client-side (planUsageChipEnabled()) whenever + // this key is absent, but readPlanUsageTelemetryEnabled() deliberately treats + // absence as "no telemetry" — nothing reconciled those two defaults, so a fresh + // install showed a checked box that silently collected nothing. GET now persists + // the resolved default the first time anything reads settings. mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); const res = await harness.app.inject({ method: 'GET', url: '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/api/settings' }); expect(res.statusCode).toBe(200); - expect(JSON.parse(res.body)).toEqual({}); + expect(JSON.parse(res.body)).toEqual({ showPlanUsageLimits: true }); + // Reconciliation actually reached disk, not just the response. + expect(mockedWriteFile).toHaveBeenCalledWith( + expect.anything(), + JSON.stringify({ showPlanUsageLimits: true }, null, 2) + ); }); - it('returns parsed settings when file exists', async () => { + it('reconciles showPlanUsageLimits to true when the file exists but omits it', async () => { const settings = { subagentTrackingEnabled: true, showSystemStats: false }; mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never); + const res = await harness.app.inject({ method: 'GET', url: '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/api/settings' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ ...settings, showPlanUsageLimits: true }); + }); + + it('never overwrites an explicit false', async () => { + const settings = { subagentTrackingEnabled: true, showPlanUsageLimits: false }; + mockedReadFile.mockResolvedValue(JSON.stringify(settings) as never); + mockedWriteFile.mockClear(); + const res = await harness.app.inject({ method: 'GET', url: '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/api/settings' }); expect(res.statusCode).toBe(200); expect(JSON.parse(res.body)).toEqual(settings); + // No reconciliation write when the key is already explicit. + expect(mockedWriteFile).not.toHaveBeenCalled(); }); }); diff --git a/test/statusline-cli-flag.test.ts b/test/statusline-cli-flag.test.ts new file mode 100644 index 00000000..83f9add4 --- /dev/null +++ b/test/statusline-cli-flag.test.ts @@ -0,0 +1,96 @@ +/** + * @fileoverview Tests for the plan-usage statusLine exporter riding an EPHEMERAL + * `claude --settings` CLI flag (buildSpawnCommand's statusLineCommand option), + * which superseded writing it into `.claude/settings.local.json` — see + * resolveStatusLineCliCommand in hooks-config.ts and its own tests. Verified + * live against a real Claude CLI (isolated tmux socket, 2026-08-31) that + * `--settings` accepts this exact shape and takes precedence over a file-based + * statusLine. + * + * Extracting and re-parsing the `--settings` argument goes through a REAL + * shell (bash -c) rather than a hand-rolled unescaper: the exporter command + * itself embeds both single and double quotes, so trusting anything but the + * shell's own quoting rules to reverse shellescape() would just be testing + * this file's guess at the algorithm, not the actual behavior a spawned pane + * sees. + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; + +const EXPORTER_CMD = 'curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || echo codeman'; + +/** Extract the `--settings ` fragment from a built command and have a + * real shell resolve its quoting, printing the arg back out verbatim. */ +function extractSettingsJson(cmd: string): unknown { + const idx = cmd.indexOf('--settings '); + expect(idx).toBeGreaterThan(-1); + const fragment = cmd.slice(idx); + const out = execFileSync('bash', ['-c', `set -- ${fragment}; printf '%s' "$2"`]).toString(); + return JSON.parse(out); +} + +describe('buildSpawnCommand statusLineCommand (claude mode)', () => { + it('omits --settings entirely when no statusLineCommand and no effort are given', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' }); + expect(cmd).not.toContain('--settings'); + }); + + it('embeds the exporter command under a statusLine settings key', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: EXPORTER_CMD }); + expect(cmd).toContain('--settings'); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: EXPORTER_CMD } }); + }); + + it('merges statusLine and ultracode into the SAME --settings object', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + effort: 'ultracode', + statusLineCommand: EXPORTER_CMD, + }); + // Only one --settings flag total — never two (Claude Code accepts just one). + expect(cmd.match(/--settings/g)).toHaveLength(1); + expect(extractSettingsJson(cmd)).toEqual({ + ultracode: true, + statusLine: { type: 'command', command: EXPORTER_CMD }, + }); + }); + + it('keeps a regular --effort flag separate from --settings when both are present', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + effort: 'high', + statusLineCommand: EXPORTER_CMD, + }); + expect(cmd).toContain('--effort'); + expect(cmd).toContain('--settings'); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: EXPORTER_CMD } }); + }); + + it('shell-escapes an exporter command containing single AND double quotes without breaking the flag', () => { + // The real exporter (generateStatusLineCommand) embeds both — a naive + // `'${value}'` wrap would be broken out of by the single quotes. + const tricky = `echo '{}'; printf '{"a":1}' | curl -sk`; + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: tricky }); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: tricky } }); + }); + + it('round-trips the REAL exporter script path unmodified (ensureStatusLineExporterScript)', async () => { + // What resolveStatusLineCliCommand actually hands to buildSpawnCommand at spawn + // 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 }); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: real } }); + }); + + it('never adds --settings for non-claude modes even if statusLineCommand is somehow set', () => { + const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'sid-1', statusLineCommand: EXPORTER_CMD } as never); + expect(cmd).not.toContain('--settings'); + }); +});