feat(cli): add streaming output to Dashboard and child process termination

- Add broadcastStreamEvent() for real-time CLI output to Dashboard
- Send CLI_EXECUTION_STARTED/OUTPUT/COMPLETED events via /api/hook
- Add killCurrentCliProcess() to terminate child CLI on SIGINT/SIGTERM
- Track child process reference for cleanup on interruption
- Make CLI stream viewer panel full-height (calc(100vh - 32px))
- Fix completion status not updating by using consistent executionId

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
catlog22
2025-12-30 15:56:18 +08:00
parent 754cddd4ad
commit 4d73a3c9a9
3 changed files with 156 additions and 26 deletions

View File

@@ -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<string, unknown>): 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<string, unknown>): 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;