singularity-forge/src/headless-ui.ts
2026-05-06 06:22:09 +02:00

676 lines
21 KiB
TypeScript

/**
* Headless UI Handling — auto-response, progress formatting, and supervised stdin
*
* Handles extension UI requests (auto-responding in headless mode),
* formats progress events for stderr output, and reads orchestrator
* commands from stdin in supervised mode.
*/
import type { Readable } from "node:stream";
import {
attachJsonlLineReader,
type RpcClient,
} from "@singularity-forge/pi-coding-agent";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ExtensionUIRequest {
type: "extension_ui_request";
id: string;
method: string;
title?: string;
options?: string[];
message?: string;
prefill?: string;
timeout?: number;
[key: string]: unknown;
}
export type { ExtensionUIRequest };
/** Context passed alongside an event for richer formatting. */
export interface ProgressContext {
verbose: boolean;
toolDuration?: number; // ms, for tool_execution_end
lastCost?: { costUsd: number; inputTokens: number; outputTokens: number };
thinkingPreview?: string; // accumulated LLM text to show before tool calls
isError?: boolean; // tool execution ended with an error
}
/** Context for periodic headless heartbeat lines during long quiet waits. */
export interface HeadlessHeartbeatContext {
elapsedMs: number;
quietMs: number;
totalEvents: number;
toolCallCount: number;
eventDelta?: number;
toolCallDelta?: number;
openToolCount?: number;
openToolDetails?: string[];
activeUnit?: string;
activeModel?: string;
lastEventType?: string;
lastEventDetail?: string;
}
// ---------------------------------------------------------------------------
// ANSI Color Helpers
// ---------------------------------------------------------------------------
const _c = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
italic: "\x1b[3m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
cyan: "\x1b[36m",
gray: "\x1b[90m",
};
/** Build a no-op color map (all codes empty). */
function noColor(): typeof _c {
const nc: Record<string, string> = {};
for (const k of Object.keys(_c)) nc[k] = "";
return nc as 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",
"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
// ---------------------------------------------------------------------------
/**
* Produce a short human-readable summary of tool arguments.
* Returns a string like "path/to/file.ts" or "grep pattern *.ts" — never the
* full JSON blob.
*/
export function summarizeToolArgs(
toolName: unknown,
toolInput: unknown,
): string {
const name = String(toolName ?? "");
const input =
toolInput && typeof toolInput === "object"
? (toolInput as Record<string, unknown>)
: {};
// Helper: extract file path from either 'path' or 'file_path' (tools use both)
const filePath = (): string => shortPath(input.path ?? input.file_path) || "";
switch (name) {
case "Read":
case "read":
return filePath();
case "Write":
case "write":
return filePath();
case "Edit":
case "edit":
return filePath();
case "hashline_edit":
return filePath();
case "Bash":
case "bash": {
const cmd = String(input.command ?? "");
return cmd.length > 80 ? cmd.slice(0, 77) + "..." : cmd;
}
case "async_bash": {
const cmd = String(input.command ?? "");
return cmd.length > 80 ? cmd.slice(0, 77) + "..." : cmd;
}
case "await_job": {
const jobs = input.jobs;
if (Array.isArray(jobs) && jobs.length > 0) return jobs.join(", ");
return "";
}
case "cancel_job":
return String(input.job_id ?? "");
case "Glob":
case "glob":
return String(input.pattern ?? "");
case "find": {
const pat = String(input.pattern ?? "");
const p = shortPath(input.path);
return p ? `${pat} in ${p}` : pat;
}
case "Grep":
case "grep":
case "Search":
case "search": {
const pat = String(input.pattern ?? "");
const g = input.glob ? ` ${input.glob}` : "";
return `${pat}${g}`;
}
case "ls":
return shortPath(input.path) || "";
case "lsp": {
const action = String(input.action ?? "");
const file = shortPath(input.file);
const sym = input.symbol ? ` ${input.symbol}` : "";
return file ? `${action} ${file}${sym}` : action;
}
case "Task":
case "task": {
const desc = String(input.description ?? input.prompt ?? "");
return desc.length > 60 ? desc.slice(0, 57) + "..." : desc;
}
case "subagent": {
const agent = String(input.agent ?? "");
const t = String(input.task ?? "");
const summary = t.length > 50 ? t.slice(0, 47) + "..." : t;
return agent ? `${agent}: ${summary}` : summary;
}
case "browser_navigate":
return String(input.url ?? "");
default: {
// SF tools: show milestone/slice/task IDs when present
if (name.startsWith("sf_")) {
return summarizeSfTool(name, input);
}
// Fallback: show first string-valued key up to 60 chars
for (const v of Object.values(input)) {
if (typeof v === "string" && v.length > 0) {
return v.length > 60 ? v.slice(0, 57) + "..." : v;
}
}
return "";
}
}
}
/** Summarize SF extension tool args into a compact identifier string. */
function summarizeSfTool(name: string, input: Record<string, unknown>): string {
const parts: string[] = [];
if (input.milestoneId) parts.push(String(input.milestoneId));
if (input.sliceId) parts.push(String(input.sliceId));
if (input.taskId) parts.push(String(input.taskId));
if (parts.length > 0) {
const id = parts.join("/");
// For completion tools, add the one-liner if present
if (name.includes("complete") && typeof input.oneLiner === "string") {
const ol =
input.oneLiner.length > 50
? input.oneLiner.slice(0, 47) + "..."
: input.oneLiner;
return `${id} ${ol}`;
}
return id;
}
// Fallback for SF tools without IDs (e.g. sf_decision_save)
if (input.decision) {
const d = String(input.decision);
return d.length > 60 ? d.slice(0, 57) + "..." : d;
}
return "";
}
function shortPath(p: unknown): string {
if (typeof p !== "string") return "";
// Strip common CWD prefix to save space
const cwd = process.cwd();
if (p.startsWith(cwd + "/")) return p.slice(cwd.length + 1);
// Strip /Users/*/Developer/ prefix
return p.replace(/^\/Users\/[^/]+\/Developer\//, "");
}
// ---------------------------------------------------------------------------
// Format Duration
// ---------------------------------------------------------------------------
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms >= 60_000) {
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
}
const s = (ms / 1000).toFixed(1);
return `${s}s`;
}
// ---------------------------------------------------------------------------
// Extension UI Auto-Responder
// ---------------------------------------------------------------------------
export function handleExtensionUIRequest(
event: ExtensionUIRequest,
client: RpcClient,
): void {
const { id, method } = event;
switch (method) {
case "select": {
// Lock-guard prompts list "View status" first, but headless needs "Force start"
// to proceed. Detect by title and pick the force option.
const title = String(event.title ?? "");
let selected = event.options?.[0] ?? "";
if (title.includes("Auto-mode is running") && event.options) {
const forceOption = event.options.find((o) =>
o.toLowerCase().includes("force start"),
);
if (forceOption) selected = forceOption;
}
client.sendUIResponse(id, { value: selected });
break;
}
case "confirm":
client.sendUIResponse(id, { confirmed: true });
break;
case "input":
client.sendUIResponse(id, { value: "" });
break;
case "editor":
client.sendUIResponse(id, { value: event.prefill ?? "" });
break;
case "notify":
case "setStatus":
case "setWidget":
case "setTitle":
case "set_editor_text":
client.sendUIResponse(id, { value: "" });
break;
default:
process.stderr.write(
`[headless] Warning: unknown extension_ui_request method "${method}", cancelling\n`,
);
client.sendUIResponse(id, { cancelled: true });
break;
}
}
// ---------------------------------------------------------------------------
// Progress Formatter
// ---------------------------------------------------------------------------
export function formatProgress(
event: Record<string, unknown>,
ctx: ProgressContext,
): string | null {
const type = String(event.type ?? "");
// Emit accumulated thinking preview before tool calls
if (ctx.thinkingPreview) {
// thinkingPreview is handled by the caller in headless.ts — it prepends
// the thinking line before the current event's line. We return the thinking
// line as a prefix joined with newline.
}
switch (type) {
case "tool_execution_start": {
if (!ctx.verbose) return null;
const name = String(event.toolName ?? "unknown");
const args = summarizeToolArgs(event.toolName, event.args);
const argStr = args ? ` ${c.dim}${args}${c.reset}` : "";
return `${c.dim}${tag("tool")}${c.reset}${name}${argStr}`;
}
case "tool_execution_end": {
if (!ctx.verbose) return null;
const name = String(event.toolName ?? "unknown");
const durationStr =
ctx.toolDuration != null
? ` ${c.dim}${formatDuration(ctx.toolDuration)}${c.reset}`
: "";
if (ctx.isError) {
return `${c.red}${tag("tool")}${name} error${c.reset}${durationStr}`;
}
return `${c.dim}${tag("tool")}${name} done${c.reset}${durationStr}`;
}
case "agent_start":
return `${c.dim}${tag("agent")}Session started${c.reset}`;
case "agent_end": {
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`;
line += ` ${c.dim}(${cost}, ${tokens})${c.reset}`;
}
return line;
}
case "extension_error":
return `${c.red}${tag("error")}${formatExtensionError(event)}${c.reset}`;
case "extension_ui_request": {
const method = String(event.method ?? "");
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);
return isImportant
? `${c.bold}${tag("forge")}${msg}${c.reset}`
: `${tag("forge")}${msg}`;
}
if (method === "setStatus") {
const statusKey = String(event.statusKey ?? "");
const msg = String(event.message ?? "");
if (!statusKey && !msg) return null; // suppress empty status lines
// 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) {
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("status")}${msg}${c.reset}`;
return null;
}
return null;
}
default:
return null;
}
}
function formatExtensionError(event: Record<string, unknown>): string {
const extensionPath = String(event.extensionPath ?? "unknown extension");
const eventName = String(event.event ?? "unknown event");
const error = event.error as Record<string, unknown> | string | undefined;
const errorText =
typeof error === "string"
? error
: error && typeof error === "object"
? String(error.message ?? error.name ?? JSON.stringify(error))
: "unknown error";
return `Extension error in ${extensionPath} during ${eventName}: ${errorText}`;
}
/**
* Format a thinking preview line from accumulated LLM text deltas.
* Used as a fallback when streaming is not enabled — shows a truncated one-liner.
*/
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}${tag("thinking")}${truncated}${c.reset}`;
}
/**
* Format a text preview line from accumulated assistant text deltas.
* Used as a fallback when streaming is not enabled — shows a truncated one-liner.
* Unlike thinking, text is NOT italicized.
*/
export function formatTextLine(text: string): string {
const trimmed = text.replace(/\s+/g, " ").trim();
const truncated =
trimmed.length > 120 ? trimmed.slice(0, 117) + "..." : trimmed;
return `${c.dim}${tag("text")}${truncated}${c.reset}`;
}
// ---------------------------------------------------------------------------
// Streaming Text / Thinking Formatters
// ---------------------------------------------------------------------------
/**
* Format a text_start marker — printed once when the assistant begins a text block.
*/
export function formatTextStart(): string {
return `${c.dim}${tag("text")}${c.reset}`.trimEnd();
}
/**
* Format a text_end marker — printed after the last text_delta.
*/
export function formatTextEnd(): string {
return ""; // empty — newline handled by caller
}
/**
* Format a thinking_start marker.
*/
export function formatThinkingStart(): string {
return `${c.dim}${c.italic}${tag("thinking")}${c.reset}`.trimEnd();
}
/**
* Format a thinking_end marker.
*/
export function formatThinkingEnd(): string {
return ""; // empty — newline handled by caller
}
/**
* 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}${tag("cost")}$${costUsd.toFixed(4)} (${inputTokens + outputTokens} tokens)${c.reset}`;
}
/**
* Format a periodic liveness line for headless runs.
*
* Purpose: make long model calls and quiet auto-mode phases observable without
* changing machine-readable JSON output.
*/
export function formatHeadlessHeartbeat(ctx: HeadlessHeartbeatContext): string {
const lastEvent = ctx.lastEventType
? ctx.lastEventDetail
? `${ctx.lastEventType}:${ctx.lastEventDetail}`
: ctx.lastEventType
: "none";
const activityParts: string[] = [];
if (ctx.eventDelta !== undefined) {
if (ctx.eventDelta > 0) {
activityParts.push(`+${ctx.eventDelta} events`);
} else {
activityParts.push("no new events");
}
}
if (ctx.toolCallDelta !== undefined && ctx.toolCallDelta > 0) {
activityParts.push(`+${ctx.toolCallDelta} tools`);
}
const activity =
activityParts.length > 0 ? `; activity=${activityParts.join(", ")}` : "";
const openTools =
ctx.openToolCount && ctx.openToolCount > 0
? ctx.openToolDetails?.length
? `; openTools=${ctx.openToolCount}[${ctx.openToolDetails.join(", ")}]`
: `; openTools=${ctx.openToolCount}`
: "";
const unit = ctx.activeUnit ? `; unit=${ctx.activeUnit}` : "";
const model = ctx.activeModel ? `; model=${ctx.activeModel}` : "";
return `${c.dim}${tag("headless")}still running ${formatDuration(ctx.elapsedMs)}; quiet ${formatDuration(ctx.quietMs)}; last=${lastEvent}; events=${ctx.totalEvents}; tools=${ctx.toolCallCount}${activity}${openTools}${unit}${model}${c.reset}`;
}
// ---------------------------------------------------------------------------
// Phase Label Parser
// ---------------------------------------------------------------------------
/**
* Parse a statusKey into a human-readable phase label.
* statusKey format varies but common patterns:
* "milestone:M1", "slice:S1.1", "task:T1.1.1", "phase:discuss", etc.
*/
function parsePhaseLabel(statusKey: string, message: string): string | null {
// Direct phase/milestone/slice/task keys
const parts = statusKey.split(":");
if (parts.length >= 2) {
const [kind, value] = parts;
switch (kind.toLowerCase()) {
case "milestone":
return `Milestone ${value}${message ? " -- " + message : ""}`;
case "slice":
return `Slice ${value}${message ? " -- " + message : ""}`;
case "task":
return `Task ${value}${message ? " -- " + message : ""}`;
case "phase":
return `Phase: ${value}${message ? " -- " + message : ""}`;
default:
return `${kind}: ${value}${message ? " -- " + message : ""}`;
}
}
// 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;
}
// ---------------------------------------------------------------------------
// Supervised Stdin Reader
// ---------------------------------------------------------------------------
export function startSupervisedStdinReader(
client: RpcClient,
onResponse: (id: string) => void,
): () => void {
return attachJsonlLineReader(process.stdin as Readable, (line) => {
handleSupervisedStdinLine(line, client, onResponse);
});
}
function shouldWarnInvalidOrchestratorLine(line: string): boolean {
// Only warn for lines that look like JSON-RPC frames — not incidental JSON-like
// log output (e.g. `{ "level": "warn", ... }` or `[INFO] Starting task`).
// Real JSON-RPC 2.0 frames must have `"jsonrpc":"2.0"` somewhere in the string.
const trimmed = line.trimStart();
if (!trimmed.startsWith("{")) return false;
return line.includes('"jsonrpc"') || line.includes("'jsonrpc'");
}
/**
* Handle one supervised JSONL stdin frame.
*
* Purpose: accept real orchestrator control records while ignoring accidental
* pasted prose in headless terminals without flooding stderr.
*
* Consumer: startSupervisedStdinReader, which receives LF-framed stdin lines.
*/
export function handleSupervisedStdinLine(
line: string,
client: RpcClient,
onResponse: (id: string) => void,
stderr: Pick<NodeJS.WriteStream, "write"> = process.stderr,
): void {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(line);
} catch {
if (shouldWarnInvalidOrchestratorLine(line)) {
stderr.write(
`[headless] Warning: invalid JSON from orchestrator stdin, skipping\n`,
);
}
return;
}
const type = String(msg.type ?? "");
switch (type) {
case "extension_ui_response": {
const id = String(msg.id ?? "");
const value = msg.value !== undefined ? String(msg.value) : undefined;
const confirmed =
typeof msg.confirmed === "boolean" ? msg.confirmed : undefined;
const cancelled =
typeof msg.cancelled === "boolean" ? msg.cancelled : undefined;
client.sendUIResponse(id, { value, confirmed, cancelled });
if (id) {
onResponse(id);
}
break;
}
case "prompt":
client.prompt(String(msg.message ?? ""));
break;
case "steer":
client.steer(String(msg.message ?? ""));
break;
case "follow_up":
client.followUp(String(msg.message ?? ""));
break;
default:
stderr.write(
`[headless] Warning: unknown message type "${type}" from orchestrator stdin\n`,
);
break;
}
}