/** * KHAELOR * File: src/cli/print.ts * Description: Non-interactive --print mode — one real agent turn without the TUI, final text on stdout (mirrors claude -p). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { ResolvedConfig } from "../config/index.js"; import { buildUsageTotals } from "../session/index.js"; import type { DurableEvent } from "../session/index.js"; import { assembleEngine, openSessionContext } from "./engine.js"; import type { FileLogger } from "./logger.js"; /** * The final answer of the last completed model response that ended the turn * (stopReason ≠ tool_use): its settled text blocks, in block order. */ export function finalAnswerText(events: readonly DurableEvent[]): string { let finalRequestId: string | null = null; for (const event of events) { if (event.type === "model.response-completed" && event.payload.stopReason !== "tool_use") { finalRequestId = event.payload.requestId; } } if (finalRequestId === null) return ""; const blocks: { blockIndex: number; text: string }[] = []; for (const event of events) { if ( event.type === "model.text-block-completed" && event.payload.requestId === finalRequestId ) { blocks.push({ blockIndex: event.payload.blockIndex, text: event.payload.text }); } } blocks.sort((a, b) => a.blockIndex - b.blockIndex); return blocks.map((b) => b.text).join("\n"); } export interface PrintRunOptions { config: ResolvedConfig; cwd: string; prompt: string; logger: FileLogger; sessionsDir?: string; stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream; } /** * Run one agent turn headless. Permission `ask`s resolve to deny (silence is * not consent); the session log is written exactly as in interactive mode. * Returns the process exit code. */ export async function runPrintMode(options: PrintRunOptions): Promise { const stdout = options.stdout ?? process.stdout; const stderr = options.stderr ?? process.stderr; const context = await openSessionContext({ cwd: options.cwd, ...(options.sessionsDir !== undefined ? { sessionsDir: options.sessionsDir } : {}), logger: options.logger, }); const engine = await assembleEngine({ config: options.config, cwd: options.cwd, context, logger: options.logger, // no asker: non-interactive — every permission ask resolves deny (§5.2) }); let exitCode = 0; try { await engine.captureBaseline(); engine.session.publishDurable({ type: "user.message-created", payload: { text: options.prompt, mentions: [] }, }); const outcome = await engine.runTurn(); const events = engine.session.events(); const answer = finalAnswerText(events); if (outcome.kind === "done") { stdout.write(answer.length > 0 ? `${answer}\n` : ""); } else if (outcome.kind === "failed") { if (answer.length > 0) stdout.write(`${answer}\n`); stderr.write(`khaelor: turn failed (${outcome.reason}): ${outcome.detail}\n`); exitCode = 1; } else { stderr.write(`khaelor: turn ended without completing (${outcome.kind})\n`); exitCode = 1; } // Honest run summary on stderr — real usage only, never estimated. const usage = buildUsageTotals(events).totals; stderr.write( `khaelor: session ${engine.session.sessionId} · ${usage.requests} model call(s) · ` + `in ${usage.inputTokens} out ${usage.outputTokens} ` + `cache-write ${usage.cacheWriteTokens} cache-read ${usage.cacheReadTokens} tokens\n` + `khaelor: log ${engine.log.filePath}\n`, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); stderr.write(`khaelor: ${message}\n`); options.logger.error("print mode failed", { error: message }); exitCode = 1; } finally { await engine.shutdown(); } return exitCode; }