Files
Codeman/src/bash-tool-parser.ts
T
arkonandClaude Opus 4.7 02e2f3e8b5 refactor: remove dead code and narrow internal exports (knip sweep)
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>
2026-04-23 11:57:00 +02:00

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();
}
}