From d4aa3c8cca7bc4fa7be0140c01067d61305b7f1a Mon Sep 17 00:00:00 2001 From: timkjr Date: Sun, 30 Aug 2026 17:31:25 -0500 Subject: [PATCH 1/4] fix(statusline): inject plan-usage telemetry via ephemeral CLI flag, never disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codeman's plan-usage chip wrote a statusLine.command into the case's .claude/settings.local.json to receive Claude Code's rate_limits blob. That file-based statusLine took precedence over the user's own global/project statusline for ANY `claude` run in that directory, including entirely outside Codeman, with no disclosure in the App Settings UI (labeled only as a header-display toggle) and no way to remove it once written (the removal code path was unreachable dead code — nothing ever called it with false). Replace the disk write with an EPHEMERAL `claude --settings '{"statusLine":{...}}'` CLI flag, resolved fresh at spawn time (resolveStatusLineCliCommand in hooks-config.ts) and merged with effort/ultracode into one --settings object (buildClaudeSettingsFlag in tmux-manager.ts, since Claude Code accepts only one --settings flag). Never touches disk, so a plain `claude` run outside Codeman is untouched. Self-healing: any legacy disk-written exporter from an older build is stripped the first time a session starts in that workspace again. Still respects a user's own hand-authored statusLine (skips the flag entirely rather than overriding it). Mid-fix bug found and fixed: the exporter's command legitimately depends on $CODEMAN_SESSION_ID/$CODEMAN_API_URL/$CODEMAN_HOOK_SECRET_FILE and an internal $INPUT, all meant to be expanded only when Claude Code itself executes the statusline, using the pane's tmux-setenv'd environment. Passing that text through --settings routed it through execSync's own implicit /bin/sh -c first (tmux respawn-pane's `bash -c "..."` wrapper) — POSIX double quotes don't suppress $ expansion, so those vars got expanded prematurely against the server's own environment (unset there), producing malformed JSON that printed as literal error text in the statusline. Fixed by writing the exporter as a real, shared script file (ensureStatusLineExporterScript, marker-versioned so stale copies self-heal) and passing only its bare path via --settings — nothing for any intermediate shell to mangle. Verified against a real Claude CLI on an isolated tmux socket, and via direct execSync reproduction of the exact nested wrapping createSession/respawnPane use. A hard "never inject, even ephemerally" kill-switch was added and then removed in the same pass: with the disk-leak fixed, disabling injection only cost the plan-usage telemetry the feature exists to provide, for no remaining benefit. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015GyMnFWnUzc41TDeHg9juW --- src/hooks-config.ts | 111 ++++++++++++++++++++++++++++- src/mux-interface.ts | 9 +++ src/session-cli-registry-bridge.ts | 27 ++++++- src/session.ts | 10 +++ src/tmux-manager.ts | 22 ++++++ src/web/routes/session-routes.ts | 35 ++++----- src/web/routes/system-routes.ts | 33 ++++----- test/hooks-config.test.ts | 84 +++++++++++++++++++++- test/statusline-cli-flag.test.ts | 92 ++++++++++++++++++++++++ 9 files changed, 379 insertions(+), 44 deletions(-) create mode 100644 test/statusline-cli-flag.test.ts diff --git a/src/hooks-config.ts b/src/hooks-config.ts index d8a8a38b..d711f5ea 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -31,7 +31,7 @@ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, lstat, readdir, realpath, rename, unlink, rmdir, chmod } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -906,6 +906,115 @@ 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_V1'; + +function statusLineExporterScriptContent(): string { + // Byte-identical logic to generateStatusLineCommand(), just as a real file + // instead of an inline string — see ensureStatusLineExporterScript for why + // that distinction matters (it is NOT cosmetic). + 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` + + `printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` + + `curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` + + `-H 'Content-Type: application/json' ` + + `-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` + + `--data @- 2>/dev/null || echo codeman\n` + ); +} + +/** + * Write (or refresh) the SHARED, single exporter script every claude session + * points its ephemeral --settings statusLine flag at, and return its absolute + * path. Idempotent: only rewrites when the marker-versioned content differs. + * + * This is the fix for a real bug found live 2026-08-31: the exporter's + * command string legitimately depends on `$CODEMAN_SESSION_ID`, + * `$CODEMAN_API_URL`, `$CODEMAN_HOOK_SECRET_FILE`, and its own internal + * `$INPUT` — all meant to be expanded ONLY when Claude Code itself finally + * executes the statusLine command, using the PANE's tmux-setenv'd + * environment. Passing that command as literal TEXT through + * `--settings '...'` routes it through this server's OWN spawn-time shell + * layers first (tmux respawn-pane's `bash -c "..."`, itself invoked via + * execSync's implicit `/bin/sh -c`) — and POSIX double quotes do NOT + * suppress `$` expansion, so those vars got expanded there and then, against + * the SERVER process's environment (where they are unset), producing a + * mangled curl call that posted malformed JSON and printed the server's raw + * error response as the statusline text itself. A bare file PATH has no `$`, + * quotes, or pipes for any of those intermediate shells to mangle — the + * script's own content (containing the real `$VAR`s) is never touched by a + * shell until Claude Code executes the file itself, at which point the + * pane's real environment is in scope. This mirrors the existing #208 fix in + * tmux-manager.ts (never embed a literal `$SHELL` meant for later + * expansion — resolve it, or in this case reference a file, instead). + */ +export async function ensureStatusLineExporterScript(): Promise { + const scriptPath = dataPath('statusline-exporter.sh'); + const desired = statusLineExporterScriptContent(); + let current: string | null = null; + try { + current = await readFile(scriptPath, 'utf-8'); + } catch { + // Doesn't exist yet. + } + if (current !== desired) { + await writeFile(scriptPath, desired); + await chmod(scriptPath, 0o755); + } + return scriptPath; +} + +/** + * Resolve the statusLine command to pass as an EPHEMERAL `claude --settings` + * CLI flag for this one process (see buildClaudeSettingsFlag in + * tmux-manager.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. + * + * Returns undefined when telemetry wasn't requested, or when the workspace + * already has its OWN hand-configured statusLine (never override a real one). + */ +export async function resolveStatusLineCliCommand( + casePath: string, + telemetryRequested: boolean +): Promise { + const settingsPath = join(casePath, '.claude', 'settings.local.json'); + let userHasOwnStatusLine = false; + if (existsSync(settingsPath)) { + try { + const existing = JSON.parse(await readFile(settingsPath, 'utf-8')); + const current = existing.statusLine as { command?: unknown } | undefined; + if (current && typeof current.command === 'string') { + if (current.command.includes(STATUSLINE_MARKER)) { + await applyStatusLineConfig(casePath, false); // strip legacy disk-written exporter + } else { + userHasOwnStatusLine = true; + } + } + } catch { + // Malformed — leave it alone, same guard applyStatusLineConfig itself uses. + } + } + if (!telemetryRequested || userHasOwnStatusLine) return undefined; + return ensureStatusLineExporterScript(); +} + // ─── Agent skill injection ─────────────────────────────────────────────────── /** diff --git a/src/mux-interface.ts b/src/mux-interface.ts index f4e3dd24..0e2009dd 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -90,6 +90,13 @@ export interface CreateSessionOptions { envOverrides?: Record; /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ effort?: EffortLevel; + /** + * Claude-only: request the plan-usage statusLine exporter for this session, + * injected as an EPHEMERAL `--settings` CLI flag (never written to disk — see + * generateStatusLineCommand/resolveStatusLineCliCommand). Skipped when the + * workspace already has its own hand-configured statusLine. + */ + statusLineTelemetry?: boolean; /** tmux history-limit (scrollback lines) allocated when this session is created. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ @@ -125,6 +132,8 @@ export interface RespawnPaneOptions { envOverrides?: Record; /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ effort?: EffortLevel; + /** Preserved across respawns — see CreateSessionOptions.statusLineTelemetry. */ + statusLineTelemetry?: boolean; /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index d49e2447..e11e1449 100644 --- a/src/session-cli-registry-bridge.ts +++ b/src/session-cli-registry-bridge.ts @@ -56,6 +56,14 @@ export interface SpawnBridgeOptions { effort?: EffortLevel; sessionName?: string; claudeCliVersion?: string | null; + /** + * Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the + * exporter. Claude only. Rides the SAME `--settings` JSON object as `effortSettingsJson` + * (see buildSpawnCommandFromRegistry): Claude Code accepts only one `--settings` flag + * per invocation, so the two must be merged before reaching the argv engine rather than + * rendered as two independent params. + */ + statusLineCommand?: string; } /** @@ -186,8 +194,23 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri // than re-deriving the ultracode special case) keeps the EFFORT_LEVELS allowlist and the // settings-JSON shape single-sourced in session-cli-builder.ts. const [effortFlag, effortValue] = buildEffortCliArgs(options.effort); - if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue; - else if (effortFlag === '--effort') engineValues.effortLevel = effortValue; + if (effortFlag === '--effort') { + engineValues.effortLevel = effortValue; + } + + // Fold the ephemeral plan-usage statusLine exporter (see resolveStatusLineCliCommand in + // hooks-config.ts) into the SAME `--settings` JSON object as ultracode/ effort, since Claude + // Code accepts only one `--settings` flag per invocation — rendering them as two independent + // params would let the second one silently win. Claude-only in practice (statusLineCommand + // is resolved claude-mode-only upstream), but this merge is mode-agnostic. + if ((effortFlag === '--settings' && effortValue) || options.statusLineCommand) { + const settingsObj: Record = + effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}; + if (options.statusLineCommand) { + settingsObj.statusLine = { type: 'command', command: options.statusLineCommand }; + } + engineValues.effortSettingsJson = JSON.stringify(settingsObj); + } // Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes // the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means diff --git a/src/session.ts b/src/session.ts index 59caaf55..40abec0a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -577,6 +577,11 @@ export class Session extends EventEmitter { // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. private _effort: EffortLevel | undefined; + // Claude-only: request the plan-usage statusLine exporter for this session, + // injected as an ephemeral `--settings` CLI flag at spawn (never written to + // disk). Preserved across respawns like _effort above. + private _statusLineTelemetry: boolean | undefined; + // tmux history-limit (scrollback lines) allocated when this session's pane is created. private readonly _tmuxHistoryLimit: number; @@ -673,6 +678,8 @@ export class Session extends EventEmitter { envOverrides?: Record; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; + /** Claude-only: request the plan-usage statusLine exporter (ephemeral --settings flag, never disk-written) */ + statusLineTelemetry?: boolean; /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */ tmuxHistoryLimit?: number; /** Restored per-session attachment history. May include server-private external paths. */ @@ -826,6 +833,7 @@ export class Session extends EventEmitter { if (config.effort && isEffortLevel(config.effort)) { this._effort = config.effort; } + this._statusLineTelemetry = config.statusLineTelemetry; this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; this._docker = config.docker; @@ -1746,6 +1754,7 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, + statusLineTelemetry: this._statusLineTelemetry, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -2061,6 +2070,7 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, + statusLineTelemetry: this._statusLineTelemetry, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9fdd2bda..61eb1c4f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -67,6 +67,7 @@ import { legacyConfigForMode, } from './session-cli-registry-bridge.js'; import type { CliEntry } from './config/cli-registry/types.js'; +import { resolveStatusLineCliCommand } from './hooks-config.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -661,6 +662,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; /** @@ -1729,6 +1732,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, + statusLineTelemetry, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, @@ -1787,6 +1791,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); + // Claude-only, 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 = + mode === 'claude' && !remote && !docker + ? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) + : undefined; + const baseCmd = buildSpawnCommand({ mode, sessionId, @@ -1803,6 +1816,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { ompConfig, resumeSessionId, effort, + statusLineCommand, sessionName: name, }); @@ -2027,6 +2041,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, + statusLineTelemetry, remote, docker, name, @@ -2042,6 +2057,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); + // See createSession()'s identical resolution for rationale. + const statusLineCommand = + mode === 'claude' && !remote && !docker + ? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) + : undefined; + const baseCmd = buildSpawnCommand({ mode, sessionId, @@ -2058,6 +2079,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { ompConfig, resumeSessionId, effort, + statusLineCommand, sessionName: name, }); const config = niceConfig || DEFAULT_NICE_CONFIG; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 9ea80060..2f6649f3 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -94,7 +94,6 @@ import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, - applyStatusLineConfig, applyAgentSkill, refreshUserAgentSkill, seedAgentSessionPreamble, @@ -948,27 +947,18 @@ 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 request (App Settings → header chip). NO LONGER a + // disk write here — a settings.local.json statusLine took precedence over + // the user's own global/project statusLine for ANY `claude` run in that + // directory, including entirely outside Codeman, with no disclosure and no + // way to undo it (real bug, found 2026-08-31). The request now flows + // through as an ordinary session field (statusLineTelemetryRequested below) + // and Session/TmuxManager resolve it into an EPHEMERAL `claude --settings` + // CLI flag at actual spawn time (resolveStatusLineCliCommand in + // hooks-config.ts) — never written to disk, so a plain `claude` run outside + // Codeman is untouched. That resolution also self-heals: it strips any + // legacy disk-written exporter an older Codeman build left behind. + const statusLineTelemetryRequested = body.statusLineTelemetry === true; // Hooks for the workspace this session runs in (install vs refresh-only is the // `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote @@ -1102,6 +1092,7 @@ export function registerSessionRoutes( resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, + statusLineTelemetry: statusLineTelemetryRequested, tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, remote, owner, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index f409e911..ab4f2194 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -5,7 +5,6 @@ */ import { FastifyInstance } from 'fastify'; -import { getCli } from '../../config/cli-registry/registry.js'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, readdirSync } from 'node:fs'; @@ -33,7 +32,6 @@ import { import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { workflowRunWatcher } from '../../workflow-run-watcher.js'; -import { applyStatusLineConfig } from '../../hooks-config.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { buildAwayDigest, @@ -994,7 +992,9 @@ export function registerSystemRoutes( } // statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored // settings) — strip them before persisting so settings.json stays clean. - const { statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings; + // statusLineTelemetry is an action field only (see below); it must never + // land in settingsToStore. + const { statusLineTelemetry: _statusLineTelemetry, acknowledgeUnauthTunnel, ...settingsToStore } = settings; const merged = { ...existing, ...settingsToStore }; await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); @@ -1034,21 +1034,18 @@ export function registerSystemRoutes( }); // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js). - // Telemetry COLLECTION is server-side and enable-sticky — when a client turns - // the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter - // into every ACTIVE Claude session's working dir so the live % starts flowing - // immediately (no new session needed). We deliberately never auto-REMOVE here: - // the exporter is benign/print-through and a per-repo settings.local.json is - // shared by sibling sessions, so one device's "off" must not yank the exporter - // another device's chip depends on. Each dir handled once. - if (statusLineTelemetry === true) { - const dirs = new Set(); - for (const session of ctx.sessions.values()) { - if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir) - dirs.add(session.workingDir); - } - await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {}))); - } + // Telemetry COLLECTION was previously server-side and enable-sticky 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 is exactly 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). Telemetry + // is now requested per-session at CREATE/RESPAWN time only (statusLineTelemetry + // threaded through cron/ralph-loop/quick-start/interactive-create, see those + // route handlers), resolved into an ephemeral `--settings` CLI flag — fixed at + // spawn, so there is nothing to (re)inject into an ALREADY-RUNNING session + // here, unlike the old disk mechanism. The action field above is received and + // discarded; flipping the chip ON only affects sessions created from now on. // Handle tunnel toggle dynamically if ('tunnelEnabled' in settings) { diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 774774b3..299b37e6 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -6,7 +6,17 @@ */ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; -import { closeSync, existsSync, openSync, readFileSync, writeFileSync, mkdirSync, rmSync, symlinkSync } from 'node:fs'; +import { + closeSync, + existsSync, + openSync, + readFileSync, + writeFileSync, + mkdirSync, + rmSync, + symlinkSync, + statSync, +} from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawn } from 'node:child_process'; @@ -15,8 +25,10 @@ import { ensureCodemanHooks, generateBackgroundWakeScript, generateHooksConfig, + generateStatusLineCommand, generateSubagentStopGuardScript, refreshStaleCodemanHooks, + resolveStatusLineCliCommand, settingsWriteBlocker, stripCaseEnvKeys, updateCaseEnvVars, @@ -1305,3 +1317,73 @@ describe('Hook Config Generation - Extended', () => { expect(stopHooks[0].hooks[0].command).toContain('stop'); }); }); + +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(); + }); +}); diff --git a/test/statusline-cli-flag.test.ts b/test/statusline-cli-flag.test.ts new file mode 100644 index 00000000..d6721d2a --- /dev/null +++ b/test/statusline-cli-flag.test.ts @@ -0,0 +1,92 @@ +/** + * @fileoverview Tests for the plan-usage statusLine exporter riding an EPHEMERAL + * `claude --settings` CLI flag (buildSpawnCommand's statusLineCommand option), + * which superseded writing it into `.claude/settings.local.json` — see + * resolveStatusLineCliCommand in hooks-config.ts and its own tests. Verified + * live against a real Claude CLI (isolated tmux socket, 2026-08-31) that + * `--settings` accepts this exact shape and takes precedence over a file-based + * statusLine. + * + * Extracting and re-parsing the `--settings` argument goes through a REAL + * shell (bash -c) rather than a hand-rolled unescaper: the exporter command + * itself embeds both single and double quotes, so trusting anything but the + * shell's own quoting rules to reverse shellescape() would just be testing + * this file's guess at the algorithm, not the actual behavior a spawned pane + * sees. + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; + +const EXPORTER_CMD = 'curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || echo codeman'; + +/** Extract the `--settings ` fragment from a built command and have a + * real shell resolve its quoting, printing the arg back out verbatim. */ +function extractSettingsJson(cmd: string): unknown { + const idx = cmd.indexOf('--settings '); + expect(idx).toBeGreaterThan(-1); + const fragment = cmd.slice(idx); + const out = execFileSync('bash', ['-c', `set -- ${fragment}; printf '%s' "$2"`]).toString(); + return JSON.parse(out); +} + +describe('buildSpawnCommand statusLineCommand (claude mode)', () => { + it('omits --settings entirely when no statusLineCommand and no effort are given', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1' }); + expect(cmd).not.toContain('--settings'); + }); + + it('embeds the exporter command under a statusLine settings key', () => { + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: EXPORTER_CMD }); + expect(cmd).toContain('--settings'); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: EXPORTER_CMD } }); + }); + + it('merges statusLine and ultracode into the SAME --settings object', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + effort: 'ultracode', + statusLineCommand: EXPORTER_CMD, + }); + // Only one --settings flag total — never two (Claude Code accepts just one). + expect(cmd.match(/--settings/g)).toHaveLength(1); + expect(extractSettingsJson(cmd)).toEqual({ + ultracode: true, + statusLine: { type: 'command', command: EXPORTER_CMD }, + }); + }); + + it('keeps a regular --effort flag separate from --settings when both are present', () => { + const cmd = buildSpawnCommand({ + mode: 'claude', + sessionId: 'sid-1', + effort: 'high', + statusLineCommand: EXPORTER_CMD, + }); + expect(cmd).toContain('--effort'); + expect(cmd).toContain('--settings'); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: EXPORTER_CMD } }); + }); + + it('shell-escapes an exporter command containing single AND double quotes without breaking the flag', () => { + // The real exporter (generateStatusLineCommand) embeds both — a naive + // `'${value}'` wrap would be broken out of by the single quotes. + const tricky = `echo '{}'; printf '{"a":1}' | curl -sk`; + const cmd = buildSpawnCommand({ mode: 'claude', sessionId: 'sid-1', statusLineCommand: tricky }); + expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: tricky } }); + }); + + it('round-trips the REAL exporter command unmodified (generateStatusLineCommand)', async () => { + const { generateStatusLineCommand } = await import('../src/hooks-config.js'); + const real = generateStatusLineCommand(); + 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'); + }); +}); From e15e8e43e8a57acc23ef6168c7bd72e7460b5db4 Mon Sep 17 00:00:00 2001 From: timkjr Date: Sun, 30 Aug 2026 18:56:32 -0500 Subject: [PATCH 2/4] feat(statusline): wrap the user's own real statusline instead of skipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the exporter no longer lives in a fixed per-case file, it can compose with the user's actual configured statusline rather than just backing off when one is found. findEffectiveUserStatusLineCommand() walks Claude Code's own settings precedence for a workspace: project-local .claude/settings.local.json > project-shared .claude/settings.json > the user's global ~/.claude/settings.json. A legacy Codeman-marked entry left behind in the project's own settings.local.json is never treated as a real user command — it's skipped and precedence continues to the next layer. The shared exporter script (bumped to a V2 marker so stale copies self-heal) now fires the telemetry POST in a background subshell — its own stdout/stderr discarded so nothing leaks into the visible statusline, and confirmed non-blocking (~4ms, even against an unreachable endpoint) — then, if the pane's environment carries CODEMAN_USER_STATUSLINE_CMD, feeds it the same stdin blob and relays its stdout as ours. Otherwise it falls back to the plain "codeman" marker as before. The discovered command is threaded to the pane via `tmux setenv CODEMAN_USER_STATUSLINE_CMD` (_configureStatusLineUserCommand) rather than embedded in the spawn command line, for the same premature-shell-expansion reason as the parent commit: tmux stores a setenv value verbatim and never re-parses it, so once shellescape()d for that one command, the command's own $/quotes survive untouched into the pane's environment. Verified live via direct shell execution of the generated script (both branches: fallback and user-command wrapping) before deploy. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015GyMnFWnUzc41TDeHg9juW --- src/hooks-config.ts | 60 +++++++++++++++++++++++++++---- src/tmux-manager.ts | 33 ++++++++++++++++- test/hooks-config.test.ts | 75 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 160 insertions(+), 8 deletions(-) diff --git a/src/hooks-config.ts b/src/hooks-config.ts index d711f5ea..1e2ddd20 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -912,24 +912,72 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean): * whenever the script content changes so `ensureStatusLineExporterScript`'s * content comparison rewrites stale copies on next use. */ -const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V1'; +const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V2'; function statusLineExporterScriptContent(): string { - // Byte-identical logic to generateStatusLineCommand(), just as a real file - // instead of an inline string — see ensureStatusLineExporterScript for why - // that distinction matters (it is NOT cosmetic). + // The telemetry POST runs in a BACKGROUND subshell so it can never add + // latency to a render, and its own stdout/stderr are discarded so they + // never leak into the visible statusline. If 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 SAME stdin blob is fed to it and + // ITS stdout becomes ours, so the user keeps seeing their own statusline + // untouched. Absent that, fall back to the plain "codeman" marker. 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` + - `printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` + + `(\n` + + ` printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` + `curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` + `-H 'Content-Type: application/json' ` + `-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` + - `--data @- 2>/dev/null || echo codeman\n` + `--data @- >/dev/null 2>&1\n` + + `) &\n` + + `if [ -n "$CODEMAN_USER_STATUSLINE_CMD" ]; then\n` + + ` printf '%s' "$INPUT" | sh -c "$CODEMAN_USER_STATUSLINE_CMD"\n` + + `else\n` + + ` echo codeman\n` + + `fi\n` ); } +async function readStatusLineCommandFromFile(settingsPath: string): Promise { + if (!existsSync(settingsPath)) return undefined; + try { + const parsed = JSON.parse(await readFile(settingsPath, 'utf-8')); + const current = parsed.statusLine as { command?: unknown } | undefined; + return current && typeof current.command === 'string' ? current.command : undefined; + } catch { + return undefined; // Malformed — treat as absent, same posture as applyStatusLineConfig. + } +} + +/** + * Walk Claude Code's OWN settings precedence for `workingDir` to find whatever + * statusLine command is ACTUALLY effective there right now: project-local + * `.claude/settings.local.json` > project-shared `.claude/settings.json` > + * the user's global `~/.claude/settings.json`. Returns undefined when none of + * the three configures one. + * + * A legacy Codeman-marked entry in the project's OWN settings.local.json + * (written by an older build's disk-based mechanism) is never treated as a + * real user command — resolveStatusLineCliCommand strips it before this ever + * runs, so ordinarily this function never even sees one; the marker check + * here is a second, defensive guard in case something else wrote a copy in + * between, and precedence simply continues to the next layer instead of + * stopping on it. + */ +export async function findEffectiveUserStatusLineCommand(workingDir: string): Promise { + const projectLocal = await readStatusLineCommandFromFile(join(workingDir, '.claude', 'settings.local.json')); + if (projectLocal && !projectLocal.includes(STATUSLINE_MARKER)) return projectLocal; + + const projectShared = await readStatusLineCommandFromFile(join(workingDir, '.claude', 'settings.json')); + if (projectShared) return projectShared; + + return readStatusLineCommandFromFile(join(homedir(), '.claude', 'settings.json')); +} + /** * Write (or refresh) the SHARED, single exporter script every claude session * points its ephemeral --settings statusLine flag at, and return its absolute diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 61eb1c4f..481ab9ef 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -67,7 +67,7 @@ import { legacyConfigForMode, } from './session-cli-registry-bridge.js'; import type { CliEntry } from './config/cli-registry/types.js'; -import { resolveStatusLineCliCommand } from './hooks-config.js'; +import { resolveStatusLineCliCommand, findEffectiveUserStatusLineCommand } from './hooks-config.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -1707,6 +1707,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). @@ -1799,6 +1823,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode === 'claude' && !remote && !docker ? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) : 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, @@ -1879,6 +1907,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode, legacyConfigForMode(mode, options as unknown as Record) ); + this._configureStatusLineUserCommand(muxName, userStatusLineCommand); // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. @@ -2062,6 +2091,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode === 'claude' && !remote && !docker ? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) : undefined; + const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; const baseCmd = buildSpawnCommand({ mode, @@ -2099,6 +2129,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode, legacyConfigForMode(mode, options as unknown as Record) ); + this._configureStatusLineUserCommand(muxName, userStatusLineCommand); // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 299b37e6..0b36c552 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -18,11 +18,12 @@ import { statSync, } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; +import { tmpdir, homedir } from 'node:os'; import { spawn } from 'node:child_process'; import { applyStatusLineConfig, ensureCodemanHooks, + findEffectiveUserStatusLineCommand, generateBackgroundWakeScript, generateHooksConfig, generateStatusLineCommand, @@ -1387,3 +1388,75 @@ describe('resolveStatusLineCliCommand', () => { expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined(); }); }); + +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'); + }); +}); From d5b75af628432cbca9d8cc0f3661e1bda21d43f2 Mon Sep 17 00:00:00 2001 From: timkjr Date: Mon, 7 Sep 2026 20:37:29 -0500 Subject: [PATCH 3/4] fix(statusline): sticky telemetry collection, footer print-through, EOF fix Responds to Ark0N's review round on the ephemeral-CLI-flag statusline injection rework: - Rebase-detail fixes: registry-gated telemetry eligibility via getCli(mode)?.capabilities.statusLineTelemetry instead of a hardcoded mode === 'claude' check, using the capability flag master's CLI-registry refactor already declares for exactly this purpose. - Design question settled: sticky (a). Rather than persisting the toggle as a new field and threading it through every session-creation path (cron, Ralph Loop API, quick-start), eliminated the per-session field entirely. readPlanUsageTelemetryEnabled() (hooks-config.ts) reads the existing showPlanUsageLimits setting fresh from settings.json at every claude create/respawn (TmuxManager.createSession/respawnPane) - no per-session state to survive a restart, and it applies uniformly to every creation path for free, since they all flow through the same TmuxManager methods. This required fixing a real bug found along the way: showPlanUsageLimits was not actually round-tripping through settings.json on save - settings-ui.js explicitly excluded it from the PUT body as a pure per-device display key. It now flows through normally (both true and false); the load-side per-device merge behavior is unchanged. Removed entirely as a result: the statusLineTelemetry field from CreateSessionSchema/SettingsUpdateSchema, CreateSessionOptions/ RespawnPaneOptions, Session._statusLineTelemetry (this is what makes the restart-persistence bug moot rather than patched), and the frontend send sites. - Footer print-through restored: the no-user-statusline branch of the exporter script now runs the telemetry POST in the foreground so its own stdout becomes the in-terminal footer, falling back to a plain "codeman" marker only on curl failure. - Background-subshell EOF fix: the wrap-a-real-statusline branch closes stdin too, not just stdout/stderr (`>/dev/null 2>&1 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/usage-limits-display-plan.md | 22 ++-- src/hooks-config.ts | 88 +++++++++---- src/mux-interface.ts | 9 -- src/session.ts | 10 -- src/tmux-manager.ts | 24 ++-- src/web/public/session-ui.js | 7 - src/web/public/settings-ui.js | 38 +++--- src/web/routes/session-routes.ts | 26 ++-- src/web/routes/system-routes.ts | 40 +++--- src/web/schemas.ts | 12 +- src/web/server.ts | 5 +- test/hooks-config.test.ts | 122 ++++++++++++++++++ .../session-routes-workspace-hooks.test.ts | 8 +- ...system-routes-settings-partial-put.test.ts | 6 +- test/statusline-cli-flag.test.ts | 10 +- 17 files changed, 288 insertions(+), 143 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed423971..67e4855c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,7 +207,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the Claude `statusLineTelemetry` flag on session create). It renders compact Claude and Codex provider rows. Claude data comes from Codeman's marked `statusLine.command` exporter, which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine, and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve DISPLAY ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs the two call sites that must never disagree (the App Settings checkbox, the chip's visibility). The SAME persisted setting also doubles as the server-side telemetry COLLECTION switch — `readPlanUsageTelemetryEnabled()` (hooks-config.ts) reads it fresh from `settings.json` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), so it applies uniformly to every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) with no per-session state and no per-request field — a Codeman restart cannot silently kill it (there is nothing per-session to lose). Claude data comes from Codeman's marked `statusLine.command` exporter (injected as an EPHEMERAL `claude --settings` CLI flag, never written to disk — see `resolveStatusLineCliCommand`), which POSTs `rate_limits` to `POST /api/status-telemetry`, never overwrites a user's hand-authored statusLine (it WRAPS it instead — `findEffectiveUserStatusLineCommand`), and prints the footer through. Main Codex usage comes from a read-only host `account/rateLimits/read` app-server poll at startup and every 5 minutes; exclude model-specific buckets such as Spark, and omit the Codex row when no signed-in limit is available. Distinct from auto-resume, which reacts to Claude's limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 43705d0b..2821aedb 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -90,7 +90,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do ### Plan-usage chip (statusLine telemetry) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is _ours_, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through**. Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox, chip visibility, and Claude create-time exporter flag. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`. +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, `|| echo codeman` on failure; backgrounded — `>/dev/null 2>&1 **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`). **Default changed in 1.9.3: desktop now defaults ON, handhelds stay OFF, resolved via `planUsageChipEnabled()`.** The per-device notes further down describing it as opt-in/synced record the original 2026-06-14 shape, not current behavior. Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet. > +> **2026-09-07 rework — the "Injection lifecycle" section below (disk-write reconcile via `applyStatusLineConfig`) is SUPERSEDED and describes the OLD mechanism, kept for history.** That disk write let a Codeman-marked `statusLine.command` in `.claude/settings.local.json` take precedence over the user's own global/project statusline for ANY `claude` run in that directory — including entirely outside Codeman — with no disclosure and no way to undo it (real bug, found 2026-08-31). The exporter is now injected as an EPHEMERAL `claude --settings` CLI flag at spawn (`resolveStatusLineCliCommand`/`ensureStatusLineExporterScript`, hooks-config.ts) — never written to disk — and it WRAPS the user's own real statusline (`findEffectiveUserStatusLineCommand`) rather than replacing it. `showPlanUsageLimits` now doubles as the telemetry COLLECTION switch too: `readPlanUsageTelemetryEnabled()` reads it fresh from `settings.json` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), so it applies uniformly across every claude-creation path — interactive Run, cron, the Ralph Loop API, quick-start — with no per-session state (a Codeman restart cannot silently kill it) and no per-request field on the wire at all. +> > Two surfaces from one `statusLine` callback: > - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red. > - **In-terminal statusline footer** — the **current session's** status: `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%`. @@ -110,33 +112,33 @@ Fixed path (sessionId in the **body**, not the URL) so the auth exemption is an 2. **Fresh load / reconnect:** server stores the latest in `plan-usage-latest.ts`; `getLightState()` includes it as `planUsage`; the per-connection **init snapshot** replays it; `handleInit` paints the chip immediately (authoritative over localStorage). Null until the first telemetry of the process. 3. **Offline / cross-restart:** `restorePlanUsageChip()` reads `localStorage` on load (12h freshness guard). -### 5. Injection lifecycle — works for *any* user, never self-destructs +### 5. Injection lifecycle (SUPERSEDED 2026-09-07 — see header note; kept for history) The setting `showPlanUsageLimits` is **synced** (in `settings.json`, not a per-device `displayKey`). -- **On toggle** (`PUT /api/settings`, `system-routes.ts`): reconcile the exporter across **all active Claude sessions' working dirs** — inject on enable, remove on disable. Server-side and authoritative, so existing sessions get the footer + feed the chip *immediately*, no new session needed, no dependency on a client's synced localStorage. -- **On session create** (`session-routes.ts`): **ADD-ONLY** — inject when `statusLineTelemetry` is true; **never remove**. Sessions in a repo share one `settings.local.json`, so a single create-with-false (e.g. a client whose synced setting hadn't loaded) must not yank the statusLine out from under other live sessions. Removal happens only via the explicit toggle. -- `applyStatusLineConfig()` is **`isOurs`-guarded** (matches `/api/status-telemetry`), so a user's own hand-authored statusLine is never touched, and it **updates an out-of-date ours-command** so fixes (e.g. `-k`) propagate. **No `CASES_DIR` gate** — runs for linked cases / real repos (where sessions actually run), mirroring `updateCaseModel`. +- ~~**On toggle** (`PUT /api/settings`, `system-routes.ts`): reconcile the exporter across **all active Claude sessions' working dirs** — inject on enable, remove on disable.~~ There is nothing to (re)inject into an already-running session under the new CLI-flag mechanism — the NEXT respawn (a Ralph cycle, `/clear`, a PTY-exit restart) already reads the setting fresh. +- ~~**On session create** (`session-routes.ts`): **ADD-ONLY** — inject when `statusLineTelemetry` is true; **never remove**.~~ There is no `statusLineTelemetry` request field anymore. `TmuxManager.createSession`/`respawnPane` read `readPlanUsageTelemetryEnabled()` fresh at spawn instead, uniformly across every claude-creation path. +- ~~`applyStatusLineConfig()` is **`isOurs`-guarded**~~ — `applyStatusLineConfig` still exists but only for the SELF-HEAL path now (`resolveStatusLineCliCommand` strips a legacy disk-written exporter the first time a session starts in a workspace an older Codeman build touched). ## Codeman-specific considerations 1. **Account-global limits.** The 5h/7d pools are shared across all sessions on the account → one shared header chip (freshest sample wins), not a per-tab bar. 2. **The footer is owned, by necessity.** A statusLine command always replaces Claude's default footer. Since `rate_limits` *only* arrives via statusLine, we reconstruct a useful **session-status** footer (model · tokens · ctx %) from the same payload rather than showing the limits there. -3. **`isOurs`-guarded.** Never removes/overwrites a user's own statusLine on disable; only manages the Codeman exporter. +3. **Never overwrites, now WRAPS.** The exporter composes with a user's own real statusline (`findEffectiveUserStatusLineCommand`) rather than replacing it; `applyStatusLineConfig`'s `isOurs`-guard now only backs the legacy self-heal removal path. 4. **Security envelope unchanged.** The exporter runs arbitrary shell every render — same trust model as the hook curls (localhost + `$CODEMAN_HOOK_SECRET_FILE`); reuses the hook-secret gate. -5. **Claude-only.** OpenCode/Codex emit no `rate_limits` JSON; injection is gated to `mode === 'claude'`. +5. **Claude-only, registry-gated.** Injection is gated on `getCli(mode)?.capabilities.statusLineTelemetry` (currently `true` only for claude) rather than a hardcoded `mode === 'claude'` string. 6. **Future — auto-resume synergy.** Live percentages would let `SessionAutoOps` pre-arm *before* the wall instead of reacting to the stall footer. Not built. ## Files shipped - `src/usage-telemetry.ts` — pure parse/format (`parseStatusTelemetry`, `parseSessionStatus`, `formatSessionStatusText`, `telemetrySignature`) + `test/usage-telemetry.test.ts`. -- `src/hooks-config.ts` — `generateStatusLineCommand()` (`curl -sk`), `applyStatusLineConfig()` (add/update/remove, `isOurs`-guarded). +- `src/hooks-config.ts` — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` (ephemeral CLI-flag injection, never disk), `findEffectiveUserStatusLineCommand()` (wrap the user's real statusline), `readPlanUsageTelemetryEnabled()` (fresh global-setting read), `applyStatusLineConfig()` (legacy self-heal removal only now). +- `src/session-cli-registry-bridge.ts` — merges the exporter path into the SAME `--settings` JSON object as effort/ultracode (Claude Code accepts only one `--settings` flag per invocation). - `src/web/routes/status-telemetry-routes.ts` — `POST /api/status-telemetry`. - `src/web/plan-usage-latest.ts` — process-wide last-known store for init replay. -- `src/web/schemas.ts` — `StatusTelemetrySchema` + `showPlanUsageLimits` + create-payload `statusLineTelemetry`. +- `src/web/schemas.ts` — `StatusTelemetrySchema` + `showPlanUsageLimits` (no separate create-payload or action field anymore). - `src/web/middleware/auth.ts` — exemption extended to `/api/status-telemetry`. -- `src/web/routes/session-routes.ts` — add-only create-time injection. -- `src/web/routes/system-routes.ts` — settings-toggle reconcile. +- `src/tmux-manager.ts` — `createSession`/`respawnPane` read `readPlanUsageTelemetryEnabled()` fresh at spawn. - `src/web/server.ts` — `getLightState().planUsage` (init snapshot). - `src/web/sse-events.ts` + `constants.js` — `session:statusTelemetry`. - Frontend: `app.js` (`_onSessionStatusTelemetry`, `updatePlanUsageChip`, `restorePlanUsageChip`, `handleInit`), `settings-ui.js` (toggle + `applyHeaderVisibilitySettings`), `index.html` (chip + toggle row), `styles.css` (chip + colors), `session-ui.js` (create payload). diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 1e2ddd20..e637a3cb 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -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 @@ -912,32 +913,41 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean): * whenever the script content changes so `ensureStatusLineExporterScript`'s * content comparison rewrites stale copies on next use. */ -const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V2'; +const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V3'; function statusLineExporterScriptContent(): string { - // The telemetry POST runs in a BACKGROUND subshell so it can never add - // latency to a render, and its own stdout/stderr are discarded so they - // never leak into the visible statusline. If 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 SAME stdin blob is fed to it and - // ITS stdout becomes ours, so the user keeps seeing their own statusline - // untouched. Absent that, fall back to the plain "codeman" marker. + // Where the telemetry POST runs depends on who owns the footer. When the pane's + // env carries CODEMAN_USER_STATUSLINE_CMD (set via tmux setenv by TmuxManager + // when findEffectiveUserStatusLineCommand found the user's own REAL statusLine — + // see that function's doc comment), the user's command owns the footer, so the + // POST runs in a BACKGROUND subshell with stdin/stdout/stderr all closed + // (`>/dev/null 2>&1 /dev/null)" ` + + `--data @-`; return ( `#!/bin/sh\n` + `# ${STATUSLINE_EXPORTER_SCRIPT_MARKER} — auto-generated by Codeman; safe to delete, regenerated on demand.\n` + `INPUT=$(cat 2>/dev/null || echo '{}')\n` + - `(\n` + - ` printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` + - `curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` + - `-H 'Content-Type: application/json' ` + - `-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` + - `--data @- >/dev/null 2>&1\n` + - `) &\n` + `if [ -n "$CODEMAN_USER_STATUSLINE_CMD" ]; then\n` + + ` ( ${post} ) >/dev/null 2>&1 /dev/null || echo codeman\n` + `fi\n` ); } @@ -1019,13 +1029,34 @@ export async function ensureStatusLineExporterScript(): Promise { return scriptPath; } +/** + * Whether plan-usage telemetry collection is CURRENTLY wanted — read FRESH + * from the persisted `showPlanUsageLimits` setting on every call, never + * cached and never per-session. Reusing that setting rather than inventing a + * second persisted flag: it's the SAME boolean the App Settings chip checkbox + * already writes (see `planUsageChipEnabled()` in settings-ui.js). + * + * This is what lets the on/off decision survive a Codeman restart (there is + * no per-session state to lose — see the now-removed `Session._statusLineTelemetry`, + * which WAS such a per-session field and went stale on every restart) and + * apply uniformly across every claude session-creation path — interactive + * create, cron, the Ralph Loop API, quick-start — with none of them needing + * to thread a request-time flag through: they all already construct a + * session via TmuxManager.createSession/respawnPane, which reads this at + * spawn time. + */ +export async function readPlanUsageTelemetryEnabled(): Promise { + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {}); + return settings.showPlanUsageLimits === true; +} + /** * Resolve the statusLine command to pass as an EPHEMERAL `claude --settings` - * CLI flag for this one process (see buildClaudeSettingsFlag in - * tmux-manager.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 + * 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). * @@ -1033,14 +1064,17 @@ export async function ensureStatusLineExporterScript(): Promise { * 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. + * starts there again — no manual cleanup required. This self-heal runs + * regardless of `telemetryEnabled`, so a legacy leftover is cleaned up even + * while the setting is currently off. * - * Returns undefined when telemetry wasn't requested, or when the workspace - * already has its OWN hand-configured statusLine (never override a real one). + * 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, - telemetryRequested: boolean + telemetryEnabled: boolean ): Promise { const settingsPath = join(casePath, '.claude', 'settings.local.json'); let userHasOwnStatusLine = false; @@ -1059,7 +1093,7 @@ export async function resolveStatusLineCliCommand( // Malformed — leave it alone, same guard applyStatusLineConfig itself uses. } } - if (!telemetryRequested || userHasOwnStatusLine) return undefined; + if (!telemetryEnabled || userHasOwnStatusLine) return undefined; return ensureStatusLineExporterScript(); } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 0e2009dd..f4e3dd24 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -90,13 +90,6 @@ export interface CreateSessionOptions { envOverrides?: Record; /** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */ effort?: EffortLevel; - /** - * Claude-only: request the plan-usage statusLine exporter for this session, - * injected as an EPHEMERAL `--settings` CLI flag (never written to disk — see - * generateStatusLineCommand/resolveStatusLineCliCommand). Skipped when the - * workspace already has its own hand-configured statusLine. - */ - statusLineTelemetry?: boolean; /** tmux history-limit (scrollback lines) allocated when this session is created. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ @@ -132,8 +125,6 @@ export interface RespawnPaneOptions { envOverrides?: Record; /** Claude CLI effort level (preserved across respawns, injected via `--settings`) */ effort?: EffortLevel; - /** Preserved across respawns — see CreateSessionOptions.statusLineTelemetry. */ - statusLineTelemetry?: boolean; /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ diff --git a/src/session.ts b/src/session.ts index 40abec0a..59caaf55 100644 --- a/src/session.ts +++ b/src/session.ts @@ -577,11 +577,6 @@ export class Session extends EventEmitter { // the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session. private _effort: EffortLevel | undefined; - // Claude-only: request the plan-usage statusLine exporter for this session, - // injected as an ephemeral `--settings` CLI flag at spawn (never written to - // disk). Preserved across respawns like _effort above. - private _statusLineTelemetry: boolean | undefined; - // tmux history-limit (scrollback lines) allocated when this session's pane is created. private readonly _tmuxHistoryLimit: number; @@ -678,8 +673,6 @@ export class Session extends EventEmitter { envOverrides?: Record; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; - /** Claude-only: request the plan-usage statusLine exporter (ephemeral --settings flag, never disk-written) */ - statusLineTelemetry?: boolean; /** tmux history-limit (scrollback lines) allocated when this session's pane is created. */ tmuxHistoryLimit?: number; /** Restored per-session attachment history. May include server-private external paths. */ @@ -833,7 +826,6 @@ export class Session extends EventEmitter { if (config.effort && isEffortLevel(config.effort)) { this._effort = config.effort; } - this._statusLineTelemetry = config.statusLineTelemetry; this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; this._remote = config.remote; this._docker = config.docker; @@ -1754,7 +1746,6 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, - statusLineTelemetry: this._statusLineTelemetry, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -2070,7 +2061,6 @@ export class Session extends EventEmitter { resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, - statusLineTelemetry: this._statusLineTelemetry, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 481ab9ef..751b3c69 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -67,7 +67,11 @@ import { legacyConfigForMode, } from './session-cli-registry-bridge.js'; import type { CliEntry } from './config/cli-registry/types.js'; -import { resolveStatusLineCliCommand, findEffectiveUserStatusLineCommand } from './hooks-config.js'; +import { + resolveStatusLineCliCommand, + readPlanUsageTelemetryEnabled, + findEffectiveUserStatusLineCommand, +} from './hooks-config.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -1756,7 +1760,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, - statusLineTelemetry, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, @@ -1815,13 +1818,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); - // Claude-only, 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. + // 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 = - mode === 'claude' && !remote && !docker - ? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) + 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 @@ -2070,7 +2073,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { resumeSessionId, envOverrides, effort, - statusLineTelemetry, remote, docker, name, @@ -2088,8 +2090,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // See createSession()'s identical resolution for rationale. const statusLineCommand = - mode === 'claude' && !remote && !docker - ? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true) + getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker + ? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled()) : undefined; const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 26ed4390..92debff2 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1062,13 +1062,6 @@ Object.assign(CodemanApp.prototype, { ...(hasEnvOverrides ? { envOverrides } : {}), ...(effort ? { effort } : {}), ...(modelOverride !== undefined ? { modelOverride } : {}), - // Plan-usage statusLine exporter (App Settings → Display). The server - // ADDS our exporter on create when true; when false it intentionally - // leaves any existing exporter in place (a per-repo settings.local.json - // is shared by sibling sessions, so create-with-false must not yank it - // — see the comment in session-routes create). Disabling the setting - // removes it via the App Settings toggle path (system-routes), not here. - statusLineTelemetry: this.planUsageChipEnabled(globalSettings), }) }).then(r => r.json()) ); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 75e088a6..4f892be8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2271,22 +2271,26 @@ Object.assign(CodemanApp.prototype, { // Save to server (includes notification prefs for cross-browser persistence). // Strip device-specific DISPLAY keys so they never sync across devices — - // localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits - // is per-device too (desktop can show the usage chip while mobile stays hidden). + // localEcho/cjk/extendedKeyboard/skin are per-platform. // webglRendererEnabled is per-device as well (renderer choice is GPU-specific, // and syncing would leak mobile's hidden-checkbox false onto desktop); it's // also absent from SettingsUpdateSchema, which is .strict() — sending it // would 400 the whole settings PUT. - // Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on - // ENABLE only, so a device with the chip OFF never strips the exporter that - // another device's chip depends on — see system-routes settings handler). + // showPlanUsageLimits is the ONE exception to "per-device keys never sync": + // its DISPLAY stays per-device (loadAppSettingsFromServer only seeds it into + // localStorage when a device has no value yet — same as every other display + // key), but it ALSO doubles as the server-side plan-usage telemetry + // COLLECTION switch (readPlanUsageTelemetryEnabled in hooks-config.ts, read + // fresh at every claude session create/respawn), so unlike the others it + // MUST flow through in `serverSettings` below on every save — including + // OFF, which used to be un-sendable under the old one-way "ENABLE only" + // action field this replaces. const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, language: _language, - showPlanUsageLimits: _pul, showAttachmentsButton: _ahb, showFileViewerButton: _fvb, webglRendererEnabled: _wgl, @@ -2316,7 +2320,6 @@ Object.assign(CodemanApp.prototype, { try { const res = await this._apiPut('/api/settings', { ...serverSettings, - ...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}), notificationPreferences: notifPrefsToSave, voiceSettings, }); @@ -2579,10 +2582,11 @@ Object.assign(CodemanApp.prototype, { // Resolved per-device state of the plan-usage chip. Desktop defaults ON, // handhelds default OFF (the mobile block in getDefaultSettings() sets false, // and the mobile-header-buttons-policy guard depends on that staying false). - // Single source of truth for THREE call sites that must never disagree: the - // App Settings checkbox, the chip's visibility, and the statusLineTelemetry - // flag sent on session create. A chip shown without telemetry renders "—" - // forever, which is exactly the drift this helper prevents. + // Single source of truth for the two call sites that must never disagree: + // the App Settings checkbox and the chip's visibility. Telemetry COLLECTION + // no longer has a THIRD client-side call site here at all — the server reads + // this same persisted setting directly (readPlanUsageTelemetryEnabled in + // hooks-config.ts), fresh, at every claude session create/respawn. planUsageChipEnabled(settings = null) { const s = settings ?? this.loadAppSettingsFromStorage(); return s.showPlanUsageLimits ?? this.getDefaultSettings().showPlanUsageLimits ?? true; @@ -3074,11 +3078,13 @@ Object.assign(CodemanApp.prototype, { 'sessionLineageLines', ]); // The plan-usage chip is a PER-DEVICE display setting (desktop default ON, - // handheld default OFF): desktop can show it while mobile stays hidden. It - // used to sync, so an older server.json may still carry a value — drop it - // so the server value is NEVER - // seeded into a device that didn't explicitly enable it (collection is handled - // separately via the statusLineTelemetry action, not this display flag). + // handheld default OFF): desktop can show it while mobile stays hidden. Drop + // the server's stored value here so it is NEVER seeded into a device that + // didn't explicitly enable it — even though this SAME setting also drives + // server-side telemetry collection now (readPlanUsageTelemetryEnabled in + // hooks-config.ts), that's a read the server does directly from settings.json + // at spawn time; it has nothing to do with what gets merged into THIS + // device's local display preference. delete appSettings.showPlanUsageLimits; // Merge settings: non-display keys always sync from server, // display keys only seed from server when localStorage has no value diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 2f6649f3..48fd7328 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -947,18 +947,19 @@ export function registerSessionRoutes( await updateCaseModel(workingDir, body.modelOverride || null); } - // Plan-usage telemetry request (App Settings → header chip). NO LONGER a - // disk write here — a settings.local.json statusLine took precedence over - // the user's own global/project statusLine for ANY `claude` run in that - // directory, including entirely outside Codeman, with no disclosure and no - // way to undo it (real bug, found 2026-08-31). The request now flows - // through as an ordinary session field (statusLineTelemetryRequested below) - // and Session/TmuxManager resolve it into an EPHEMERAL `claude --settings` - // CLI flag at actual spawn time (resolveStatusLineCliCommand in - // hooks-config.ts) — never written to disk, so a plain `claude` run outside - // Codeman is untouched. That resolution also self-heals: it strips any - // legacy disk-written exporter an older Codeman build left behind. - const statusLineTelemetryRequested = body.statusLineTelemetry === 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 @@ -1092,7 +1093,6 @@ export function registerSessionRoutes( resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, - statusLineTelemetry: statusLineTelemetryRequested, tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit, remote, owner, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index ab4f2194..5d6b3c93 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -990,11 +990,9 @@ export function registerSystemRoutes( } catch { /* ignore */ } - // statusLineTelemetry and acknowledgeUnauthTunnel are ACTION fields (not stored - // settings) — strip them before persisting so settings.json stays clean. - // statusLineTelemetry is an action field only (see below); it must never - // land in settingsToStore. - const { statusLineTelemetry: _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 +1005,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,19 +1031,23 @@ export function registerSystemRoutes( } }); - // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js). - // Telemetry COLLECTION was previously server-side and enable-sticky 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 is exactly 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). Telemetry - // is now requested per-session at CREATE/RESPAWN time only (statusLineTelemetry - // threaded through cron/ralph-loop/quick-start/interactive-create, see those - // route handlers), resolved into an ephemeral `--settings` CLI flag — fixed at - // spawn, so there is nothing to (re)inject into an ALREADY-RUNNING session - // here, unlike the old disk mechanism. The action field above is received and - // discarded; flipping the chip ON only affects sessions created from now on. + // Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js), + // but `showPlanUsageLimits` ALSO doubles as the telemetry COLLECTION switch, + // persisted here in settingsToStore like any other setting (no special-casing + // needed — see readPlanUsageTelemetryEnabled's doc comment in hooks-config.ts). + // Telemetry COLLECTION used to be a SEPARATE, action-only, sticky mechanism + // here: toggling the chip ON re-injected a statusLine.command into every + // ACTIVE Claude session's settings.local.json so live % started flowing + // without a new session. That disk write was the bug fixed 2026-08-31 (it + // took precedence over the user's own statusline for ANY `claude` run in + // that directory, including outside Codeman, with no way to undo it). + // Collection is now decided by TmuxManager.createSession/respawnPane reading + // `showPlanUsageLimits` FRESH from settings.json at spawn time — no + // per-session field, no per-request threading through cron/Ralph-loop/ + // quick-start/interactive-create (they all reach the same read), and no + // (re)injection into an already-running session needed here: the NEXT + // respawn (a Ralph cycle, `/clear`, a PTY-exit restart) already picks up + // whatever this PUT just persisted. // Handle tunnel toggle dynamically if ('tunnelEnabled' in settings) { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 3ae465c2..df9028c5 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -524,8 +524,6 @@ export const CreateSessionSchema = z.object({ effort: effortLevelSchema, /** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */ modelOverride: z.string().max(50).optional(), - /** Inject the Claude statusLine source for the shared plan-usage chip. Claude sessions only; Codex is host-polled. */ - statusLineTelemetry: z.boolean().optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, @@ -1289,13 +1287,11 @@ export const SettingsUpdateSchema = z showFileBrowser: z.boolean().optional(), showSubagents: z.boolean().optional(), showMultiMonitorButton: z.boolean().optional(), + // Doubles as the plan-usage telemetry COLLECTION switch, read fresh from + // disk by readPlanUsageTelemetryEnabled() (hooks-config.ts) at every claude + // session create/respawn — not just the chip's DISPLAY preference. See that + // function's doc comment for why one persisted field serves both. showPlanUsageLimits: z.boolean().optional(), - // Action field (NOT persisted as a setting): when true, (re)injects the - // plan-usage statusLine exporter into active Claude sessions so live usage % - // starts flowing. Sent on ENABLE only — the chip's DISPLAY is per-device - // (client-side), but telemetry COLLECTION is server-side, so the per-device - // toggle signals it out-of-band here rather than via showPlanUsageLimits. - statusLineTelemetry: z.boolean().optional(), showRedrawButton: z.boolean().optional(), // Input gestureControlEnabled: z.boolean().optional(), diff --git a/src/web/server.ts b/src/web/server.ts index b554ada5..e84c626c 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1450,7 +1450,10 @@ export class WebServer extends EventEmitter { // PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It // used to be server-revealed from a synced setting, but that leaked the desktop // choice onto mobile — display is now per-device only (like the response viewer). - // Telemetry collection stays server-side via the statusLineTelemetry action. + // Telemetry collection stays server-side, reading `showPlanUsageLimits` fresh + // from settings.json at every claude session create/respawn (see + // readPlanUsageTelemetryEnabled in hooks-config.ts) — the same setting this + // display-visibility check reads, doing double duty. // Detached single-session ("solo") window: inject the target session id so // the client can enter solo mode even if a (network-first) service worker // later serves a cached shell. The client primarily detects solo mode from diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 0b36c552..1048486a 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import { + chmodSync, closeSync, existsSync, openSync, @@ -18,6 +19,7 @@ import { statSync, } from 'node:fs'; import { join } from 'node:path'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; import { tmpdir, homedir } from 'node:os'; import { spawn } from 'node:child_process'; import { @@ -28,6 +30,7 @@ import { generateHooksConfig, generateStatusLineCommand, generateSubagentStopGuardScript, + readPlanUsageTelemetryEnabled, refreshStaleCodemanHooks, resolveStatusLineCliCommand, settingsWriteBlocker, @@ -1319,6 +1322,48 @@ describe('Hook Config Generation - Extended', () => { }); }); +describe('readPlanUsageTelemetryEnabled', () => { + const backup = existsSync(SETTINGS_PATH) ? readFileSync(SETTINGS_PATH, 'utf-8') : null; + + afterEach(() => { + if (backup !== null) { + writeFileSync(SETTINGS_PATH, backup); + } else { + rmSync(SETTINGS_PATH, { force: true }); + } + }); + + it('reads true fresh from the persisted showPlanUsageLimits setting', async () => { + mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: true })); + expect(await readPlanUsageTelemetryEnabled()).toBe(true); + }); + + it('reads false when the setting is explicitly false', async () => { + mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: false })); + expect(await readPlanUsageTelemetryEnabled()).toBe(false); + }); + + it('defaults to false when the setting is absent or the file is missing', async () => { + rmSync(SETTINGS_PATH, { force: true }); + expect(await readPlanUsageTelemetryEnabled()).toBe(false); + + mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ someOtherSetting: true })); + expect(await readPlanUsageTelemetryEnabled()).toBe(false); + }); + + it('never caches — a change on disk is visible on the very next call', async () => { + mkdirSync(join(SETTINGS_PATH, '..'), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: false })); + expect(await readPlanUsageTelemetryEnabled()).toBe(false); + + writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: true })); + expect(await readPlanUsageTelemetryEnabled()).toBe(true); + }); +}); + describe('resolveStatusLineCliCommand', () => { const testDir = join(tmpdir(), 'codeman-statusline-cli-test-' + Date.now()); @@ -1389,6 +1434,83 @@ describe('resolveStatusLineCliCommand', () => { }); }); +describe('statusline exporter script (real shell execution)', () => { + const testDir = join(tmpdir(), 'codeman-statusline-script-exec-test-' + Date.now()); + const binDir = join(tmpdir(), 'codeman-statusline-script-exec-bin-' + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + mkdirSync(binDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + rmSync(binDir, { recursive: true, force: true }); + }); + + // A stand-in for the real `curl` binary, placed FIRST on PATH — same technique + // the exporter's own review used ("an arg-echoing stand-in"). It ignores every + // arg curl would have received; only its own scripted behavior matters here. + function writeFakeCurl(script: string): void { + const curlPath = join(binDir, 'curl'); + writeFileSync(curlPath, `#!/bin/sh\n${script}\n`); + chmodSync(curlPath, 0o755); + } + + function runExporter( + env: Record + ): 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'); diff --git a/test/routes/session-routes-workspace-hooks.test.ts b/test/routes/session-routes-workspace-hooks.test.ts index 82c55820..523db4e0 100644 --- a/test/routes/session-routes-workspace-hooks.test.ts +++ b/test/routes/session-routes-workspace-hooks.test.ts @@ -156,7 +156,7 @@ describe('POST /api/sessions workspace hooks', () => { const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json'); const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null; - const res = await createSession({ name: 'hooks-no-dir', mode: 'claude', statusLineTelemetry: true }); + const res = await createSession({ name: 'hooks-no-dir', mode: 'claude' }); expect(res.statusCode).toBe(200); const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null; @@ -166,8 +166,9 @@ describe('POST /api/sessions workspace hooks', () => { it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => { // A claude-mode attachRemoteSession create overwrites workingDir with // `user@host:session` — locally a RELATIVE path, so a mkdir would create it - // as a junk directory under the server cwd. statusLineTelemetry rides along: - // applyStatusLineConfig mkdirs the same way and used to run for remote attaches. + // as a junk directory under the server cwd. The statusLine exporter rides + // along: applyStatusLineConfig mkdirs the same way and used to run for + // remote attaches. // SAFETY (2026-08-29): write straight to `getDataDir()` — `test/setup.ts` // already sandboxes the data dir for the whole file (temp HOME, inherited // CODEMAN_DATA_DIR stripped; same convention as the docker-hosts fixtures @@ -188,7 +189,6 @@ describe('POST /api/sessions workspace hooks', () => { const res = await createSession({ name: 'hooks-remote', mode: 'claude', - statusLineTelemetry: true, attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' }, }); expect(res.statusCode).toBe(200); diff --git a/test/routes/system-routes-settings-partial-put.test.ts b/test/routes/system-routes-settings-partial-put.test.ts index 56134313..bc38b150 100644 --- a/test/routes/system-routes-settings-partial-put.test.ts +++ b/test/routes/system-routes-settings-partial-put.test.ts @@ -4,7 +4,7 @@ * The three service toggles (subagent watcher, workflow-run watcher, image * watcher) used to read the RAW REQUEST BODY with `??` defaults, so any key the * caller omitted was treated as "apply the default". A body of just - * `{statusLineTelemetry:true}` therefore STARTED the subagent watcher (`?? true`) + * `{showPlanUsageLimits:true}` therefore STARTED the subagent watcher (`?? true`) * and STOPPED the workflow + image watchers (`?? false`), silently undoing the * persisted config. Nothing triggered it in practice only because every shipped * client sends a full settings payload rebuilt from the DOM. @@ -91,8 +91,8 @@ describe('PUT /api/settings — partial body must not reset service toggles', () const res = await harness.app.inject({ method: 'PUT', url: '/api/settings', - // Action-only body: the exact shape that used to flip all three watchers. - payload: { statusLineTelemetry: true }, + // Minimal single-key body: the exact shape that used to flip all three watchers. + payload: { showPlanUsageLimits: true }, }); expect(res.statusCode).toBe(200); diff --git a/test/statusline-cli-flag.test.ts b/test/statusline-cli-flag.test.ts index d6721d2a..83f9add4 100644 --- a/test/statusline-cli-flag.test.ts +++ b/test/statusline-cli-flag.test.ts @@ -78,9 +78,13 @@ describe('buildSpawnCommand statusLineCommand (claude mode)', () => { expect(extractSettingsJson(cmd)).toEqual({ statusLine: { type: 'command', command: tricky } }); }); - it('round-trips the REAL exporter command unmodified (generateStatusLineCommand)', async () => { - const { generateStatusLineCommand } = await import('../src/hooks-config.js'); - const real = generateStatusLineCommand(); + 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 } }); }); From aeb55c92b051c77bc25fca3d254bd724460fc74d Mon Sep 17 00:00:00 2001 From: timkjr Date: Thu, 10 Sep 2026 19:10:14 -0500 Subject: [PATCH 4/4] fix(settings): reconcile showPlanUsageLimits default on first read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planUsageChipEnabled() (settings-ui.js) shows the header chip and the App Settings checkbox as already ON whenever showPlanUsageLimits has never been set — a discoverability default from 1.9.3. readPlanUsageTelemetryEnabled() (hooks-config.ts) deliberately treats an absent key as "no telemetry" — a privacy default, pinned by its own unit tests (never POST usage data without an explicit persisted yes). Nothing reconciled those two independent guesses, so a fresh install showed a checked box that silently collected nothing until the user opened Settings and hit Save at least once. Verified live: an install that had never touched this setting had no showPlanUsageLimits key in settings.json at all, and its running Claude process's argv carried no --settings flag — zero telemetry ever collected despite the chip rendering as enabled. GET /api/settings now persists the resolved default (true) the first time the key is truly absent — not explicit false — so "chip visible" and "telemetry collected" become the same fact. readPlanUsageTelemetryEnabled's own absent-means-false contract is untouched; after this runs once the key is never absent again, so that branch stays correct in isolation while being unreachable in practice for any install that has ever called this route. An explicit false set afterward is respected forever. Co-Authored-By: Claude Sonnet 5 --- src/web/routes/system-routes.ts | 42 ++++++- ...es-settings-get-plan-usage-default.test.ts | 108 ++++++++++++++++++ test/routes/system-routes.test.ts | 28 ++++- 3 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 test/routes/system-routes-settings-get-plan-usage-default.test.ts diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 5d6b3c93..93c45ded 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -936,7 +936,47 @@ export function registerSystemRoutes( // ========== Settings ========== app.get('/api/settings', async () => { - return readJsonConfig(SETTINGS_PATH, 'settings', {}); + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings', {}); + + // Plan-usage chip default reconciliation (PR #361 follow-up): the client's + // own default resolution (planUsageChipEnabled() in settings-ui.js) shows + // the header chip and the App Settings checkbox as already ON whenever this + // key has never been set — a discoverability default from 1.9.3, unrelated + // to consent. Meanwhile readPlanUsageTelemetryEnabled() (hooks-config.ts) + // deliberately treats an absent key as "no telemetry" (privacy: never POST + // usage data without an explicit persisted yes, pinned by its own unit + // tests). Nothing ever reconciled those two independent guesses, so a + // fresh install showed a checked box that silently did nothing until the + // user opened Settings and hit Save at least once — verified live: an + // install that had never touched this setting had NO showPlanUsageLimits + // key in settings.json, and its running Claude process's argv carried no + // --settings flag at all, i.e. zero telemetry ever collected. + // + // Resolve it ONCE, here, the first time anything reads settings: if the + // key is truly ABSENT (never explicit true or false), persist the same + // desktop-default-ON resolution the client already shows, so "chip visible" + // and "telemetry collected" become the same fact instead of two defaults + // that happen to disagree. readPlanUsageTelemetryEnabled()'s own + // absent-means-false contract is untouched — after this runs once the key + // is never absent again, so that branch stays correct in isolation (its + // unit tests keep passing unmodified) while being unreachable in practice + // for any install that has ever called this route. An explicit false the + // user sets afterward is respected forever; this only fires on true absence. + if (!('showPlanUsageLimits' in settings)) { + settings.showPlanUsageLimits = true; + try { + const dir = dirname(SETTINGS_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + await fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)); + } catch { + // Best-effort: the resolved default still reaches this response even + // if the write fails, so the caller sees consistent data either way. + } + } + + return settings; }); app.put('/api/settings', async (req) => { diff --git a/test/routes/system-routes-settings-get-plan-usage-default.test.ts b/test/routes/system-routes-settings-get-plan-usage-default.test.ts new file mode 100644 index 00000000..56221a08 --- /dev/null +++ b/test/routes/system-routes-settings-get-plan-usage-default.test.ts @@ -0,0 +1,108 @@ +/** + * @fileoverview GET /api/settings must reconcile `showPlanUsageLimits` the + * first time it is ever read, closing the gap left by PR #361. + * + * planUsageChipEnabled() (settings-ui.js) shows the header chip and the App + * Settings checkbox as already ON whenever this key has never been set — a + * discoverability default from 1.9.3. readPlanUsageTelemetryEnabled() + * (hooks-config.ts) deliberately treats an absent key as "no telemetry" — + * a privacy default, pinned by its own unit tests. Nothing reconciled those + * two independent guesses, so a fresh install showed a checked box that + * silently collected nothing until the user opened Settings and hit Save + * at least once. + * + * These tests pin the fix: GET /api/settings persists the resolved default + * ONCE when the key is truly absent, and never overwrites an explicit value + * either way afterward. + * + * Uses app.inject() — no real HTTP ports needed. Port: N/A. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; + +// vi.mock factories are hoisted above module-level consts, so the mutable +// persisted-settings fixture has to be built inside vi.hoisted(). +const { state } = vi.hoisted(() => ({ + // Mutated per-test to control what "disk" holds before the GET. + state: { persisted: {} as Record, exists: true }, +})); + +vi.mock('node:fs/promises', () => ({ + default: { + readFile: vi.fn(async () => { + if (!state.exists) { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + } + return JSON.stringify(state.persisted); + }), + writeFile: vi.fn(async (_path: string, content: string) => { + state.persisted = JSON.parse(content); + state.exists = true; + }), + }, +})); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) }; +}); + +describe('GET /api/settings — showPlanUsageLimits default reconciliation', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + it('persists the resolved default (true) the first time the key is absent', async () => { + state.persisted = { someOtherSetting: true }; + state.exists = true; + + const res = await harness.app.inject({ method: 'GET', url: '/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); + }); +}); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 3873f2f5..be690ce1 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -411,21 +411,43 @@ describe('system-routes', () => { // ========== GET /api/settings ========== describe('GET /api/settings', () => { - it('returns empty object when settings file does not exist', async () => { + it('reconciles showPlanUsageLimits to true when the settings file does not exist', async () => { + // The chip/checkbox default to ON client-side (planUsageChipEnabled()) whenever + // this key is absent, but readPlanUsageTelemetryEnabled() deliberately treats + // absence as "no telemetry" — nothing reconciled those two defaults, so a fresh + // install showed a checked box that silently collected nothing. GET now persists + // the resolved default the first time anything reads settings. mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); const res = await harness.app.inject({ method: 'GET', url: '/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(); }); });