import { type CliProgressRenderer, type CliVerboseLine, aiActCliProgressRenderer, cliAiActProgressScope, } from './verbose-ai-act'; import { type CliVerboseScreenshotCollectOptions, type CliVerboseScreenshotExportMode, collectScreenshotRefs, renderScreenshotList, } from './verbose-screenshot'; export const cliVerboseFlag = 'verbose'; // Scope -> renderer registry for the generic progress bus. Adding a new // producer means registering one entry here; the dispatcher core below is // producer-agnostic. const progressRenderers: Record = { [cliAiActProgressScope]: aiActCliProgressRenderer, }; function progressRendererForEvent( event: unknown, ): { scope: string; renderer: CliProgressRenderer } | undefined { if (typeof event !== 'object' || event === null) { return undefined; } const scope = (event as { scope?: unknown }).scope; if (typeof scope !== 'string') { return undefined; } const renderer = progressRenderers[scope]; return renderer ? { scope, renderer } : undefined; } const progressEventName = 'midscene_progress'; const cliVerboseContextKey = '__midscene_cli_verbose_context__'; export type CliVerboseFormat = 'text' | 'jsonl'; export interface CliVerboseContext { enabled: boolean; format?: CliVerboseFormat; scriptName?: string; commandName?: string; startedAt?: number; renderedLineKeys?: Set; } export interface CliVerboseEvent { event: string; scriptName?: string; command?: string; status?: 'ok' | 'error'; durationMs?: number; error?: string; [key: string]: unknown; } type DumpUpdateAgent = { addDumpUpdateListener?: ( listener: (dump: string, executionDump?: unknown) => void, ) => () => void; // Generic progress bus; consumers narrow by `event.scope`. addProgressListener?: (listener: (event: unknown) => void) => () => void; reportFile?: string | null; }; interface CliVerboseExecutionTaskLike { taskId?: unknown; type?: unknown; subType?: unknown; status?: unknown; param?: unknown; thought?: unknown; output?: unknown; log?: unknown; errorMessage?: unknown; timing?: { cost?: unknown; }; recorder?: unknown; uiContext?: { screenshot?: unknown; }; } interface CliVerboseStepSummary { id?: unknown; index: number; total: number; type?: unknown; subType?: unknown; status?: unknown; param?: unknown; message: string; error?: unknown; durationMs?: unknown; screenshots?: Array>; } interface CliVerboseExecutionDumpLike { id?: unknown; name?: unknown; description?: unknown; tasks: CliVerboseExecutionTaskLike[]; } const getGlobalContext = (): { current: CliVerboseContext } => { const globalWithContext = globalThis as unknown as Record< string, { current: CliVerboseContext } | undefined >; if (!globalWithContext[cliVerboseContextKey]) { globalWithContext[cliVerboseContextKey] = { current: { enabled: false } }; } return globalWithContext[cliVerboseContextKey]!; }; function parseVerboseFormat(rawFormat: string): CliVerboseFormat { if (rawFormat === 'text' || rawFormat === 'jsonl') { return rawFormat; } throw new Error( `Unsupported --${cliVerboseFlag} format "${rawFormat}". Use "--${cliVerboseFlag}" or "--${cliVerboseFlag}=jsonl".`, ); } export function stripVerboseFlag(argv: readonly string[]): { rawArgs: string[]; verbose: boolean; format: CliVerboseFormat; } { const rawArgs: string[] = []; let verbose = false; let format: CliVerboseFormat = 'text'; for (const arg of argv) { if (arg === `--${cliVerboseFlag}`) { verbose = true; continue; } if (arg.startsWith(`--${cliVerboseFlag}=`)) { verbose = true; format = parseVerboseFormat(arg.slice(`--${cliVerboseFlag}=`.length)); continue; } rawArgs.push(arg); } return { rawArgs, verbose, format }; } export async function withCliVerboseContext( context: CliVerboseContext, fn: () => Promise, ): Promise { const globalContext = getGlobalContext(); const previous = globalContext.current; globalContext.current = { ...context, renderedLineKeys: context.renderedLineKeys ?? new Set(), }; try { return await fn(); } finally { globalContext.current = previous; } } export function getCliVerboseContext(): CliVerboseContext { return getGlobalContext().current; } export function isCliVerboseEnabled(): boolean { return getCliVerboseContext().enabled; } export function emitCliVerboseEvent(event: CliVerboseEvent): void { const context = getCliVerboseContext(); if (!context.enabled) { return; } const payload = { type: progressEventName, timestamp: new Date().toISOString(), scriptName: context.scriptName, command: context.commandName, ...event, }; if (context.format === 'jsonl') { console.log(JSON.stringify(payload)); return; } const text = renderCliVerboseEventText(payload, context); if (text) { console.log(text); } } function errorMessageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function compactText(value: unknown): string { if (value === undefined || value === null || value === '') { return ''; } if (typeof value === 'string') { return value.length > 180 ? `${value.slice(0, 177)}...` : value; } if (typeof value === 'number' || typeof value === 'boolean') { return String(value); } try { const json = JSON.stringify(value); return json.length > 180 ? `${json.slice(0, 177)}...` : json; } catch { return String(value); } } function compactPrimitiveCliVerboseValue( value: unknown, ): { handled: true; value: unknown } | { handled: false } { if (value === undefined || value === null) { return { handled: true, value }; } if (typeof value === 'string') { return { handled: true, value: value.length > 180 ? `${value.slice(0, 177)}...` : value, }; } if (typeof value === 'number' || typeof value === 'boolean') { return { handled: true, value }; } if (typeof value !== 'object') { return { handled: true, value: String(value) }; } return { handled: false }; } function compactStructuredCliVerboseValue(value: unknown): unknown { const primitive = compactPrimitiveCliVerboseValue(value); if (primitive.handled) { return primitive.value; } if (Array.isArray(value)) { return value.slice(0, 5).map(compactStructuredCliVerboseValue); } if (!isRecord(value)) { return String(value); } const entries = Object.entries(value).slice(0, 6); return Object.fromEntries( entries.map(([key, entryValue]) => [ key, compactStructuredCliVerboseValue(entryValue), ]), ); } export function compactCliVerboseValue(value: unknown): unknown { const primitive = compactPrimitiveCliVerboseValue(value); if (primitive.handled) { return primitive.value; } if (Array.isArray(value)) { return value.slice(0, 5).map(compactCliVerboseValue); } if (isRecord(value) && typeof value.prompt === 'string') { return compactCliVerboseValue(value.prompt); } if (!isRecord(value)) { return String(value); } const entries = Object.entries(value).slice(0, 6); return Object.fromEntries( entries.map(([key, entryValue]) => [ key, compactCliVerboseValue(entryValue), ]), ); } export function compactCliVerboseArgs( args: Record, ): Record { return Object.fromEntries( Object.entries(args) .slice(0, 8) .map(([key, value]) => [key, compactStructuredCliVerboseValue(value)]), ); } function summarizeParam(param: unknown): unknown { if (!param || typeof param !== 'object') { return compactCliVerboseValue(param); } const record = param as Record; if (typeof record.prompt === 'string') { return compactCliVerboseValue(record.prompt); } if ( record.locate && typeof record.locate === 'object' && record.locate !== null ) { const locate = record.locate as Record; return { locate: compactCliVerboseValue(locate.prompt), ...Object.fromEntries( Object.entries(record) .filter(([key]) => key !== 'locate') .slice(0, 4) .map(([key, value]) => [key, compactCliVerboseValue(value)]), ), }; } return compactCliVerboseValue(record); } function summarizeUserInstruction(value: unknown): string { if (typeof value === 'string') { return compactText(value); } if (isRecord(value) && typeof value.prompt === 'string') { return compactText(value.prompt); } return compactText(value); } function summarizeTaskParamText(task: CliVerboseExecutionTaskLike): string { if (!isRecord(task.param)) { return compactText(summarizeParam(task.param)); } if (task.type === 'Planning' && task.subType !== 'Locate') { if (task.param.userInstructionDisplay) { return summarizeUserInstruction(task.param.userInstructionDisplay); } if (task.param.userInstruction) { return summarizeUserInstruction(task.param.userInstruction); } } if (task.param.dataDemand) { return compactText(summarizeParam(task.param.dataDemand)); } if (task.param.assertion) { return compactText(summarizeParam(task.param.assertion)); } return compactText(summarizeParam(task.param)); } function summarizeSubGoals(value: unknown): string { if (!Array.isArray(value) || value.length === 0) { return ''; } const goals = value .slice(0, 6) .map((goal) => { if (!isRecord(goal)) { return ''; } const index = typeof goal.index === 'number' ? `${goal.index}. ` : ''; const status = typeof goal.status === 'string' ? `[${goal.status}] ` : ''; const description = typeof goal.description === 'string' ? goal.description : ''; return `${index}${status}${description}`.trim(); }) .filter(Boolean); return goals.length > 0 ? `sub-goals: ${goals.join('; ')}` : ''; } function summarizeActions(value: unknown): string { if (!Array.isArray(value) || value.length === 0) { return ''; } const actions = value .slice(0, 5) .map((action) => { if (!isRecord(action)) { return ''; } const type = typeof action.type === 'string' ? action.type : 'Action'; const param = compactText(summarizeParam(action.param)); return param ? `${type}: ${param}` : type; }) .filter(Boolean); return actions.length > 0 ? `actions: ${actions.join('; ')}` : ''; } function summarizeTaskOutputText(task: CliVerboseExecutionTaskLike): string { if (!isRecord(task.output)) { return compactText(task.output); } return ( summarizeSubGoals(task.output.updateSubGoals) || compactText(task.output.log) || compactText(task.output.output) || summarizeActions(task.output.actions) ); } function summarizeTaskText(task: CliVerboseExecutionTaskLike): string { const parts = [task.type, task.subType].filter( (part): part is string => typeof part === 'string' && part.length > 0, ); const label = parts.length > 0 ? parts.join('/') : 'Task'; const status = typeof task.status === 'string' && task.status.length > 0 ? task.status : 'unknown'; const param = summarizeTaskParamText(task); const output = summarizeTaskOutputText(task); const thought = compactText(task.thought); const detail = output || thought || param; return detail ? `${label} ${status}: ${detail}` : `${label} ${status}`; } function isCliVerboseExecutionDumpLike( value: unknown, ): value is CliVerboseExecutionDumpLike { return isRecord(value) && Array.isArray(value.tasks); } function summarizeDumpUpdate( executionDump: unknown, screenshotOptions: CliVerboseScreenshotCollectOptions = {}, ): Record | undefined { if (!isCliVerboseExecutionDumpLike(executionDump)) { return undefined; } const tasks = executionDump.tasks; const latestTask = tasks[tasks.length - 1]; const steps = tasks.map( (task, index): CliVerboseStepSummary => ({ id: task.taskId, index: index + 1, total: tasks.length, type: task.type, subType: task.subType, status: task.status, param: summarizeParam(task.param), message: summarizeTaskText(task), error: task.errorMessage, durationMs: task.timing?.cost, screenshots: collectScreenshotRefs(task, screenshotOptions).slice(-3), }), ); return { execution: { id: executionDump.id, name: executionDump.name, description: compactCliVerboseValue(executionDump.description), taskCount: tasks.length, }, steps, task: latestTask ? { id: latestTask.taskId, index: tasks.length, total: tasks.length, type: latestTask.type, subType: latestTask.subType, status: latestTask.status, param: summarizeParam(latestTask.param), message: summarizeTaskText(latestTask), error: latestTask.errorMessage, durationMs: latestTask.timing?.cost, } : undefined, screenshots: collectScreenshotRefs(tasks, screenshotOptions).slice(-5), }; } function stringifyArgs(args: unknown): string { if (!isRecord(args) || Object.keys(args).length === 0) { return ''; } return Object.entries(args) .map(([key, value]) => `${key}=${compactText(value)}`) .join(', '); } function isActVerboseEvent(command?: string, tool?: string): boolean { return command === 'act' || tool === 'act'; } function renderLinesOnce( context: CliVerboseContext, lines: CliVerboseLine[], ): string | undefined { if (lines.length === 0) { return undefined; } if (!context.renderedLineKeys) { context.renderedLineKeys = new Set(); } const renderedLineKeys = context.renderedLineKeys; const pendingLines = lines.filter((line) => { if (renderedLineKeys.has(line.key)) { return false; } renderedLineKeys.add(line.key); return true; }); return pendingLines.length > 0 ? pendingLines.map((line) => line.text).join('\n') : undefined; } function renderCliVerboseEventText( event: CliVerboseEvent, context: CliVerboseContext, ): string | undefined { const command = typeof event.command === 'string' ? event.command : 'command'; const tool = typeof event.tool === 'string' ? event.tool : undefined; switch (event.event) { case 'agent_progress': { const scope = typeof event.scope === 'string' ? event.scope : undefined; const renderer = scope ? progressRenderers[scope] : undefined; const progressEvent = isRecord(event.progress) ? event.progress : undefined; if (!renderer || !progressEvent) { return undefined; } return renderLinesOnce(context, renderer.buildLines(progressEvent)); } case 'command_start': { if (isActVerboseEvent(command, tool)) { return undefined; } const args = stringifyArgs(event.args); return args ? `[Midscene] ${command} started (${args})` : `[Midscene] ${command} started`; } case 'agent_ready': if (isActVerboseEvent(command, tool)) { return undefined; } return `[Midscene] ${tool ?? command} ready`; case 'recording_ready': return '[Midscene] Recording. Press Ctrl+C to stop and save.'; case 'recording_stopping': if (event.reason === 'watchdog') { return '[Midscene] Recording watchdog reached; finalizing and saving.'; } if (event.reason === 'sigterm') { return '[Midscene] SIGTERM received; finalizing and saving.'; } return event.reason === 'sighup' ? '[Midscene] SIGHUP received; finalizing and saving.' : '[Midscene] Ctrl+C received; finalizing and saving. Press Ctrl+C again to force exit.'; case 'dump_update': { if (isActVerboseEvent(command, tool)) { return undefined; } const task = isRecord(event.task) ? event.task : undefined; const steps = Array.isArray(event.steps) ? event.steps.filter(isRecord) : []; const execution = isRecord(event.execution) ? event.execution : undefined; const executionName = typeof execution?.name === 'string' ? execution.name : 'execution'; const lines = steps.length > 0 ? [ `[Midscene] Progress: ${executionName} (${steps.length} steps)`, ...steps.map((step) => { const index = typeof step.index === 'number' && typeof step.total === 'number' ? `Step ${step.index}/${step.total}` : 'Step'; const message = typeof step.message === 'string' ? step.message : typeof step.status === 'string' ? step.status : 'updated'; return `[Midscene] ${index}: ${message}`; }), ] : [ `[Midscene] Step: ${ typeof task?.message === 'string' ? task.message : typeof task?.status === 'string' ? task.status : 'updated' }`, ]; const screenshots = renderScreenshotList(event.screenshots); if (screenshots) { lines.push(`[Midscene] Screenshot: ${screenshots}`); } if (typeof event.report === 'string' && event.report.length > 0) { lines.push(`[Midscene] Report: ${event.report}`); } return lines.join('\n'); } case 'artifact': { if (isActVerboseEvent(command, tool)) { return undefined; } const kind = typeof event.kind === 'string' ? event.kind : 'artifact'; const path = typeof event.path === 'string' ? event.path : undefined; return path ? `[Midscene] ${kind} saved: ${path}` : `[Midscene] ${kind} ready`; } case 'command_done': { if (isActVerboseEvent(command, tool)) { if (event.status === 'error') { const error = typeof event.error === 'string' && event.error.length > 0 ? `: ${event.error}` : ''; return renderLinesOnce(context, [ { key: `aiAct:command_failed:${event.error ?? 'unknown'}`, text: `[Midscene][aiAct] Failed${error}`, }, ]); } return undefined; } const status = event.status === 'error' ? 'failed' : 'finished'; const duration = typeof event.durationMs === 'number' ? ` in ${event.durationMs}ms` : ''; const error = event.status === 'error' && typeof event.error === 'string' ? `: ${event.error}` : ''; return `[Midscene] ${command} ${status}${duration}${error}`; } default: return undefined; } } export function attachCliVerboseDumpListener( agent: DumpUpdateAgent, options?: { toolName?: string }, ): () => void { if (!isCliVerboseEnabled()) { return () => {}; } const isActTool = options?.toolName === 'act'; const screenshotExportCache = new Map(); if (isActTool && typeof agent.addProgressListener === 'function') { return agent.addProgressListener((event) => { const match = progressRendererForEvent(event); if (!match) { return; } const context = getCliVerboseContext(); const isTextMode = context.format !== 'jsonl'; const progress = match.renderer.normalize(event, { reportFile: agent.reportFile, exportMode: isTextMode ? 'report' : 'none', cache: screenshotExportCache, }); if (!progress) { return; } emitCliVerboseEvent({ event: 'agent_progress', tool: options?.toolName, scope: match.scope, progress, }); }); } if (isActTool) { return () => {}; } if (typeof agent.addDumpUpdateListener !== 'function') { return () => {}; } return agent.addDumpUpdateListener((_dump, executionDump) => { const context = getCliVerboseContext(); const isTextMode = context.format !== 'jsonl'; const summaryExportMode: CliVerboseScreenshotExportMode = isTextMode && !isActTool ? 'tmp' : 'none'; const summary = summarizeDumpUpdate(executionDump, { exportMode: summaryExportMode, cache: screenshotExportCache, }); if (!summary) { return; } emitCliVerboseEvent({ event: 'dump_update', tool: options?.toolName, report: agent.reportFile || undefined, ...summary, }); }); } export { errorMessageOf as cliVerboseErrorMessage };