fix(headless): suppress notification spam, categorize messages, distinguish phase vs status

Three small UX fixes for headless / autopilot logs:

1. Add `zz-notifications` to TUI_FOOTER_STATUS_KEYS — these are sticky
   notification dots from the interactive TUI footer; they have no
   meaning in headless and were spamming the log.

2. Categorize notification messages by prefix so headless output is
   scannable: [mcp] for MCP-client-ready, [search] for web search status,
   [parallel] for slice-parallel/subagent dispatch. Falls through to
   the existing important/non-important formatting for everything else.

3. Distinguish phase transitions from generic status updates: phase:/
   milestone:/slice:/task: prefixed keys get [phase]; everything else
   gets [status]. Previously both used [phase], which was misleading.

Patterns based on bunker commits 14ec4d97f / c15afb45f (which were the
research source) but written fresh against our existing
TUI_FOOTER_STATUS_KEYS structure rather than cherry-picked.

The assistant-text-preview commit (cf0274c63) is a separate, larger
refactor in headless.ts and is deferred to v3.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-29 13:43:40 +02:00
parent c41912ff55
commit dff0df5fdc

View file

@ -90,8 +90,39 @@ const TUI_FOOTER_STATUS_KEYS = new Set([
"ollama",
"sf-fast",
"sf-auto",
"zz-notifications",
]);
/**
* Categorize notification messages so headless logs are scannable
* `[mcp]` for MCP-client ready lines, `[search]` for search status,
* `[parallel]` for slice-parallel/subagent dispatch. Returns null to
* fall through to the default formatting.
*/
function formatCategorizedNotification(message: string): string | null {
if (message.startsWith("MCP client ready")) return `[mcp] ${message}`;
if (message.startsWith("Web search:")) return `[search] ${message}`;
if (message.startsWith("Native Anthropic web search")) return `[search] ${message}`;
if (message.includes("dispatching") && message.includes("subagents"))
return `[parallel] ${message}`;
if (message.startsWith("Slice-parallel:")) return `[parallel] ${message}`;
if (message.startsWith("sf-reactive:")) return `[parallel] ${message}`;
return null;
}
/**
* True when statusKey indicates a real phase/milestone/slice/task transition
* (rather than a generic status update). Drives [phase] vs [status] tagging.
*/
function isPhaseStatusKey(statusKey: string): boolean {
return (
statusKey.startsWith("phase:") ||
statusKey.startsWith("milestone:") ||
statusKey.startsWith("slice:") ||
statusKey.startsWith("task:")
);
}
// ---------------------------------------------------------------------------
// Tool-Arg Summarizer
// ---------------------------------------------------------------------------
@ -351,6 +382,9 @@ export function formatProgress(
if (method === "notify") {
const msg = String(event.message ?? "");
if (!msg) return null;
// Categorise known prefixes so headless logs are scannable.
const categorised = formatCategorizedNotification(msg);
if (categorised) return categorised;
// Bold important notifications
const isImportant =
/^(committed:|verification gate:|milestone|blocked:)/i.test(msg);
@ -368,10 +402,13 @@ export function formatProgress(
// Show meaningful phase transitions.
if (statusKey) {
const label = parsePhaseLabel(statusKey, msg);
if (label) return `${c.cyan}${tag("phase")}${label}${c.reset}`;
if (label) {
const labelTag = isPhaseStatusKey(statusKey) ? tag("phase") : tag("status");
return `${c.cyan}${labelTag}${label}${c.reset}`;
}
}
// Fallback: show message if non-empty.
if (msg) return `${c.cyan}${tag("phase")}${msg}${c.reset}`;
if (msg) return `${c.cyan}${tag("status")}${msg}${c.reset}`;
return null;
}