headless: clean up sf headless auto stderr output

Three fixes to make the headless progress stream readable at a glance:

1. Filter TUI footer widget keys from setStatus — 0-emoji, 0-color-band,
   authority, ollama, sf-fast, and sf-auto are sticky indicators for the
   interactive TUI footer, not workflow phases. They no longer leak
   through as [phase] ollama / [phase] sf-fast noise.

2. Unify tag prefix column width at 11 chars via a new tag() helper in
   headless-ui.ts. All of [tool], [agent], [forge], [phase], [thinking],
   [cost], [text] now align on the same column, matching the existing
   [headless] and [thinking] widths.

3. Dedupe consecutive identical progress lines in headless.ts so a
   widget that re-emits the same setStatus on every LLM call prints
   once instead of flooding stderr. Two different lines still both show;
   only adjacent duplicates collapse.

Also tightens parsePhaseLabel so an unknown bare statusKey with no
message returns null rather than leaking the raw key — a defense in
depth if the footer-widget allowlist drifts behind a new extension.

Tests: 4 new cases in headless-progress.test.ts covering footer-key
suppression, bare-key suppression, workflow-phase passthrough, and
tag-alignment. 88/88 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-19 05:47:02 +02:00
parent 55ee2cb5c7
commit 941eb4c830
3 changed files with 111 additions and 20 deletions

View file

@ -63,6 +63,32 @@ function noColor(): typeof _c {
const colorsDisabled = !!process.env['NO_COLOR'] || !process.stderr.isTTY
const c: typeof _c = colorsDisabled ? noColor() : _c
// ---------------------------------------------------------------------------
// Tag prefix helper (uniform column width across all formatters)
// ---------------------------------------------------------------------------
// Width chosen so every tag lines up on the same column. 11 covers the
// widest existing labels ("[headless] ", "[thinking] ").
const TAG_WIDTH = 11
function tag(label: string): string {
const t = `[${label}]`
return t + ' '.repeat(Math.max(1, TAG_WIDTH - t.length))
}
// TUI footer widget keys registered by extensions (emoji indicator,
// color band, permissions tier, ollama status, service-tier fast/auto).
// These are sticky indicators for the interactive footer — not workflow
// phases — so suppress them in headless output.
const TUI_FOOTER_STATUS_KEYS = new Set([
'0-emoji',
'0-color-band',
'authority',
'ollama',
'sf-fast',
'sf-auto',
])
// ---------------------------------------------------------------------------
// Tool-Arg Summarizer
// ---------------------------------------------------------------------------
@ -268,7 +294,7 @@ export function formatProgress(event: Record<string, unknown>, ctx: ProgressCont
const name = String(event.toolName ?? 'unknown')
const args = summarizeToolArgs(event.toolName, event.args)
const argStr = args ? ` ${c.dim}${args}${c.reset}` : ''
return ` ${c.dim}[tool]${c.reset} ${name}${argStr}`
return `${c.dim}${tag('tool')}${c.reset}${name}${argStr}`
}
case 'tool_execution_end': {
@ -276,16 +302,16 @@ export function formatProgress(event: Record<string, unknown>, ctx: ProgressCont
const name = String(event.toolName ?? 'unknown')
const durationStr = ctx.toolDuration != null ? ` ${c.dim}${formatDuration(ctx.toolDuration)}${c.reset}` : ''
if (ctx.isError) {
return ` ${c.red}[tool] ${name} error${c.reset}${durationStr}`
return `${c.red}${tag('tool')}${name} error${c.reset}${durationStr}`
}
return ` ${c.dim}[tool] ${name} done${c.reset}${durationStr}`
return `${c.dim}${tag('tool')}${name} done${c.reset}${durationStr}`
}
case 'agent_start':
return `${c.dim}[agent] Session started${c.reset}`
return `${c.dim}${tag('agent')}Session started${c.reset}`
case 'agent_end': {
let line = `${c.dim}[agent] Session ended${c.reset}`
let line = `${c.dim}${tag('agent')}Session ended${c.reset}`
if (ctx.lastCost) {
const cost = `$${ctx.lastCost.costUsd.toFixed(4)}`
const tokens = `${ctx.lastCost.inputTokens + ctx.lastCost.outputTokens} tokens`
@ -303,22 +329,23 @@ export function formatProgress(event: Record<string, unknown>, ctx: ProgressCont
// Bold important notifications
const isImportant = /^(committed:|verification gate:|milestone|blocked:)/i.test(msg)
return isImportant
? `${c.bold}[forge] ${msg}${c.reset}`
: `[forge] ${msg}`
? `${c.bold}${tag('forge')}${msg}${c.reset}`
: `${tag('forge')}${msg}`
}
if (method === 'setStatus') {
// Parse statusKey for phase transitions
const statusKey = String(event.statusKey ?? '')
const msg = String(event.message ?? '')
if (!statusKey && !msg) return null // suppress empty status lines
// Show meaningful phase transitions
// Drop sticky TUI footer widgets — they have no meaning in a headless run.
if (TUI_FOOTER_STATUS_KEYS.has(statusKey)) return null
// Show meaningful phase transitions.
if (statusKey) {
const label = parsePhaseLabel(statusKey, msg)
if (label) return `${c.cyan}[phase] ${label}${c.reset}`
if (label) return `${c.cyan}${tag('phase')}${label}${c.reset}`
}
// Fallback: show message if non-empty
if (msg) return `${c.cyan}[phase] ${msg}${c.reset}`
// Fallback: show message if non-empty.
if (msg) return `${c.cyan}${tag('phase')}${msg}${c.reset}`
return null
}
@ -337,7 +364,7 @@ export function formatProgress(event: Record<string, unknown>, ctx: ProgressCont
export function formatThinkingLine(text: string): string {
const trimmed = text.replace(/\s+/g, ' ').trim()
const truncated = trimmed.length > 120 ? trimmed.slice(0, 117) + '...' : trimmed
return `${c.dim}${c.italic}[thinking] ${truncated}${c.reset}`
return `${c.dim}${c.italic}${tag('thinking')}${truncated}${c.reset}`
}
// ---------------------------------------------------------------------------
@ -348,7 +375,7 @@ export function formatThinkingLine(text: string): string {
* Format a text_start marker printed once when the assistant begins a text block.
*/
export function formatTextStart(): string {
return `${c.dim}[text]${c.reset}`
return `${c.dim}${tag('text')}${c.reset}`.trimEnd()
}
/**
@ -362,7 +389,7 @@ export function formatTextEnd(): string {
* Format a thinking_start marker.
*/
export function formatThinkingStart(): string {
return `${c.dim}${c.italic}[thinking]${c.reset}`
return `${c.dim}${c.italic}${tag('thinking')}${c.reset}`.trimEnd()
}
/**
@ -376,7 +403,7 @@ export function formatThinkingEnd(): string {
* Format a cost line (used for periodic cost updates in verbose mode).
*/
export function formatCostLine(costUsd: number, inputTokens: number, outputTokens: number): string {
return `${c.dim}[cost] $${costUsd.toFixed(4)} (${inputTokens + outputTokens} tokens)${c.reset}`
return `${c.dim}${tag('cost')}$${costUsd.toFixed(4)} (${inputTokens + outputTokens} tokens)${c.reset}`
}
// ---------------------------------------------------------------------------
@ -407,9 +434,11 @@ function parsePhaseLabel(statusKey: string, message: string): string | null {
}
}
// Single-word status keys with a message
if (message) return `${statusKey}: ${message}`
return statusKey || null
// Unknown single-word keys: only surface when there's an accompanying
// message worth showing. Bare widget keys (e.g. a new footer indicator
// not yet added to TUI_FOOTER_STATUS_KEYS) are suppressed rather than
// leaking through.
return message ? `${statusKey}: ${message}` : null
}
// ---------------------------------------------------------------------------

View file

@ -389,6 +389,11 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
const toolStartTimes = new Map<string, number>()
let lastCostData: { costUsd: number; inputTokens: number; outputTokens: number } | undefined
let thinkingBuffer = ''
// Drop only adjacent identical formatProgress output. A widget that
// re-emits the same setStatus on every LLM call would otherwise print
// the same line N times in a row. Two different lines still both show;
// a run of identical ones collapses to one.
let lastProgressLine: string | null = null
// Streaming state: tracks whether we're inside a text or thinking block
let inTextBlock = false
let inThinkingBlock = false
@ -655,7 +660,10 @@ async function runHeadlessOnce(options: HeadlessOptions, restartCount: number):
}
const line = formatProgress(eventObj, ctx)
if (line) process.stderr.write(line + '\n')
if (line && line !== lastProgressLine) {
process.stderr.write(line + '\n')
lastProgressLine = line
}
}
// Handle execution_complete (v2 structured completion)

View file

@ -169,6 +169,60 @@ describe('formatProgress', () => {
}, ctx())
assert.equal(result, null)
})
it('suppresses TUI footer widget setStatus keys', () => {
for (const key of ['0-emoji', '0-color-band', 'authority', 'ollama', 'sf-fast', 'sf-auto']) {
const r = formatProgress({
type: 'extension_ui_request',
method: 'setStatus',
statusKey: key,
message: '⏳',
}, ctx())
assert.equal(r, null, `expected null for ${key}`)
}
})
it('suppresses bare unknown statusKey with no message', () => {
const r = formatProgress({
type: 'extension_ui_request',
method: 'setStatus',
statusKey: 'something-new',
message: '',
}, ctx())
assert.equal(r, null)
})
it('keeps workflow phases with colon prefix', () => {
const r = formatProgress({
type: 'extension_ui_request',
method: 'setStatus',
statusKey: 'phase:discuss',
message: 'starting',
}, ctx())
assert.ok(r && r.includes('Phase: discuss'))
})
})
describe('tag prefix alignment', () => {
it('pads all tag prefixes to the same column width', () => {
const prefixes = [
formatProgress({ type: 'agent_start' }, ctx())!,
formatProgress({
type: 'extension_ui_request',
method: 'notify',
message: 'hi',
}, ctx())!,
formatCostLine(0.01, 100, 50),
formatThinkingLine('x'),
]
// Under NO_COLOR / non-TTY, prefixes begin with "[tag]" followed by
// spaces to column 11, then content.
for (const p of prefixes) {
const m = p.match(/^\[[a-z]+\]\s+/)
assert.ok(m, `prefix missing on: ${JSON.stringify(p)}`)
assert.equal(m![0].length, 11, `wrong width on: ${JSON.stringify(p)}`)
}
})
})
describe('unknown events', () => {