diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index 3d1326f2..2f2843d3 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -11,7 +11,8 @@ import { getExecutionHistory, getExecutionHistoryAsync, getExecutionDetail, - getConversationDetail + getConversationDetail, + killCurrentCliProcess } from '../tools/cli-executor.js'; import { getStorageStats, @@ -66,6 +67,43 @@ function notifyDashboard(data: Record): void { req.end(); } +/** + * Broadcast WebSocket event to Dashboard for real-time streaming + * Uses specific event types that match frontend handlers + */ +function broadcastStreamEvent(eventType: string, payload: Record): void { + const data = JSON.stringify({ + type: eventType, + ...payload, + timestamp: new Date().toISOString() + }); + + const req = http.request({ + hostname: 'localhost', + port: Number(DASHBOARD_PORT), + path: '/api/hook', + method: 'POST', + timeout: 1000, // Short timeout for streaming + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data) + } + }); + + // Fire and forget - don't block streaming + req.on('socket', (socket) => { + socket.unref(); + }); + req.on('error', () => { + // Silently ignore errors for streaming events + }); + req.on('timeout', () => { + req.destroy(); + }); + req.write(data); + req.end(); +} + interface CliExecOptions { prompt?: string; // Prompt via --prompt/-p option (preferred for multi-line) file?: string; // Read prompt from file @@ -678,7 +716,34 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec console.log(); } - // Notify dashboard: execution started + // Generate execution ID for streaming (use custom ID or timestamp-based) + const executionId = id || `${Date.now()}-${tool}`; + const startTime = Date.now(); + + // Handle process interruption (SIGINT/SIGTERM) to notify dashboard + const handleInterrupt = (signal: string) => { + const duration = Date.now() - startTime; + console.log(chalk.yellow(`\n Interrupted by ${signal}`)); + + // Kill child process (gemini/codex/qwen CLI) if running + killCurrentCliProcess(); + + // Broadcast interruption to dashboard + broadcastStreamEvent('CLI_EXECUTION_COMPLETED', { + executionId, + success: false, + duration, + interrupted: true + }); + + // Give time for the event to be sent before exiting + setTimeout(() => process.exit(130), 100); + }; + + process.on('SIGINT', () => handleInterrupt('SIGINT')); + process.on('SIGTERM', () => handleInterrupt('SIGTERM')); + + // Notify dashboard: execution started (legacy) notifyDashboard({ event: 'started', tool, @@ -687,10 +752,28 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec custom_id: id || null }); - // Streaming output handler - only active when --stream flag is passed - const onOutput = stream ? (chunk: any) => { - process.stdout.write(chunk.data); - } : null; + // Broadcast CLI_EXECUTION_STARTED for real-time streaming viewer + // Note: /api/hook wraps extraData into payload, so send fields directly + broadcastStreamEvent('CLI_EXECUTION_STARTED', { + executionId, + tool, + mode + }); + + // Streaming output handler - broadcasts to dashboard AND writes to stdout + const onOutput = (chunk: any) => { + // Always broadcast to dashboard for real-time viewing + // Note: /api/hook wraps extraData into payload, so send fields directly + broadcastStreamEvent('CLI_OUTPUT', { + executionId, + chunkType: chunk.type, + data: chunk.data + }); + // Write to terminal only when --stream flag is passed + if (stream) { + process.stdout.write(chunk.data); + } + }; try { const result = await cliExecutorTool.execute({ @@ -704,8 +787,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec resume, id, // custom execution ID noNative, - stream: !!stream // stream=true → streaming enabled, stream=false/undefined → cache output - }, onOutput); + stream: !!stream // stream=true → streaming enabled (no cache), stream=false → cache output (default) + }, onOutput); // Always pass onOutput for real-time dashboard streaming // If not streaming (default), print output now if (!stream && result.stdout) { @@ -735,7 +818,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec console.log(chalk.dim(` Output (optional): ccw cli output ${result.execution.id}`)); } - // Notify dashboard: execution completed + // Notify dashboard: execution completed (legacy) notifyDashboard({ event: 'completed', tool, @@ -746,8 +829,16 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec turn_count: result.conversation.turn_count }); + // Broadcast CLI_EXECUTION_COMPLETED for real-time streaming viewer + broadcastStreamEvent('CLI_EXECUTION_COMPLETED', { + executionId, // Use the same executionId as started event + success: true, + duration: result.execution.duration_ms + }); + // Ensure clean exit after successful execution - process.exit(0); + // Delay to allow HTTP request to complete + setTimeout(() => process.exit(0), 150); } else { console.log(chalk.red(` ✗ Failed (${result.execution.status})`)); console.log(chalk.gray(` ID: ${result.execution.id}`)); @@ -783,7 +874,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec console.log(chalk.gray(` • Wait and retry - rate limit exceeded`)); } - // Notify dashboard: execution failed + // Notify dashboard: execution failed (legacy) notifyDashboard({ event: 'completed', tool, @@ -794,7 +885,15 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec duration_ms: result.execution.duration_ms }); - process.exit(1); + // Broadcast CLI_EXECUTION_COMPLETED for real-time streaming viewer + broadcastStreamEvent('CLI_EXECUTION_COMPLETED', { + executionId, // Use the same executionId as started event + success: false, + duration: result.execution.duration_ms + }); + + // Delay to allow HTTP request to complete + setTimeout(() => process.exit(1), 150); } } catch (error) { const err = error as Error; @@ -816,7 +915,7 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec console.log(chalk.gray(` • Run: ccw cli status`)); } - // Notify dashboard: execution error + // Notify dashboard: execution error (legacy) notifyDashboard({ event: 'error', tool, @@ -824,7 +923,14 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec error: err.message }); - process.exit(1); + // Broadcast CLI_EXECUTION_ERROR for real-time streaming viewer + broadcastStreamEvent('CLI_EXECUTION_ERROR', { + executionId, + error: err.message + }); + + // Delay to allow HTTP request to complete + setTimeout(() => process.exit(1), 150); } } diff --git a/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css b/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css index bbe9e8bb..0a8c5b5b 100644 --- a/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css +++ b/ccw/src/templates/dashboard-css/33-cli-stream-viewer.css @@ -22,11 +22,12 @@ /* ===== Main Panel ===== */ .cli-stream-viewer { position: fixed; - top: 60px; + top: 16px; right: 16px; - width: 650px; + bottom: 16px; + width: 700px; max-width: calc(100vw - 32px); - max-height: calc(100vh - 80px); + height: calc(100vh - 32px); background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 8px; @@ -357,8 +358,7 @@ /* ===== Terminal Content ===== */ .cli-stream-content { flex: 1; - min-height: 300px; - max-height: 500px; + min-height: 0; overflow-y: auto; padding: 12px 16px; background: hsl(220 13% 8%); @@ -566,15 +566,11 @@ /* ===== Responsive ===== */ @media (max-width: 768px) { .cli-stream-viewer { - top: 56px; + top: 8px; right: 8px; left: 8px; + bottom: 8px; width: auto; - max-height: calc(100vh - 72px); - } - - .cli-stream-content { - min-height: 200px; - max-height: 350px; + height: calc(100vh - 16px); } } diff --git a/ccw/src/tools/cli-executor.ts b/ccw/src/tools/cli-executor.ts index c4264bd0..8a767fdf 100644 --- a/ccw/src/tools/cli-executor.ts +++ b/ccw/src/tools/cli-executor.ts @@ -10,6 +10,28 @@ import { spawn, ChildProcess } from 'child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs'; import { join, relative } from 'path'; +// Track current running child process for cleanup on interruption +let currentChildProcess: ChildProcess | null = null; + +/** + * Kill the current running CLI child process + * Called when parent process receives SIGINT/SIGTERM + */ +export function killCurrentCliProcess(): boolean { + if (currentChildProcess && !currentChildProcess.killed) { + debugLog('KILL', 'Killing current child process', { pid: currentChildProcess.pid }); + currentChildProcess.kill('SIGTERM'); + // Force kill after 2 seconds if still running + setTimeout(() => { + if (currentChildProcess && !currentChildProcess.killed) { + currentChildProcess.kill('SIGKILL'); + } + }, 2000); + return true; + } + return false; +} + // Debug logging utility - check env at runtime for --debug flag support function isDebugEnabled(): boolean { return process.env.DEBUG === 'true' || process.env.DEBUG === '1' || process.env.CCW_DEBUG === 'true'; @@ -914,6 +936,9 @@ async function executeCliTool( stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); + // Track current child process for cleanup on interruption + currentChildProcess = child; + debugLog('SPAWN', `Process spawned`, { pid: child.pid }); // Write prompt to stdin if using stdin mode (for gemini/qwen) @@ -947,6 +972,9 @@ async function executeCliTool( // Handle completion child.on('close', async (code) => { + // Clear current child process reference + currentChildProcess = null; + const endTime = Date.now(); const duration = endTime - startTime;