Merge pull request #361 from timkjr/fix/statusline-injection-opt-out

fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk
This commit is contained in:
Ark0N
2026-09-14 15:58:48 +02:00
committed by GitHub
18 changed files with 906 additions and 104 deletions
+1 -1
View File
@@ -209,7 +209,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`.
+1 -1
View File
@@ -92,7 +92,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 </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
+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.
>
> **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).
+192 -1
View File
@@ -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 &`) — closing stdout/stderr keeps it from adding
// latency or leaking into the visible statusline, and closing stdin too is what
// 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 (
`#!/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 &\n` +
` printf '%s' "$INPUT" | sh -c "$CODEMAN_USER_STATUSLINE_CMD"\n` +
`else\n` +
` ${post} 2>/dev/null || echo codeman\n` +
`fi\n`
);
}
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
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<string | undefined> {
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<string> {
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<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`
* 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<string | undefined> {
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 ───────────────────────────────────────────────────
/**
+25 -2
View File
@@ -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<string, unknown> =
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
+55
View File
@@ -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,
@@ -728,6 +733,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;
/**
@@ -1832,6 +1839,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).
@@ -1915,6 +1946,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,
@@ -1931,6 +1975,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
statusLineCommand,
sessionName: name,
});
@@ -1993,6 +2038,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode,
legacyConfigForMode(mode, options as unknown as Record<string, unknown>)
);
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.
@@ -2170,6 +2216,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,
@@ -2186,6 +2239,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
ompConfig,
resumeSessionId,
effort,
statusLineCommand,
sessionName: name,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -2205,6 +2259,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode,
legacyConfigForMode(mode, options as unknown as Record<string, unknown>)
);
this._configureStatusLineUserCommand(muxName, userStatusLineCommand);
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
-7
View File
@@ -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())
);
+22 -16
View File
@@ -2310,22 +2310,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,
@@ -2360,7 +2364,6 @@ Object.assign(CodemanApp.prototype, {
try {
const res = await this._apiPut('/api/settings', {
...serverSettings,
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
@@ -2624,10 +2627,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;
@@ -3127,11 +3131,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
+13 -22
View File
@@ -94,7 +94,6 @@ import {
writeHooksConfig,
updateCaseModel,
stripCaseEnvKeys,
applyStatusLineConfig,
applyAgentSkill,
refreshUserAgentSkill,
seedAgentSessionPreamble,
@@ -949,27 +948,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
+62 -23
View File
@@ -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('/api/settings', async () => {
return readJsonConfig(SETTINGS_PATH, 'settings', {});
const settings = await readJsonConfig<Record<string, unknown>>(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('/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<string>();
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) {
+4 -8
View File
@@ -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,
@@ -1298,13 +1296,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(),
+4 -1
View File
@@ -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
+279 -2
View File
@@ -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<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', () => {
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');
});
});
@@ -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);
@@ -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<string, unknown>, 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<typeof import('node:fs')>();
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: '/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: '/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: '/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: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(res.json().showPlanUsageLimits).toBe(true);
});
});
@@ -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: '/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);
+25 -3
View File
@@ -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: '/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: '/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: '/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();
});
});
+96
View File
@@ -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 <arg>` 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');
});
});