fix(statusline): guard the shim for Docker, drop the ancestor walk, remove on chip-off (#405)

Follow-ups to the delegating statusline shim from discussion #405, answering
the four design questions and the Docker one raised there.

Docker cases: the injected command is now a self-selecting shell guard,
`if [ -x <node> ] && [ -f <shim> ]; then exec <node> <shim>; fi;` followed by
the inline curl exporter. A Docker case bind-mounts the workspace, and with it
settings.local.json, at the same absolute path inside the container, but
neither the host's node nor ~/.codeman exists there, so a bare shim command
would have rendered a broken statusline in every container session. The same
string now runs the shim on the host and the curl inside the container. The
settings-save injection loop needs no docker guard for that reason; it does
skip remote attaches now, whose workingDir is a user@host pseudo-path.

Settings precedence: the shim reads exactly the three files Claude Code
documents, .claude/settings.local.json and .claude/settings.json under
workspace.project_dir (the launch directory), then ~/.claude/settings.json.
No ancestor walk and no user-level settings.local.json: delegating to a
command Claude Code would have ignored is the original failure in a new coat.

The bare word: all three paths that produced `codeman` are gone. The route
answers an unknown session with an empty body, formatSessionStatusText()
returns '' with nothing to show, and the inline fallback ends in `|| true`
(plus `curl -f`, so an HTTP error body never renders as the statusline). The
shim also treats a literal `codeman` from an older server as no telemetry and
prints nothing rather than a brand word when it has neither a delegate nor a
footer, which is what Claude Code shows a user with no statusline of their own.

Removal: turning the plan-usage chip OFF now takes the exporter out of the
workspaces of the caller's live Claude sessions. statusLineTelemetry:false is
sent only by the save that flips the chip off on that device
(statusLineTelemetryAction() in settings-ui.js), so a phone whose chip was
never on cannot strip the exporter a desktop's chip depends on; a second
device with the chip still on re-injects on its next save or session create.
Nothing in src/ called applyStatusLineConfig(dir, false) before.

Tests run the generated shim AND the injected command as real subprocesses
(the fallback half with the shim path pointed at nothing, the container's
view), plus both directions of the action field through PUT /api/settings.
Measured against the live server: a render costs ~85 ms through the shim
versus ~26 ms for the old inline curl (node start ~33 ms, the rest TLS to
the loopback HTTPS server plus the delegate spawn), off the input path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 13:45:32 +02:00
parent c375268879
commit d1cd7884d4
17 changed files with 623 additions and 218 deletions
+8 -4
View File
@@ -1326,14 +1326,18 @@ describe('applyStatusLineConfig', () => {
rmSync(testDir, { recursive: true, force: true });
});
it('injects the delegating shim rather than an inline exporter', async () => {
it('injects the guarded shim command with the inline exporter as its fallback', async () => {
await applyStatusLineConfig(testDir, true);
const { statusLine } = read();
expect(statusLine.type).toBe('command');
// The shim runs first wherever it exists: it is what gives the user their
// own statusline back. The inline half after it is what renders where the
// shim cannot (inside a Docker case's container), and it must not carry the
// brand-word fallback the old exporter printed.
expect(statusLine.command.startsWith('if [ -x ')).toBe(true);
expect(statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
// The inline form SHADOWS the user's statusline, which is the whole reason
// the shim exists. It may never be the command we inject by choice.
expect(statusLine.command).not.toContain(LEGACY_STATUSLINE_MARKER);
expect(statusLine.command).toContain(LEGACY_STATUSLINE_MARKER);
expect(statusLine.command).not.toContain('echo codeman');
});
it('upgrades a pre-shim inline exporter in place', async () => {
+46
View File
@@ -0,0 +1,46 @@
/**
* `statusLineTelemetryAction()` in settings-ui.js: the one place that decides
* what a settings save tells the server about the plan-usage exporter.
*
* The chip is per-device (desktop default ON, phones OFF), while the exporter
* it depends on lives in each repo's shared `.claude/settings.local.json`. So
* the save may send `true` freely (every save while the chip is on re-injects,
* which is how a second device catches up) but may send `false` ONLY on the
* save that turned the chip off on this device. A phone with the chip off
* saving its font size must not strip the exporter a desktop's chip depends on.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadSettingsUi() {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: () => null },
console,
});
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
vm.runInContext(source, context, { filename: 'settings-ui.js' });
return CodemanApp.prototype as { statusLineTelemetryAction: (prev: boolean, now: boolean) => boolean | undefined };
}
describe('statusLineTelemetryAction', () => {
const ui = loadSettingsUi();
it('sends true on every save while the chip is on', () => {
expect(ui.statusLineTelemetryAction(true, true)).toBe(true);
expect(ui.statusLineTelemetryAction(false, true)).toBe(true);
});
it('sends false only on the save that turned the chip off', () => {
expect(ui.statusLineTelemetryAction(true, false)).toBe(false);
});
it('sends nothing from a device whose chip was already off', () => {
expect(ui.statusLineTelemetryAction(false, false)).toBeUndefined();
});
});
+5 -2
View File
@@ -49,10 +49,13 @@ describe('POST /api/status-telemetry', () => {
});
});
it('does not broadcast for an unknown session; returns the brand footer', async () => {
it('does not broadcast for an unknown session, and answers an empty body', async () => {
// Never the bare brand word: the exporter prints this answer as the
// statusline, and `codeman` on the statusline of every hand-run `claude` in
// a managed repo is the symptom discussion #405 opened with.
const res = await post({ sessionId: 'does-not-exist', data: REAL });
expect(res.statusCode).toBe(200);
expect(res.body).toBe('codeman');
expect(res.body).toBe('');
expect(h.ctx.broadcast).not.toHaveBeenCalled();
});
@@ -0,0 +1,133 @@
/**
* PUT /api/settings: the `statusLineTelemetry` ACTION field, both directions.
*
* `true` (sent on every save while the plan-usage chip is on) injects Codeman's
* statusLine exporter into every live Claude session's workspace so the chip's
* data starts flowing without a new session. `false` (sent only when the chip
* was just turned OFF on a device) takes the exporter back out of those same
* workspaces. Before this, nothing in `src/` ever called the disable path, so
* turning the chip off left the line in every repo it had ever reached
* (discussion #405).
*
* Both directions are `isOurs`-guarded in `applyStatusLineConfig`, so a
* statusLine the user wrote themselves is never added to, replaced, or removed.
* Remote-attach sessions are skipped in both: their `workingDir` is a
* `user@host:session` pseudo-path that the enable path would otherwise create
* as a junk local directory.
*
* Uses app.inject(), real temp workspaces under the test HOME. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/mock-session.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { STATUSLINE_SHIM_TOKEN } from '../../src/statusline-shim.js';
// The three service toggles start/stop real watchers from this handler; stub
// them so a settings PUT in a test never starts a filesystem watcher.
const { subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
const makeWatcher = () => ({
isRunning: vi.fn(() => false),
start: vi.fn(),
stop: vi.fn(),
getStats: vi.fn(() => ({})),
watchSession: vi.fn(),
getRecentRunSummaries: vi.fn(() => []),
});
return { subagentWatcher: makeWatcher(), imageWatcher: makeWatcher(), workflowRunWatcher: makeWatcher() };
});
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
const settingsFile = (dir: string) => join(dir, '.claude', 'settings.local.json');
const readSettings = (dir: string) => JSON.parse(readFileSync(settingsFile(dir), 'utf-8'));
const writeSettings = (dir: string, value: object) => {
mkdirSync(join(dir, '.claude'), { recursive: true });
writeFileSync(settingsFile(dir), JSON.stringify(value, null, 2));
};
describe('PUT /api/settings statusLineTelemetry', () => {
let h: RouteTestHarness;
let root: string;
let claudeDir: string;
let shellDir: string;
let remoteDir: string;
const put = (body: unknown) => h.app.inject({ method: 'PUT', url: '/api/settings', payload: body });
beforeEach(async () => {
h = await createRouteTestHarness(registerSystemRoutes);
root = mkdtempSync(join(tmpdir(), 'codeman-statusline-toggle-'));
claudeDir = join(root, 'claude-repo');
shellDir = join(root, 'shell-repo');
remoteDir = join(root, 'remote-attach');
for (const dir of [claudeDir, shellDir]) mkdirSync(dir, { recursive: true });
const claude = createMockSession('claude-1');
claude.workingDir = claudeDir;
const shell = createMockSession('shell-1');
shell.mode = 'shell';
shell.workingDir = shellDir;
const remote = createMockSession('remote-1');
remote.workingDir = remoteDir;
Object.assign(remote, { remote: { host: 'box', session: 'codeman-ssh-remote-1' } });
h.ctx.sessions.clear();
for (const s of [claude, shell, remote]) h.ctx.sessions.set(s.id, s);
});
afterEach(async () => {
await h.app.close();
});
it('true injects the exporter into live Claude workspaces only', async () => {
const res = await put({ statusLineTelemetry: true });
expect(res.statusCode).toBe(200);
expect(readSettings(claudeDir).statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
// A shell session has no statusline to export from.
expect(existsSync(settingsFile(shellDir))).toBe(false);
// A remote attach's workingDir is a pseudo-path: nothing must be created for it.
expect(existsSync(remoteDir)).toBe(false);
});
it('false removes the exporter it injected', async () => {
await put({ statusLineTelemetry: true });
expect(readSettings(claudeDir).statusLine).toBeDefined();
const res = await put({ statusLineTelemetry: false });
expect(res.statusCode).toBe(200);
expect(readSettings(claudeDir).statusLine).toBeUndefined();
});
it('false keeps every other key in the workspace settings file', async () => {
writeSettings(claudeDir, { permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
await put({ statusLineTelemetry: true });
await put({ statusLineTelemetry: false });
expect(readSettings(claudeDir)).toEqual({ permissions: { allow: ['Read'] }, hooks: { Stop: [] } });
});
it('false never removes a statusLine the user wrote themselves', async () => {
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
writeSettings(claudeDir, { statusLine: mine });
await put({ statusLineTelemetry: false });
expect(readSettings(claudeDir).statusLine).toEqual(mine);
});
it('false creates nothing in a workspace that never had the exporter', async () => {
await put({ statusLineTelemetry: false });
expect(existsSync(settingsFile(claudeDir))).toBe(false);
expect(existsSync(remoteDir)).toBe(false);
});
it('is an action field, never persisted into settings.json', async () => {
await put({ statusLineTelemetry: false, showTokenCount: true });
const res = await h.app.inject({ method: 'GET', url: '/api/settings' });
const stored = JSON.parse(res.body);
const settings = stored.data ?? stored;
expect(settings.showTokenCount).toBe(true);
expect('statusLineTelemetry' in settings).toBe(false);
});
});
+168 -49
View File
@@ -1,10 +1,13 @@
/**
* The generated plan-usage statusLine shim.
* The generated plan-usage statusLine shim and the command that launches it.
*
* Like the DeepSeek status shim, this file is emitted as a STRING and executed
* by someone else — Claude Code, before every render — so tsc never sees it.
* The assertions therefore run the real file in a real `node` process, with a
* real temp HOME and a real listener, rather than inspecting the source text.
* Like the DeepSeek status shim, the shim is emitted as a STRING and executed
* by someone else, Claude Code, before every render, so tsc never sees it. The
* assertions therefore run the real file in a real `node` process, with a real
* temp HOME and a real listener, rather than inspecting the source text. The
* injected command is exercised the same way, through `sh -c`, because its
* fallback half is the only thing that renders inside a Docker case's
* container and a typo there is invisible to every other check.
*
* The load-bearing property is the pair: the shim must keep forwarding plan
* usage to Codeman AND give the user back the statusline it shadows. Losing
@@ -21,7 +24,6 @@ import {
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
@@ -29,18 +31,25 @@ import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import {
ensureStatusLineShim,
generateShimStatusLineCommand,
LEGACY_STATUSLINE_MARKER,
resetStatusLineShimForTest,
statusLineShimGuard,
statusLineShimPath,
STATUSLINE_SHIM_TOKEN,
} from '../src/statusline-shim.js';
import { generateStatusLineCommand, isCodemanStatusLine } from '../src/hooks-config.js';
const PORT = 3252;
/** A port nothing listens on, for the unreachable-Codeman case. Claimed here so
* the repo-wide `const PORT =` search a contributor runs finds it too. */
const PORT_DEAD = 3253;
/** Point a settings file's statusLine at a shell command. */
function writeStatusLine(file: string, command: string): void {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify({ statusLine: { type: 'command', command } }, null, 2));
}
describe('statusLine shim: provisioning', () => {
beforeEach(() => {
resetStatusLineShimForTest();
@@ -78,20 +87,21 @@ describe('statusLine shim: provisioning', () => {
expect(statSync(path).mode & 0o777).toBe(0o700);
});
it('names the shim so the injected command carries the ownership token', () => {
// applyStatusLineConfig decides ownership on this substring. If the file is
it('names the shim so the guard carries the ownership token', () => {
// isCodemanStatusLine decides ownership on this substring. If the file is
// ever renamed out from under it, Codeman stops recognising its own entries
// and starts treating them as hand-authored.
const command = generateShimStatusLineCommand();
expect(command).toBeTruthy();
expect(command).toContain(STATUSLINE_SHIM_TOKEN);
const guard = statusLineShimGuard();
expect(guard).toBeTruthy();
expect(guard).toContain(STATUSLINE_SHIM_TOKEN);
// Absolute node, not a bare `node`: a managed session's PATH need not have one.
expect(command).toContain(process.execPath);
expect(guard).toContain(process.execPath);
});
it('quotes both paths, so a data dir with a space still runs', () => {
const command = generateShimStatusLineCommand()!;
expect(command).toBe(`'${process.execPath}' '${statusLineShimPath()}'`);
it('tests both paths before exec-ing, and quotes them, so a data dir with a space still runs', () => {
const node = `'${process.execPath}'`;
const shim = `'${statusLineShimPath()}'`;
expect(statusLineShimGuard()).toBe(`if [ -x ${node} ] && [ -f ${shim} ]; then exec ${node} ${shim}; fi;`);
});
});
@@ -99,6 +109,7 @@ describe('statusLine shim: rendering', () => {
let shim: string;
let server: Server;
let received: Array<{ url: string; body: string }> = [];
let footer = 'CODEMAN-FOOTER';
let workspace: string;
let fakeHome: string;
@@ -121,11 +132,7 @@ describe('statusLine shim: rendering', () => {
});
}
/** Point a settings file's statusLine at a shell command. */
function writeStatusLine(file: string, command: string): void {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify({ statusLine: { type: 'command', command } }, null, 2));
}
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
beforeAll(async () => {
resetStatusLineShimForTest();
@@ -137,7 +144,7 @@ describe('statusLine shim: rendering', () => {
req.on('end', () => {
received.push({ url: req.url ?? '', body });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('CODEMAN-FOOTER');
res.end(footer);
});
});
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
@@ -149,6 +156,7 @@ describe('statusLine shim: rendering', () => {
beforeEach(() => {
received = [];
footer = 'CODEMAN-FOOTER';
const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-'));
fakeHome = join(root, 'home');
workspace = join(root, 'repo');
@@ -173,12 +181,14 @@ describe('statusLine shim: rendering', () => {
expect(stdout).toContain('"display_name":"Opus 5"');
});
it('never delegates to its own entry', async () => {
it('never delegates to its own entry, and prints nothing rather than a brand word', async () => {
// No other statusLine exists, so the only candidate is the shim's own. If
// the loop guard failed this would fork until something ran out.
// the loop guard failed this would fork until something ran out. And with
// nothing to shadow and no Codeman to ask, the line stays blank: the bare
// word `codeman` is the symptom discussion #405 opened with.
const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0);
expect(stdout).toBe('codeman');
expect(stdout).toBe('');
});
it('never delegates to the pre-shim inline exporter', async () => {
@@ -189,7 +199,7 @@ describe('statusLine shim: rendering', () => {
`curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" || echo codeman`
);
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('codeman');
expect(stdout).toBe('');
});
it('prefers a project statusline to the global one', async () => {
@@ -199,22 +209,46 @@ describe('statusLine shim: rendering', () => {
expect(stdout).toBe('PROJECT');
});
it('finds the workspace statusline from a subdirectory', async () => {
// Claude Code applies a project's settings from the workspace root, which is
// routinely an ancestor of the directory the session sits in.
it('reads project settings from the launch directory, not the current one', async () => {
// Claude Code applies a project's settings from the directory it was
// launched in (workspace.project_dir), which the blob keeps reporting after
// the working directory changes mid-session.
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
const deep = join(workspace, 'src', 'nested');
mkdirSync(deep, { recursive: true });
const { stdout } = await render({ cwd: deep }, {}, deep);
const elsewhere = join(dirname(workspace), 'elsewhere');
mkdirSync(elsewhere, { recursive: true });
const { stdout } = await render(
{ cwd: elsewhere, workspace: { current_dir: elsewhere, project_dir: workspace } },
{},
elsewhere
);
expect(stdout).toBe('PROJECT');
});
it('does not walk up from the launch directory', async () => {
// Claude Code reads project settings from the launch directory alone, so a
// .claude in an ancestor is one it would have ignored. Delegating to it
// would run a statusline the user never sees otherwise.
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo ANCESTOR');
const sub = join(workspace, 'packages', 'inner');
mkdirSync(sub, { recursive: true });
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
const { stdout } = await render({ cwd: sub, workspace: { current_dir: sub, project_dir: sub } }, {}, sub);
expect(stdout).toBe('GLOBAL');
});
it('ignores a user-level settings.local.json, which Claude Code does not read', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.local.json'), 'echo NOT-A-REAL-FILE');
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('GLOBAL');
});
it('forwards telemetry to Codeman WHILE delegating', async () => {
// The whole point: taking the user's line back must not cost the header chip.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render(
{ cwd: workspace, rate_limits: { five_hour: { used_percentage: 12, resets_at: 99 } } },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
managed()
);
expect(stdout).toBe('THE-USERS-LINE');
@@ -226,32 +260,34 @@ describe('statusLine shim: rendering', () => {
});
it("prints Codeman's own footer when there is no line to shadow", async () => {
const { stdout } = await render(
{ cwd: workspace },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
);
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
expect(received).toHaveLength(1);
});
it('treats the bare brand word from an older server as no telemetry', async () => {
// A pre-1.28 route answers `codeman` for a session it does not know. That
// word on the statusline is what cost discussion #405 seven repositories
// of debugging, so it must never be printed, whichever server answers.
footer = 'codeman';
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('');
expect(received).toHaveLength(1);
});
it('skips the POST entirely outside a managed session', async () => {
// Running `claude` by hand in a managed repo must cost nothing extra, and
// must not render the old bare-word `codeman` the server returns for an
// unknown session id.
// Running `claude` by hand in a managed repo must cost nothing extra.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('THE-USERS-LINE');
expect(received).toEqual([]);
});
it('falls back rather than blanking when the delegate fails silently', async () => {
// A blank statusline reads as a broken terminal, so a delegate that exits
// non-zero with no output must not win.
it('falls back to the footer when the delegate fails silently', async () => {
// A delegate that exits non-zero with no output must not win over a footer
// Codeman can supply.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'exit 3');
const { stdout } = await render(
{ cwd: workspace },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
);
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
});
@@ -297,7 +333,90 @@ describe('statusLine shim: rendering', () => {
join(fakeHome, '.claude', 'settings.json'),
JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } })
);
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('codeman');
const { stdout } = await render({ cwd: workspace }, managed());
expect(stdout).toBe('CODEMAN-FOOTER');
});
});
describe('the injected statusLine command', () => {
let server: Server;
let received: string[] = [];
let fakeHome: string;
/** Run the command the way Claude Code does: through a shell, JSON on stdin. */
function run(command: string, env: Record<string, string>, stdin = '{"model":{"display_name":"Opus"}}') {
return new Promise<{ stdout: string; code: number | null }>((resolve) => {
const child = spawn('/bin/sh', ['-c', command], {
cwd: fakeHome,
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.on('close', (code) => resolve({ stdout, code }));
child.stdin.end(stdin);
});
}
const managed = () => ({ CODEMAN_SESSION_ID: 'sess-2', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` });
beforeAll(async () => {
resetStatusLineShimForTest();
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push(body);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('CODEMAN-FOOTER');
});
});
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
});
afterAll(async () => {
await new Promise<void>((r) => server.close(() => r()));
});
beforeEach(() => {
received = [];
fakeHome = mkdtempSync(join(tmpdir(), 'codeman-statusline-cmd-'));
});
it('is recognised as ours by both of its halves', () => {
const command = generateStatusLineCommand();
expect(command.startsWith('if [ -x ')).toBe(true);
expect(command).toContain(STATUSLINE_SHIM_TOKEN);
expect(command).toContain(LEGACY_STATUSLINE_MARKER);
expect(isCodemanStatusLine(command)).toBe(true);
// The word the whole change exists to remove.
expect(command).not.toContain('echo codeman');
});
it('runs the shim where the shim exists', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await run(generateStatusLineCommand(), managed());
expect(stdout).toBe('THE-USERS-LINE');
expect(JSON.parse(received[0]).sessionId).toBe('sess-2');
});
it('falls through to the inline curl exporter where the shim does not exist', async () => {
// Inside a Docker case's container the workspace's settings.local.json is
// bind-mounted at the same absolute path, but neither the host's node nor
// its data dir is. The same command must still report telemetry there.
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await run(command, managed());
expect(received).toHaveLength(1);
expect(JSON.parse(received[0])).toMatchObject({ sessionId: 'sess-2', data: { model: { display_name: 'Opus' } } });
// The inline half cannot delegate, so it prints the footer through.
expect(stdout).toBe('CODEMAN-FOOTER');
});
it('prints nothing, not a brand word, when the inline half has no Codeman to reach', async () => {
const command = generateStatusLineCommand().split(statusLineShimPath()).join(join(fakeHome, 'no-such-shim.mjs'));
const { stdout, code } = await run(command, { CODEMAN_API_URL: '', CODEMAN_SESSION_ID: '' });
expect(code).toBe(0);
expect(stdout).toBe('');
});
});
+3 -2
View File
@@ -119,8 +119,9 @@ describe('formatSessionStatusText', () => {
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
});
it('falls back to a brand string when there is no data', () => {
expect(formatSessionStatusText(null)).toBe('codeman');
it('prints nothing when there is no data, never a brand string', () => {
expect(formatSessionStatusText(null)).toBe('');
expect(formatSessionStatusText({})).toBe('');
});
});