- Updated the README to reflect new features in Claude Code, including the ability to spawn subagents via the Task tool and coordinate teams with improved visibility. - Added details on the rendering of subagent sessions as expandable inline cards, including execution traces, metrics, and tool calls. - Enhanced description of teammate messages, highlighting color-coded cards and team lifecycle visibility. - Clarified session summary metrics to differentiate between teammate and subagent counts for better user insights. This commit significantly improves the documentation of team and subagent visualization features, providing users with a clearer understanding of the capabilities and enhancements in the application.
274 lines
7.7 KiB
TypeScript
274 lines
7.7 KiB
TypeScript
/**
|
|
* SshFileSystemProvider - FileSystemProvider backed by SSH2 SFTP.
|
|
*
|
|
* Wraps an ssh2 SFTPWrapper to provide the same filesystem interface
|
|
* used by session-data services, enabling remote file access.
|
|
*/
|
|
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import { PassThrough, type Readable } from 'stream';
|
|
|
|
import type {
|
|
FileSystemProvider,
|
|
FsDirent,
|
|
FsStatResult,
|
|
ReadStreamOptions,
|
|
} from './FileSystemProvider';
|
|
import type { SFTPWrapper } from 'ssh2';
|
|
|
|
const logger = createLogger('Infrastructure:SshFileSystemProvider');
|
|
|
|
export type SftpErrorKind = 'not_found' | 'transient' | 'permanent';
|
|
|
|
export class SshFileSystemProvider implements FileSystemProvider {
|
|
private static readonly MAX_RETRIES = 3;
|
|
private static readonly RETRY_BASE_DELAY_MS = 75;
|
|
|
|
readonly type = 'ssh' as const;
|
|
private sftp: SFTPWrapper;
|
|
|
|
constructor(sftp: SFTPWrapper) {
|
|
this.sftp = sftp;
|
|
}
|
|
|
|
async exists(filePath: string): Promise<boolean> {
|
|
try {
|
|
await this.stat(filePath);
|
|
return true;
|
|
} catch (error) {
|
|
const errorKind = this.classifySftpError(error);
|
|
if (errorKind === 'not_found') {
|
|
return false;
|
|
}
|
|
|
|
// For transient SFTP failures (e.g. code=4), avoid false negatives.
|
|
if (errorKind === 'transient') {
|
|
const code = this.getErrorCode(error);
|
|
logger.debug(
|
|
`exists(${filePath}) got retryable SFTP error (${String(code)}); treating path as potentially present`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
|
|
let lastError: unknown;
|
|
for (let attempt = 1; attempt <= SshFileSystemProvider.MAX_RETRIES; attempt++) {
|
|
try {
|
|
return await new Promise<string>((resolve, reject) => {
|
|
this.sftp.readFile(filePath, { encoding }, (err, data) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
resolve(data as unknown as string);
|
|
});
|
|
});
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (
|
|
this.classifySftpError(error) === 'transient' &&
|
|
attempt < SshFileSystemProvider.MAX_RETRIES
|
|
) {
|
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error(`Failed to read file: ${filePath}`);
|
|
}
|
|
|
|
async stat(filePath: string): Promise<FsStatResult> {
|
|
let lastError: unknown;
|
|
for (let attempt = 1; attempt <= SshFileSystemProvider.MAX_RETRIES; attempt++) {
|
|
try {
|
|
return await new Promise<FsStatResult>((resolve, reject) => {
|
|
this.sftp.stat(filePath, (err, stats) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
// SFTP stats use mode bitmask for file type detection
|
|
const S_IFMT = 0o170000;
|
|
const S_IFREG = 0o100000;
|
|
const S_IFDIR = 0o040000;
|
|
const mode = stats.mode;
|
|
|
|
resolve({
|
|
size: stats.size,
|
|
mtimeMs: (stats.mtime ?? 0) * 1000,
|
|
// SFTP doesn't provide birth time, use mtime as fallback
|
|
birthtimeMs: (stats.mtime ?? 0) * 1000,
|
|
isFile: () => (mode & S_IFMT) === S_IFREG,
|
|
isDirectory: () => (mode & S_IFMT) === S_IFDIR,
|
|
});
|
|
});
|
|
});
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (
|
|
this.classifySftpError(error) === 'transient' &&
|
|
attempt < SshFileSystemProvider.MAX_RETRIES
|
|
) {
|
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error(`Failed to stat: ${filePath}`);
|
|
}
|
|
|
|
async readdir(dirPath: string): Promise<FsDirent[]> {
|
|
let lastError: unknown;
|
|
for (let attempt = 1; attempt <= SshFileSystemProvider.MAX_RETRIES; attempt++) {
|
|
try {
|
|
return await new Promise<FsDirent[]>((resolve, reject) => {
|
|
this.sftp.readdir(dirPath, (err, list) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
const S_IFMT = 0o170000;
|
|
const S_IFREG = 0o100000;
|
|
const S_IFDIR = 0o040000;
|
|
|
|
const entries: FsDirent[] = [];
|
|
for (const item of list) {
|
|
const mode = item.attrs.mode;
|
|
entries.push(
|
|
this.buildDirent(
|
|
item.filename,
|
|
mode,
|
|
S_IFMT,
|
|
S_IFREG,
|
|
S_IFDIR,
|
|
item.attrs.size,
|
|
item.attrs.mtime
|
|
)
|
|
);
|
|
}
|
|
resolve(entries);
|
|
});
|
|
});
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (
|
|
this.classifySftpError(error) === 'transient' &&
|
|
attempt < SshFileSystemProvider.MAX_RETRIES
|
|
) {
|
|
await this.sleep(SshFileSystemProvider.RETRY_BASE_DELAY_MS * attempt);
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error
|
|
? lastError
|
|
: new Error(`Failed to read directory: ${dirPath}`);
|
|
}
|
|
|
|
createReadStream(filePath: string, opts?: ReadStreamOptions): Readable {
|
|
try {
|
|
const sftpStream = this.sftp.createReadStream(filePath, {
|
|
start: opts?.start,
|
|
encoding: opts?.encoding ?? undefined,
|
|
});
|
|
|
|
// Wrap in PassThrough to ensure Node Readable compatibility
|
|
const passthrough = new PassThrough();
|
|
sftpStream.pipe(passthrough);
|
|
sftpStream.on('error', (err: Error) => {
|
|
passthrough.destroy(err);
|
|
});
|
|
|
|
return passthrough;
|
|
} catch (err) {
|
|
logger.error(`Error creating read stream for ${filePath}:`, err);
|
|
// Return an errored stream
|
|
const errStream = new PassThrough();
|
|
process.nextTick(() => errStream.destroy(err as Error));
|
|
return errStream;
|
|
}
|
|
}
|
|
|
|
dispose(): void {
|
|
try {
|
|
this.sftp.end();
|
|
} catch {
|
|
// Ignore errors during cleanup
|
|
}
|
|
}
|
|
|
|
private async sleep(ms: number): Promise<void> {
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
private getErrorCode(error: unknown): string {
|
|
if (typeof error === 'object' && error !== null && 'code' in error) {
|
|
const code = (error as { code?: unknown }).code;
|
|
if (typeof code === 'number') {
|
|
return String(code);
|
|
}
|
|
if (typeof code === 'string') {
|
|
return code;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
private isNotFoundError(error: unknown): boolean {
|
|
const code = this.getErrorCode(error);
|
|
return code === '2' || code === 'ENOENT';
|
|
}
|
|
|
|
private isRetryableError(error: unknown): boolean {
|
|
const code = this.getErrorCode(error);
|
|
return (
|
|
code === '4' ||
|
|
code === 'EAGAIN' ||
|
|
code === 'ECONNRESET' ||
|
|
code === 'ETIMEDOUT' ||
|
|
code === 'EPIPE'
|
|
);
|
|
}
|
|
|
|
private classifySftpError(error: unknown): SftpErrorKind {
|
|
if (this.isNotFoundError(error)) {
|
|
return 'not_found';
|
|
}
|
|
if (this.isRetryableError(error)) {
|
|
return 'transient';
|
|
}
|
|
return 'permanent';
|
|
}
|
|
|
|
private buildDirent(
|
|
filename: string,
|
|
mode: number,
|
|
sifmt: number,
|
|
sifreg: number,
|
|
sifdir: number,
|
|
size?: number,
|
|
mtimeSeconds?: number
|
|
): FsDirent {
|
|
const mtimeMs = typeof mtimeSeconds === 'number' ? mtimeSeconds * 1000 : undefined;
|
|
return {
|
|
name: filename,
|
|
isFile: () => (mode & sifmt) === sifreg,
|
|
isDirectory: () => (mode & sifmt) === sifdir,
|
|
size,
|
|
mtimeMs,
|
|
birthtimeMs: mtimeMs,
|
|
};
|
|
}
|
|
}
|