import type { HarnessEvent } from "../execution/events.js"; import type { HarnessDescriptor } from "../knowledge/descriptor.js"; const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`; const green = (s: string) => `\x1b[32m${s}\x1b[0m`; const red = (s: string) => `\x1b[31m${s}\x1b[0m`; const blue = (s: string) => `\x1b[34m${s}\x1b[0m`; const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; export interface RenderState { streamed: boolean; } export const createRenderState = (): RenderState => ({ streamed: false }); export const renderEvent = ( event: HarnessEvent, state: RenderState, h?: HarnessDescriptor, ): void => { switch (event.kind) { case "identity": { process.stdout.write(dim(` ● session ${event.sessionId} (${event.authority})\n`)); // RFC-06: a resume-last turn names how the id was picked. The fork // line renders when the descriptor carries a fork flag // (contextInspection.forkFlag, claude only): the announced id is // the fork id, never the source id. if (event.resumeLast === true) { const line = h?.contextInspection?.forkFlag !== undefined ? `forked most-recent session as ${event.sessionId} (exact-cwd scope)` : `resumed most-recent ${event.sessionId} (exact-cwd scope)`; process.stdout.write(dim(` ● ${line}\n`)); } break; } case "token": process.stdout.write(event.text); state.streamed = true; break; case "message": if (!state.streamed) process.stdout.write(event.text); break; case "tool": process.stdout.write(cyan(`\n ⚙ ${event.name}`)); break; case "tool-result": if (event.isError === true) process.stdout.write(red(`\n ✗ tool ${event.name ?? event.callId ?? "result"} failed`)); break; case "usage": break; case "progress": // droppable break; case "compaction": { // ADR 0009. The counts are the harness's own; hcn derives none, and // three of the six report none at all, so the line has to read // without them. const counts = event.tokensBefore !== undefined && event.tokensAfter !== undefined ? ` ${event.tokensBefore} → ${event.tokensAfter}` : ""; process.stdout.write(dim(`\n ⇄ compaction ${event.state}${counts}`)); break; } case "limit": process.stdout.write(yellow(`\n ⚠ limit: ${event.code} ${event.message}`)); break; case "question": // issue #41: the structured question fields ARE the question - this // render is a convenience view of them, not the contract. process.stdout.write(blue(`\n ? ${event.question}\n`)); for (const option of event.options) { const mark = option === event.recommended ? "*" : " "; process.stdout.write(blue(` ${mark} ${option}\n`)); } break; case "warning": process.stdout.write(yellow(`\n ! ${event.message}`)); break; case "error": process.stdout.write(red(`\n ✗ ${event.message}`)); break; case "failure": { const detail = event.message ?? `${event.class}`; // D6: native failures get an unmistakable prefix and the native exit // code shown as data, so a human never reads a harness error as an // hcn error. if (event.class === "native") { const nat = event.nativeExitCode !== undefined ? ` [native exit ${event.nativeExitCode}]` : ""; process.stdout.write(red(`\n ✗ NATIVE${nat} ${detail}`)); } else { process.stdout.write(red(`\n ✗ failure ${event.class}: ${detail}`)); } break; } case "done": { const mark = event.cause === "clean" || event.cause === "awaiting-input" ? event.cause === "awaiting-input" ? blue("○ awaiting input") : green("○ clean") : red(`○ ${event.cause}`); const denials = event.denialCount !== undefined ? yellow(` [denials: ${event.denialCount}]`) : ""; const tail = event.failure ? ` ${event.failure.class}: ${event.failure.message}` : ""; process.stdout.write(`\n ${mark} (exit ${event.exitCode ?? "none"})${denials}${tail}\n`); break; } default: { const exhaustive: never = event as never; process.stdout.write(dim(`\n ? ${(exhaustive as { kind: string }).kind}`)); } } }; /** Write one NDJSON event, reporting whether stdout took it immediately. * * `false` means the kernel buffer is full and the bytes are queued in the * process. A caller streaming a turn MUST wait for `drain` before writing * again (see `writeEventNdjsonAsync`); a caller writing one terminal pair on * the way out may ignore it, because Node flushes pending stdout writes * before it exits. */ export const writeEventNdjson = (event: HarnessEvent): boolean => process.stdout.write(`${JSON.stringify(event)}\n`); /** The streaming form: resolves once stdout has taken the line. * * Without this a slow reader is absorbed by the process rather than pushed * back on: hcn keeps pulling from the harness and buffering, so memory grows * with the turn instead of the harness being stalled. RFC-01 rule 8 names * both hops; this is the hcn-to-consumer one. */ export const writeEventNdjsonAsync = async (event: HarnessEvent): Promise => { await new Promise((resolve, reject) => { process.stdout.write(`${JSON.stringify(event)}\n`, (error) => { if (error) reject(error); else resolve(); }); }); };