mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Knip-driven cleanup. All changes verified with tsc --noEmit, lint, and
build.
Removed (zero consumers):
- VERIFICATION_PROMPT constant + its barrel re-export
- createInitialOrchestratorPersistState factory
- transcriptWatcher singleton export
- createAnsiPatternFull / createAnsiPatternSimple factories
- TimerInfo interface + unused AiCheckResult/AiPlanCheckResult imports
in respawn-controller.ts
- 35 unused Zod z.infer \`*Input\` types in schemas.ts
- Dead re-exports: SessionMode from session.ts, AuthSessionRecord from
web/ports/index.ts, EnhancedPlanTask/CheckpointReview from
ralph-tracker.ts, 7 unused entries in utils/index.ts
- 14 event/config interfaces that lived only as JSDoc hints (no TS type
position usage): Session/Respawn/RalphLoop/RalphTracker/
SessionManager/SessionAutoOps/Subagent/TaskQueue/TaskTracker/
TranscriptWatcher/Image/OrchestratorLoop Events + RespawnPreset +
SessionOutput
Narrowed to module scope (kept but no longer exported):
- buildPermissionArgs in session-cli-builder.ts
- 28 type/interface declarations used only within their own file:
Ai{Idle,Plan}Check{Config,State}, BashToolParser{Events,Config},
FileStream/CreateStream{Options,Result}, PlanSubagentEvent,
SubagentCallback, RalphLoopConfig, RalphLoop{Events,Options},
ActiveTimerInfo, DetectionStatus, ActionLogEntry, AutoOpsCallbacks,
TunnelStatus, Timer/LRUMap/StaleExpirationMap Options, AuthState,
SessionListenerDeps, SseStreamManagerDeps, and 8 more
Docs: CLAUDE.md advice for global-regex `lastIndex` now points to the
remaining `execPattern()` helper instead of the deleted factories.
Knip delta: unused files 42→0, unused exports 161→16, unused types 92→0.
The 16 remaining exports are a mobile-test helper toolkit intentionally
kept for upcoming tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
689 lines
20 KiB
TypeScript
689 lines
20 KiB
TypeScript
/**
|
|
* @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';
|
|
import { CleanupManager, Debouncer, stripAnsi } from './utils/index.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.
|
|
*/
|
|
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.
|
|
*/
|
|
interface BashToolParserConfig {
|
|
/** Session ID this parser belongs to */
|
|
sessionId: string;
|
|
/** Whether the parser is enabled (default: true) */
|
|
enabled?: boolean;
|
|
/** Working directory for resolving relative paths */
|
|
workingDir?: string;
|
|
}
|
|
|
|
// ========== 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;
|
|
private _workingDir: string;
|
|
private _homeDir: string;
|
|
|
|
// Centralized resource cleanup for auto-remove timers
|
|
private cleanup = new CleanupManager();
|
|
|
|
// Flag to prevent operations after destroy
|
|
private _destroyed: boolean = false;
|
|
|
|
// Debouncing
|
|
private _updateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
|
|
|
constructor(config: BashToolParserConfig) {
|
|
super();
|
|
this._sessionId = config.sessionId;
|
|
this._enabled = config.enabled ?? true;
|
|
this._workingDir = config.workingDir || process.cwd();
|
|
this._homeDir = process.env.HOME || '/home/user';
|
|
}
|
|
|
|
// ========== 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());
|
|
}
|
|
|
|
/** Current working directory used for path resolution */
|
|
get workingDir(): string {
|
|
return this._workingDir;
|
|
}
|
|
|
|
// ========== Path Normalization ==========
|
|
|
|
/**
|
|
* Normalize a file path to its canonical form.
|
|
* - Expands ~ to home directory
|
|
* - Resolves relative paths against working directory
|
|
* - Normalizes . and .. components
|
|
* - Removes trailing slashes
|
|
*/
|
|
normalizePath(path: string): string {
|
|
if (!path) return '';
|
|
|
|
let normalized = path.trim();
|
|
|
|
// Expand ~ to home directory
|
|
if (normalized.startsWith('~/')) {
|
|
normalized = this._homeDir + normalized.slice(1);
|
|
} else if (normalized === '~') {
|
|
normalized = this._homeDir;
|
|
}
|
|
|
|
// If not absolute, resolve against working directory
|
|
if (!normalized.startsWith('/')) {
|
|
normalized = this._workingDir + '/' + normalized;
|
|
}
|
|
|
|
// Normalize path components (resolve . and ..)
|
|
const parts = normalized.split('/');
|
|
const stack: string[] = [];
|
|
|
|
for (const part of parts) {
|
|
if (part === '' || part === '.') {
|
|
continue;
|
|
} else if (part === '..') {
|
|
if (stack.length > 1) {
|
|
stack.pop();
|
|
}
|
|
} else {
|
|
stack.push(part);
|
|
}
|
|
}
|
|
|
|
return '/' + stack.join('/');
|
|
}
|
|
|
|
/**
|
|
* Extract just the filename from a path.
|
|
*/
|
|
private getFilename(path: string): string {
|
|
const parts = path.split('/');
|
|
return parts[parts.length - 1] || '';
|
|
}
|
|
|
|
/**
|
|
* Check if a path is a "shallow root path" - an absolute path with only one
|
|
* component after root (e.g., /test.txt, /file.log).
|
|
* These are often typos where the user meant a relative path.
|
|
*/
|
|
private isShallowRootPath(path: string): boolean {
|
|
if (!path.startsWith('/')) return false;
|
|
const parts = path.split('/').filter((p) => p !== '');
|
|
return parts.length === 1;
|
|
}
|
|
|
|
/**
|
|
* Check if a path is inside (or is) the working directory.
|
|
*/
|
|
isPathInWorkingDir(path: string): boolean {
|
|
const normalized = this.normalizePath(path);
|
|
return normalized.startsWith(this._workingDir + '/') || normalized === this._workingDir;
|
|
}
|
|
|
|
/**
|
|
* Smart path equivalence check.
|
|
* Two paths are considered equivalent if:
|
|
* 1. They normalize to the same path (standard case)
|
|
* 2. One is a "shallow root path" (e.g., /test.txt) and the other is the
|
|
* same filename inside the working directory - the shallow root path
|
|
* is likely a typo and they probably meant the same file.
|
|
*/
|
|
pathsAreEquivalent(path1: string, path2: string): boolean {
|
|
const norm1 = this.normalizePath(path1);
|
|
const norm2 = this.normalizePath(path2);
|
|
|
|
// Standard check: exact normalized match
|
|
if (norm1 === norm2) return true;
|
|
|
|
// Smart check: shallow root path vs working dir path with same filename
|
|
const file1 = this.getFilename(norm1);
|
|
const file2 = this.getFilename(norm2);
|
|
|
|
if (file1 !== file2) return false; // Different filenames, can't be equivalent
|
|
|
|
const shallow1 = this.isShallowRootPath(path1);
|
|
const shallow2 = this.isShallowRootPath(path2);
|
|
const inWorkDir1 = this.isPathInWorkingDir(norm1);
|
|
const inWorkDir2 = this.isPathInWorkingDir(norm2);
|
|
|
|
// If one is shallow root (e.g., /test.txt) and other is in working dir
|
|
// with same filename, treat as equivalent (user likely made a typo)
|
|
if (shallow1 && inWorkDir2) return true;
|
|
if (shallow2 && inWorkDir1) return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Given multiple paths, deduplicate and return the "best" paths.
|
|
* Uses smart equivalence checking:
|
|
* - Standard normalization for relative vs absolute paths
|
|
* - Detects likely typos (e.g., /file.txt when workingDir/file.txt exists)
|
|
* - Prefers paths inside the working directory
|
|
* - Prefers longer, more explicit paths
|
|
*/
|
|
deduplicatePaths(paths: string[]): string[] {
|
|
if (paths.length <= 1) return paths;
|
|
|
|
const result: string[] = [];
|
|
const seenNormalized = new Set<string>();
|
|
|
|
// Sort paths: prefer paths in working dir first, then by length (longer first)
|
|
const sortedPaths = [...paths].sort((a, b) => {
|
|
const aInWorkDir = this.isPathInWorkingDir(a);
|
|
const bInWorkDir = this.isPathInWorkingDir(b);
|
|
if (aInWorkDir && !bInWorkDir) return -1;
|
|
if (bInWorkDir && !aInWorkDir) return 1;
|
|
return b.length - a.length; // Longer paths first
|
|
});
|
|
|
|
for (const path of sortedPaths) {
|
|
const normalized = this.normalizePath(path);
|
|
|
|
// Check if we've already seen an equivalent path
|
|
let isDuplicate = false;
|
|
for (const existing of result) {
|
|
if (this.pathsAreEquivalent(path, existing)) {
|
|
isDuplicate = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!isDuplicate && !seenNormalized.has(normalized)) {
|
|
result.push(path);
|
|
seenNormalized.add(normalized);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ========== Public Methods ==========
|
|
|
|
/**
|
|
* Update the working directory (e.g., when session changes directory).
|
|
*/
|
|
setWorkingDir(workingDir: string): void {
|
|
this._workingDir = workingDir;
|
|
}
|
|
|
|
/**
|
|
* Check if a file path is already being tracked by an active tool.
|
|
* Uses path normalization to detect equivalent paths.
|
|
* For example, "/test.txt" and "/home/user/test.txt" when workingDir
|
|
* is "/home/user" would NOT be considered equivalent (different files),
|
|
* but "test.txt" and "/home/user/test.txt" WOULD be (same file).
|
|
*/
|
|
isFilePathTracked(filePath: string): boolean {
|
|
const normalizedNew = this.normalizePath(filePath);
|
|
|
|
return Array.from(this._activeTools.values()).some((t) => {
|
|
if (t.status !== 'running') return false;
|
|
|
|
return t.filePaths.some((existingPath) => {
|
|
const normalizedExisting = this.normalizePath(existingPath);
|
|
return normalizedExisting === normalizedNew;
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all tracked paths (normalized) for debugging.
|
|
*/
|
|
getTrackedPaths(): { raw: string; normalized: string }[] {
|
|
const paths: { raw: string; normalized: string }[] = [];
|
|
for (const tool of this._activeTools.values()) {
|
|
if (tool.status === 'running') {
|
|
for (const path of tool.filePaths) {
|
|
paths.push({ raw: path, normalized: this.normalizePath(path) });
|
|
}
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
/**
|
|
* 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 || this._destroyed) return;
|
|
|
|
// Append to line buffer (raw data — lines will be stripped individually in processLine)
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process pre-stripped terminal data (ANSI codes already removed).
|
|
* Use this when the caller has already stripped ANSI to avoid redundant regex work.
|
|
*/
|
|
processCleanData(data: string): void {
|
|
if (!this._enabled || this._destroyed) return;
|
|
|
|
this._lineBuffer += data;
|
|
|
|
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);
|
|
}
|
|
|
|
const lines = this._lineBuffer.split('\n');
|
|
this._lineBuffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
this.processCleanLine(line);
|
|
}
|
|
}
|
|
|
|
// ========== Private Methods ==========
|
|
|
|
/**
|
|
* Process a single line of terminal output (raw — will strip ANSI).
|
|
*/
|
|
private processLine(line: string): void {
|
|
const cleanLine = stripAnsi(line);
|
|
this.processCleanLine(cleanLine);
|
|
}
|
|
|
|
/**
|
|
* Process a single pre-stripped line of terminal output.
|
|
*/
|
|
private processCleanLine(cleanLine: string): void {
|
|
if (this._handleToolStart(cleanLine)) return;
|
|
if (this._handleToolCompletion(cleanLine)) return;
|
|
if (this._handleTextCommand(cleanLine)) return;
|
|
this._handleLogFileMention(cleanLine);
|
|
}
|
|
|
|
private _handleToolStart(cleanLine: string): boolean {
|
|
const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN);
|
|
if (!startMatch) return false;
|
|
|
|
const command = startMatch[1];
|
|
const timeout = startMatch[2]?.trim();
|
|
|
|
if (!this.isFileViewerCommand(command)) return true;
|
|
|
|
const filePaths = this.extractFilePaths(command);
|
|
|
|
// Skip if any file path is already tracked (cross-pattern dedup)
|
|
if (filePaths.some((fp) => this.isFilePathTracked(fp))) return true;
|
|
|
|
if (filePaths.length > 0) {
|
|
const tool = this._createActiveTool(command, filePaths, 'running', timeout);
|
|
|
|
// Enforce max tools limit
|
|
if (this._activeTools.size >= MAX_ACTIVE_TOOLS) {
|
|
// Remove oldest tool (O(n) min-scan instead of O(n log n) sort)
|
|
let oldestKey: string | undefined;
|
|
let oldestTime = Infinity;
|
|
for (const [key, entry] of this._activeTools) {
|
|
if (entry.startedAt < oldestTime) {
|
|
oldestTime = entry.startedAt;
|
|
oldestKey = key;
|
|
}
|
|
}
|
|
if (oldestKey) {
|
|
this._activeTools.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
this._activeTools.set(tool.id, tool);
|
|
this._lastToolId = tool.id;
|
|
|
|
this.emit('toolStart', tool);
|
|
this.scheduleUpdate();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private _handleToolCompletion(cleanLine: string): boolean {
|
|
if (!TOOL_COMPLETION_PATTERN.test(cleanLine) || !this._lastToolId) return false;
|
|
|
|
const tool = this._activeTools.get(this._lastToolId);
|
|
if (tool && tool.status === 'running') {
|
|
tool.status = 'completed';
|
|
this.emit('toolEnd', tool);
|
|
this.scheduleUpdate();
|
|
|
|
this._scheduleAutoRemove(tool.id, 2000, 'auto-remove completed tool');
|
|
}
|
|
this._lastToolId = null;
|
|
return true;
|
|
}
|
|
|
|
private _handleTextCommand(cleanLine: string): boolean {
|
|
const textCmdMatch = cleanLine.match(TEXT_COMMAND_PATTERN);
|
|
if (!textCmdMatch) return false;
|
|
|
|
const filePath = textCmdMatch[2];
|
|
|
|
// Don't add if file path already tracked (cross-pattern dedup)
|
|
if (this.isFilePathTracked(filePath)) return true;
|
|
|
|
const tool = this._createActiveTool(cleanLine.trim(), [filePath], 'running');
|
|
|
|
this._activeTools.set(tool.id, tool);
|
|
this.emit('toolStart', tool);
|
|
this.scheduleUpdate();
|
|
|
|
// Auto-remove suggestions after 30 seconds
|
|
this._scheduleAutoRemove(tool.id, 30000, 'auto-remove suggestion tool');
|
|
return true;
|
|
}
|
|
|
|
private _handleLogFileMention(cleanLine: string): void {
|
|
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;
|
|
|
|
// Skip if file path already tracked (cross-pattern dedup)
|
|
if (this.isFilePathTracked(filePath)) continue;
|
|
|
|
const tool = this._createActiveTool(`View: ${filePath}`, [filePath], 'running');
|
|
|
|
this._activeTools.set(tool.id, tool);
|
|
this.emit('toolStart', tool);
|
|
this.scheduleUpdate();
|
|
|
|
// Auto-remove after 60 seconds
|
|
this._scheduleAutoRemove(tool.id, 60000, 'auto-remove log file tool');
|
|
}
|
|
}
|
|
|
|
private _createActiveTool(
|
|
command: string,
|
|
filePaths: string[],
|
|
status: ActiveBashTool['status'],
|
|
timeout?: string
|
|
): ActiveBashTool {
|
|
return {
|
|
id: uuidv4(),
|
|
command,
|
|
filePaths,
|
|
timeout,
|
|
startedAt: Date.now(),
|
|
status,
|
|
sessionId: this._sessionId,
|
|
};
|
|
}
|
|
|
|
private _scheduleAutoRemove(toolId: string, delayMs: number, description: string): void {
|
|
this.cleanup.setTimeout(
|
|
() => {
|
|
if (this._destroyed) return;
|
|
this._activeTools.delete(toolId);
|
|
this.scheduleUpdate();
|
|
},
|
|
delayMs,
|
|
{ description }
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* Returns deduplicated paths, preferring more complete/absolute versions.
|
|
*/
|
|
private extractFilePaths(command: string): string[] {
|
|
const rawPaths: 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) {
|
|
rawPaths.push(cleanPath);
|
|
}
|
|
}
|
|
|
|
// Deduplicate paths that resolve to the same file
|
|
return this.deduplicatePaths(rawPaths);
|
|
}
|
|
|
|
/**
|
|
* Schedule a debounced update emission.
|
|
*/
|
|
private scheduleUpdate(): void {
|
|
this._updateDeb.schedule(() => {
|
|
this.emitUpdate();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Emit the current active tools list.
|
|
*/
|
|
private emitUpdate(): void {
|
|
this.emit('toolsUpdate', this.activeTools);
|
|
}
|
|
|
|
/**
|
|
* Clean up resources.
|
|
*/
|
|
destroy(): void {
|
|
this._destroyed = true;
|
|
this._updateDeb.dispose();
|
|
this.cleanup.dispose();
|
|
this._activeTools.clear();
|
|
this.removeAllListeners();
|
|
}
|
|
}
|