chore: remove non-functional subagent input feature

Subagents (Task tool background agents) are autonomous and cannot
receive input mid-execution. They complete and return results to
the parent session. Removed the input UI and API endpoint.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-26 04:35:50 +01:00
co-authored by Claude Opus 4.5
parent 6187444118
commit 3f73a5f908
12 changed files with 1302 additions and 48 deletions
+251 -42
View File
@@ -115,6 +115,8 @@ export interface BashToolParserConfig {
sessionId: string;
/** Whether the parser is enabled (default: true) */
enabled?: boolean;
/** Working directory for resolving relative paths */
workingDir?: string;
}
// ========== BashToolParser Class ==========
@@ -140,6 +142,11 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
private _activeTools: Map<string, ActiveBashTool> = new Map();
private _lineBuffer: string = '';
private _lastToolId: string | null = null;
private _workingDir: string;
private _homeDir: string;
// Track normalized paths to detect equivalents (normalized -> original)
private _normalizedPathMap: Map<string, string> = new Map();
// Debouncing
private _pendingUpdate: boolean = false;
@@ -149,6 +156,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
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 ==========
@@ -168,8 +177,205 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
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.
*/
@@ -189,6 +395,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
*/
reset(): void {
this._activeTools.clear();
this._normalizedPathMap.clear();
this._lineBuffer = '';
this._lastToolId = null;
this.emitUpdate();
@@ -244,6 +451,11 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
if (this.isFileViewerCommand(command)) {
const filePaths = this.extractFilePaths(command);
// Skip if any file path is already tracked (cross-pattern dedup)
if (filePaths.some(fp => this.isFilePathTracked(fp))) {
return;
}
if (filePaths.length > 0) {
const tool: ActiveBashTool = {
id: uuidv4(),
@@ -309,22 +521,20 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
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);
// Don't add if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) {
return;
}
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;
}
@@ -337,32 +547,28 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// 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
);
// Skip if file path already tracked (cross-pattern dedup)
if (this.isFilePathTracked(filePath)) continue;
if (!isDuplicate) {
const tool: ActiveBashTool = {
id: uuidv4(),
command: `View: ${filePath}`,
filePaths: [filePath],
timeout: undefined,
startedAt: Date.now(),
status: 'running',
sessionId: this._sessionId,
};
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._activeTools.set(tool.id, tool);
this.emit('toolStart', tool);
this.scheduleUpdate();
// Auto-remove after 60 seconds
setTimeout(() => {
this._activeTools.delete(tool.id);
this.scheduleUpdate();
// Auto-remove after 60 seconds
setTimeout(() => {
this._activeTools.delete(tool.id);
this.scheduleUpdate();
}, 60000);
}
}, 60000);
}
}
@@ -385,9 +591,10 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
/**
* Extract file paths from a command string.
* Returns deduplicated paths, preferring more complete/absolute versions.
*/
private extractFilePaths(command: string): string[] {
const paths: string[] = [];
const rawPaths: string[] = [];
let match;
// Reset regex state
@@ -409,12 +616,13 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
// Clean up path (remove trailing punctuation)
const cleanPath = path.replace(/[,;:]+$/, '');
if (cleanPath && !paths.includes(cleanPath)) {
paths.push(cleanPath);
if (cleanPath) {
rawPaths.push(cleanPath);
}
}
return paths;
// Deduplicate paths that resolve to the same file
return this.deduplicatePaths(rawPaths);
}
/**
@@ -454,6 +662,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
this._updateTimer = null;
}
this._activeTools.clear();
this._normalizedPathMap.clear();
this.removeAllListeners();
}
}
+1 -1
View File
@@ -469,7 +469,7 @@ export class Session extends EventEmitter {
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._bashToolParser = new BashToolParser({ sessionId: this.id, workingDir: this.workingDir });
this._bashToolHandlers = {
toolStart: (tool) => this.emit('bashToolStart', tool),
toolEnd: (tool) => this.emit('bashToolEnd', tool),
+14
View File
@@ -665,8 +665,17 @@ class ClaudemanApp {
registerFilePathLinkProvider() {
const self = this;
// Debug: Track if provider is being invoked
let lastInvokedLine = -1;
this.terminal.registerLinkProvider({
provideLinks(bufferLineNumber, callback) {
// Debug logging - only log if line changed to avoid spam
if (bufferLineNumber !== lastInvokedLine) {
lastInvokedLine = bufferLineNumber;
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
}
const buffer = self.terminal.buffer.active;
const line = buffer.getLine(bufferLineNumber);
@@ -736,9 +745,14 @@ class ClaudemanApp {
addLink(match[1], match.index);
}
if (links.length > 0) {
console.debug('[LinkProvider] Found links:', links.map(l => l.text));
}
callback(links.length > 0 ? links : undefined);
}
});
console.log('[LinkProvider] File path link provider registered');
}
showWelcome() {
+6 -3
View File
@@ -1829,6 +1829,7 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed');
});
// ========== Hook Events ==========
this.app.post('/api/hook-event', async (req) => {
@@ -3005,6 +3006,11 @@ export class WebServer extends EventEmitter {
async start(): Promise<void> {
await this.setupRoutes();
// Restore screen sessions BEFORE accepting connections
// This prevents race conditions where clients connect before state is ready
await this.restoreScreenSessions();
await this.app.listen({ port: this.port, host: '0.0.0.0' });
const protocol = this.https ? 'https' : 'http';
console.log(`Claudeman web interface running at ${protocol}://localhost:${this.port}`);
@@ -3027,9 +3033,6 @@ export class WebServer extends EventEmitter {
this.recordPeriodicTokenUsage();
}, 5 * 60 * 1000);
// Restore screen sessions from previous run
await this.restoreScreenSessions();
// Start subagent watcher for Claude Code background agent visibility (if enabled)
if (this.isSubagentTrackingEnabled()) {
subagentWatcher.start();