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'); + }); +});