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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 15:59:00 +02:00
parent 707ea345eb
commit fa1ea8d9fe
5 changed files with 66 additions and 6 deletions
+8 -2
View File
@@ -1023,8 +1023,14 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
// 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;
}
+6 -1
View File
@@ -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,
+11 -3
View File
@@ -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.
}
}