mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +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:
@@ -4,10 +4,13 @@ A Claude Code session manager with an autonomous Ralph Loop for task assignment
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- **Web Interface**: Beautiful, responsive web UI for managing sessions and running prompts
|
||||||
- **Session Management**: Spawn and manage multiple Claude CLI sessions as subprocesses
|
- **Session Management**: Spawn and manage multiple Claude CLI sessions as subprocesses
|
||||||
|
- **Timed Runs**: Schedule Claude to work for a specific duration with live countdown
|
||||||
|
- **Real-time Output**: Stream Claude's responses in real-time via Server-Sent Events
|
||||||
- **Task Queue**: Priority-based task queue with dependency support
|
- **Task Queue**: Priority-based task queue with dependency support
|
||||||
- **Ralph Loop**: Autonomous control loop that assigns tasks to idle sessions and monitors completion
|
- **Ralph Loop**: Autonomous control loop that assigns tasks to idle sessions and monitors completion
|
||||||
- **Time-Aware Loops**: Set minimum durations for extended autonomous work sessions
|
- **Cost Tracking**: Track total API costs across all sessions
|
||||||
- **State Persistence**: All state persisted to `~/.claudeman/state.json`
|
- **State Persistence**: All state persisted to `~/.claudeman/state.json`
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -20,6 +23,24 @@ npm link # Optional: make 'claudeman' available globally
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Web Interface (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the web interface
|
||||||
|
claudeman web
|
||||||
|
|
||||||
|
# Open http://localhost:3000 in your browser
|
||||||
|
```
|
||||||
|
|
||||||
|
The web interface provides:
|
||||||
|
- **Prompt Input**: Enter prompts and optionally set a working directory
|
||||||
|
- **Duration Timer**: Set duration in minutes for timed runs (0 = single run)
|
||||||
|
- **Live Output**: See Claude's response in real-time as it streams
|
||||||
|
- **Countdown**: Large timer display when running timed jobs
|
||||||
|
- **Session Monitoring**: View all active sessions at the bottom panel
|
||||||
|
|
||||||
|
### CLI Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start a Claude session
|
# Start a Claude session
|
||||||
claudeman start --dir /path/to/project
|
claudeman start --dir /path/to/project
|
||||||
@@ -37,6 +58,16 @@ claudeman status
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
### Web Interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start web interface on default port (3000)
|
||||||
|
claudeman web
|
||||||
|
|
||||||
|
# Use a different port
|
||||||
|
claudeman web --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
### Session Management
|
### Session Management
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Generated
+1270
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -8,9 +8,10 @@
|
|||||||
"claudeman": "./dist/index.js"
|
"claudeman": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc && mkdir -p dist/web && cp -r src/web/public dist/web/",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
|
"web": "node dist/index.js web",
|
||||||
"clean": "rm -rf dist"
|
"clean": "rm -rf dist"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -22,8 +23,10 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/static": "^8.0.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
|
"fastify": "^5.1.0",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+21
@@ -449,4 +449,25 @@ program
|
|||||||
console.log('');
|
console.log('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Web interface command
|
||||||
|
program
|
||||||
|
.command('web')
|
||||||
|
.description('Start the web interface')
|
||||||
|
.option('-p, --port <port>', 'Port to listen on', '3000')
|
||||||
|
.action(async (options) => {
|
||||||
|
const { startWebServer } = await import('./web/server.js');
|
||||||
|
const port = parseInt(options.port, 10);
|
||||||
|
|
||||||
|
console.log(chalk.cyan(`Starting Claudeman web interface on port ${port}...`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startWebServer(port);
|
||||||
|
console.log(chalk.green(`\n✓ Web interface running at http://localhost:${port}`));
|
||||||
|
console.log(chalk.gray(' Press Ctrl+C to stop\n'));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(chalk.red(`✗ Failed to start web server: ${(err as Error).message}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export { program };
|
export { program };
|
||||||
|
|||||||
+147
-63
@@ -3,11 +3,29 @@ import { EventEmitter } from 'node:events';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { SessionState, SessionStatus, SessionConfig } from './types.js';
|
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 {
|
export interface SessionEvents {
|
||||||
output: (data: string) => void;
|
output: (data: string) => void;
|
||||||
|
message: (msg: ClaudeMessage) => void;
|
||||||
error: (data: string) => void;
|
error: (data: string) => void;
|
||||||
exit: (code: number | null) => void;
|
exit: (code: number | null) => void;
|
||||||
completion: (phrase: string) => void;
|
completion: (result: string, cost: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Session extends EventEmitter {
|
export class Session extends EventEmitter {
|
||||||
@@ -19,8 +37,13 @@ export class Session extends EventEmitter {
|
|||||||
private _status: SessionStatus = 'idle';
|
private _status: SessionStatus = 'idle';
|
||||||
private _currentTaskId: string | null = null;
|
private _currentTaskId: string | null = null;
|
||||||
private _outputBuffer: string = '';
|
private _outputBuffer: string = '';
|
||||||
|
private _textOutput: string = '';
|
||||||
private _errorBuffer: string = '';
|
private _errorBuffer: string = '';
|
||||||
private _lastActivityAt: number;
|
private _lastActivityAt: number;
|
||||||
|
private _claudeSessionId: string | null = null;
|
||||||
|
private _totalCost: number = 0;
|
||||||
|
private _messages: ClaudeMessage[] = [];
|
||||||
|
private _lineBuffer: string = '';
|
||||||
|
|
||||||
constructor(config: Partial<SessionConfig> & { workingDir: string }) {
|
constructor(config: Partial<SessionConfig> & { workingDir: string }) {
|
||||||
super();
|
super();
|
||||||
@@ -46,6 +69,10 @@ export class Session extends EventEmitter {
|
|||||||
return this._outputBuffer;
|
return this._outputBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get textOutput(): string {
|
||||||
|
return this._textOutput;
|
||||||
|
}
|
||||||
|
|
||||||
get errorBuffer(): string {
|
get errorBuffer(): string {
|
||||||
return this._errorBuffer;
|
return this._errorBuffer;
|
||||||
}
|
}
|
||||||
@@ -54,6 +81,18 @@ export class Session extends EventEmitter {
|
|||||||
return this._lastActivityAt;
|
return this._lastActivityAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get claudeSessionId(): string | null {
|
||||||
|
return this._claudeSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalCost(): number {
|
||||||
|
return this._totalCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
get messages(): ClaudeMessage[] {
|
||||||
|
return this._messages;
|
||||||
|
}
|
||||||
|
|
||||||
isIdle(): boolean {
|
isIdle(): boolean {
|
||||||
return this._status === 'idle';
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (this.process) {
|
if (this.process) {
|
||||||
reject(new Error('Session already started'));
|
reject(new Error('Session already has a running process'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._status = 'busy';
|
||||||
|
this._outputBuffer = '';
|
||||||
|
this._textOutput = '';
|
||||||
|
this._errorBuffer = '';
|
||||||
|
this._messages = [];
|
||||||
|
this._lineBuffer = '';
|
||||||
|
this._lastActivityAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Spawn claude CLI with --print flag for non-interactive mode
|
// Spawn claude with streaming JSON output
|
||||||
this.process = spawn('claude', ['--print'], {
|
this.process = spawn('claude', [
|
||||||
|
'-p',
|
||||||
|
'--output-format', 'stream-json',
|
||||||
|
prompt
|
||||||
|
], {
|
||||||
cwd: this.workingDir,
|
cwd: this.workingDir,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
});
|
});
|
||||||
|
|
||||||
this._status = 'idle';
|
|
||||||
this._lastActivityAt = Date.now();
|
|
||||||
|
|
||||||
this.process.stdout?.on('data', (data: Buffer) => {
|
this.process.stdout?.on('data', (data: Buffer) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
this._outputBuffer += text;
|
this._outputBuffer += text;
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
this.emit('output', text);
|
this.emit('output', text);
|
||||||
this.checkForCompletion(text);
|
this.processJsonLines(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.process.stderr?.on('data', (data: Buffer) => {
|
this.process.stderr?.on('data', (data: Buffer) => {
|
||||||
@@ -113,22 +171,32 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
this.process.on('error', (err) => {
|
this.process.on('error', (err) => {
|
||||||
this._status = 'error';
|
this._status = 'error';
|
||||||
this.emit('error', err.message);
|
this.process = null;
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.process.on('exit', (code) => {
|
this.process.on('exit', (code) => {
|
||||||
this._status = 'stopped';
|
|
||||||
this.process = null;
|
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);
|
this.emit('exit', code);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Give process time to start
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.process && !this.process.killed) {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._status = 'error';
|
this._status = 'error';
|
||||||
reject(err);
|
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> {
|
stop(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!this.process) {
|
if (!this.process) {
|
||||||
@@ -152,11 +276,8 @@ export class Session extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.process.once('exit', cleanup);
|
this.process.once('exit', cleanup);
|
||||||
|
|
||||||
// Try graceful shutdown first
|
|
||||||
this.process.kill('SIGTERM');
|
this.process.kill('SIGTERM');
|
||||||
|
|
||||||
// Force kill after timeout
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.process && !this.process.killed) {
|
if (this.process && !this.process.killed) {
|
||||||
this.process.kill('SIGKILL');
|
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 {
|
assignTask(taskId: string): void {
|
||||||
this._currentTaskId = taskId;
|
this._currentTaskId = taskId;
|
||||||
this._status = 'busy';
|
this._status = 'busy';
|
||||||
this._outputBuffer = '';
|
this._outputBuffer = '';
|
||||||
|
this._textOutput = '';
|
||||||
this._errorBuffer = '';
|
this._errorBuffer = '';
|
||||||
|
this._messages = [];
|
||||||
this._lastActivityAt = Date.now();
|
this._lastActivityAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +303,7 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getOutput(): string {
|
getOutput(): string {
|
||||||
return this._outputBuffer;
|
return this._textOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
getError(): string {
|
getError(): string {
|
||||||
@@ -208,28 +312,8 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
clearBuffers(): void {
|
clearBuffers(): void {
|
||||||
this._outputBuffer = '';
|
this._outputBuffer = '';
|
||||||
|
this._textOutput = '';
|
||||||
this._errorBuffer = '';
|
this._errorBuffer = '';
|
||||||
}
|
this._messages = [];
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// Claudeman App
|
||||||
|
class ClaudemanApp {
|
||||||
|
constructor() {
|
||||||
|
this.sessions = new Map();
|
||||||
|
this.currentRun = null;
|
||||||
|
this.totalCost = 0;
|
||||||
|
this.totalTasks = 0;
|
||||||
|
this.eventSource = null;
|
||||||
|
this.sessionsCollapsed = true;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.connectSSE();
|
||||||
|
this.loadState();
|
||||||
|
this.startTimerUpdates();
|
||||||
|
|
||||||
|
// Start with sessions collapsed
|
||||||
|
document.getElementById('sessionsPanel').classList.add('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE Connection
|
||||||
|
connectSSE() {
|
||||||
|
this.eventSource = new EventSource('/api/events');
|
||||||
|
|
||||||
|
this.eventSource.onopen = () => {
|
||||||
|
this.setConnectionStatus('connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.eventSource.onerror = () => {
|
||||||
|
this.setConnectionStatus('disconnected');
|
||||||
|
setTimeout(() => this.connectSSE(), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('init', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.handleInit(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('session:created', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.sessions.set(data.id, data);
|
||||||
|
this.renderSessions();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('session:updated', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.sessions.set(data.id, data);
|
||||||
|
this.updateStats();
|
||||||
|
this.renderSessions();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('session:output', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.appendOutput(data.data);
|
||||||
|
this.updateSessionOutput(data.id, data.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('session:message', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const msg = data.message;
|
||||||
|
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
const text = msg.message.content
|
||||||
|
.filter(b => b.type === 'text')
|
||||||
|
.map(b => b.text)
|
||||||
|
.join('');
|
||||||
|
if (text) {
|
||||||
|
this.appendOutput(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('session:completion', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.totalCost += data.cost || 0;
|
||||||
|
this.totalTasks++;
|
||||||
|
this.appendOutput(`\n\n✓ Completed (Cost: $${(data.cost || 0).toFixed(4)})\n`);
|
||||||
|
this.updateStats();
|
||||||
|
this.setRunning(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('session:error', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.appendOutput(`\n❌ Error: ${data.error}\n`, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('scheduled:created', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.currentRun = data;
|
||||||
|
this.showTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('scheduled:updated', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.currentRun = data;
|
||||||
|
this.updateTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('scheduled:completed', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.currentRun = data;
|
||||||
|
this.hideTimer();
|
||||||
|
this.appendOutput(`\n\n🎉 Scheduled run completed! Tasks: ${data.completedTasks}, Cost: $${data.totalCost.toFixed(4)}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('scheduled:stopped', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.currentRun = data;
|
||||||
|
this.hideTimer();
|
||||||
|
this.appendOutput(`\n\n⏹ Scheduled run stopped.\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('scheduled:log', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
this.appendOutput(`\n${data.log}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionStatus(status) {
|
||||||
|
const el = document.getElementById('connectionStatus');
|
||||||
|
const dot = el.querySelector('.status-dot');
|
||||||
|
const text = el.querySelector('span:last-child');
|
||||||
|
|
||||||
|
dot.className = 'status-dot ' + status;
|
||||||
|
text.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInit(data) {
|
||||||
|
this.sessions.clear();
|
||||||
|
data.sessions.forEach(s => this.sessions.set(s.id, s));
|
||||||
|
|
||||||
|
// Check for active scheduled run
|
||||||
|
const activeRun = data.scheduledRuns.find(r => r.status === 'running');
|
||||||
|
if (activeRun) {
|
||||||
|
this.currentRun = activeRun;
|
||||||
|
this.showTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0);
|
||||||
|
this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0);
|
||||||
|
this.totalTasks = data.scheduledRuns.reduce((sum, r) => sum + (r.completedTasks || 0), 0);
|
||||||
|
|
||||||
|
this.updateStats();
|
||||||
|
this.renderSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadState() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/status');
|
||||||
|
const data = await res.json();
|
||||||
|
this.handleInit(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load state:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async startRun() {
|
||||||
|
const prompt = document.getElementById('promptInput').value.trim();
|
||||||
|
const dir = document.getElementById('dirInput').value.trim();
|
||||||
|
const duration = parseInt(document.getElementById('durationInput').value) || 0;
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
alert('Please enter a prompt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setRunning(true);
|
||||||
|
this.clearOutput();
|
||||||
|
this.appendOutput('Starting...\n\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (duration > 0) {
|
||||||
|
// Scheduled run with timer
|
||||||
|
const res = await fetch('/api/scheduled', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
workingDir: dir || undefined,
|
||||||
|
durationMinutes: duration
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single run
|
||||||
|
const res = await fetch('/api/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
workingDir: dir || undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.appendOutput(`❌ Error: ${err.message}\n`, 'error');
|
||||||
|
this.setRunning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopCurrentRun() {
|
||||||
|
if (!this.currentRun) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/scheduled/${this.currentRun.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
alert('Error stopping run: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(running) {
|
||||||
|
const btn = document.getElementById('runBtn');
|
||||||
|
if (running) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="loading"></span> Running...';
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<span class="btn-icon">▶</span> Run';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output
|
||||||
|
appendOutput(text, type = '') {
|
||||||
|
const container = document.getElementById('outputContainer');
|
||||||
|
|
||||||
|
// Remove placeholder if present
|
||||||
|
const placeholder = container.querySelector('.output-placeholder');
|
||||||
|
if (placeholder) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = type ? `msg-${type}` : '';
|
||||||
|
span.textContent = text;
|
||||||
|
container.appendChild(span);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOutput() {
|
||||||
|
const container = document.getElementById('outputContainer');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="output-placeholder">
|
||||||
|
<div class="placeholder-icon">💬</div>
|
||||||
|
<div class="placeholder-text">Run a prompt to see Claude's output here</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
showTimer() {
|
||||||
|
document.getElementById('timerBanner').style.display = 'block';
|
||||||
|
this.updateTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
hideTimer() {
|
||||||
|
document.getElementById('timerBanner').style.display = 'none';
|
||||||
|
this.currentRun = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimer() {
|
||||||
|
if (!this.currentRun || this.currentRun.status !== 'running') return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const remaining = Math.max(0, this.currentRun.endAt - now);
|
||||||
|
const total = this.currentRun.endAt - this.currentRun.startedAt;
|
||||||
|
const elapsed = now - this.currentRun.startedAt;
|
||||||
|
const percent = Math.min(100, (elapsed / total) * 100);
|
||||||
|
|
||||||
|
document.getElementById('timerValue').textContent = this.formatTime(remaining);
|
||||||
|
document.getElementById('timerProgress').style.width = `${percent}%`;
|
||||||
|
document.getElementById('timerTasks').textContent = `${this.currentRun.completedTasks} tasks completed`;
|
||||||
|
document.getElementById('timerCost').textContent = `$${this.currentRun.totalCost.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimerUpdates() {
|
||||||
|
setInterval(() => this.updateTimer(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(ms) {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
updateStats() {
|
||||||
|
document.getElementById('statCost').textContent = `$${this.totalCost.toFixed(2)}`;
|
||||||
|
document.getElementById('statTasks').textContent = this.totalTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
toggleSessions() {
|
||||||
|
const panel = document.getElementById('sessionsPanel');
|
||||||
|
panel.classList.toggle('collapsed');
|
||||||
|
this.sessionsCollapsed = panel.classList.contains('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSessions() {
|
||||||
|
const count = this.sessions.size;
|
||||||
|
document.getElementById('sessionCount').textContent = count;
|
||||||
|
|
||||||
|
const list = document.getElementById('sessionsList');
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
list.innerHTML = '<div style="padding: 1rem; color: var(--text-muted);">No active sessions</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = Array.from(this.sessions.values()).map(s => `
|
||||||
|
<div class="session-card" data-session="${s.id}">
|
||||||
|
<div class="session-card-header">
|
||||||
|
<div class="session-status">
|
||||||
|
<span class="session-status-dot ${s.status}"></span>
|
||||||
|
<span>${s.id.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="session-cost">$${(s.totalCost || 0).toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-output" id="session-output-${s.id}">${this.escapeHtml(s.textOutput || '')}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSessionOutput(sessionId, text) {
|
||||||
|
const el = document.getElementById(`session-output-${sessionId}`);
|
||||||
|
if (el) {
|
||||||
|
el.textContent += text;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
const app = new ClaudemanApp();
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Claudeman</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="logo">⚡ Claudeman</h1>
|
||||||
|
<div class="connection-status" id="connectionStatus">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span>Connecting</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value" id="statCost">$0.00</span>
|
||||||
|
<span class="stat-label">Total Cost</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-value" id="statTasks">0</span>
|
||||||
|
<span class="stat-label">Tasks Done</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Timer Banner (shown when timed run is active) -->
|
||||||
|
<div class="timer-banner" id="timerBanner" style="display: none;">
|
||||||
|
<div class="timer-info">
|
||||||
|
<span class="timer-label">Time Remaining</span>
|
||||||
|
<span class="timer-value" id="timerValue">00:00:00</span>
|
||||||
|
</div>
|
||||||
|
<div class="timer-progress">
|
||||||
|
<div class="timer-progress-fill" id="timerProgress"></div>
|
||||||
|
</div>
|
||||||
|
<div class="timer-meta">
|
||||||
|
<span id="timerTasks">0 tasks completed</span>
|
||||||
|
<span id="timerCost">$0.00</span>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="app.stopCurrentRun()">Stop</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main">
|
||||||
|
<!-- Control Panel -->
|
||||||
|
<section class="control-panel">
|
||||||
|
<h2>Run Claude</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Prompt</label>
|
||||||
|
<textarea id="promptInput" placeholder="What should Claude do?"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Directory</label>
|
||||||
|
<input type="text" id="dirInput" placeholder="Current directory">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Duration (minutes)</label>
|
||||||
|
<input type="number" id="durationInput" value="0" min="0" max="720">
|
||||||
|
<span class="form-hint">0 = single run</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary btn-large" id="runBtn" onclick="app.startRun()">
|
||||||
|
<span class="btn-icon">▶</span>
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Output Panel -->
|
||||||
|
<section class="output-panel">
|
||||||
|
<div class="output-header">
|
||||||
|
<h2>Output</h2>
|
||||||
|
<div class="output-actions">
|
||||||
|
<button class="btn btn-sm" onclick="app.clearOutput()">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-container" id="outputContainer">
|
||||||
|
<div class="output-placeholder">
|
||||||
|
<div class="placeholder-icon">💬</div>
|
||||||
|
<div class="placeholder-text">Run a prompt to see Claude's output here</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Sessions Panel (collapsible) -->
|
||||||
|
<aside class="sessions-panel" id="sessionsPanel">
|
||||||
|
<div class="sessions-header" onclick="app.toggleSessions()">
|
||||||
|
<h3>Sessions <span class="session-count" id="sessionCount">0</span></h3>
|
||||||
|
<span class="toggle-icon" id="toggleIcon">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="sessions-list" id="sessionsList"></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
/* Variables */
|
||||||
|
:root {
|
||||||
|
--bg-dark: #0a0a0a;
|
||||||
|
--bg-card: #141414;
|
||||||
|
--bg-input: #1a1a1a;
|
||||||
|
--bg-hover: #222;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--text: #eee;
|
||||||
|
--text-dim: #888;
|
||||||
|
--text-muted: #555;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #60a5fa;
|
||||||
|
--green: #22c55e;
|
||||||
|
--yellow: #eab308;
|
||||||
|
--red: #ef4444;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected { background: var(--green); }
|
||||||
|
.status-dot.disconnected { background: var(--red); }
|
||||||
|
|
||||||
|
.header-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timer Banner */
|
||||||
|
.timer-banner {
|
||||||
|
background: linear-gradient(135deg, #1e3a5f 0%, #0f172a 100%);
|
||||||
|
border-bottom: 1px solid var(--accent);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
color: var(--accent-hover);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-progress {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main */
|
||||||
|
.main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 350px 1fr;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Control Panel */
|
||||||
|
.control-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--red);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Output Panel */
|
||||||
|
.output-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-container {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sessions Panel */
|
||||||
|
.sessions-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-panel.collapsed {
|
||||||
|
max-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-header:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-header h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: white;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-panel.collapsed .toggle-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0 1.5rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 300px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status-dot.idle { background: var(--green); }
|
||||||
|
.session-status-dot.busy { background: var(--yellow); animation: pulse 1.5s infinite; }
|
||||||
|
.session-status-dot.error { background: var(--red); }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-cost {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-output {
|
||||||
|
height: 120px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-output:empty::after {
|
||||||
|
content: 'Waiting...';
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message styling in output */
|
||||||
|
.msg-system { color: var(--text-muted); }
|
||||||
|
.msg-assistant { color: var(--text); }
|
||||||
|
.msg-error { color: var(--red); }
|
||||||
|
.msg-cost { color: var(--green); font-weight: 500; }
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg-dark); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-stats {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-panel {
|
||||||
|
position: relative;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sessions-list {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.loading {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { Session, ClaudeMessage } from '../session.js';
|
||||||
|
import { getStore } from '../state-store.js';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
interface ScheduledRun {
|
||||||
|
id: string;
|
||||||
|
prompt: string;
|
||||||
|
workingDir: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
startedAt: number;
|
||||||
|
endAt: number;
|
||||||
|
status: 'running' | 'completed' | 'failed' | 'stopped';
|
||||||
|
sessionId: string | null;
|
||||||
|
completedTasks: number;
|
||||||
|
totalCost: number;
|
||||||
|
logs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebServer extends EventEmitter {
|
||||||
|
private app: FastifyInstance;
|
||||||
|
private sessions: Map<string, Session> = new Map();
|
||||||
|
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||||
|
private sseClients: Set<FastifyReply> = new Set();
|
||||||
|
private store = getStore();
|
||||||
|
private port: number;
|
||||||
|
|
||||||
|
constructor(port: number = 3000) {
|
||||||
|
super();
|
||||||
|
this.port = port;
|
||||||
|
this.app = Fastify({ logger: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupRoutes(): Promise<void> {
|
||||||
|
// Serve static files
|
||||||
|
await this.app.register(fastifyStatic, {
|
||||||
|
root: join(__dirname, 'public'),
|
||||||
|
prefix: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
// SSE endpoint for real-time updates
|
||||||
|
this.app.get('/api/events', (req, reply) => {
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sseClients.add(reply);
|
||||||
|
|
||||||
|
// Send initial state
|
||||||
|
this.sendSSE(reply, 'init', this.getFullState());
|
||||||
|
|
||||||
|
req.raw.on('close', () => {
|
||||||
|
this.sseClients.delete(reply);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
this.app.get('/api/status', async () => this.getFullState());
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
this.app.get('/api/sessions', async () => this.getSessionsState());
|
||||||
|
|
||||||
|
this.app.post('/api/sessions', async (req) => {
|
||||||
|
const body = req.body as { workingDir?: string };
|
||||||
|
const workingDir = body.workingDir || process.cwd();
|
||||||
|
const session = new Session({ workingDir });
|
||||||
|
|
||||||
|
this.sessions.set(session.id, session);
|
||||||
|
this.setupSessionListeners(session);
|
||||||
|
|
||||||
|
this.broadcast('session:created', session.toDetailedState());
|
||||||
|
return { success: true, session: session.toDetailedState() };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.delete('/api/sessions/:id', async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { success: false, error: 'Session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.stop();
|
||||||
|
this.sessions.delete(id);
|
||||||
|
this.broadcast('session:deleted', { id });
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.get('/api/sessions/:id', async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { error: 'Session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.toDetailedState();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.get('/api/sessions/:id/output', async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { error: 'Session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
textOutput: session.textOutput,
|
||||||
|
messages: session.messages,
|
||||||
|
errorBuffer: session.errorBuffer,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run prompt in session
|
||||||
|
this.app.post('/api/sessions/:id/run', async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const { prompt } = req.body as { prompt: string };
|
||||||
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return { error: 'Session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.isBusy()) {
|
||||||
|
return { error: 'Session is busy' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run async, don't wait
|
||||||
|
session.runPrompt(prompt).catch(err => {
|
||||||
|
this.broadcast('session:error', { id, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.broadcast('session:running', { id, prompt });
|
||||||
|
return { success: true, message: 'Prompt started' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick run (create session, run prompt, return result)
|
||||||
|
this.app.post('/api/run', async (req) => {
|
||||||
|
const { prompt, workingDir } = req.body as { prompt: string; workingDir?: string };
|
||||||
|
const dir = workingDir || process.cwd();
|
||||||
|
|
||||||
|
const session = new Session({ workingDir: dir });
|
||||||
|
this.sessions.set(session.id, session);
|
||||||
|
this.setupSessionListeners(session);
|
||||||
|
|
||||||
|
this.broadcast('session:created', session.toDetailedState());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await session.runPrompt(prompt);
|
||||||
|
return { success: true, sessionId: session.id, ...result };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, sessionId: session.id, error: (err as Error).message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scheduled runs
|
||||||
|
this.app.get('/api/scheduled', async () => {
|
||||||
|
return Array.from(this.scheduledRuns.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.post('/api/scheduled', async (req) => {
|
||||||
|
const { prompt, workingDir, durationMinutes } = req.body as {
|
||||||
|
prompt: string;
|
||||||
|
workingDir?: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes);
|
||||||
|
return { success: true, run };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.delete('/api/scheduled/:id', async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const run = this.scheduledRuns.get(id);
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
return { success: false, error: 'Scheduled run not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.stopScheduledRun(id);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.get('/api/scheduled/:id', async (req) => {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const run = this.scheduledRuns.get(id);
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
return { error: 'Scheduled run not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return run;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSessionListeners(session: Session): void {
|
||||||
|
session.on('output', (data) => {
|
||||||
|
this.broadcast('session:output', { id: session.id, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('message', (msg: ClaudeMessage) => {
|
||||||
|
this.broadcast('session:message', { id: session.id, message: msg });
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('error', (error) => {
|
||||||
|
this.broadcast('session:error', { id: session.id, error });
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('completion', (result, cost) => {
|
||||||
|
this.broadcast('session:completion', { id: session.id, result, cost });
|
||||||
|
this.broadcast('session:updated', session.toDetailedState());
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('exit', (code) => {
|
||||||
|
this.broadcast('session:exit', { id: session.id, code });
|
||||||
|
this.broadcast('session:updated', session.toDetailedState());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise<ScheduledRun> {
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const run: ScheduledRun = {
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
workingDir,
|
||||||
|
durationMinutes,
|
||||||
|
startedAt: now,
|
||||||
|
endAt: now + durationMinutes * 60 * 1000,
|
||||||
|
status: 'running',
|
||||||
|
sessionId: null,
|
||||||
|
completedTasks: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
logs: [`[${new Date().toISOString()}] Scheduled run started`],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.scheduledRuns.set(id, run);
|
||||||
|
this.broadcast('scheduled:created', run);
|
||||||
|
|
||||||
|
// Start the run loop
|
||||||
|
this.runScheduledLoop(id);
|
||||||
|
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runScheduledLoop(runId: string): Promise<void> {
|
||||||
|
const run = this.scheduledRuns.get(runId);
|
||||||
|
if (!run || run.status !== 'running') return;
|
||||||
|
|
||||||
|
const addLog = (msg: string) => {
|
||||||
|
run.logs.push(`[${new Date().toISOString()}] ${msg}`);
|
||||||
|
this.broadcast('scheduled:log', { id: runId, log: run.logs[run.logs.length - 1] });
|
||||||
|
};
|
||||||
|
|
||||||
|
while (Date.now() < run.endAt && run.status === 'running') {
|
||||||
|
try {
|
||||||
|
// Create a session for this iteration
|
||||||
|
const session = new Session({ workingDir: run.workingDir });
|
||||||
|
this.sessions.set(session.id, session);
|
||||||
|
this.setupSessionListeners(session);
|
||||||
|
run.sessionId = session.id;
|
||||||
|
|
||||||
|
addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`);
|
||||||
|
this.broadcast('scheduled:updated', run);
|
||||||
|
|
||||||
|
// Run the prompt
|
||||||
|
const timeRemaining = Math.round((run.endAt - Date.now()) / 60000);
|
||||||
|
const enhancedPrompt = `${run.prompt}\n\nNote: You have approximately ${timeRemaining} minutes remaining in this scheduled run. Work efficiently.`;
|
||||||
|
|
||||||
|
const result = await session.runPrompt(enhancedPrompt);
|
||||||
|
run.completedTasks++;
|
||||||
|
run.totalCost += result.cost;
|
||||||
|
|
||||||
|
addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`);
|
||||||
|
this.broadcast('scheduled:updated', run);
|
||||||
|
|
||||||
|
// Small pause between iterations
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
} catch (err) {
|
||||||
|
addLog(`Error: ${(err as Error).message}`);
|
||||||
|
this.broadcast('scheduled:updated', run);
|
||||||
|
// Continue despite errors
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.status === 'running') {
|
||||||
|
run.status = 'completed';
|
||||||
|
addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcast('scheduled:completed', run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async stopScheduledRun(id: string): Promise<void> {
|
||||||
|
const run = this.scheduledRuns.get(id);
|
||||||
|
if (!run) return;
|
||||||
|
|
||||||
|
run.status = 'stopped';
|
||||||
|
run.logs.push(`[${new Date().toISOString()}] Run stopped by user`);
|
||||||
|
|
||||||
|
if (run.sessionId) {
|
||||||
|
const session = this.sessions.get(run.sessionId);
|
||||||
|
if (session) {
|
||||||
|
await session.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcast('scheduled:stopped', run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSessionsState() {
|
||||||
|
return Array.from(this.sessions.values()).map(s => s.toDetailedState());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFullState() {
|
||||||
|
return {
|
||||||
|
sessions: this.getSessionsState(),
|
||||||
|
scheduledRuns: Array.from(this.scheduledRuns.values()),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendSSE(reply: FastifyReply, event: string, data: unknown): void {
|
||||||
|
try {
|
||||||
|
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
|
} catch {
|
||||||
|
this.sseClients.delete(reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcast(event: string, data: unknown): void {
|
||||||
|
for (const client of this.sseClients) {
|
||||||
|
this.sendSSE(client, event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this.setupRoutes();
|
||||||
|
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||||
|
console.log(`Claudeman web interface running at http://localhost:${this.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
// Stop all sessions
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
await session.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop all scheduled runs
|
||||||
|
for (const [id] of this.scheduledRuns) {
|
||||||
|
await this.stopScheduledRun(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startWebServer(port: number = 3000): Promise<WebServer> {
|
||||||
|
const server = new WebServer(port);
|
||||||
|
await server.start();
|
||||||
|
return server;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user