fix: rock-solid idle detection in respawn controller

- Add 300-char rolling window to catch working patterns split across PTY chunks
- Check completion message BEFORE working patterns (priority fix)
- Clear rolling window on completion message (transition point)
- Increase working pattern absence threshold from 3s to 8s
- Add Session.isWorking safety check before confirming idle
- Add 20+ more working patterns (Compiling, Building, Processing, etc.)
- Make AI idle checker prompt more conservative (err toward WORKING)
- Update documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-26 00:55:29 +01:00
co-authored by Claude Opus 4.5
parent 5b87a41aac
commit 9aa5973970
21 changed files with 2561 additions and 360 deletions
+17 -3
View File
@@ -73,8 +73,22 @@ const VERDICT_PATTERN = /^\s*(IDLE|WORKING)\b/i;
/** The prompt sent to the AI checker */
const AI_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the session is IDLE (done working, waiting for new input) or WORKING (still actively processing).
IDLE indicators: prompt character at the end, completion summary shown ("Worked for Xm Xs"), clear stopping point, no spinners, cost summary displayed
WORKING indicators: spinner chars, "Thinking"/"Writing"/"Reading"/"Running" text, active tool execution, truncated mid-output, partial lines
IMPORTANT: When in doubt, answer WORKING. Brief pauses between tool executions do NOT mean the session is idle. Claude may be processing or about to output more.
IDLE indicators (need MULTIPLE of these to confirm idle):
- Completion summary shown (e.g., "✻ Worked for 2m 46s", "Worked for 5s")
- Prompt character visible at the end (❯ or similar)
- Cost summary displayed (e.g., "$0.12 spent")
- Clear end of output with no pending work
WORKING indicators (ANY of these means WORKING):
- Spinner characters (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ or similar)
- Activity text: Thinking, Writing, Reading, Running, Searching, Editing, Creating, Deleting, Analyzing, Executing, Synthesizing, Compiling, Building, Processing, Loading, Generating, Testing, Checking, Validating
- Tool execution in progress (commands being run)
- Truncated or partial lines at the end
- File operations in progress
- Output that appears mid-stream or incomplete
- No completion summary visible yet
Terminal output (most recent at bottom):
---
@@ -82,7 +96,7 @@ Terminal output (most recent at bottom):
---
Answer with EXACTLY one word on the first line: IDLE or WORKING
Then optionally explain briefly why.`;
If uncertain, answer WORKING. Then briefly explain why.`;
// ========== AiIdleChecker Class ==========
+459
View File
@@ -0,0 +1,459 @@
/**
* @fileoverview Bash Tool Parser - Detects active Bash tool commands with file paths
*
* This module parses terminal output from Claude Code sessions to detect:
* - Bash tool invocations (● Bash(command) pattern)
* - File paths within commands (for tail, cat, head, grep, watch, less)
* - Tool completion (✓ or ✗ status)
*
* When a file-viewing command is detected, emits events with clickable paths
* that can be used to open live log viewer windows.
*
* @module bash-tool-parser
*/
import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
import { ActiveBashTool } from './types.js';
// ========== Configuration Constants ==========
/**
* Maximum number of active tools to track per session.
* Older tools are removed when this limit is reached.
*/
const MAX_ACTIVE_TOOLS = 20;
/**
* Debounce interval for event emissions (milliseconds).
* Prevents UI jitter from rapid consecutive updates.
*/
const EVENT_DEBOUNCE_MS = 50;
/**
* Maximum line buffer size to prevent unbounded growth from long lines.
*/
const MAX_LINE_BUFFER_SIZE = 64 * 1024;
// ========== Pre-compiled Regex Patterns ==========
/**
* Matches Bash tool invocation line from Claude Code output.
* Pattern: ● Bash(command) or ● Bash(command) timeout: 5m 0s
* The tool name can appear with or without the bullet point.
*
* Capture groups:
* - 1: The command being executed
* - 2: Optional timeout string
*/
const BASH_TOOL_START_PATTERN = /(?:^|\s)●?\s*Bash\((.+?)\)(?:\s+timeout:\s*([^\n]+))?/;
/**
* Matches tool completion indicators.
* ✓ indicates success, ✗ indicates failure.
*/
const TOOL_COMPLETION_PATTERN = /(?:✓|✗)\s+Bash/;
/**
* Commands that view/stream file content (worth tracking for live viewing).
* These are the commands where clicking to open a log viewer makes sense.
*/
const FILE_VIEWER_COMMANDS = /^(?:tail|cat|head|less|grep|watch|multitail)\s+/;
/**
* Alternative: Commands with -f flag (follow mode) are especially interesting
*/
const FOLLOW_MODE_PATTERN = /\s-[A-Za-z]*f[A-Za-z]*\s|\s--follow\s/;
/**
* Extracts file paths from a command string.
* Matches paths starting with / or ~ followed by path characters.
* Excludes common non-path patterns like flags.
*
* Note: This is a simpler approach - we run it on each command string
* rather than trying to match globally.
*/
const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([\/~][^\s'"<>|;&\n]+)/g;
/**
* Pattern to detect paths that are likely not real files (flags, etc.)
*/
const INVALID_PATH_PATTERN = /^[\/~]-|\/dev\/null$/;
/**
* Pattern to detect command suggestions in plain text output.
* Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper.
* This catches commands Claude mentions but doesn't execute.
*/
const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([\/~][^\s'"<>|;&\n]+)/;
/**
* Pattern to detect log file paths mentioned in text (even without commands).
* Matches paths ending in .log, .txt, .out, or in common log directories.
*/
const LOG_FILE_MENTION_PATTERN = /([\/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g;
// ========== Event Interfaces ==========
/**
* Events emitted by BashToolParser.
*/
export interface BashToolParserEvents {
/** New Bash tool with file paths started */
toolStart: [tool: ActiveBashTool];
/** Bash tool completed */
toolEnd: [tool: ActiveBashTool];
/** Active tools list updated */
toolsUpdate: [tools: ActiveBashTool[]];
}
/**
* Configuration options for BashToolParser.
*/
export interface BashToolParserConfig {
/** Session ID this parser belongs to */
sessionId: string;
/** Whether the parser is enabled (default: true) */
enabled?: boolean;
}
// ========== BashToolParser Class ==========
/**
* Parses Claude Code terminal output to detect Bash tool commands with file paths.
* Emits events when file-viewing commands are detected, allowing the UI to
* display clickable paths for opening live log viewers.
*
* @example
* ```typescript
* const parser = new BashToolParser({ sessionId: 'abc123' });
* parser.on('toolStart', (tool) => {
* console.log(`New tool: ${tool.command}`);
* console.log(`File paths: ${tool.filePaths.join(', ')}`);
* });
* parser.processTerminalData(terminalOutput);
* ```
*/
export class BashToolParser extends EventEmitter<BashToolParserEvents> {
private _sessionId: string;
private _enabled: boolean;
private _activeTools: Map<string, ActiveBashTool> = new Map();
private _lineBuffer: string = '';
private _lastToolId: string | null = null;
// Debouncing
private _pendingUpdate: boolean = false;
private _updateTimer: ReturnType<typeof setTimeout> | null = null;
constructor(config: BashToolParserConfig) {
super();
this._sessionId = config.sessionId;
this._enabled = config.enabled ?? true;
}
// ========== Public Accessors ==========
/** Whether the parser is currently enabled */
get enabled(): boolean {
return this._enabled;
}
/** Session ID this parser belongs to */
get sessionId(): string {
return this._sessionId;
}
/** Currently active tools */
get activeTools(): ActiveBashTool[] {
return Array.from(this._activeTools.values());
}
// ========== Public Methods ==========
/**
* Enables the parser.
*/
enable(): void {
this._enabled = true;
}
/**
* Disables the parser.
*/
disable(): void {
this._enabled = false;
}
/**
* Resets the parser state, clearing all tracked tools.
*/
reset(): void {
this._activeTools.clear();
this._lineBuffer = '';
this._lastToolId = null;
this.emitUpdate();
}
/**
* Process terminal data to detect Bash tool patterns.
* Call this with each chunk of PTY output.
*
* @param data - Raw terminal data (may include ANSI codes)
*/
processTerminalData(data: string): void {
if (!this._enabled) return;
// Append to line buffer
this._lineBuffer += data;
// Prevent unbounded growth
if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) {
const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2);
this._lineBuffer = trimPoint > 0
? this._lineBuffer.slice(trimPoint + 1)
: this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2);
}
// Process complete lines
const lines = this._lineBuffer.split('\n');
// Keep the last incomplete line in buffer
this._lineBuffer = lines.pop() || '';
for (const line of lines) {
this.processLine(line);
}
}
// ========== Private Methods ==========
/**
* Process a single line of terminal output.
*/
private processLine(line: string): void {
// Strip ANSI codes for cleaner pattern matching
const cleanLine = this.stripAnsi(line);
// Check for tool start
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
if (startMatch) {
const command = startMatch[1];
const timeout = startMatch[2]?.trim();
// Check if this is a file-viewing command
if (this.isFileViewerCommand(command)) {
const filePaths = this.extractFilePaths(command);
if (filePaths.length > 0) {
const tool: ActiveBashTool = {
id: uuidv4(),
command,
filePaths,
timeout,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
// Enforce max tools limit
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
// Remove oldest tool
const oldest = Array.from(this._activeTools.entries())
.sort((a, b) => a[1].startedAt - b[1].startedAt)[0];
if (oldest) {
this._activeTools.delete(oldest[0]);
}
}
this._activeTools.set(tool.id, tool);
this._lastToolId = tool.id;
this.emit('toolStart', tool);
this.scheduleUpdate();
}
}
return;
}
// Check for tool completion
if (TOOL_COMPLETION_PATTERN.test(cleanLine) && this._lastToolId) {
const tool = this._activeTools.get(this._lastToolId);
if (tool && tool.status === 'running') {
tool.status = 'completed';
this.emit('toolEnd', tool);
this.scheduleUpdate();
// Remove completed tool after a short delay to allow UI to show completion
setTimeout(() => {
this._activeTools.delete(tool.id);
this.scheduleUpdate();
}, 2000);
}
this._lastToolId = null;
return;
}
// Fallback: Check for command suggestions in plain text (e.g., "tail -f /tmp/file.log")
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
if (textCmdMatch) {
const filePath = textCmdMatch[2];
// Create a suggestion tool (marked as 'suggestion' status)
const tool: ActiveBashTool = {
id: uuidv4(),
command: cleanLine.trim(),
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running', // Shows as clickable
sessionId: this._sessionId,
};
// Don't add duplicates (same file path within last 5 seconds)
const isDuplicate = Array.from(this._activeTools.values()).some(
t => t.filePaths.includes(filePath) && (Date.now() - t.startedAt) < 5000
);
if (!isDuplicate) {
this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove suggestions after 30 seconds
setTimeout(() => {
this._activeTools.delete(tool.id);
this.scheduleUpdate();
}, 30000);
}
return;
}
// Last fallback: Check for log file paths mentioned anywhere in the line
LOG_FILE_MENTION_PATTERN.lastIndex = 0;
let logMatch;
while ((logMatch = LOG_FILE_MENTION_PATTERN.exec(cleanLine)) !== null) {
const filePath = logMatch[1].replace(/[,;:]+$/, ''); // Clean trailing punctuation
// Skip if it looks invalid
if (INVALID_PATH_PATTERN.test(filePath)) continue;
// Don't add duplicates
const isDuplicate = Array.from(this._activeTools.values()).some(
t => t.filePaths.includes(filePath) && (Date.now() - t.startedAt) < 10000
);
if (!isDuplicate) {
const tool: ActiveBashTool = {
id: uuidv4(),
command: `View: ${filePath}`,
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
this._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove after 60 seconds
setTimeout(() => {
this._activeTools.delete(tool.id);
this.scheduleUpdate();
}, 60000);
}
}
}
/**
* Check if a command is a file-viewing command worth tracking.
*/
private isFileViewerCommand(command: string): boolean {
// Commands that typically view files
if (FILE_VIEWER_COMMANDS.test(command)) {
return true;
}
// Any command with -f (follow) flag is interesting
if (FOLLOW_MODE_PATTERN.test(` ${command} `)) {
return true;
}
return false;
}
/**
* Extract file paths from a command string.
*/
private extractFilePaths(command: string): string[] {
const paths: string[] = [];
let match;
// Reset regex state
FILE_PATH_PATTERN.lastIndex = 0;
while ((match = FILE_PATH_PATTERN.exec(command)) !== null) {
const path = match[1];
// Skip invalid paths
if (INVALID_PATH_PATTERN.test(path)) {
continue;
}
// Skip if it looks like a flag (starts with -)
if (path.includes('/-')) {
continue;
}
// Clean up path (remove trailing punctuation)
const cleanPath = path.replace(/[,;:]+$/, '');
if (cleanPath && !paths.includes(cleanPath)) {
paths.push(cleanPath);
}
}
return paths;
}
/**
* Strip ANSI escape codes from a string.
*/
private stripAnsi(str: string): string {
// Comprehensive ANSI pattern
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
}
/**
* Schedule a debounced update emission.
*/
private scheduleUpdate(): void {
if (this._pendingUpdate) return;
this._pendingUpdate = true;
this._updateTimer = setTimeout(() => {
this._pendingUpdate = false;
this.emitUpdate();
}, EVENT_DEBOUNCE_MS);
}
/**
* Emit the current active tools list.
*/
private emitUpdate(): void {
this.emit('toolsUpdate', this.activeTools);
}
/**
* Clean up resources.
*/
destroy(): void {
if (this._updateTimer) {
clearTimeout(this._updateTimer);
this._updateTimer = null;
}
this._activeTools.clear();
this.removeAllListeners();
}
}
+426
View File
@@ -0,0 +1,426 @@
/**
* @fileoverview File Stream Manager - Manages tail -f processes for live log viewing
*
* This module spawns and manages `tail -f` processes for streaming live file
* content to the frontend. It handles:
* - Spawning tail processes with initial history
* - Streaming output via callbacks
* - Cleanup when streams are closed
* - Security validation (path within working directory)
*
* @module file-stream-manager
*/
import { spawn, ChildProcess } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { resolve, relative, isAbsolute } from 'node:path';
import { homedir } from 'node:os';
import { EventEmitter } from 'node:events';
// ========== Configuration Constants ==========
/**
* Default number of historical lines to show when opening a file.
*/
const DEFAULT_TAIL_LINES = 50;
/**
* Maximum file size to stream (100MB).
* Skip files larger than this to prevent memory issues.
*/
const MAX_FILE_SIZE = 100 * 1024 * 1024;
/**
* Maximum concurrent streams per session.
*/
const MAX_STREAMS_PER_SESSION = 5;
/**
* Inactivity timeout for streams (5 minutes).
* Streams with no data for this long will be auto-closed.
*/
const STREAM_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
// ========== Types ==========
/**
* Represents an active file stream.
*/
export interface FileStream {
/** Unique stream identifier */
id: string;
/** Session this stream belongs to */
sessionId: string;
/** Absolute path to the file being streamed */
filePath: string;
/** Tail process handle */
process: ChildProcess;
/** Timestamp when stream was created */
createdAt: number;
/** Timestamp of last data received */
lastDataAt: number;
/** Whether the stream is still active */
active: boolean;
/** Callback for sending data to client */
onData: (data: string) => void;
/** Callback for stream end */
onEnd: () => void;
/** Callback for errors */
onError: (error: string) => void;
}
/**
* Options for creating a file stream.
*/
export interface CreateStreamOptions {
/** Session ID requesting the stream */
sessionId: string;
/** Path to the file to stream */
filePath: string;
/** Working directory for path validation */
workingDir: string;
/** Number of historical lines to show (default: 50) */
lines?: number;
/** Callback for data */
onData: (data: string) => void;
/** Callback for stream end */
onEnd: () => void;
/** Callback for errors */
onError: (error: string) => void;
}
/**
* Result of creating a stream.
*/
export interface CreateStreamResult {
success: boolean;
streamId?: string;
error?: string;
}
// ========== FileStreamManager Class ==========
/**
* Manages file streaming via tail -f processes.
* Ensures security by validating paths and limiting concurrent streams.
*
* @example
* ```typescript
* const manager = new FileStreamManager();
*
* const result = await manager.createStream({
* sessionId: 'session-123',
* filePath: '/var/log/app.log',
* workingDir: '/var/log',
* onData: (data) => sseClient.write(data),
* onEnd: () => sseClient.close(),
* onError: (err) => console.error(err),
* });
*
* // Later, to stop:
* manager.closeStream(result.streamId);
* ```
*/
export class FileStreamManager extends EventEmitter {
private streams: Map<string, FileStream> = new Map();
private sessionStreamCounts: Map<string, number> = new Map();
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super();
// Start cleanup timer for inactive streams
this.cleanupTimer = setInterval(() => this.cleanupInactiveStreams(), 60 * 1000);
}
// ========== Public Methods ==========
/**
* Create a new file stream.
*
* @param options - Stream configuration
* @returns Result with stream ID on success, error on failure
*/
async createStream(options: CreateStreamOptions): Promise<CreateStreamResult> {
const { sessionId, filePath, workingDir, lines = DEFAULT_TAIL_LINES, onData, onEnd, onError } = options;
// Check concurrent stream limit for this session
const currentCount = this.sessionStreamCounts.get(sessionId) || 0;
if (currentCount >= MAX_STREAMS_PER_SESSION) {
return {
success: false,
error: `Maximum ${MAX_STREAMS_PER_SESSION} concurrent streams per session`,
};
}
// Resolve and validate path
const validationResult = this.validatePath(filePath, workingDir);
if (!validationResult.valid) {
return { success: false, error: validationResult.error };
}
const absolutePath = validationResult.absolutePath!;
// Check file exists and size
try {
const stats = statSync(absolutePath);
if (stats.size > MAX_FILE_SIZE) {
return {
success: false,
error: `File too large (${Math.round(stats.size / 1024 / 1024)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB limit)`,
};
}
} catch {
return { success: false, error: 'File not found or not accessible' };
}
// Generate stream ID
const streamId = `${sessionId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Spawn tail process
const tailProcess = spawn('tail', ['-f', '-n', String(lines), absolutePath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (!tailProcess.pid) {
return { success: false, error: 'Failed to spawn tail process' };
}
const stream: FileStream = {
id: streamId,
sessionId,
filePath: absolutePath,
process: tailProcess,
createdAt: Date.now(),
lastDataAt: Date.now(),
active: true,
onData,
onEnd,
onError,
};
// Handle stdout
tailProcess.stdout?.on('data', (data: Buffer) => {
if (stream.active) {
stream.lastDataAt = Date.now();
onData(data.toString());
}
});
// Handle stderr
tailProcess.stderr?.on('data', (data: Buffer) => {
if (stream.active) {
onError(data.toString());
}
});
// Handle process exit
tailProcess.on('exit', (_code) => {
if (stream.active) {
stream.active = false;
onEnd();
this.removeStream(streamId);
}
});
// Handle errors
tailProcess.on('error', (err) => {
if (stream.active) {
stream.active = false;
onError(err.message);
this.removeStream(streamId);
}
});
// Store stream
this.streams.set(streamId, stream);
this.sessionStreamCounts.set(sessionId, currentCount + 1);
return { success: true, streamId };
}
/**
* Close a file stream.
*
* @param streamId - ID of the stream to close
* @returns true if stream was closed, false if not found
*/
closeStream(streamId: string): boolean {
const stream = this.streams.get(streamId);
if (!stream) return false;
stream.active = false;
// Kill the tail process
try {
stream.process.kill('SIGTERM');
// Force kill after 1 second if still running
setTimeout(() => {
try {
stream.process.kill('SIGKILL');
} catch {
// Already dead
}
}, 1000);
} catch {
// Process may have already exited
}
stream.onEnd();
this.removeStream(streamId);
return true;
}
/**
* Close all streams for a session.
*
* @param sessionId - Session ID whose streams to close
* @returns Number of streams closed
*/
closeSessionStreams(sessionId: string): number {
let closed = 0;
for (const [streamId, stream] of this.streams) {
if (stream.sessionId === sessionId) {
this.closeStream(streamId);
closed++;
}
}
return closed;
}
/**
* Get all active streams for a session.
*
* @param sessionId - Session ID to query
* @returns Array of stream info
*/
getSessionStreams(sessionId: string): Array<{ id: string; filePath: string; createdAt: number }> {
const result: Array<{ id: string; filePath: string; createdAt: number }> = [];
for (const [, stream] of this.streams) {
if (stream.sessionId === sessionId && stream.active) {
result.push({
id: stream.id,
filePath: stream.filePath,
createdAt: stream.createdAt,
});
}
}
return result;
}
/**
* Get count of active streams.
*/
get activeStreamCount(): number {
return this.streams.size;
}
/**
* Clean up and destroy the manager.
*/
destroy(): void {
// Stop cleanup timer
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
// Close all streams
for (const streamId of this.streams.keys()) {
this.closeStream(streamId);
}
}
// ========== Private Methods ==========
/**
* Validate that a file path is safe to access.
* Must be within the working directory (no path traversal).
*/
private validatePath(
filePath: string,
workingDir: string
): { valid: boolean; absolutePath?: string; error?: string } {
// Expand ~ to home directory
let expandedPath = filePath;
if (expandedPath.startsWith('~')) {
expandedPath = expandedPath.replace(/^~/, homedir());
}
// Resolve to absolute path
const absolutePath = isAbsolute(expandedPath)
? resolve(expandedPath)
: resolve(workingDir, expandedPath);
// Normalize the working directory
const normalizedWorkingDir = resolve(workingDir);
// Check if the resolved path is within the working directory
// or common log directories
const allowedPaths = [
normalizedWorkingDir,
'/var/log',
'/tmp',
resolve(homedir(), '.local/share'),
resolve(homedir(), '.cache'),
resolve(homedir(), 'logs'),
];
const isAllowed = allowedPaths.some((allowed) => {
const rel = relative(allowed, absolutePath);
return rel && !rel.startsWith('..') && !isAbsolute(rel);
});
if (!isAllowed) {
return {
valid: false,
error: `Path must be within working directory or allowed log directories`,
};
}
// Check for path traversal attempts
if (absolutePath.includes('..')) {
return { valid: false, error: 'Path traversal not allowed' };
}
// Check file exists
if (!existsSync(absolutePath)) {
return { valid: false, error: 'File does not exist' };
}
return { valid: true, absolutePath };
}
/**
* Remove a stream from tracking.
*/
private removeStream(streamId: string): void {
const stream = this.streams.get(streamId);
if (stream) {
// Decrement session count
const count = this.sessionStreamCounts.get(stream.sessionId) || 0;
if (count <= 1) {
this.sessionStreamCounts.delete(stream.sessionId);
} else {
this.sessionStreamCounts.set(stream.sessionId, count - 1);
}
}
this.streams.delete(streamId);
}
/**
* Clean up streams that have been inactive for too long.
*/
private cleanupInactiveStreams(): void {
const now = Date.now();
for (const [streamId, stream] of this.streams) {
if (now - stream.lastDataAt > STREAM_INACTIVITY_TIMEOUT_MS) {
console.log(`[FileStreamManager] Closing inactive stream: ${streamId}`);
this.closeStream(streamId);
}
}
}
}
// Export singleton instance
export const fileStreamManager = new FileStreamManager();
+91 -35
View File
@@ -679,9 +679,28 @@ export class RespawnController extends EventEmitter {
'Thinking', 'Writing', 'Reading', 'Running', 'Searching',
'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing',
'Synthesizing', 'Brewing', // Claude's processing indicators
'Compiling', 'Building', 'Installing', 'Fetching', 'Downloading',
'Processing', 'Generating', 'Loading', 'Starting', 'Updating',
'Checking', 'Validating', 'Testing', 'Formatting', 'Linting',
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars
'◐', '◓', '◑', '◒', // Alternative spinners
'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷', // Braille spinners
];
/**
* Rolling window buffer for working pattern detection.
* Prevents split-chunk issues where "Thinking" arrives as "Thin" + "king".
* Size: 300 chars should be enough to catch any split pattern.
*/
private workingPatternWindow: string = '';
private static readonly WORKING_PATTERN_WINDOW_SIZE = 300;
/**
* Minimum time without working patterns before considering idle (ms).
* Increased from 3s to 8s to avoid false positives during tool execution gaps.
*/
private static readonly MIN_WORKING_PATTERN_ABSENCE_MS = 8000;
/**
* Creates a new RespawnController.
*
@@ -821,7 +840,7 @@ export class RespawnController extends EventEmitter {
const completionMessageDetected = this.completionMessageTime !== null;
const outputSilent = msSinceLastOutput >= this.config.completionConfirmMs;
const tokensStable = msSinceTokenChange >= this.config.completionConfirmMs;
const workingPatternsAbsent = msSinceLastWorking >= 3000; // 3s without working patterns
const workingPatternsAbsent = msSinceLastWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS;
// Calculate confidence level (0-100)
// Hook signals are definitive (100% confidence)
@@ -1176,6 +1195,42 @@ export class RespawnController extends EventEmitter {
this.lastTokenChangeTime = now;
}
// Detect completion message FIRST (Layer 1) - PRIMARY DETECTION
// Check this before working patterns because completion message indicates
// the work is done, even if working patterns are still in the rolling window
if (this.isCompletionMessage(data)) {
// Clear the rolling window - completion marks a transition point
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer
if (this._state === 'watching') {
this.startCompletionConfirmTimer();
return;
}
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
}
return;
}
// Detect working patterns (Layer 4)
const isWorking = this.hasWorkingPattern(data);
if (isWorking) {
@@ -1218,38 +1273,6 @@ export class RespawnController extends EventEmitter {
return;
}
// Detect completion message (Layer 1) - PRIMARY DETECTION
if (this.isCompletionMessage(data)) {
this.completionMessageTime = now;
this.workingDetected = false;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer
if (this._state === 'watching') {
this.startCompletionConfirmTimer();
return;
}
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
break;
case 'waiting_clear':
this.checkClearComplete(); // /clear is quick, no need to wait
break;
case 'waiting_init':
this.startStepConfirmTimer('init');
break;
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
}
return;
}
// In confirming_idle or ai_checking state, substantial output cancels the flow.
// This prevents false triggers when Claude pauses briefly mid-work.
if (this._state === 'confirming_idle' || this._state === 'ai_checking') {
@@ -1363,6 +1386,7 @@ export class RespawnController extends EventEmitter {
private startMonitoringInit(): void {
this.setState('monitoring_init');
this.terminalBuffer.clear();
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.logAction('step', 'Monitoring if /init triggered work...');
@@ -1404,6 +1428,7 @@ export class RespawnController extends EventEmitter {
private sendKickstart(): void {
this.setState('sending_kickstart');
this.terminalBuffer.clear();
this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer(
'step-delay',
@@ -1567,9 +1592,25 @@ export class RespawnController extends EventEmitter {
/**
* Check if data contains working patterns.
* Uses rolling window to catch patterns split across chunks (e.g., "Thin" + "king").
*/
private hasWorkingPattern(data: string): boolean {
return this.WORKING_PATTERNS.some(pattern => data.includes(pattern));
// Always update the rolling window first to maintain continuity
this.workingPatternWindow += data;
if (this.workingPatternWindow.length > RespawnController.WORKING_PATTERN_WINDOW_SIZE) {
this.workingPatternWindow = this.workingPatternWindow.slice(-RespawnController.WORKING_PATTERN_WINDOW_SIZE);
}
// Check the rolling window (includes current data, catches both complete and split patterns)
return this.WORKING_PATTERNS.some(pattern => this.workingPatternWindow.includes(pattern));
}
/**
* Clear the working pattern rolling window.
* Called when starting a new detection cycle.
*/
private clearWorkingPatternWindow(): void {
this.workingPatternWindow = '';
}
/**
@@ -1653,7 +1694,7 @@ export class RespawnController extends EventEmitter {
// Check pre-filter conditions
const silenceMet = msSinceOutput >= this.config.completionConfirmMs;
const noWorkingMet = msSinceWorking >= 3000;
const noWorkingMet = msSinceWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS;
const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs;
if (silenceMet && noWorkingMet && tokensStableMet) {
@@ -2250,6 +2291,17 @@ export class RespawnController extends EventEmitter {
* @param reason - What triggered the confirmation
*/
private onIdleConfirmed(reason: string): void {
// Safety check: if Session thinks it's still working, don't trigger idle
// This catches cases where our detection missed working patterns
if (this.session.isWorking) {
this.log(`Idle confirmation rejected - Session reports isWorking=true (reason was: ${reason})`);
this.logAction('detection', 'Rejected: Session still working');
this.setState('watching');
this.startNoOutputTimer();
this.startPreFilterTimer();
return;
}
this.log(`Idle confirmed via: ${reason}`);
const status = this.getDetectionStatus();
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
@@ -2292,6 +2344,7 @@ export class RespawnController extends EventEmitter {
private sendUpdateDocs(): void {
this.setState('sending_update');
this.terminalBuffer.clear(); // Clear buffer for fresh detection
this.clearWorkingPatternWindow(); // Clear rolling window
this.stepTimer = this.startTrackedTimer(
'step-delay',
@@ -2319,6 +2372,7 @@ export class RespawnController extends EventEmitter {
private sendClear(): void {
this.setState('sending_clear');
this.terminalBuffer.clear();
this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer(
'step-delay',
@@ -2361,6 +2415,7 @@ export class RespawnController extends EventEmitter {
private sendInit(): void {
this.setState('sending_init');
this.terminalBuffer.clear();
this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer(
'step-delay',
@@ -2390,6 +2445,7 @@ export class RespawnController extends EventEmitter {
// Go back to watching state for next cycle
this.setState('watching');
this.terminalBuffer.clear();
this.clearWorkingPatternWindow(); // Clear rolling window for fresh detection
this.promptDetected = false;
this.workingDetected = false;
this.resetHookState(); // Clear hook signals for next cycle
+49 -2
View File
@@ -21,13 +21,14 @@ import { existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem } from './types.js';
import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import { ScreenManager } from './screen-manager.js';
export type { BackgroundTask } from './task-tracker.js';
export type { RalphTrackerState, RalphTodoItem } from './types.js';
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
export { withTimeout };
// ============================================================================
@@ -292,6 +293,12 @@ export interface SessionEvents {
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
/** Ralph completion phrase detected */
ralphCompletionDetected: (phrase: string) => void;
/** Bash tool with file paths started */
bashToolStart: (tool: ActiveBashTool) => void;
/** Bash tool completed */
bashToolEnd: (tool: ActiveBashTool) => void;
/** Active Bash tools list updated */
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
}
/**
@@ -410,6 +417,14 @@ export class Session extends EventEmitter {
completionDetected: (phrase: string) => void;
} | null = null;
// Bash tool tracking (file paths for live log viewing)
private _bashToolParser: BashToolParser;
private _bashToolHandlers: {
toolStart: (tool: ActiveBashTool) => void;
toolEnd: (tool: ActiveBashTool) => void;
toolsUpdate: (tools: ActiveBashTool[]) => void;
} | null = null;
constructor(config: Partial<SessionConfig> & {
workingDir: string;
mode?: SessionMode;
@@ -453,6 +468,17 @@ export class Session extends EventEmitter {
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
// Initialize Bash tool parser and forward events (store handlers for cleanup)
this._bashToolParser = new BashToolParser({ sessionId: this.id });
this._bashToolHandlers = {
toolStart: (tool) => this.emit('bashToolStart', tool),
toolEnd: (tool) => this.emit('bashToolEnd', tool),
toolsUpdate: (tools) => this.emit('bashToolsUpdate', tools),
};
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
}
get status(): SessionStatus {
@@ -540,6 +566,15 @@ export class Session extends EventEmitter {
return this._ralphTracker.getTodoStats();
}
// Bash tool tracking getters
get bashToolParser(): BashToolParser {
return this._bashToolParser;
}
get activeTools(): ActiveBashTool[] {
return this._bashToolParser.activeTools;
}
get parentAgentId(): string | null {
return this._parentAgentId;
}
@@ -852,6 +887,9 @@ export class Session extends EventEmitter {
// Forward to Ralph tracker to detect Ralph loops and todos
this._ralphTracker.processTerminalData(data);
// Forward to Bash tool parser to detect file-viewing commands
this._bashToolParser.processTerminalData(data);
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
this.parseTokensFromStatusLine(data);
@@ -1503,6 +1541,15 @@ export class Session extends EventEmitter {
this._ralphHandlers = null;
}
// Remove BashToolParser handlers
if (this._bashToolHandlers) {
this._bashToolParser.off('toolStart', this._bashToolHandlers.toolStart);
this._bashToolParser.off('toolEnd', this._bashToolHandlers.toolEnd);
this._bashToolParser.off('toolsUpdate', this._bashToolHandlers.toolsUpdate);
this._bashToolHandlers = null;
}
this._bashToolParser.destroy();
}
/**
+28
View File
@@ -974,6 +974,34 @@ export function createInitialRunSummaryStats(): RunSummaryStats {
};
}
// ========== Active Bash Tool Types ==========
/**
* Status of an active Bash tool command.
*/
export type ActiveBashToolStatus = 'running' | 'completed';
/**
* Represents an active Bash tool command detected in Claude's output.
* Used to display clickable file paths for file-viewing commands.
*/
export interface ActiveBashTool {
/** Unique identifier for this tool invocation */
id: string;
/** The full command being executed */
command: string;
/** Extracted file paths from the command (clickable) */
filePaths: string[];
/** Timeout string if specified (e.g., "16m 0s") */
timeout?: string;
/** Timestamp when the tool started */
startedAt: number;
/** Current status */
status: ActiveBashToolStatus;
/** Session ID this tool belongs to */
sessionId: string;
}
// ========== Spawn1337 Protocol Re-exports ==========
export type {
+129 -3
View File
@@ -18,7 +18,8 @@ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from
import { execSync } from 'node:child_process';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { EventEmitter } from 'node:events';
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem } from '../session.js';
import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
import { SpawnOrchestrator, type SessionCreator } from '../spawn-orchestrator.js';
import type { SpawnOrchestratorConfig } from '../spawn-types.js';
@@ -502,7 +503,14 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return session.toDetailedState();
// Include respawn controller state if active
const controller = this.respawnControllers.get(id);
return {
...session.toDetailedState(),
respawnEnabled: controller?.getConfig()?.enabled ?? false,
respawnConfig: controller?.getConfig() ?? null,
respawn: controller?.getStatus() ?? null,
};
});
this.app.get('/api/sessions/:id/output', async (req) => {
@@ -565,6 +573,101 @@ export class WebServer extends EventEmitter {
return { success: true, summary: tracker.getSummary() };
});
// Get active Bash tools for a session (file-viewing commands)
this.app.get('/api/sessions/:id/active-tools', async (req) => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return {
success: true,
data: {
tools: session.activeTools,
}
};
});
// Stream file content via tail -f (SSE endpoint)
this.app.get('/api/sessions/:id/tail-file', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath, lines } = req.query as { path?: string; lines?: string };
const session = this.sessions.get(id);
if (!session) {
reply.code(404).send({ success: false, error: 'Session not found' });
return;
}
if (!filePath) {
reply.code(400).send({ success: false, error: 'Missing path parameter' });
return;
}
// Set up SSE headers
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Track stream for cleanup
const streamRef: { id?: string } = {};
// Create the file stream
const result = await fileStreamManager.createStream({
sessionId: id,
filePath,
workingDir: session.workingDir,
lines: lines ? parseInt(lines, 10) : undefined,
onData: (data) => {
// Send data as SSE event
reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`);
},
onEnd: () => {
reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`);
reply.raw.end();
},
onError: (error) => {
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`);
},
});
if (!result.success) {
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`);
reply.raw.end();
return;
}
streamRef.id = result.streamId;
// Notify client of successful connection
reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`);
// Handle client disconnect
req.raw.on('close', () => {
if (streamRef.id) {
fileStreamManager.closeStream(streamRef.id);
}
});
});
// Close a file stream
this.app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
const { id, streamId } = req.params as { id: string; streamId: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const closed = fileStreamManager.closeStream(streamId);
return { success: closed };
});
// Configure Ralph (Ralph Wiggum) settings
this.app.post('/api/sessions/:id/ralph-config', async (req) => {
const { id } = req.params as { id: string };
@@ -2019,6 +2122,8 @@ export class WebServer extends EventEmitter {
}
session.removeAllListeners();
// Close any active file streams for this session
fileStreamManager.closeSessionStreams(sessionId);
await session.stop(killScreen);
this.sessions.delete(sessionId);
// Only remove from state.json if we're also killing the screen.
@@ -2171,6 +2276,19 @@ export class WebServer extends EventEmitter {
if (tracker) tracker.recordRalphCompletion(phrase);
});
// Bash tool tracking events (for clickable file paths)
session.on('bashToolStart', (tool: ActiveBashTool) => {
this.broadcast('session:bashToolStart', { sessionId: session.id, tool });
});
session.on('bashToolEnd', (tool: ActiveBashTool) => {
this.broadcast('session:bashToolEnd', { sessionId: session.id, tool });
});
session.on('bashToolsUpdate', (tools: ActiveBashTool[]) => {
this.broadcast('session:bashToolsUpdate', { sessionId: session.id, tools });
});
}
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
@@ -2580,7 +2698,15 @@ export class WebServer extends EventEmitter {
}
private getSessionsState() {
return Array.from(this.sessions.values()).map(s => s.toDetailedState());
return Array.from(this.sessions.values()).map(s => {
const controller = this.respawnControllers.get(s.id);
return {
...s.toDetailedState(),
respawnEnabled: controller?.getConfig()?.enabled ?? false,
respawnConfig: controller?.getConfig() ?? null,
respawn: controller?.getStatus() ?? null,
};
});
}
// Clean up old completed scheduled runs