import { tmpdir } from 'node:os' import type { RelayDispatcher, RequestContext } from './dispatcher' import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. import { expandTilde } from './context' import { DEFAULT_MAX_RESULTS, searchWithRg } from './fs-handler-utils' import { searchWithGitGrep } from './fs-handler-git-fallback' import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator' import { runListFilesScan } from './fs-list-files-fallback-chain' import { lstatRelayPath, readRelayDir, realpathRelayPath, statRelayPath } from './fs-path-metadata-requests' import { copyRelayPath, createRelayDir, createRelayDirNoClobber, createRelayFile, deleteRelayPath, renameRelayPath, renameRelayPathNoClobber, writeRelayFile } from './fs-path-mutation-requests' import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' import { readRelayFileRange } from './fs-handler-file-range' import { FileRangeReadRequestError } from '../shared/file-range-read' import { readVerifiedTerminalArtifact, writeVerifiedTerminalArtifact } from './fs-handler-terminal-artifact' import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' export class FsHandler { private dispatcher: RelayDispatcher private watchRegistry: RelayFilesystemWatchRegistry private streamRegistry = new RelayStreamRegistry() private listFilesScans = new ListFilesScanCoordinator() constructor( dispatcher: RelayDispatcher, _context: RelayContext, watcherPool?: RelayWatcherProcessPool ) { this.dispatcher = dispatcher this.watchRegistry = new RelayFilesystemWatchRegistry(dispatcher, watcherPool) this.registerHandlers() this.dispatcher.onClientDetached?.(() => { // Why: a detached client's fs.streamAck frames will never arrive; wake // any pump parked on the ack window so it re-checks staleness and exits // instead of stranding its open file handle. this.streamRegistry.wakeAllAckWaiters() }) } getWatchRegistry(): RelayFilesystemWatchRegistry { return this.watchRegistry } private registerHandlers(): void { this.dispatcher.onRequest('fs.readDir', (p) => readRelayDir(p)) this.dispatcher.onRequest('fs.readFile', (p) => this.readFile(p)) this.dispatcher.onRequest('fs.readFileStream', (p, c) => this.readFileStream(p, c)) this.dispatcher.onRequest('fs.readFileRange', (p) => this.readFileRange(p)) this.dispatcher.onRequest('fs.readTerminalArtifact', (p) => this.readTerminalArtifact(p)) this.dispatcher.onRequest('fs.tempDir', () => this.tempDir()) this.dispatcher.onRequest('fs.writeFile', (p) => writeRelayFile(p)) this.dispatcher.onRequest('fs.writeTerminalArtifact', (p) => this.writeTerminalArtifact(p)) this.dispatcher.onRequest('fs.stat', (p) => statRelayPath(p)) this.dispatcher.onRequest('fs.lstat', (p) => lstatRelayPath(p)) this.dispatcher.onRequest('fs.deletePath', (p) => deleteRelayPath(p, this.watchRegistry)) this.dispatcher.onRequest('fs.createFile', (p) => createRelayFile(p)) this.dispatcher.onRequest('fs.createDir', (p) => createRelayDir(p)) this.dispatcher.onRequest('fs.createDirNoClobber', (p) => createRelayDirNoClobber(p)) this.dispatcher.onRequest('fs.rename', (p) => renameRelayPath(p)) this.dispatcher.onRequest('fs.renameNoClobber', (p) => renameRelayPathNoClobber(p)) this.dispatcher.onRequest('fs.copy', (p) => copyRelayPath(p)) this.dispatcher.onRequest('fs.realpath', (p) => realpathRelayPath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.getCapabilities', async () => ({ quickOpenSearchVersion: 1, rangedReadVersion: 1 })) this.dispatcher.onRequest('fs.listFiles', (p, c) => this.listFiles(p, c)) this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) this.dispatcher.onRequest('fs.watch', (p, context) => this.watchRegistry.watch( expandTilde(p.rootPath as string), context, typeof p.watchId === 'number' && Number.isSafeInteger(p.watchId) ? p.watchId : undefined ) ) this.dispatcher.onRequest('fs.unwatchAndWait', (p, context) => this.watchRegistry.unwatchAndWait(expandTilde(p.rootPath as string), context) ) this.dispatcher.onNotification('fs.unwatch', (p, context) => this.watchRegistry.unwatch(expandTilde(p.rootPath as string), context) ) this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) this.dispatcher.onNotification('fs.streamAck', (p) => this.streamAck(p)) } private async readFile(params: Record) { const filePath = expandTilde(params.filePath as string) return readRelayFileContent(filePath) } // Why hand-checked: relay params arrive as raw casts with no schema. The // offsets are validated inside readRelayFileRange, next to the read syscall. private async readFileRange(params: Record) { if (typeof params.filePath !== 'string' || params.filePath.length === 0) { throw new FileRangeReadRequestError('fs.readFileRange requires a filePath') } return readRelayFileRange(expandTilde(params.filePath), params.position, params.length) } private async readTerminalArtifact(params: Record) { return readVerifiedTerminalArtifact({ ...params, filePath: expandTilde(params.filePath as string) }) } private async readFileStream(params: Record, context?: RequestContext) { const filePath = expandTilde(params.filePath as string) const ctx = context ?? { clientId: 0, isStale: () => false } return readRelayFileStreamMetadata(filePath, this.dispatcher, this.streamRegistry, ctx, { // Why: only target the requesting client when the dispatcher actually // routed this request (context present) — direct-call tests and legacy // paths keep broadcast semantics. ...(context ? { clientId: context.clientId } : {}), paceWithAcks: params.flowControl === 'ack' }) } private async tempDir(): Promise { return tmpdir() } private cancelStream(params: Record): void { const streamId = params.streamId as number | undefined if (typeof streamId === 'number') { this.streamRegistry.abort(streamId) } } private streamAck(params: Record): void { const streamId = params.streamId as number | undefined const seq = params.seq as number | undefined if (typeof streamId === 'number' && typeof seq === 'number') { this.streamRegistry.recordAck(streamId, seq) } } private async writeTerminalArtifact(params: Record) { return writeVerifiedTerminalArtifact({ ...params, filePath: expandTilde(params.filePath as string) }) } private async search(params: Record) { const query = params.query as string const rootPath = expandTilde(params.rootPath as string) const caseSensitive = params.caseSensitive as boolean | undefined const wholeWord = params.wholeWord as boolean | undefined const useRegex = params.useRegex as boolean | undefined const includePattern = params.includePattern as string | undefined const excludePattern = params.excludePattern as string | undefined const maxResults = Math.min( (params.maxResults as number) || DEFAULT_MAX_RESULTS, DEFAULT_MAX_RESULTS ) const options = { caseSensitive, wholeWord, useRegex, includePattern, excludePattern, maxResults } try { return await searchWithRg(rootPath, query, options) } catch (error) { if (!(error instanceof RipgrepUnavailableError)) { throw error } return searchWithGitGrep(rootPath, query, options) } } private listFiles(params: Record, context?: RequestContext): Promise { const rootPath = expandTilde(params.rootPath as string) const maxResults = typeof params.maxResults === 'number' && Number.isInteger(params.maxResults) && params.maxResults > 0 ? Math.min(params.maxResults, 20_001) : undefined const searchQuery = typeof params.searchQuery === 'string' && params.searchQuery.trim().length > 0 ? params.searchQuery : undefined // Why: the main-to-relay RPC adds excludePaths so nested linked worktrees // don't get double-scanned. The shared helper validates the shape and // normalizes into root-relative prefixes; malformed input yields [] so // the request still succeeds (older apps omit the field entirely). const excludePathPrefixes = buildExcludePathPrefixes(rootPath, params.excludePaths) // Why #7721: full-tree scans are the relay's most expensive request; the // coordinator caps them at one per client, coalescing duplicates and // aborting a stale scan when the workspace changes or the host cancels. return this.listFilesScans.run({ clientId: context?.clientId ?? 0, key: JSON.stringify([rootPath, excludePathPrefixes, maxResults, searchQuery]), signal: context?.signal, start: (signal) => runListFilesScan(rootPath, excludePathPrefixes, signal, maxResults, searchQuery) }) } private async workspaceSpaceScan(params: Record, context: RequestContext) { const rootPath = expandTilde(params.rootPath as string) return scanWorkspaceSpaceDirectory(rootPath, context) } dispose(): void { this.watchRegistry.dispose() void this.streamRegistry.disposeAll() } }