mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
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:
+8
-2
@@ -1023,8 +1023,14 @@ export async function ensureStatusLineExporterScript(): Promise<string> {
|
|||||||
// Doesn't exist yet.
|
// Doesn't exist yet.
|
||||||
}
|
}
|
||||||
if (current !== desired) {
|
if (current !== desired) {
|
||||||
await writeFile(scriptPath, desired);
|
// Temp file + rename: live sessions execute this script on every statusline
|
||||||
await chmod(scriptPath, 0o755);
|
// 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;
|
return scriptPath;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -2174,7 +2174,12 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Pass --session-id to use the SAME ID as the Codeman session
|
// 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(
|
const args = buildInteractiveArgs(
|
||||||
this.id,
|
this.id,
|
||||||
this._claudeMode,
|
this._claudeMode,
|
||||||
|
|||||||
+11
-3
@@ -1850,16 +1850,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
* same reasoning that made the exporter script itself necessary (see
|
* same reasoning that made the exporter script itself necessary (see
|
||||||
* ensureStatusLineExporterScript's doc comment). Only this ONE line needs
|
* ensureStatusLineExporterScript's doc comment). Only this ONE line needs
|
||||||
* shellescape(); the stored value itself is opaque to tmux from then on.
|
* 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 {
|
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 {
|
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,
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — the exporter just falls back to the plain "codeman" marker.
|
// Non-critical: the exporter prints its own footer, or nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
rmSync,
|
rmSync,
|
||||||
symlinkSync,
|
symlinkSync,
|
||||||
statSync,
|
statSync,
|
||||||
|
readdirSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
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');
|
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 () => {
|
it('never overrides a real, hand-authored statusLine', async () => {
|
||||||
const claudeDir = join(testDir, '.claude');
|
const claudeDir = join(testDir, '.claude');
|
||||||
mkdirSync(claudeDir, { recursive: true });
|
mkdirSync(claudeDir, { recursive: true });
|
||||||
|
|||||||
@@ -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', () => {
|
describe('Codex command builder', () => {
|
||||||
it('controls decorative TUI animation through Codex config', () => {
|
it('controls decorative TUI animation through Codex config', () => {
|
||||||
expect(buildCodexCommand({ animations: false })).toBe('codex --config tui.animations=false');
|
expect(buildCodexCommand({ animations: false })).toBe('codex --config tui.animations=false');
|
||||||
|
|||||||
Reference in New Issue
Block a user