mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +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,
|
getExecutionHistory,
|
||||||
getExecutionHistoryAsync,
|
getExecutionHistoryAsync,
|
||||||
getExecutionDetail,
|
getExecutionDetail,
|
||||||
getConversationDetail
|
getConversationDetail,
|
||||||
|
killCurrentCliProcess
|
||||||
} from '../tools/cli-executor.js';
|
} from '../tools/cli-executor.js';
|
||||||
import {
|
import {
|
||||||
getStorageStats,
|
getStorageStats,
|
||||||
@@ -66,6 +67,43 @@ function notifyDashboard(data: Record<string, unknown>): void {
|
|||||||
req.end();
|
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 {
|
interface CliExecOptions {
|
||||||
prompt?: string; // Prompt via --prompt/-p option (preferred for multi-line)
|
prompt?: string; // Prompt via --prompt/-p option (preferred for multi-line)
|
||||||
file?: string; // Read prompt from file
|
file?: string; // Read prompt from file
|
||||||
@@ -678,7 +716,34 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
console.log();
|
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({
|
notifyDashboard({
|
||||||
event: 'started',
|
event: 'started',
|
||||||
tool,
|
tool,
|
||||||
@@ -687,10 +752,28 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
custom_id: id || null
|
custom_id: id || null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Streaming output handler - only active when --stream flag is passed
|
// Broadcast CLI_EXECUTION_STARTED for real-time streaming viewer
|
||||||
const onOutput = stream ? (chunk: any) => {
|
// Note: /api/hook wraps extraData into payload, so send fields directly
|
||||||
process.stdout.write(chunk.data);
|
broadcastStreamEvent('CLI_EXECUTION_STARTED', {
|
||||||
} : null;
|
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 {
|
try {
|
||||||
const result = await cliExecutorTool.execute({
|
const result = await cliExecutorTool.execute({
|
||||||
@@ -704,8 +787,8 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
resume,
|
resume,
|
||||||
id, // custom execution ID
|
id, // custom execution ID
|
||||||
noNative,
|
noNative,
|
||||||
stream: !!stream // stream=true → streaming enabled, stream=false/undefined → cache output
|
stream: !!stream // stream=true → streaming enabled (no cache), stream=false → cache output (default)
|
||||||
}, onOutput);
|
}, onOutput); // Always pass onOutput for real-time dashboard streaming
|
||||||
|
|
||||||
// If not streaming (default), print output now
|
// If not streaming (default), print output now
|
||||||
if (!stream && result.stdout) {
|
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}`));
|
console.log(chalk.dim(` Output (optional): ccw cli output ${result.execution.id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify dashboard: execution completed
|
// Notify dashboard: execution completed (legacy)
|
||||||
notifyDashboard({
|
notifyDashboard({
|
||||||
event: 'completed',
|
event: 'completed',
|
||||||
tool,
|
tool,
|
||||||
@@ -746,8 +829,16 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
turn_count: result.conversation.turn_count
|
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
|
// Ensure clean exit after successful execution
|
||||||
process.exit(0);
|
// Delay to allow HTTP request to complete
|
||||||
|
setTimeout(() => process.exit(0), 150);
|
||||||
} else {
|
} else {
|
||||||
console.log(chalk.red(` ✗ Failed (${result.execution.status})`));
|
console.log(chalk.red(` ✗ Failed (${result.execution.status})`));
|
||||||
console.log(chalk.gray(` ID: ${result.execution.id}`));
|
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`));
|
console.log(chalk.gray(` • Wait and retry - rate limit exceeded`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify dashboard: execution failed
|
// Notify dashboard: execution failed (legacy)
|
||||||
notifyDashboard({
|
notifyDashboard({
|
||||||
event: 'completed',
|
event: 'completed',
|
||||||
tool,
|
tool,
|
||||||
@@ -794,7 +885,15 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
duration_ms: result.execution.duration_ms
|
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) {
|
} catch (error) {
|
||||||
const err = error as 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`));
|
console.log(chalk.gray(` • Run: ccw cli status`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify dashboard: execution error
|
// Notify dashboard: execution error (legacy)
|
||||||
notifyDashboard({
|
notifyDashboard({
|
||||||
event: 'error',
|
event: 'error',
|
||||||
tool,
|
tool,
|
||||||
@@ -824,7 +923,14 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
|||||||
error: err.message
|
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 ===== */
|
/* ===== Main Panel ===== */
|
||||||
.cli-stream-viewer {
|
.cli-stream-viewer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 60px;
|
top: 16px;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
width: 650px;
|
bottom: 16px;
|
||||||
|
width: 700px;
|
||||||
max-width: calc(100vw - 32px);
|
max-width: calc(100vw - 32px);
|
||||||
max-height: calc(100vh - 80px);
|
height: calc(100vh - 32px);
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--card));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -357,8 +358,7 @@
|
|||||||
/* ===== Terminal Content ===== */
|
/* ===== Terminal Content ===== */
|
||||||
.cli-stream-content {
|
.cli-stream-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 300px;
|
min-height: 0;
|
||||||
max-height: 500px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: hsl(220 13% 8%);
|
background: hsl(220 13% 8%);
|
||||||
@@ -566,15 +566,11 @@
|
|||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.cli-stream-viewer {
|
.cli-stream-viewer {
|
||||||
top: 56px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
|
bottom: 8px;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-height: calc(100vh - 72px);
|
height: calc(100vh - 16px);
|
||||||
}
|
|
||||||
|
|
||||||
.cli-stream-content {
|
|
||||||
min-height: 200px;
|
|
||||||
max-height: 350px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,28 @@ import { spawn, ChildProcess } from 'child_process';
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
||||||
import { join, relative } from 'path';
|
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
|
// Debug logging utility - check env at runtime for --debug flag support
|
||||||
function isDebugEnabled(): boolean {
|
function isDebugEnabled(): boolean {
|
||||||
return process.env.DEBUG === 'true' || process.env.DEBUG === '1' || process.env.CCW_DEBUG === 'true';
|
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']
|
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe']
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track current child process for cleanup on interruption
|
||||||
|
currentChildProcess = child;
|
||||||
|
|
||||||
debugLog('SPAWN', `Process spawned`, { pid: child.pid });
|
debugLog('SPAWN', `Process spawned`, { pid: child.pid });
|
||||||
|
|
||||||
// Write prompt to stdin if using stdin mode (for gemini/qwen)
|
// Write prompt to stdin if using stdin mode (for gemini/qwen)
|
||||||
@@ -947,6 +972,9 @@ async function executeCliTool(
|
|||||||
|
|
||||||
// Handle completion
|
// Handle completion
|
||||||
child.on('close', async (code) => {
|
child.on('close', async (code) => {
|
||||||
|
// Clear current child process reference
|
||||||
|
currentChildProcess = null;
|
||||||
|
|
||||||
const endTime = Date.now();
|
const endTime = Date.now();
|
||||||
const duration = endTime - startTime;
|
const duration = endTime - startTime;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user