From fa1ea8d9fe4299274960aed1c18c5baf67971966 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 14 Sep 2026 15:55:17 +0200 Subject: [PATCH] fix(statusline): unset a stale user statusline var, write the exporter script atomically Three small follow-ups from the #361 review. A tmux setenv survives respawn-pane, so _configureStatusLineUserCommand returning early when the user has no statusline left a previously exported CODEMAN_USER_STATUSLINE_CMD in place: a user who deleted their own statusline kept getting the stale one wrapped, and lost Codeman's footer print-through, until the tmux session was recreated. It now issues `setenv -u` in that case, the same shape as the effort-level cleanup in applyEnvOverrides. ensureStatusLineExporterScript truncated and rewrote a script that live sessions execute on every statusline render, and chmod'd it after the write. It now writes a temp file next to the target, chmods that, and rename()s it into place. The non-tmux direct-PTY fallback carries no exporter; that is now stated at the spawn site and in the architecture-invariants paragraph rather than left as a silent gap. Co-Authored-By: Claude Fable 5.1 --- src/hooks-config.ts | 10 ++++++++-- src/session.ts | 7 ++++++- src/tmux-manager.ts | 14 +++++++++++--- test/hooks-config.test.ts | 15 +++++++++++++++ test/tmux-manager.test.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 513dbc28..3908f90d 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1023,8 +1023,14 @@ export async function ensureStatusLineExporterScript(): Promise { // Doesn't exist yet. } if (current !== desired) { - await writeFile(scriptPath, desired); - await chmod(scriptPath, 0o755); + // Temp file + rename: live sessions execute this script on every statusline + // render, and a truncate-then-write (plus a chmod AFTER the write) opened two + // windows in which Claude Code could run an empty or non-executable file. + // rename() swaps the complete, already-executable file in atomically. + const tmpPath = `${scriptPath}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmpPath, desired); + await chmod(tmpPath, 0o755); + await rename(tmpPath, scriptPath); } return scriptPath; } diff --git a/src/session.ts b/src/session.ts index 67935a5c..c08afa10 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2174,7 +2174,12 @@ export class Session extends EventEmitter { } try { // Pass --session-id to use the SAME ID as the Codeman session - // This ensures subagents can be directly matched to the correct tab + // This ensures subagents can be directly matched to the correct tab. + // No plan-usage statusLine exporter on this path: the ephemeral + // `--settings` injection (resolveStatusLineCliCommand, hooks-config.ts) + // is wired into the tmux spawn builders only, so a direct-PTY session + // has no Claude telemetry in the header chip. Documented in + // architecture-invariants (Plan-usage chip); tmux is the supported path. const args = buildInteractiveArgs( this.id, this._claudeMode, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 80a5776a..1a201005 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -1850,16 +1850,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * 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. + * + * With NO user command the variable is UNSET rather than left alone: a tmux + * setenv survives respawn-pane, so a user who deleted their own statusline + * would otherwise keep getting the stale one wrapped (and lose Codeman's + * footer print-through) until the tmux session was recreated. Same shape as + * the CLAUDE_CODE_EFFORT_LEVEL cleanup in applyEnvOverrides. */ private _configureStatusLineUserCommand(muxName: string, command: string | undefined): void { - if (!command) return; + const setOrUnset = command + ? `CODEMAN_USER_STATUSLINE_CMD ${shellescape(command)}` + : '-u CODEMAN_USER_STATUSLINE_CMD'; try { - execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} CODEMAN_USER_STATUSLINE_CMD ${shellescape(command)}`, { + execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${setOrUnset}`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', }); } catch { - // Non-critical — the exporter just falls back to the plain "codeman" marker. + // Non-critical: the exporter prints its own footer, or nothing. } } diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 1f17d03d..7dfbb38f 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -17,6 +17,7 @@ import { rmSync, symlinkSync, statSync, + readdirSync, } from 'node:fs'; import { join } from 'node:path'; import { SETTINGS_PATH } from '../src/web/route-helpers.js'; @@ -1403,6 +1404,20 @@ describe('resolveStatusLineCliCommand', () => { expect(readFileSync(cmd!, 'utf-8')).toContain('CODEMAN_STATUSLINE_EXPORTER_V'); }); + it('refreshes a stale exporter script atomically: executable on arrival, no temp file left behind', async () => { + const scriptPath = (await resolveStatusLineCliCommand(testDir, true))!; + // Simulate a script an older build wrote (different marker suffix). + writeFileSync(scriptPath, '#!/bin/sh\n# CODEMAN_STATUSLINE_EXPORTER_V0\necho stale\n'); + chmodSync(scriptPath, 0o644); + + const again = await resolveStatusLineCliCommand(testDir, true); + expect(again).toBe(scriptPath); + expect(readFileSync(scriptPath, 'utf-8')).not.toContain('echo stale'); + expect(statSync(scriptPath).mode & 0o111).not.toBe(0); + const siblings = readdirSync(join(scriptPath, '..')).filter((f) => f.startsWith('statusline-exporter.sh.')); + expect(siblings).toEqual([]); + }); + it('never overrides a real, hand-authored statusLine', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true }); diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index be228835..1de9a3ef 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -99,6 +99,32 @@ describe('TmuxManager (unit)', () => { }); }); + describe('statusline user-command env', () => { + // A tmux setenv survives respawn-pane, so the absence of a user statusline + // must UNSET the variable rather than leave a stale one for the exporter + // to wrap. + it('unsets CODEMAN_USER_STATUSLINE_CMD when the user has no statusline', () => { + mockedExecSync.mockClear(); + ( + manager as unknown as { _configureStatusLineUserCommand: (m: string, c?: string) => void } + )._configureStatusLineUserCommand('codeman-abc', undefined); + const cmds = mockedExecSync.mock.calls.map((c) => String(c[0])); + expect(cmds.some((c) => c.includes("setenv -t 'codeman-abc' -u CODEMAN_USER_STATUSLINE_CMD"))).toBe(true); + }); + + it('sets CODEMAN_USER_STATUSLINE_CMD, shell-escaped, when the user has one', () => { + mockedExecSync.mockClear(); + ( + manager as unknown as { _configureStatusLineUserCommand: (m: string, c?: string) => void } + )._configureStatusLineUserCommand('codeman-abc', `printf '%s' "$1" | jq -r .model`); + const cmds = mockedExecSync.mock.calls.map((c) => String(c[0])); + const setCmd = cmds.find((c) => c.includes('CODEMAN_USER_STATUSLINE_CMD')); + expect(setCmd).toBeDefined(); + expect(setCmd).not.toContain(' -u '); + expect(setCmd).toContain("setenv -t 'codeman-abc' CODEMAN_USER_STATUSLINE_CMD "); + }); + }); + describe('Codex command builder', () => { it('controls decorative TUI animation through Codex config', () => { expect(buildCodexCommand({ animations: false })).toBe('codex --config tui.animations=false');