diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 3908f90d..58edfc33 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -856,17 +856,19 @@ const STATUSLINE_MARKER = '/api/status-telemetry'; * (present in every managed session via tmux setenv), so the config is static. */ export function generateStatusLineCommand(): string { - // `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the + // `curl -sfk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the // production setup; without -k curl returns 000 and the statusline shows - // nothing. -k is safe here (loopback only). Falls back to a brand string so the - // footer is never blank if Codeman is unreachable. + // nothing. -k is safe here (loopback only); -f keeps an HTTP error body off + // the statusline. On any failure it prints NOTHING: the old `|| echo codeman` + // is the bare word that a hand-run `claude` in a managed repo rendered, and + // that reads as a broken config (discussion #405). return ( `INPUT=$(cat 2>/dev/null || echo '{}'); ` + `printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` + - `curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` + + `curl -sfk -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` + `--data @- 2>/dev/null || true` ); } @@ -913,7 +915,7 @@ 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_V3'; +const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V4'; function statusLineExporterScriptContent(): string { // Where the telemetry POST runs depends on who owns the footer. When the pane's @@ -930,12 +932,14 @@ function statusLineExporterScriptContent(): string { // Absent a user statusline, NOTHING else will print the footer, so the POST runs // in the FOREGROUND and ITS OWN stdout becomes the footer — `/api/status-telemetry` // returns formatSessionStatusText(...) (model/tokens/context %) precisely so this - // can happen — falling back to the plain "codeman" marker only if curl itself - // fails (`|| echo codeman`, refused/unreachable Codeman). `--max-time` bounds a + // can happen. If curl itself fails (refused/unreachable Codeman, or an HTTP + // error, which `-f` keeps off stdout) the footer is simply EMPTY (`|| true`): + // the old `|| echo codeman` rendered a bare brand word that reads as a broken + // config, the symptom discussion #405 opened with. `--max-time` bounds a // HUNG (not just refused) Codeman so it cannot wedge the render indefinitely. const post = `printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` + - `curl -sk --max-time 5 -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` + + `curl -sfk --max-time 5 -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 @-`; @@ -947,7 +951,7 @@ function statusLineExporterScriptContent(): string { ` ( ${post} ) >/dev/null 2>&1 /dev/null || echo codeman\n` + + ` ${post} 2>/dev/null || true\n` + `fi\n` ); } diff --git a/src/usage-telemetry.ts b/src/usage-telemetry.ts index 2d8fe4aa..f8a823d3 100644 --- a/src/usage-telemetry.ts +++ b/src/usage-telemetry.ts @@ -173,10 +173,13 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess * Format the in-terminal statusline footer: the CURRENT SESSION's status — * `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits, * which live in the Codeman header chip. Claude requires a statusLine command to - * emit the rate_limits JSON at all, so this is what that command prints back. + * emit the rate_limits JSON at all, so this is what that command prints back + * when it has no statusline of the user's own to wrap. With nothing to show it + * returns '' rather than a brand word: a bare `codeman` on the statusline is + * the symptom discussion #405 opened with. */ export function formatSessionStatusText(s: SessionStatus | null): string { - if (!s) return 'codeman'; + if (!s) return ''; const groups: string[] = []; if (s.modelDisplayName) groups.push(s.modelDisplayName); const tok: string[] = []; @@ -184,7 +187,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string { if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`); if (tok.length) groups.push(tok.join(' ')); if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`); - return groups.length ? groups.join(' ') : 'codeman'; + return groups.length ? groups.join(' ') : ''; } /** diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index 79a343fb..86aa8437 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -8,8 +8,9 @@ * (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth). * * Returns a compact plain-text status string for the exporter to print as the - * in-terminal footer (print-through), so injecting our statusLine doesn't leave - * the terminal footer blank. + * in-terminal footer (print-through) when it has no statusline of the user's + * own to wrap. An unknown session gets an EMPTY body: the old brand-word + * answer rendered as the statusline itself (discussion #405). */ import { FastifyInstance } from 'fastify'; @@ -36,10 +37,11 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session reply.type('text/plain; charset=utf-8'); - // Unknown session — minimal footer, no broadcast. + // Unknown session: nothing to broadcast and nothing to print. Never a brand + // word here, it would render as the statusline. if (!ctx.sessions.has(sessionId)) { lastSig.delete(sessionId); - return 'codeman'; + return ''; } const payload = data as RawStatuslinePayload | undefined; diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 7dfbb38f..2f65bd9f 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -1516,10 +1516,17 @@ describe('statusline exporter script (real shell execution)', () => { expect(result.stdout.trim()).toBe('model: opus | 42% used'); }); - it('no-user-statusline branch: falls back to the plain "codeman" marker when curl fails', async () => { + it('no-user-statusline branch: prints NOTHING when curl fails (never a bare brand word)', async () => { writeFakeCurl(`exit 1`); const result = await runExporter(baseEnv); - expect(result.stdout.trim()).toBe('codeman'); + expect(result.stdout).toBe(''); + expect(result.code).toBe(0); + }); + + it('asks curl to fail on HTTP errors (-f) so an error body never becomes the footer', async () => { + const scriptPath = await resolveStatusLineCliCommand(testDir, true); + expect(readFileSync(scriptPath!, 'utf-8')).toContain('curl -sfk'); + expect(readFileSync(scriptPath!, 'utf-8')).not.toContain('echo codeman'); }); it('wrap branch: never blocks a reader-to-EOF on a slow/hung curl (background subshell closes stdin too)', async () => { diff --git a/test/routes/status-telemetry-routes.test.ts b/test/routes/status-telemetry-routes.test.ts index c6d15cb5..a126f431 100644 --- a/test/routes/status-telemetry-routes.test.ts +++ b/test/routes/status-telemetry-routes.test.ts @@ -49,10 +49,10 @@ 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; returns an EMPTY footer, never a brand word', async () => { 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(); }); diff --git a/test/statusline-cli-flag.test.ts b/test/statusline-cli-flag.test.ts index 83f9add4..dd85368a 100644 --- a/test/statusline-cli-flag.test.ts +++ b/test/statusline-cli-flag.test.ts @@ -19,7 +19,7 @@ import { describe, it, expect } from 'vitest'; import { execFileSync } from 'node:child_process'; import { buildSpawnCommand } from '../src/tmux-manager.js'; -const EXPORTER_CMD = 'curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || echo codeman'; +const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true'; /** Extract the `--settings ` fragment from a built command and have a * real shell resolve its quoting, printing the arg back out verbatim. */ diff --git a/test/usage-telemetry.test.ts b/test/usage-telemetry.test.ts index ac018f2c..f5797dcb 100644 --- a/test/usage-telemetry.test.ts +++ b/test/usage-telemetry.test.ts @@ -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 (a bare brand word reads as a broken statusline)', () => { + expect(formatSessionStatusText(null)).toBe(''); + expect(formatSessionStatusText({} as never)).toBe(''); }); });