mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user