mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user