mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
feat: add web interface with real-time streaming and timed runs
- Add Fastify-based web server with SSE for real-time updates - Create responsive dark-themed UI with: - Prompt input with directory and duration options - Live countdown timer for timed runs - Real-time output streaming - Collapsible sessions panel - Cost and task tracking - Support timed runs that repeat prompts for specified duration - Add /api endpoints for sessions, scheduled runs, and status - Update Session class to parse Claude CLI JSON output - Add 'claudeman web' command to start the interface Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+147
-63
@@ -3,11 +3,29 @@ import { EventEmitter } from 'node:events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { SessionState, SessionStatus, SessionConfig } from './types.js';
|
||||
|
||||
export interface ClaudeMessage {
|
||||
type: 'system' | 'assistant' | 'user' | 'result';
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
message?: {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
};
|
||||
result?: string;
|
||||
is_error?: boolean;
|
||||
total_cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
export interface SessionEvents {
|
||||
output: (data: string) => void;
|
||||
message: (msg: ClaudeMessage) => void;
|
||||
error: (data: string) => void;
|
||||
exit: (code: number | null) => void;
|
||||
completion: (phrase: string) => void;
|
||||
completion: (result: string, cost: number) => void;
|
||||
}
|
||||
|
||||
export class Session extends EventEmitter {
|
||||
@@ -19,8 +37,13 @@ export class Session extends EventEmitter {
|
||||
private _status: SessionStatus = 'idle';
|
||||
private _currentTaskId: string | null = null;
|
||||
private _outputBuffer: string = '';
|
||||
private _textOutput: string = '';
|
||||
private _errorBuffer: string = '';
|
||||
private _lastActivityAt: number;
|
||||
private _claudeSessionId: string | null = null;
|
||||
private _totalCost: number = 0;
|
||||
private _messages: ClaudeMessage[] = [];
|
||||
private _lineBuffer: string = '';
|
||||
|
||||
constructor(config: Partial<SessionConfig> & { workingDir: string }) {
|
||||
super();
|
||||
@@ -46,6 +69,10 @@ export class Session extends EventEmitter {
|
||||
return this._outputBuffer;
|
||||
}
|
||||
|
||||
get textOutput(): string {
|
||||
return this._textOutput;
|
||||
}
|
||||
|
||||
get errorBuffer(): string {
|
||||
return this._errorBuffer;
|
||||
}
|
||||
@@ -54,6 +81,18 @@ export class Session extends EventEmitter {
|
||||
return this._lastActivityAt;
|
||||
}
|
||||
|
||||
get claudeSessionId(): string | null {
|
||||
return this._claudeSessionId;
|
||||
}
|
||||
|
||||
get totalCost(): number {
|
||||
return this._totalCost;
|
||||
}
|
||||
|
||||
get messages(): ClaudeMessage[] {
|
||||
return this._messages;
|
||||
}
|
||||
|
||||
isIdle(): boolean {
|
||||
return this._status === 'idle';
|
||||
}
|
||||
@@ -78,30 +117,49 @@ export class Session extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
toDetailedState() {
|
||||
return {
|
||||
...this.toState(),
|
||||
claudeSessionId: this._claudeSessionId,
|
||||
totalCost: this._totalCost,
|
||||
textOutput: this._textOutput,
|
||||
messageCount: this._messages.length,
|
||||
};
|
||||
}
|
||||
|
||||
async runPrompt(prompt: string): Promise<{ result: string; cost: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.process) {
|
||||
reject(new Error('Session already started'));
|
||||
reject(new Error('Session already has a running process'));
|
||||
return;
|
||||
}
|
||||
|
||||
this._status = 'busy';
|
||||
this._outputBuffer = '';
|
||||
this._textOutput = '';
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lineBuffer = '';
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
try {
|
||||
// Spawn claude CLI with --print flag for non-interactive mode
|
||||
this.process = spawn('claude', ['--print'], {
|
||||
// Spawn claude with streaming JSON output
|
||||
this.process = spawn('claude', [
|
||||
'-p',
|
||||
'--output-format', 'stream-json',
|
||||
prompt
|
||||
], {
|
||||
cwd: this.workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
this._status = 'idle';
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
this.process.stdout?.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
this._outputBuffer += text;
|
||||
this._lastActivityAt = Date.now();
|
||||
this.emit('output', text);
|
||||
this.checkForCompletion(text);
|
||||
this.processJsonLines(text);
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data: Buffer) => {
|
||||
@@ -113,22 +171,32 @@ export class Session extends EventEmitter {
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
this._status = 'error';
|
||||
this.emit('error', err.message);
|
||||
this.process = null;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
this._status = 'stopped';
|
||||
this.process = null;
|
||||
|
||||
// Find the result message
|
||||
const resultMsg = this._messages.find(m => m.type === 'result');
|
||||
|
||||
if (resultMsg && !resultMsg.is_error) {
|
||||
this._status = 'idle';
|
||||
const cost = resultMsg.total_cost_usd || 0;
|
||||
this._totalCost += cost;
|
||||
this.emit('completion', resultMsg.result || '', cost);
|
||||
resolve({ result: resultMsg.result || '', cost });
|
||||
} else if (code !== 0 || (resultMsg && resultMsg.is_error)) {
|
||||
this._status = 'error';
|
||||
reject(new Error(this._errorBuffer || 'Process exited with error'));
|
||||
} else {
|
||||
this._status = 'idle';
|
||||
resolve({ result: this._textOutput, cost: 0 });
|
||||
}
|
||||
|
||||
this.emit('exit', code);
|
||||
});
|
||||
|
||||
// Give process time to start
|
||||
setTimeout(() => {
|
||||
if (this.process && !this.process.killed) {
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
} catch (err) {
|
||||
this._status = 'error';
|
||||
reject(err);
|
||||
@@ -136,6 +204,62 @@ export class Session extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
private processJsonLines(chunk: string): void {
|
||||
this._lineBuffer += chunk;
|
||||
const lines = this._lineBuffer.split('\n');
|
||||
|
||||
// Keep incomplete line in buffer
|
||||
this._lineBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const msg = JSON.parse(line) as ClaudeMessage;
|
||||
this._messages.push(msg);
|
||||
this.emit('message', msg);
|
||||
|
||||
// Extract text content
|
||||
if (msg.type === 'system' && msg.session_id) {
|
||||
this._claudeSessionId = msg.session_id;
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
this._textOutput += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'result') {
|
||||
if (msg.total_cost_usd) {
|
||||
this._totalCost += msg.total_cost_usd;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, treat as raw text
|
||||
this._textOutput += line + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy method for compatibility with session-manager
|
||||
async start(): Promise<void> {
|
||||
// Session is ready by default, actual process starts with runPrompt
|
||||
this._status = 'idle';
|
||||
}
|
||||
|
||||
// Legacy method for sending input - wraps runPrompt
|
||||
async sendInput(input: string): Promise<void> {
|
||||
this._status = 'busy';
|
||||
this._lastActivityAt = Date.now();
|
||||
// Run the prompt asynchronously
|
||||
this.runPrompt(input).catch(err => {
|
||||
this.emit('error', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.process) {
|
||||
@@ -152,11 +276,8 @@ export class Session extends EventEmitter {
|
||||
};
|
||||
|
||||
this.process.once('exit', cleanup);
|
||||
|
||||
// Try graceful shutdown first
|
||||
this.process.kill('SIGTERM');
|
||||
|
||||
// Force kill after timeout
|
||||
setTimeout(() => {
|
||||
if (this.process && !this.process.killed) {
|
||||
this.process.kill('SIGKILL');
|
||||
@@ -165,30 +286,13 @@ export class Session extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
async sendInput(input: string): Promise<void> {
|
||||
if (!this.process || !this.process.stdin) {
|
||||
throw new Error('Session not started or stdin not available');
|
||||
}
|
||||
|
||||
this._status = 'busy';
|
||||
this._lastActivityAt = Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.process!.stdin!.write(input + '\n', (err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
assignTask(taskId: string): void {
|
||||
this._currentTaskId = taskId;
|
||||
this._status = 'busy';
|
||||
this._outputBuffer = '';
|
||||
this._textOutput = '';
|
||||
this._errorBuffer = '';
|
||||
this._messages = [];
|
||||
this._lastActivityAt = Date.now();
|
||||
}
|
||||
|
||||
@@ -199,7 +303,7 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
getOutput(): string {
|
||||
return this._outputBuffer;
|
||||
return this._textOutput;
|
||||
}
|
||||
|
||||
getError(): string {
|
||||
@@ -208,28 +312,8 @@ export class Session extends EventEmitter {
|
||||
|
||||
clearBuffers(): void {
|
||||
this._outputBuffer = '';
|
||||
this._textOutput = '';
|
||||
this._errorBuffer = '';
|
||||
}
|
||||
|
||||
private checkForCompletion(text: string): void {
|
||||
// Check for completion phrase pattern: <promise>...</promise>
|
||||
const promiseMatch = text.match(/<promise>([^<]+)<\/promise>/);
|
||||
if (promiseMatch) {
|
||||
this.emit('completion', promiseMatch[1]);
|
||||
}
|
||||
|
||||
// Also check for common completion indicators
|
||||
const completionIndicators = [
|
||||
/Task completed successfully/i,
|
||||
/All tasks done/i,
|
||||
/✓ Complete/i,
|
||||
];
|
||||
|
||||
for (const pattern of completionIndicators) {
|
||||
if (pattern.test(text)) {
|
||||
this.emit('completion', 'auto-detected');
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._messages = [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user