add ssh suport

This commit is contained in:
matt 2026-02-11 13:34:12 +00:00
parent e2670a3a02
commit 4b56186f7c
32 changed files with 1817 additions and 121 deletions

View file

@ -52,6 +52,7 @@
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"ssh2": "^1.17.0",
"unified": "^11.0.5",
"zustand": "^4.5.0"
},
@ -64,6 +65,7 @@
"@types/node": "^25.0.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/ssh2": "^1.15.5",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^3.1.4",
"autoprefixer": "^10.4.17",

View file

@ -50,6 +50,9 @@ importers:
remark-parse:
specifier: ^11.0.0
version: 11.0.0
ssh2:
specifier: ^1.17.0
version: 1.17.0
unified:
specifier: ^11.0.5
version: 11.0.5
@ -81,6 +84,9 @@ importers:
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.27)
'@types/ssh2':
specifier: ^1.15.5
version: 1.15.5
'@vitejs/plugin-react':
specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@25.0.7))
@ -1072,6 +1078,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@24.10.12':
resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==}
@ -1095,6 +1104,9 @@ packages:
'@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@ -1421,6 +1433,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
assert-plus@1.0.0:
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
engines: {node: '>=0.8'}
@ -1489,6 +1504,9 @@ packages:
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
hasBin: true
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@ -1534,6 +1552,10 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buildcheck@0.0.7:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
builder-util-runtime@9.2.4:
resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==}
engines: {node: '>=12.0.0'}
@ -1686,6 +1708,10 @@ packages:
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cpu-features@0.0.10:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
@ -2967,6 +2993,9 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nan@2.25.0:
resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -3522,6 +3551,10 @@ packages:
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'}
@ -3719,6 +3752,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -3764,6 +3800,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@ -4771,6 +4810,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@24.10.12':
dependencies:
undici-types: 7.16.0
@ -4800,6 +4843,10 @@ snapshots:
dependencies:
'@types/node': 25.0.7
'@types/ssh2@1.15.5':
dependencies:
'@types/node': 18.19.130
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@ -5224,6 +5271,10 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assert-plus@1.0.0:
optional: true
@ -5275,6 +5326,10 @@ snapshots:
baseline-browser-mapping@2.9.14: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
binary-extensions@2.3.0: {}
bl@4.1.0:
@ -5324,6 +5379,9 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
buildcheck@0.0.7:
optional: true
builder-util-runtime@9.2.4:
dependencies:
debug: 4.4.3
@ -5498,6 +5556,12 @@ snapshots:
core-util-is@1.0.3: {}
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.25.0
optional: true
crc-32@1.2.2: {}
crc32-stream@4.0.3:
@ -7265,6 +7329,9 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nan@2.25.0:
optional: true
nanoid@3.3.11: {}
napi-postinstall@0.3.4: {}
@ -7864,6 +7931,14 @@ snapshots:
sprintf-js@1.1.3:
optional: true
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.25.0
stable-hash-x@0.2.0: {}
stackback@0.0.2: {}
@ -8121,6 +8196,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tweetnacl@0.14.5: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -8184,6 +8261,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@7.16.0: {}
unified@11.0.5:

View file

@ -35,6 +35,8 @@ const getIconPath = (): string => {
};
const logger = createLogger('App');
import { SSH_STATUS } from '@preload/constants/ipcChannels';
import {
ChunkBuilder,
configManager,
@ -62,6 +64,9 @@ let dataCache: DataCache;
let fileWatcher: FileWatcher;
let notificationManager: NotificationManager;
let updaterService: UpdaterService;
let sshConnectionManager: InstanceType<
typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager
>;
let cleanupInterval: NodeJS.Timeout | null = null;
/**
@ -70,6 +75,13 @@ let cleanupInterval: NodeJS.Timeout | null = null;
function initializeServices(): void {
logger.info('Initializing services...');
// Initialize SSH connection manager
const { SshConnectionManager: SshConnMgr } =
require('./services/infrastructure/SshConnectionManager') as {
SshConnectionManager: typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager;
};
sshConnectionManager = new SshConnMgr();
// Initialize services (paths are set automatically from environment)
projectScanner = new ProjectScanner();
sessionParser = new SessionParser(projectScanner);
@ -81,16 +93,59 @@ function initializeServices(): void {
logger.info(`Projects directory: ${projectScanner.getProjectsDir()}`);
// Initialize IPC handlers
// Mode switch callback: recreates services with new provider when switching local↔SSH
const handleModeSwitch = async (mode: 'local' | 'ssh'): Promise<void> => {
logger.info(`Switching to ${mode} mode`);
// Stop file watcher
fileWatcher.stop();
// Clear data cache
dataCache.clear();
// Get provider and projects path from connection manager
const provider = sshConnectionManager.getProvider();
const projectsDir =
mode === 'ssh' ? (sshConnectionManager.getRemoteProjectsPath() ?? undefined) : undefined;
// Recreate services with new provider
projectScanner = new ProjectScanner(projectsDir, undefined, provider);
sessionParser = new SessionParser(projectScanner);
subagentResolver = new SubagentResolver(projectScanner);
// Update file watcher provider
fileWatcher.setFileSystemProvider(provider);
// Restart file watcher
fileWatcher.start();
// Notify renderer to re-fetch all data
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(SSH_STATUS, sshConnectionManager.getStatus());
}
logger.info(`Mode switch to ${mode} complete`);
};
// Initialize IPC handlers (including SSH)
initializeIpcHandlers(
projectScanner,
sessionParser,
subagentResolver,
chunkBuilder,
dataCache,
updaterService
updaterService,
sshConnectionManager,
handleModeSwitch
);
// Forward SSH state changes to renderer
sshConnectionManager.on('state-change', (status: unknown) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(SSH_STATUS, status);
}
});
// Initialize notification manager using singleton pattern
// This ensures IPC handlers and FileWatcher use the same instance
// Note: mainWindow will be set later via setMainWindow() when window is created
@ -138,6 +193,11 @@ function shutdownServices(): void {
cleanupInterval = null;
}
// Dispose SSH connection manager
if (sshConnectionManager) {
sshConnectionManager.dispose();
}
// Remove IPC handlers
removeIpcHandlers();

View file

@ -10,6 +10,7 @@
* - utility.ts: Shell operations and file reading
* - notifications.ts: Notification management
* - config.ts: App configuration
* - ssh.ts: SSH connection management
*/
import { createLogger } from '@shared/utils/logger';
@ -30,6 +31,7 @@ import {
registerSessionHandlers,
removeSessionHandlers,
} from './sessions';
import { initializeSshHandlers, registerSshHandlers, removeSshHandlers } from './ssh';
import {
initializeSubagentHandlers,
registerSubagentHandlers,
@ -48,6 +50,7 @@ import type {
DataCache,
ProjectScanner,
SessionParser,
SshConnectionManager,
SubagentResolver,
UpdaterService,
} from '../services';
@ -61,7 +64,9 @@ export function initializeIpcHandlers(
resolver: SubagentResolver,
builder: ChunkBuilder,
cache: DataCache,
updater: UpdaterService
updater: UpdaterService,
sshManager?: SshConnectionManager,
sshModeSwitchCallback?: (mode: 'local' | 'ssh') => Promise<void>
): void {
// Initialize domain handlers with their required services
initializeProjectHandlers(scanner);
@ -69,6 +74,9 @@ export function initializeIpcHandlers(
initializeSearchHandlers(scanner);
initializeSubagentHandlers(builder, cache, parser, resolver);
initializeUpdaterHandlers(updater);
if (sshManager && sshModeSwitchCallback) {
initializeSshHandlers(sshManager, sshModeSwitchCallback);
}
// Register all handlers
registerProjectHandlers(ipcMain);
@ -80,6 +88,9 @@ export function initializeIpcHandlers(
registerNotificationHandlers(ipcMain);
registerConfigHandlers(ipcMain);
registerUpdaterHandlers(ipcMain);
if (sshManager) {
registerSshHandlers(ipcMain);
}
logger.info('All handlers registered');
}
@ -98,6 +109,7 @@ export function removeIpcHandlers(): void {
removeNotificationHandlers(ipcMain);
removeConfigHandlers(ipcMain);
removeUpdaterHandlers(ipcMain);
removeSshHandlers(ipcMain);
logger.info('All handlers removed');
}

107
src/main/ipc/ssh.ts Normal file
View file

@ -0,0 +1,107 @@
/**
* SSH IPC Handlers - Manages SSH connection lifecycle from renderer requests.
*
* Channels:
* - ssh:connect - Connect to SSH host, switch to remote mode
* - ssh:disconnect - Disconnect and switch back to local mode
* - ssh:getState - Get current connection state
* - ssh:test - Test connection without switching
*/
import {
SSH_CONNECT,
SSH_DISCONNECT,
SSH_GET_STATE,
SSH_TEST,
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import type {
SshConnectionConfig,
SshConnectionManager,
SshConnectionStatus,
} from '../services/infrastructure/SshConnectionManager';
import type { IpcMain } from 'electron';
const logger = createLogger('IPC:ssh');
// =============================================================================
// Module State
// =============================================================================
let connectionManager: SshConnectionManager;
let onModeSwitch: ((mode: 'local' | 'ssh') => Promise<void>) | null = null;
// =============================================================================
// Initialization
// =============================================================================
/**
* Initialize SSH handlers with required services.
* @param manager - The SSH connection manager instance
* @param modeSwitchCallback - Called when switching between local/SSH mode
*/
export function initializeSshHandlers(
manager: SshConnectionManager,
modeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>
): void {
connectionManager = manager;
onModeSwitch = modeSwitchCallback;
}
// =============================================================================
// Handler Registration
// =============================================================================
export function registerSshHandlers(ipcMain: IpcMain): void {
ipcMain.handle(SSH_CONNECT, async (_event, config: SshConnectionConfig) => {
try {
await connectionManager.connect(config);
if (onModeSwitch) {
await onModeSwitch('ssh');
}
return { success: true, data: connectionManager.getStatus() };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('SSH connect failed:', message);
return { success: false, error: message };
}
});
ipcMain.handle(SSH_DISCONNECT, async () => {
try {
connectionManager.disconnect();
if (onModeSwitch) {
await onModeSwitch('local');
}
return { success: true, data: connectionManager.getStatus() };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('SSH disconnect failed:', message);
return { success: false, error: message };
}
});
ipcMain.handle(SSH_GET_STATE, async (): Promise<SshConnectionStatus> => {
return connectionManager.getStatus();
});
ipcMain.handle(SSH_TEST, async (_event, config: SshConnectionConfig) => {
try {
const result = await connectionManager.testConnection(config);
return { success: true, data: result };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
});
logger.info('SSH handlers registered');
}
export function removeSshHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(SSH_CONNECT);
ipcMain.removeHandler(SSH_DISCONNECT);
ipcMain.removeHandler(SSH_GET_STATE);
ipcMain.removeHandler(SSH_TEST);
}

View file

@ -137,7 +137,7 @@ async function handleReadClaudeMdFiles(
projectRoot: string
): Promise<Record<string, ClaudeMdFileInfo>> {
try {
const result = readAllClaudeMdFiles(projectRoot);
const result = await readAllClaudeMdFiles(projectRoot);
// Convert Map to object for IPC serialization
const files: Record<string, ClaudeMdFileInfo> = {};
result.files.forEach((info, key) => {
@ -160,7 +160,7 @@ async function handleReadDirectoryClaudeMd(
dirPath: string
): Promise<ClaudeMdFileInfo> {
try {
const info = readDirectoryClaudeMd(dirPath);
const info = await readDirectoryClaudeMd(dirPath);
return info;
} catch (error) {
logger.error(`Error in read-directory-claude-md:`, error);

View file

@ -9,14 +9,16 @@
* Results are memoized per projectId and can be invalidated by file watcher events.
*/
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
import { extractCwd } from '@main/utils/jsonl';
import { decodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { subprojectRegistry } from './SubprojectRegistry';
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
const logger = createLogger('Discovery:ProjectPathResolver');
interface ResolveProjectPathOptions {
@ -27,10 +29,12 @@ interface ResolveProjectPathOptions {
export class ProjectPathResolver {
private readonly projectsDir: string;
private readonly fsProvider: FileSystemProvider;
private readonly projectPathCache = new Map<string, string>();
constructor(projectsDir?: string) {
constructor(projectsDir?: string, fsProvider?: FileSystemProvider) {
this.projectsDir = projectsDir ?? getProjectsBasePath();
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
}
/**
@ -64,7 +68,7 @@ export class ProjectPathResolver {
const sessionPaths = opts.sessionPaths?.length
? opts.sessionPaths
: this.listSessionPaths(projectId);
: await this.listSessionPaths(projectId);
for (const sessionPath of sessionPaths) {
try {
@ -97,14 +101,14 @@ export class ProjectPathResolver {
this.projectPathCache.clear();
}
private listSessionPaths(projectId: string): string[] {
private async listSessionPaths(projectId: string): Promise<string[]> {
const projectDir = path.join(this.projectsDir, extractBaseDir(projectId));
if (!fs.existsSync(projectDir)) {
if (!(await this.fsProvider.exists(projectDir))) {
return [];
}
try {
const entries = fs.readdirSync(projectDir, { withFileTypes: true });
const entries = await this.fsProvider.readdir(projectDir);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
.map((entry) => path.join(projectDir, entry.name));

View file

@ -37,12 +37,15 @@ import {
isValidEncodedPath,
} from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider';
import { SessionContentFilter } from './SessionContentFilter';
import { subprojectRegistry } from './SubprojectRegistry';
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
const logger = createLogger('Discovery:ProjectScanner');
import { ProjectPathResolver } from './ProjectPathResolver';
import { SessionSearcher } from './SessionSearcher';
@ -65,22 +68,24 @@ export class ProjectScanner {
>();
// Delegated services
private readonly fsProvider: FileSystemProvider;
private readonly sessionContentFilter: typeof SessionContentFilter;
private readonly worktreeGrouper: WorktreeGrouper;
private readonly subagentLocator: SubagentLocator;
private readonly sessionSearcher: SessionSearcher;
private readonly projectPathResolver: ProjectPathResolver;
constructor(projectsDir?: string, todosDir?: string) {
constructor(projectsDir?: string, todosDir?: string, fsProvider?: FileSystemProvider) {
this.projectsDir = projectsDir ?? getProjectsBasePath();
this.todosDir = todosDir ?? getTodosBasePath();
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
// Initialize delegated services
this.sessionContentFilter = SessionContentFilter;
this.worktreeGrouper = new WorktreeGrouper(this.projectsDir);
this.subagentLocator = new SubagentLocator(this.projectsDir);
this.sessionSearcher = new SessionSearcher(this.projectsDir);
this.projectPathResolver = new ProjectPathResolver(this.projectsDir);
this.worktreeGrouper = new WorktreeGrouper(this.projectsDir, this.fsProvider);
this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider);
this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider);
this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider);
}
// ===========================================================================
@ -93,7 +98,7 @@ export class ProjectScanner {
*/
async scan(): Promise<Project[]> {
try {
if (!fs.existsSync(this.projectsDir)) {
if (!(await this.fsProvider.exists(this.projectsDir))) {
logger.warn(`Projects directory does not exist: ${this.projectsDir}`);
return [];
}
@ -101,7 +106,7 @@ export class ProjectScanner {
// Clear the subproject registry on full re-scan
subprojectRegistry.clear();
const entries = fs.readdirSync(this.projectsDir, { withFileTypes: true });
const entries = await this.fsProvider.readdir(this.projectsDir);
// Filter to only directories with valid encoding pattern
const projectDirs = entries.filter(
@ -176,7 +181,7 @@ export class ProjectScanner {
private async scanProject(encodedName: string): Promise<Project[]> {
try {
const projectPath = path.join(this.projectsDir, encodedName);
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
const entries = await this.fsProvider.readdir(projectPath);
// Get session files (.jsonl at root level)
const sessionFiles = entries.filter(
@ -199,10 +204,10 @@ export class ProjectScanner {
const sessionInfos: SessionInfo[] = await Promise.all(
sessionFiles.map(async (file) => {
const filePath = path.join(projectPath, file.name);
const stats = fs.statSync(filePath);
const stats = await this.fsProvider.stat(filePath);
let cwd: string | null = null;
try {
cwd = await extractCwd(filePath);
cwd = await extractCwd(filePath, this.fsProvider);
} catch {
// Ignore unreadable files
}
@ -328,7 +333,7 @@ export class ProjectScanner {
const baseDir = extractBaseDir(projectId);
const projectPath = path.join(this.projectsDir, baseDir);
if (!fs.existsSync(projectPath)) {
if (!(await this.fsProvider.exists(projectPath))) {
return null;
}
@ -356,11 +361,11 @@ export class ProjectScanner {
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
if (!fs.existsSync(projectPath)) {
if (!(await this.fsProvider.exists(projectPath))) {
return [];
}
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
const entries = await this.fsProvider.readdir(projectPath);
let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
// Filter to only sessions belonging to this subproject
@ -421,12 +426,12 @@ export class ProjectScanner {
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
if (!fs.existsSync(projectPath)) {
if (!(await this.fsProvider.exists(projectPath))) {
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
}
// Step 1: Get all session files with their timestamps (lightweight stat calls)
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
const entries = await this.fsProvider.readdir(projectPath);
let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
// Filter to only sessions belonging to this subproject
@ -447,7 +452,7 @@ export class ProjectScanner {
for (const file of sessionFiles) {
const filePath = path.join(projectPath, file.name);
try {
const stats = fs.statSync(filePath);
const stats = await this.fsProvider.stat(filePath);
fileInfos.push({
name: file.name,
sessionId: extractSessionId(file.name),
@ -590,18 +595,18 @@ export class ProjectScanner {
filePath: string,
projectPath: string
): Promise<Session> {
const stats = fs.statSync(filePath);
const stats = await this.fsProvider.stat(filePath);
const cachedMetadata = this.sessionMetadataCache.get(filePath);
const metadata =
cachedMetadata?.mtimeMs === stats.mtimeMs
? cachedMetadata.metadata
: await analyzeSessionFileMetadata(filePath);
: await analyzeSessionFileMetadata(filePath, this.fsProvider);
if (cachedMetadata?.mtimeMs !== stats.mtimeMs) {
this.sessionMetadataCache.set(filePath, { mtimeMs: stats.mtimeMs, metadata });
}
// Check for subagents (delegated to SubagentLocator)
const hasSubagents = this.subagentLocator.hasSubagentsSync(projectId, sessionId);
const hasSubagents = await this.subagentLocator.hasSubagents(projectId, sessionId);
// Load task list data if exists
const todoData = await this.loadTodoData(sessionId);
@ -627,7 +632,7 @@ export class ProjectScanner {
async getSession(projectId: string, sessionId: string): Promise<Session | null> {
const filePath = this.getSessionPath(projectId, sessionId);
if (!fs.existsSync(filePath)) {
if (!(await this.fsProvider.exists(filePath))) {
return null;
}
@ -646,11 +651,11 @@ export class ProjectScanner {
try {
const todoPath = buildTodoPath(path.dirname(this.projectsDir), sessionId);
if (!fs.existsSync(todoPath)) {
if (!(await this.fsProvider.exists(todoPath))) {
return undefined;
}
const content = fs.readFileSync(todoPath, 'utf8');
const content = await this.fsProvider.readFile(todoPath);
return JSON.parse(content) as unknown;
} catch (error) {
// Log but continue - task list data is non-critical
@ -686,11 +691,11 @@ export class ProjectScanner {
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
if (!fs.existsSync(projectPath)) {
if (!(await this.fsProvider.exists(projectPath))) {
return [];
}
const entries = fs.readdirSync(projectPath, { withFileTypes: true });
const entries = await this.fsProvider.readdir(projectPath);
let files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'));
@ -754,8 +759,8 @@ export class ProjectScanner {
/**
* Checks if the projects directory exists.
*/
projectsDirExists(): boolean {
return fs.existsSync(this.projectsDir);
async projectsDirExists(): Promise<boolean> {
return this.fsProvider.exists(this.projectsDir);
}
// ===========================================================================
@ -803,13 +808,16 @@ export class ProjectScanner {
*/
private async hasDisplayableContent(filePath: string, mtimeMs?: number): Promise<boolean> {
try {
const effectiveMtime = mtimeMs ?? fs.statSync(filePath).mtimeMs;
const effectiveMtime = mtimeMs ?? (await this.fsProvider.stat(filePath)).mtimeMs;
const cached = this.contentPresenceCache.get(filePath);
if (cached?.mtimeMs === effectiveMtime) {
return cached.hasContent;
}
const hasContent = await this.sessionContentFilter.hasNonNoiseMessages(filePath);
const hasContent = await this.sessionContentFilter.hasNonNoiseMessages(
filePath,
this.fsProvider
);
this.contentPresenceCache.set(filePath, { mtimeMs: effectiveMtime, hasContent });
return hasContent;
} catch {

View file

@ -21,13 +21,17 @@
* - synthetic assistant messages (model='<synthetic>')
*/
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
import { type ChatHistoryEntry, type ContentBlock } from '@main/types';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as readline from 'readline';
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
const logger = createLogger('Service:SessionContentFilter');
const defaultProvider = new LocalFileSystemProvider();
/**
* Hard noise tags - user messages with ONLY these tags are filtered out.
*/
@ -54,12 +58,15 @@ export class SessionContentFilter {
* @param filePath - Path to the session JSONL file
* @returns Promise resolving to true if session has displayable content
*/
static async hasNonNoiseMessages(filePath: string): Promise<boolean> {
if (!fs.existsSync(filePath)) {
static async hasNonNoiseMessages(
filePath: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<boolean> {
if (!(await fsProvider.exists(filePath))) {
return false;
}
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,

View file

@ -9,6 +9,7 @@
*/
import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder';
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
import {
isEnhancedAIChunk,
isUserChunk,
@ -25,11 +26,12 @@ import {
extractMarkdownPlainText,
findMarkdownSearchMatches,
} from '@shared/utils/markdownTextSearch';
import * as fs from 'fs';
import * as path from 'path';
import { subprojectRegistry } from './SubprojectRegistry';
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
const logger = createLogger('Discovery:SessionSearcher');
interface SearchableEntry {
@ -47,10 +49,12 @@ interface SearchableEntry {
export class SessionSearcher {
private readonly projectsDir: string;
private readonly chunkBuilder: ChunkBuilder;
private readonly fsProvider: FileSystemProvider;
constructor(projectsDir: string) {
constructor(projectsDir: string, fsProvider?: FileSystemProvider) {
this.projectsDir = projectsDir;
this.chunkBuilder = new ChunkBuilder();
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
}
/**
@ -81,14 +85,12 @@ export class SessionSearcher {
const projectPath = path.join(this.projectsDir, baseDir);
const sessionFilter = subprojectRegistry.getSessionFilter(projectId);
try {
await fs.promises.access(projectPath, fs.constants.R_OK);
} catch {
if (!(await this.fsProvider.exists(projectPath))) {
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
// Get all session files
const entries = await fs.promises.readdir(projectPath, { withFileTypes: true });
const entries = await this.fsProvider.readdir(projectPath);
const sessionFilesWithTime = await Promise.all(
entries
.filter((entry) => {
@ -103,7 +105,7 @@ export class SessionSearcher {
.map(async (entry) => {
const filePath = path.join(projectPath, entry.name);
try {
const stats = await fs.promises.stat(filePath);
const stats = await this.fsProvider.stat(filePath);
return { name: entry.name, filePath, mtimeMs: stats.mtimeMs };
} catch {
return null;
@ -168,7 +170,7 @@ export class SessionSearcher {
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
let sessionTitle: string | undefined;
const messages = await parseJsonlFile(filePath);
const messages = await parseJsonlFile(filePath, this.fsProvider);
const chunks = this.chunkBuilder.buildChunks(messages, []);
for (const chunk of chunks) {

View file

@ -10,11 +10,14 @@
* - Determine subagent ownership for OLD structure
*/
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
import { buildSubagentsPath, extractBaseDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
const logger = createLogger('Discovery:SubagentLocator');
/**
@ -22,20 +25,55 @@ const logger = createLogger('Discovery:SubagentLocator');
*/
export class SubagentLocator {
private readonly projectsDir: string;
private readonly fsProvider: FileSystemProvider;
constructor(projectsDir: string) {
constructor(projectsDir: string, fsProvider?: FileSystemProvider) {
this.projectsDir = projectsDir;
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
}
/**
* Checks if a session has subagent files (async).
* Uses the FileSystemProvider for filesystem access.
*
* @param projectId - The project ID
* @param sessionId - The session ID
* @returns Promise resolving to true if subagents exist
*/
async hasSubagents(projectId: string, sessionId: string): Promise<boolean> {
return this.hasSubagentsSync(projectId, sessionId);
// Check NEW structure: {projectId}/{sessionId}/subagents/
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (await this.fsProvider.exists(newSubagentsPath)) {
try {
const entries = await this.fsProvider.readdir(newSubagentsPath);
const subagentFiles = entries.filter(
(entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
);
// Check if at least one subagent file has content (not empty)
for (const entry of subagentFiles) {
const filePath = path.join(newSubagentsPath, entry.name);
try {
const stats = await this.fsProvider.stat(filePath);
// File must have size > 0 and contain at least one line
if (stats.size > 0) {
const content = await this.fsProvider.readFile(filePath);
if (content.trim().length > 0) {
return true;
}
}
} catch (error) {
// Skip this file if we can't read it - log for debugging
logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error);
continue;
}
}
} catch {
// Ignore errors
}
}
return false;
}
/**
@ -97,8 +135,8 @@ export class SubagentLocator {
try {
// Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
if (fs.existsSync(newSubagentsPath)) {
const entries = fs.readdirSync(newSubagentsPath, { withFileTypes: true });
if (await this.fsProvider.exists(newSubagentsPath)) {
const entries = await this.fsProvider.readdir(newSubagentsPath);
const newFiles = entries
.filter(
(entry) =>
@ -138,14 +176,14 @@ export class SubagentLocator {
try {
const projectPath = path.join(this.projectsDir, extractBaseDir(projectId));
if (!fs.existsSync(projectPath)) {
if (!(await this.fsProvider.exists(projectPath))) {
return [];
}
const files = fs.readdirSync(projectPath);
const agentFiles = files
.filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl'))
.map((f) => path.join(projectPath, f));
const entries = await this.fsProvider.readdir(projectPath);
const agentFiles = entries
.filter((entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl'))
.map((entry) => path.join(projectPath, entry.name));
// Filter files by checking if their sessionId matches
const matchingFiles: string[] = [];
@ -173,7 +211,7 @@ export class SubagentLocator {
async subagentBelongsToSession(filePath: string, sessionId: string): Promise<boolean> {
try {
// Read just the first line to check sessionId
const content = fs.readFileSync(filePath, 'utf-8');
const content = await this.fsProvider.readFile(filePath);
const firstNewline = content.indexOf('\n');
const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content;

View file

@ -17,19 +17,24 @@ import {
import { extractBaseDir } from '@main/utils/pathDecoder';
import * as path from 'path';
import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { SessionContentFilter } from './SessionContentFilter';
import { subprojectRegistry } from './SubprojectRegistry';
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
/**
* WorktreeGrouper provides methods for grouping projects by git repository.
*/
export class WorktreeGrouper {
private readonly projectsDir: string;
private readonly fsProvider: FileSystemProvider;
constructor(projectsDir: string) {
constructor(projectsDir: string, fsProvider?: FileSystemProvider) {
this.projectsDir = projectsDir;
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
}
/**
@ -79,7 +84,7 @@ export class WorktreeGrouper {
continue;
}
const sessionPath = path.join(projectPath, `${sessionId}.jsonl`);
if (await SessionContentFilter.hasNonNoiseMessages(sessionPath)) {
if (await SessionContentFilter.hasNonNoiseMessages(sessionPath, this.fsProvider)) {
filteredSessions.push(sessionId);
}
}

View file

@ -0,0 +1,75 @@
/**
* FileSystemProvider - Abstract interface for filesystem operations.
*
* Enables the app to read session data from either:
* - Local filesystem (default)
* - Remote SSH/SFTP connections
*
* Only covers read operations needed by session-data services.
* Write operations (ConfigManager, NotificationManager) always stay local.
*/
import type { Readable } from 'stream';
// =============================================================================
// Types
// =============================================================================
/**
* Simplified stat result matching the subset of fs.Stats used by services.
*/
export interface FsStatResult {
size: number;
mtimeMs: number;
birthtimeMs: number;
isFile(): boolean;
isDirectory(): boolean;
}
/**
* Simplified directory entry matching the subset of fs.Dirent used by services.
*/
export interface FsDirent {
name: string;
isFile(): boolean;
isDirectory(): boolean;
}
/**
* Options for createReadStream, matching the subset used by services.
*/
export interface ReadStreamOptions {
start?: number;
encoding?: BufferEncoding;
}
// =============================================================================
// Provider Interface
// =============================================================================
/**
* Abstract filesystem provider interface.
* All session-data services use this instead of direct `fs` calls.
*/
export interface FileSystemProvider {
/** Provider type identifier */
readonly type: 'local' | 'ssh';
/** Check if a file or directory exists */
exists(filePath: string): Promise<boolean>;
/** Read a file's contents as a string */
readFile(filePath: string, encoding?: BufferEncoding): Promise<string>;
/** Get file/directory stats */
stat(filePath: string): Promise<FsStatResult>;
/** Read directory entries */
readdir(dirPath: string): Promise<FsDirent[]>;
/** Create a readable stream for a file */
createReadStream(filePath: string, opts?: ReadStreamOptions): Readable;
/** Cleanup resources */
dispose(): void;
}

View file

@ -23,8 +23,11 @@ import { errorDetector } from '../error/ErrorDetector';
import { ConfigManager } from './ConfigManager';
import { type DataCache } from './DataCache';
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
import { type NotificationManager } from './NotificationManager';
import type { FileSystemProvider } from './FileSystemProvider';
const logger = createLogger('Service:FileWatcher');
/** Debounce window for file change events */
@ -55,6 +58,7 @@ export class FileWatcher extends EventEmitter {
private projectsPath: string;
private todosPath: string;
private dataCache: DataCache;
private fsProvider: FileSystemProvider;
private notificationManager: NotificationManager | null = null;
private isWatching: boolean = false;
private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -66,16 +70,28 @@ export class FileWatcher extends EventEmitter {
private activeSessionFiles = new Map<string, ActiveSessionFile>();
/** Timer for periodic catch-up scan */
private catchUpTimer: NodeJS.Timeout | null = null;
/** Timer for SSH polling mode (replaces fs.watch) */
private pollingTimer: NodeJS.Timeout | null = null;
/** Polling interval for SSH mode */
private static readonly SSH_POLL_INTERVAL_MS = 5000;
/** Track file sizes for SSH polling change detection */
private polledFileSizes = new Map<string, number>();
/** Files currently being processed (concurrency guard) */
private processingInProgress = new Set<string>();
/** Files that need reprocessing after current processing completes */
private pendingReprocess = new Set<string>();
constructor(dataCache: DataCache, projectsPath?: string, todosPath?: string) {
constructor(
dataCache: DataCache,
projectsPath?: string,
todosPath?: string,
fsProvider?: FileSystemProvider
) {
super();
this.projectsPath = projectsPath ?? getProjectsBasePath();
this.todosPath = todosPath ?? getTodosBasePath();
this.dataCache = dataCache;
this.fsProvider = fsProvider ?? new LocalFileSystemProvider();
}
/**
@ -86,6 +102,13 @@ export class FileWatcher extends EventEmitter {
this.notificationManager = manager;
}
/**
* Sets the filesystem provider. Used when switching between local and SSH modes.
*/
setFileSystemProvider(provider: FileSystemProvider): void {
this.fsProvider = provider;
}
// ===========================================================================
// Watcher Control
// ===========================================================================
@ -100,7 +123,11 @@ export class FileWatcher extends EventEmitter {
}
this.isWatching = true;
this.ensureWatchers();
if (this.fsProvider.type === 'ssh') {
this.startPollingMode();
} else {
this.ensureWatchers();
}
this.startCatchUpTimer();
}
@ -137,6 +164,13 @@ export class FileWatcher extends EventEmitter {
this.catchUpTimer = null;
}
// Clear SSH polling timer
if (this.pollingTimer) {
clearInterval(this.pollingTimer);
this.pollingTimer = null;
}
this.polledFileSizes.clear();
// Clear error detection tracking
this.lastProcessedLineCount.clear();
this.lastProcessedSize.clear();
@ -212,7 +246,7 @@ export class FileWatcher extends EventEmitter {
}
private ensureWatchers(): void {
if (!this.isWatching) {
if (!this.isWatching || this.fsProvider.type === 'ssh') {
return;
}
@ -259,6 +293,70 @@ export class FileWatcher extends EventEmitter {
});
}
// ===========================================================================
// SSH Polling Mode
// ===========================================================================
/**
* Starts polling mode for SSH connections.
* Polls the projects directory for file changes instead of using fs.watch().
*/
private startPollingMode(): void {
if (this.pollingTimer) return;
logger.info('FileWatcher: Starting SSH polling mode');
this.pollingTimer = setInterval(() => {
this.pollForChanges().catch((err) => {
logger.error('Error during SSH polling:', err);
});
}, FileWatcher.SSH_POLL_INTERVAL_MS);
}
/**
* Polls the projects directory for file changes in SSH mode.
*/
private async pollForChanges(): Promise<void> {
try {
if (!(await this.fsProvider.exists(this.projectsPath))) return;
const projectDirs = await this.fsProvider.readdir(this.projectsPath);
for (const dir of projectDirs) {
if (!dir.isDirectory()) continue;
const projectPath = path.join(this.projectsPath, dir.name);
let entries: import('./FileSystemProvider').FsDirent[];
try {
entries = await this.fsProvider.readdir(projectPath);
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
const fullPath = path.join(projectPath, entry.name);
try {
const stats = await this.fsProvider.stat(fullPath);
const lastSize = this.polledFileSizes.get(fullPath);
if (lastSize === undefined) {
// First time seeing this file
this.polledFileSizes.set(fullPath, stats.size);
} else if (stats.size !== lastSize) {
// File changed
this.polledFileSizes.set(fullPath, stats.size);
this.handleProjectsChange('change', path.join(dir.name, entry.name));
}
} catch {
continue;
}
}
}
} catch (err) {
logger.error('Error polling for changes:', err);
}
}
// ===========================================================================
// Event Handling
// ===========================================================================
@ -283,14 +381,14 @@ export class FileWatcher extends EventEmitter {
/**
* Process a debounced projects change.
*/
private processProjectsChange(eventType: string, filename: string): void {
private async processProjectsChange(eventType: string, filename: string): Promise<void> {
const parts = filename.split(path.sep);
const projectId = parts[0];
if (!projectId) return;
const fullPath = path.join(this.projectsPath, filename);
const fileExists = fs.existsSync(fullPath);
const fileExists = await this.fsProvider.exists(fullPath);
// Determine change type
let changeType: FileChangeEvent['type'];
@ -390,7 +488,7 @@ export class FileWatcher extends EventEmitter {
// Get the last processed line count for this file
const lastLineCount = this.lastProcessedLineCount.get(filePath) ?? 0;
const lastSize = this.lastProcessedSize.get(filePath) ?? 0;
const fileStats = await fs.promises.stat(filePath);
const fileStats = await this.fsProvider.stat(filePath);
const currentSize = fileStats.size;
// Fast path: no size change means no new data
@ -414,7 +512,7 @@ export class FileWatcher extends EventEmitter {
currentLineCount = messages.length;
newMessages = messages.slice(lastLineCount);
// Re-stat after full parse to capture bytes written during the parse
const postParseStats = await fs.promises.stat(filePath);
const postParseStats = await this.fsProvider.stat(filePath);
processedSize = postParseStats.size;
}
@ -492,7 +590,10 @@ export class FileWatcher extends EventEmitter {
startOffset: number
): Promise<AppendedParseResult> {
const parsedMessages: ParsedMessage[] = [];
const stream = fs.createReadStream(filePath, { start: startOffset, encoding: 'utf8' });
const stream = this.fsProvider.createReadStream(filePath, {
start: startOffset,
encoding: 'utf8',
});
let buffer = '';
let consumedBytes = 0;
@ -561,11 +662,11 @@ export class FileWatcher extends EventEmitter {
/**
* Process a debounced todos change.
*/
private processTodosChange(eventType: string, filename: string): void {
private async processTodosChange(eventType: string, filename: string): Promise<void> {
// Session ID is the filename without extension
const sessionId = path.basename(filename, '.json');
const fullPath = path.join(this.todosPath, filename);
const fileExists = fs.existsSync(fullPath);
const fileExists = await this.fsProvider.exists(fullPath);
// Determine change type
let changeType: FileChangeEvent['type'];
@ -621,7 +722,7 @@ export class FileWatcher extends EventEmitter {
for (const [filePath, info] of this.activeSessionFiles) {
try {
const stats = await fs.promises.stat(filePath);
const stats = await this.fsProvider.stat(filePath);
// Skip files not modified recently
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {

View file

@ -0,0 +1,63 @@
/**
* LocalFileSystemProvider - FileSystemProvider backed by Node's fs module.
*
* Thin wrapper around Node.js filesystem APIs.
* This is the default provider used when operating in local mode.
*/
import * as fs from 'fs';
import type {
FileSystemProvider,
FsDirent,
FsStatResult,
ReadStreamOptions,
} from './FileSystemProvider';
export class LocalFileSystemProvider implements FileSystemProvider {
readonly type = 'local' as const;
async exists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
return fs.promises.readFile(filePath, encoding);
}
async stat(filePath: string): Promise<FsStatResult> {
const stats = await fs.promises.stat(filePath);
return {
size: stats.size,
mtimeMs: stats.mtimeMs,
birthtimeMs: stats.birthtimeMs,
isFile: () => stats.isFile(),
isDirectory: () => stats.isDirectory(),
};
}
async readdir(dirPath: string): Promise<FsDirent[]> {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
return entries.map((entry) => ({
name: entry.name,
isFile: () => entry.isFile(),
isDirectory: () => entry.isDirectory(),
}));
}
createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream {
return fs.createReadStream(filePath, {
start: opts?.start,
encoding: opts?.encoding,
});
}
dispose(): void {
// No resources to clean up for local fs
}
}

View file

@ -0,0 +1,328 @@
/**
* SshConnectionManager - Manages SSH connection lifecycle.
*
* Responsibilities:
* - Connect/disconnect SSH sessions
* - Manage SFTP channel
* - Provide FileSystemProvider (local or SSH) to services
* - Emit connection state events for UI updates
* - Handle reconnection on errors
*/
import { createLogger } from '@shared/utils/logger';
import { EventEmitter } from 'events';
import * as os from 'os';
import * as path from 'path';
import { Client, type ConnectConfig } from 'ssh2';
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
import { SshFileSystemProvider } from './SshFileSystemProvider';
import type { FileSystemProvider } from './FileSystemProvider';
const logger = createLogger('Infrastructure:SshConnectionManager');
// =============================================================================
// Types
// =============================================================================
export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
export type SshAuthMethod = 'password' | 'privateKey' | 'agent';
export interface SshConnectionConfig {
host: string;
port: number;
username: string;
authMethod: SshAuthMethod;
password?: string;
privateKeyPath?: string;
}
export interface SshConnectionProfile {
id: string;
name: string;
host: string;
port: number;
username: string;
authMethod: SshAuthMethod;
privateKeyPath?: string;
}
export interface SshConnectionStatus {
state: SshConnectionState;
host: string | null;
error: string | null;
remoteProjectsPath: string | null;
}
// =============================================================================
// Connection Manager
// =============================================================================
export class SshConnectionManager extends EventEmitter {
private client: Client | null = null;
private provider: FileSystemProvider;
private localProvider: LocalFileSystemProvider;
private state: SshConnectionState = 'disconnected';
private connectedHost: string | null = null;
private lastError: string | null = null;
private remoteProjectsPath: string | null = null;
constructor() {
super();
this.localProvider = new LocalFileSystemProvider();
this.provider = this.localProvider;
}
/**
* Returns the current FileSystemProvider (local or SSH).
*/
getProvider(): FileSystemProvider {
return this.provider;
}
/**
* Returns the current connection status.
*/
getStatus(): SshConnectionStatus {
return {
state: this.state,
host: this.connectedHost,
error: this.lastError,
remoteProjectsPath: this.remoteProjectsPath,
};
}
/**
* Returns the remote projects directory path.
* Used by services to know where to scan on the remote machine.
*/
getRemoteProjectsPath(): string | null {
return this.remoteProjectsPath;
}
/**
* Returns whether we're in SSH mode.
*/
isRemote(): boolean {
return this.state === 'connected' && this.provider.type === 'ssh';
}
/**
* Connect to a remote SSH host.
*/
async connect(config: SshConnectionConfig): Promise<void> {
// Disconnect existing connection first
if (this.client) {
this.disconnect();
}
this.setState('connecting');
this.connectedHost = config.host;
try {
const client = new Client();
this.client = client;
const connectConfig = await this.buildConnectConfig(config);
await new Promise<void>((resolve, reject) => {
client.on('ready', () => resolve());
client.on('error', (err) => reject(err));
client.connect(connectConfig);
});
// Open SFTP channel
const sftp = await new Promise<ReturnType<Client['sftp']> extends void ? never : never>(
(resolve, reject) => {
client.sftp((err, sftp) => {
if (err) {
reject(err);
return;
}
resolve(sftp as never);
});
}
);
// Create SSH provider
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.provider = new SshFileSystemProvider(sftp as any);
// Resolve remote ~/.claude/projects/ path
this.remoteProjectsPath = await this.resolveRemoteProjectsPath(config.username);
// Set up disconnect handler
client.on('end', () => {
logger.info('SSH connection ended');
this.handleDisconnect();
});
client.on('close', () => {
logger.info('SSH connection closed');
this.handleDisconnect();
});
client.on('error', (err) => {
logger.error('SSH connection error:', err);
this.lastError = err.message;
this.setState('error');
});
this.setState('connected');
logger.info(`SSH connected to ${config.host}:${config.port}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(`SSH connection failed: ${message}`);
this.lastError = message;
this.setState('error');
this.cleanup();
throw err;
}
}
/**
* Test a connection without switching to SSH mode.
*/
async testConnection(config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> {
const testClient = new Client();
try {
const connectConfig = await this.buildConnectConfig(config);
await new Promise<void>((resolve, reject) => {
testClient.on('ready', () => resolve());
testClient.on('error', (err) => reject(err));
testClient.connect(connectConfig);
});
// Try to open SFTP to verify full access
await new Promise<void>((resolve, reject) => {
testClient.sftp((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
testClient.end();
return { success: true };
} catch (err) {
testClient.end();
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
}
/**
* Disconnect and switch back to local mode.
*/
disconnect(): void {
this.cleanup();
this.provider = this.localProvider;
this.connectedHost = null;
this.lastError = null;
this.remoteProjectsPath = null;
this.setState('disconnected');
logger.info('Switched to local mode');
}
/**
* Dispose of all resources.
*/
dispose(): void {
this.cleanup();
this.localProvider.dispose();
this.removeAllListeners();
}
// ===========================================================================
// Private Methods
// ===========================================================================
private async buildConnectConfig(config: SshConnectionConfig): Promise<ConnectConfig> {
const connectConfig: ConnectConfig = {
host: config.host,
port: config.port,
username: config.username,
readyTimeout: 10000,
};
switch (config.authMethod) {
case 'password':
connectConfig.password = config.password;
break;
case 'privateKey': {
const keyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa');
const { promises: fsPromises } = await import('fs');
try {
const keyData = await fsPromises.readFile(keyPath, 'utf8');
connectConfig.privateKey = keyData;
} catch (err) {
throw new Error(`Cannot read private key at ${keyPath}: ${(err as Error).message}`);
}
break;
}
case 'agent':
connectConfig.agent = process.env.SSH_AUTH_SOCK;
if (!connectConfig.agent) {
throw new Error('SSH_AUTH_SOCK environment variable is not set');
}
break;
}
return connectConfig;
}
private async resolveRemoteProjectsPath(username: string): Promise<string> {
// Try to resolve the remote home directory
// SFTP doesn't have a direct "get home dir" call, so we try common paths
const candidates = [
`/home/${username}/.claude/projects`,
`/Users/${username}/.claude/projects`,
`/root/.claude/projects`,
];
for (const candidate of candidates) {
if (await this.provider.exists(candidate)) {
return candidate;
}
}
// Fallback: try to read from environment via realpath of ~
// Default to Linux convention
return `/home/${username}/.claude/projects`;
}
private handleDisconnect(): void {
if (this.state === 'disconnected') return;
this.provider = this.localProvider;
this.remoteProjectsPath = null;
this.setState('disconnected');
}
private cleanup(): void {
if (this.provider.type === 'ssh') {
this.provider.dispose();
}
if (this.client) {
try {
this.client.end();
} catch {
// Ignore cleanup errors
}
this.client = null;
}
}
private setState(state: SshConnectionState): void {
this.state = state;
this.emit('state-change', this.getStatus());
}
}

View file

@ -0,0 +1,129 @@
/**
* 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 class SshFileSystemProvider implements FileSystemProvider {
readonly type = 'ssh' as const;
private sftp: SFTPWrapper;
constructor(sftp: SFTPWrapper) {
this.sftp = sftp;
}
async exists(filePath: string): Promise<boolean> {
return new Promise((resolve) => {
this.sftp.stat(filePath, (err) => {
resolve(!err);
});
});
}
async readFile(filePath: string, encoding: BufferEncoding = 'utf8'): Promise<string> {
return new Promise((resolve, reject) => {
this.sftp.readFile(filePath, { encoding }, (err, data) => {
if (err) {
reject(err);
return;
}
resolve(data as unknown as string);
});
});
}
async stat(filePath: string): Promise<FsStatResult> {
return new Promise((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,
});
});
});
}
async readdir(dirPath: string): Promise<FsDirent[]> {
return new Promise((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[] = list.map((item) => {
const mode = item.attrs.mode;
return {
name: item.filename,
isFile: () => (mode & S_IFMT) === S_IFREG,
isDirectory: () => (mode & S_IFMT) === S_IFDIR,
};
});
resolve(entries);
});
});
}
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
}
}
}

View file

@ -7,11 +7,19 @@
* - ConfigManager: App configuration management
* - TriggerManager: Notification trigger management (used internally by ConfigManager)
* - NotificationManager: Notification handling and persistence
* - FileSystemProvider: Abstract filesystem interface
* - LocalFileSystemProvider: Local fs implementation
* - SshFileSystemProvider: SSH/SFTP implementation
* - SshConnectionManager: SSH connection lifecycle
*/
export * from './ConfigManager';
export * from './DataCache';
export type * from './FileSystemProvider';
export * from './FileWatcher';
export * from './LocalFileSystemProvider';
export * from './NotificationManager';
export * from './SshConnectionManager';
export * from './SshFileSystemProvider';
export * from './TriggerManager';
export * from './UpdaterService';

View file

@ -12,11 +12,16 @@ import { encodePath } from '@main/utils/pathDecoder';
import { countTokens } from '@main/utils/tokenizer';
import { createLogger } from '@shared/utils/logger';
import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider';
import type { FileSystemProvider } from '../infrastructure/FileSystemProvider';
const logger = createLogger('Service:ClaudeMdReader');
const defaultProvider = new LocalFileSystemProvider();
// ===========================================================================
// Types
// ===========================================================================
@ -56,13 +61,17 @@ function expandTilde(filePath: string): string {
/**
* Reads a single CLAUDE.md file and returns its info.
* @param filePath - Path to the CLAUDE.md file (supports ~ expansion)
* @param fsProvider - Optional filesystem provider (defaults to local)
* @returns ClaudeMdFileInfo with file details
*/
function readClaudeMdFile(filePath: string): ClaudeMdFileInfo {
async function readClaudeMdFile(
filePath: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<ClaudeMdFileInfo> {
const expandedPath = expandTilde(filePath);
try {
if (!fs.existsSync(expandedPath)) {
if (!(await fsProvider.exists(expandedPath))) {
return {
path: expandedPath,
exists: false,
@ -71,7 +80,7 @@ function readClaudeMdFile(filePath: string): ClaudeMdFileInfo {
};
}
const content = fs.readFileSync(expandedPath, 'utf8');
const content = await fsProvider.readFile(expandedPath);
const charCount = content.length;
const estimatedTokens = countTokens(content);
@ -97,13 +106,17 @@ function readClaudeMdFile(filePath: string): ClaudeMdFileInfo {
* Reads all .md files in a directory and returns combined info.
* Used for project rules directory.
* @param dirPath - Path to the directory (supports ~ expansion)
* @param fsProvider - Optional filesystem provider (defaults to local)
* @returns ClaudeMdFileInfo with combined stats from all .md files
*/
function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo {
async function readDirectoryMdFiles(
dirPath: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<ClaudeMdFileInfo> {
const expandedPath = expandTilde(dirPath);
try {
if (!fs.existsSync(expandedPath)) {
if (!(await fsProvider.exists(expandedPath))) {
return {
path: expandedPath,
exists: false,
@ -112,7 +125,7 @@ function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo {
};
}
const stats = fs.statSync(expandedPath);
const stats = await fsProvider.stat(expandedPath);
if (!stats.isDirectory()) {
return {
path: expandedPath,
@ -122,7 +135,7 @@ function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo {
};
}
const mdFiles = collectMdFiles(expandedPath);
const mdFiles = await collectMdFiles(expandedPath, fsProvider);
if (mdFiles.length === 0) {
return {
@ -138,7 +151,7 @@ function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo {
for (const filePath of mdFiles) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const content = await fsProvider.readFile(filePath);
totalCharCount += content.length;
allContent.push(content);
} catch {
@ -170,18 +183,20 @@ function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo {
/**
* Recursively collect all .md files in a directory tree.
*/
function collectMdFiles(dir: string): string[] {
async function collectMdFiles(
dir: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<string[]> {
const mdFiles: string[] = [];
try {
const entries = fs.readdirSync(dir);
const entries = await fsProvider.readdir(dir);
for (const entry of entries) {
const fullPath = path.join(dir, entry);
const fullPath = path.join(dir, entry.name);
try {
const stats = fs.statSync(fullPath);
if (stats.isFile() && entry.endsWith('.md')) {
if (entry.isFile() && entry.name.endsWith('.md')) {
mdFiles.push(fullPath);
} else if (stats.isDirectory()) {
mdFiles.push(...collectMdFiles(fullPath));
} else if (entry.isDirectory()) {
mdFiles.push(...(await collectMdFiles(fullPath, fsProvider)));
}
} catch {
continue;
@ -211,18 +226,21 @@ function getEnterprisePath(): string {
* Reads auto memory MEMORY.md file for a project.
* Only reads the first 200 lines, matching Claude Code behavior.
*/
function readAutoMemoryFile(projectRoot: string): ClaudeMdFileInfo {
async function readAutoMemoryFile(
projectRoot: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<ClaudeMdFileInfo> {
const expandedRoot = expandTilde(projectRoot);
const encoded = encodePath(expandedRoot);
const homeDir = app.getPath('home');
const memoryPath = path.join(homeDir, '.claude', 'projects', encoded, 'memory', 'MEMORY.md');
try {
if (!fs.existsSync(memoryPath)) {
if (!(await fsProvider.exists(memoryPath))) {
return { path: memoryPath, exists: false, charCount: 0, estimatedTokens: 0 };
}
const content = fs.readFileSync(memoryPath, 'utf8');
const content = await fsProvider.readFile(memoryPath);
// Only first 200 lines, matching Claude Code behavior
const lines = content.split('\n');
const truncated = lines.slice(0, 200).join('\n');
@ -239,43 +257,47 @@ function readAutoMemoryFile(projectRoot: string): ClaudeMdFileInfo {
/**
* Reads all potential CLAUDE.md locations for a project.
* @param projectRoot - The root directory of the project
* @param fsProvider - Optional filesystem provider (defaults to local)
* @returns ClaudeMdReadResult with Map of path -> ClaudeMdFileInfo
*/
export function readAllClaudeMdFiles(projectRoot: string): ClaudeMdReadResult {
export async function readAllClaudeMdFiles(
projectRoot: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<ClaudeMdReadResult> {
const files = new Map<string, ClaudeMdFileInfo>();
const expandedProjectRoot = expandTilde(projectRoot);
// 1. Enterprise CLAUDE.md (platform-specific path)
const enterprisePath = getEnterprisePath();
files.set('enterprise', readClaudeMdFile(enterprisePath));
files.set('enterprise', await readClaudeMdFile(enterprisePath, fsProvider));
// 2. User memory: ~/.claude/CLAUDE.md
const userMemoryPath = '~/.claude/CLAUDE.md';
files.set('user', readClaudeMdFile(userMemoryPath));
files.set('user', await readClaudeMdFile(userMemoryPath, fsProvider));
// 3. Project memory: ${projectRoot}/CLAUDE.md
const projectMemoryPath = path.join(expandedProjectRoot, 'CLAUDE.md');
files.set('project', readClaudeMdFile(projectMemoryPath));
files.set('project', await readClaudeMdFile(projectMemoryPath, fsProvider));
// 4. Project memory alt: ${projectRoot}/.claude/CLAUDE.md
const projectMemoryAltPath = path.join(expandedProjectRoot, '.claude', 'CLAUDE.md');
files.set('project-alt', readClaudeMdFile(projectMemoryAltPath));
files.set('project-alt', await readClaudeMdFile(projectMemoryAltPath, fsProvider));
// 5. Project rules: ${projectRoot}/.claude/rules/*.md
const projectRulesPath = path.join(expandedProjectRoot, '.claude', 'rules');
files.set('project-rules', readDirectoryMdFiles(projectRulesPath));
files.set('project-rules', await readDirectoryMdFiles(projectRulesPath, fsProvider));
// 6. Project local: ${projectRoot}/CLAUDE.local.md
const projectLocalPath = path.join(expandedProjectRoot, 'CLAUDE.local.md');
files.set('project-local', readClaudeMdFile(projectLocalPath));
files.set('project-local', await readClaudeMdFile(projectLocalPath, fsProvider));
// 7. User rules: ~/.claude/rules/**/*.md
const homeDir = app.getPath('home');
const userRulesPath = path.join(homeDir, '.claude', 'rules');
files.set('user-rules', readDirectoryMdFiles(userRulesPath));
files.set('user-rules', await readDirectoryMdFiles(userRulesPath, fsProvider));
// 8. Auto memory: ~/.claude/projects/<encoded>/memory/MEMORY.md
files.set('auto-memory', readAutoMemoryFile(projectRoot));
files.set('auto-memory', await readAutoMemoryFile(projectRoot, fsProvider));
return { files };
}
@ -284,10 +306,14 @@ export function readAllClaudeMdFiles(projectRoot: string): ClaudeMdReadResult {
* Reads a specific directory's CLAUDE.md file.
* Used for directory-specific CLAUDE.md detected from file reads.
* @param dirPath - Path to the directory (supports ~ expansion)
* @param fsProvider - Optional filesystem provider (defaults to local)
* @returns ClaudeMdFileInfo for the CLAUDE.md file in that directory
*/
export function readDirectoryClaudeMd(dirPath: string): ClaudeMdFileInfo {
export async function readDirectoryClaudeMd(
dirPath: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<ClaudeMdFileInfo> {
const expandedDirPath = expandTilde(dirPath);
const claudeMdPath = path.join(expandedDirPath, 'CLAUDE.md');
return readClaudeMdFile(claudeMdPath);
return readClaudeMdFile(claudeMdPath, fsProvider);
}

View file

@ -9,11 +9,9 @@
import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as readline from 'readline';
const logger = createLogger('Util:jsonl');
import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider';
import {
type ChatHistoryEntry,
type ContentBlock,
@ -31,6 +29,12 @@ import {
// Import from extracted modules
import { extractToolCalls, extractToolResults } from './toolExtraction';
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
const logger = createLogger('Util:jsonl');
const defaultProvider = new LocalFileSystemProvider();
// Re-export for backwards compatibility
export { extractCwd } from './metadataExtraction';
export { checkMessagesOngoing } from './sessionStateDetection';
@ -43,14 +47,17 @@ export { checkMessagesOngoing } from './sessionStateDetection';
* Parse a JSONL file line by line using streaming.
* This avoids loading the entire file into memory.
*/
export async function parseJsonlFile(filePath: string): Promise<ParsedMessage[]> {
export async function parseJsonlFile(
filePath: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<ParsedMessage[]> {
const messages: ParsedMessage[] = [];
if (!fs.existsSync(filePath)) {
if (!(await fsProvider.exists(filePath))) {
return messages;
}
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
@ -299,8 +306,11 @@ export interface SessionFileMetadata {
* Analyze key session metadata in a single streaming pass.
* This avoids multiple file scans when listing sessions.
*/
export async function analyzeSessionFileMetadata(filePath: string): Promise<SessionFileMetadata> {
if (!fs.existsSync(filePath)) {
export async function analyzeSessionFileMetadata(
filePath: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<SessionFileMetadata> {
if (!(await fsProvider.exists(filePath))) {
return {
firstUserMessage: null,
messageCount: 0,
@ -309,7 +319,7 @@ export async function analyzeSessionFileMetadata(filePath: string): Promise<Sess
};
}
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,

View file

@ -3,23 +3,30 @@
*/
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as readline from 'readline';
import { LocalFileSystemProvider } from '../services/infrastructure/LocalFileSystemProvider';
import { type ChatHistoryEntry } from '../types';
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
const logger = createLogger('Util:metadataExtraction');
const defaultProvider = new LocalFileSystemProvider();
/**
* Extract CWD (current working directory) from the first entry.
* Used to get the actual project path from encoded directory names.
*/
export async function extractCwd(filePath: string): Promise<string | null> {
if (!fs.existsSync(filePath)) {
export async function extractCwd(
filePath: string,
fsProvider: FileSystemProvider = defaultProvider
): Promise<string | null> {
if (!(await fsProvider.exists(filePath))) {
return null;
}
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,

View file

@ -59,6 +59,25 @@ export const CONFIG_PIN_SESSION = 'config:pinSession';
/** Unpin a session */
export const CONFIG_UNPIN_SESSION = 'config:unpinSession';
// =============================================================================
// SSH API Channels
// =============================================================================
/** Connect to SSH host */
export const SSH_CONNECT = 'ssh:connect';
/** Disconnect SSH and switch to local */
export const SSH_DISCONNECT = 'ssh:disconnect';
/** Get current SSH connection state */
export const SSH_GET_STATE = 'ssh:getState';
/** Test SSH connection without switching */
export const SSH_TEST = 'ssh:test';
/** SSH status event channel (main -> renderer) */
export const SSH_STATUS = 'ssh:status';
// =============================================================================
// Updater API Channels
// =============================================================================

View file

@ -2,6 +2,11 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
import { contextBridge, ipcRenderer } from 'electron';
import {
SSH_CONNECT,
SSH_DISCONNECT,
SSH_GET_STATE,
SSH_STATUS,
SSH_TEST,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
@ -32,6 +37,8 @@ import type {
ElectronAPI,
NotificationTrigger,
SessionsPaginationOptions,
SshConnectionConfig,
SshConnectionStatus,
TriggerTestResult,
} from '@shared/types';
@ -310,6 +317,34 @@ const electronAPI: ElectronAPI = {
};
},
},
// SSH API
ssh: {
connect: async (config: SshConnectionConfig): Promise<SshConnectionStatus> => {
return invokeIpcWithResult<SshConnectionStatus>(SSH_CONNECT, config);
},
disconnect: async (): Promise<SshConnectionStatus> => {
return invokeIpcWithResult<SshConnectionStatus>(SSH_DISCONNECT);
},
getState: async (): Promise<SshConnectionStatus> => {
return ipcRenderer.invoke(SSH_GET_STATE);
},
test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => {
return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config);
},
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => {
ipcRenderer.on(
SSH_STATUS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
SSH_STATUS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
};
// Use contextBridge to securely expose the API to the renderer process

View file

@ -9,6 +9,8 @@
import { getTrafficLightPaddingForZoom } from '@renderer/constants/layout';
import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts';
import { useZoomFactor } from '@renderer/hooks/useZoomFactor';
import { useStore } from '@renderer/store';
import { Loader2, Wifi } from 'lucide-react';
import { UpdateBanner } from '../common/UpdateBanner';
import { UpdateDialog } from '../common/UpdateDialog';
@ -17,6 +19,47 @@ import { CommandPalette } from '../search/CommandPalette';
import { PaneContainer } from './PaneContainer';
import { Sidebar } from './Sidebar';
/**
* SshConnectionIndicator - Shows SSH connection status in the layout.
* Only visible when in SSH mode or connecting.
*/
const SshConnectionIndicator = (): React.JSX.Element | null => {
const connectionState = useStore((s) => s.connectionState);
const connectedHost = useStore((s) => s.connectedHost);
if (connectionState === 'disconnected') return null;
return (
<div
className="flex items-center gap-1.5 px-3 py-1 text-xs"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderBottom: '1px solid var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
{connectionState === 'connecting' && (
<>
<Loader2 className="size-3 animate-spin text-yellow-400" />
<span>Connecting to {connectedHost}...</span>
</>
)}
{connectionState === 'connected' && (
<>
<Wifi className="size-3 text-green-400" />
<span className="text-green-400">SSH: {connectedHost}</span>
</>
)}
{connectionState === 'error' && (
<>
<div className="size-2 rounded-full bg-red-400" />
<span className="text-red-400">SSH Error</span>
</>
)}
</div>
);
};
export const TabbedLayout = (): React.JSX.Element => {
// Enable keyboard shortcuts
useKeyboardShortcuts();
@ -31,6 +74,7 @@ export const TabbedLayout = (): React.JSX.Element => {
}
>
<UpdateBanner />
<SshConnectionIndicator />
<div className="flex flex-1 overflow-hidden">
{/* Command Palette (Cmd+K) */}
<CommandPalette />

View file

@ -1,8 +1,8 @@
import { useState } from 'react';
import { Bell, Settings, Wrench } from 'lucide-react';
import { Bell, Server, Settings, Wrench } from 'lucide-react';
export type SettingsSection = 'general' | 'notifications' | 'advanced';
export type SettingsSection = 'general' | 'connection' | 'notifications' | 'advanced';
interface SettingsTabsProps {
activeSection: SettingsSection;
@ -17,6 +17,7 @@ interface TabConfig {
const tabs: TabConfig[] = [
{ id: 'general', label: 'General', icon: Settings },
{ id: 'connection', label: 'Connection', icon: Server },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'advanced', label: 'Advanced', icon: Wrench },
];

View file

@ -8,7 +8,12 @@ import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useSettingsConfig, useSettingsHandlers } from './hooks';
import { AdvancedSection, GeneralSection, NotificationsSection } from './sections';
import {
AdvancedSection,
ConnectionSection,
GeneralSection,
NotificationsSection,
} from './sections';
import { type SettingsSection, SettingsTabs } from './SettingsTabs';
export const SettingsView = (): React.JSX.Element | null => {
@ -112,6 +117,8 @@ export const SettingsView = (): React.JSX.Element | null => {
/>
)}
{activeSection === 'connection' && <ConnectionSection />}
{activeSection === 'notifications' && (
<NotificationsSection
safeConfig={safeConfig}

View file

@ -0,0 +1,308 @@
/**
* ConnectionSection - Settings section for SSH connection management.
*
* Provides UI for:
* - Toggling between local and SSH modes
* - Configuring SSH connection (host, port, username, auth)
* - Testing and connecting to remote hosts
*/
import { useState } from 'react';
import { useStore } from '@renderer/store';
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
import { SettingRow } from '../components/SettingRow';
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
import type { SshAuthMethod, SshConnectionConfig } from '@shared/types';
export const ConnectionSection = (): React.JSX.Element => {
const connectionState = useStore((s) => s.connectionState);
const connectedHost = useStore((s) => s.connectedHost);
const connectionError = useStore((s) => s.connectionError);
const connectSsh = useStore((s) => s.connectSsh);
const disconnectSsh = useStore((s) => s.disconnectSsh);
const testConnection = useStore((s) => s.testConnection);
// Form state
const [host, setHost] = useState('');
const [port, setPort] = useState('22');
const [username, setUsername] = useState('');
const [authMethod, setAuthMethod] = useState<SshAuthMethod>('agent');
const [password, setPassword] = useState('');
const [privateKeyPath, setPrivateKeyPath] = useState('~/.ssh/id_rsa');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null);
const buildConfig = (): SshConnectionConfig => ({
host,
port: parseInt(port, 10) || 22,
username,
authMethod,
password: authMethod === 'password' ? password : undefined,
privateKeyPath: authMethod === 'privateKey' ? privateKeyPath : undefined,
});
const handleTest = async (): Promise<void> => {
setTesting(true);
setTestResult(null);
const result = await testConnection(buildConfig());
setTestResult(result);
setTesting(false);
};
const handleConnect = async (): Promise<void> => {
await connectSsh(buildConfig());
};
const handleDisconnect = async (): Promise<void> => {
await disconnectSsh();
};
const isConnecting = connectionState === 'connecting';
const isConnected = connectionState === 'connected';
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
return (
<div className="space-y-6">
<SettingsSectionHeader title="Remote Connection" />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Connect to a remote machine to view Claude Code sessions running there
</p>
{/* Connection Status */}
{isConnected && (
<div
className="flex items-center gap-3 rounded-md border px-4 py-3"
style={{
borderColor: 'rgba(34, 197, 94, 0.3)',
backgroundColor: 'rgba(34, 197, 94, 0.05)',
}}
>
<Wifi className="size-4 text-green-400" />
<div className="flex-1">
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
Connected to {connectedHost}
</p>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Viewing remote sessions via SSH
</p>
</div>
<button
onClick={() => void handleDisconnect()}
className="rounded-md px-3 py-1.5 text-sm transition-colors"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
}}
>
Disconnect
</button>
</div>
)}
{connectionError && (
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
<p className="text-sm text-red-400">{connectionError}</p>
</div>
)}
{/* Mode indicator */}
{!isConnected && (
<SettingRow label="Current Mode" description="Data source for session files">
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
<Monitor className="size-4" />
<span>Local (~/.claude/)</span>
</div>
</SettingRow>
)}
{/* SSH Connection Form */}
{!isConnected && (
<div className="space-y-4">
<h3 className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
SSH Connection
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Host
</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="192.168.1.100"
className={inputClass}
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
/>
</div>
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Port
</label>
<input
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder="22"
className={inputClass}
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="user"
className={inputClass}
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
/>
</div>
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Authentication
</label>
<select
value={authMethod}
onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)}
className="w-full rounded-md border px-3 py-1.5 text-sm"
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
>
<option value="agent">SSH Agent</option>
<option value="privateKey">Private Key</option>
<option value="password">Password</option>
</select>
</div>
{authMethod === 'privateKey' && (
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Private Key Path
</label>
<input
type="text"
value={privateKeyPath}
onChange={(e) => setPrivateKeyPath(e.target.value)}
placeholder="~/.ssh/id_rsa"
className={inputClass}
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
/>
</div>
)}
{authMethod === 'password' && (
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
}}
/>
</div>
)}
{/* Test result */}
{testResult && (
<div
className={`rounded-md border px-3 py-2 text-sm ${
testResult.success
? 'border-green-500/20 bg-green-500/10 text-green-400'
: 'border-red-500/20 bg-red-500/10 text-red-400'
}`}
>
{testResult.success
? 'Connection successful'
: `Connection failed: ${testResult.error}`}
</div>
)}
{/* Action buttons */}
<div className="flex items-center gap-3">
<button
onClick={() => void handleTest()}
disabled={!host || !username || testing || isConnecting}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
}}
>
{testing ? (
<span className="flex items-center gap-2">
<Loader2 className="size-3 animate-spin" />
Testing...
</span>
) : (
'Test Connection'
)}
</button>
<button
onClick={() => void handleConnect()}
disabled={!host || !username || isConnecting}
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text)',
}}
>
{isConnecting ? (
<span className="flex items-center gap-2">
<Loader2 className="size-3 animate-spin" />
Connecting...
</span>
) : (
<span className="flex items-center gap-2">
<WifiOff className="size-3" />
Connect
</span>
)}
</button>
</div>
</div>
)}
</div>
);
};

View file

@ -3,5 +3,6 @@
*/
export { AdvancedSection } from './AdvancedSection';
export { ConnectionSection } from './ConnectionSection';
export { GeneralSection } from './GeneralSection';
export { NotificationsSection } from './NotificationsSection';

View file

@ -5,6 +5,7 @@
import { create } from 'zustand';
import { createConfigSlice } from './slices/configSlice';
import { createConnectionSlice } from './slices/connectionSlice';
import { createConversationSlice } from './slices/conversationSlice';
import { createNotificationSlice } from './slices/notificationSlice';
import { createPaneSlice } from './slices/paneSlice';
@ -39,6 +40,7 @@ export const useStore = create<AppState>()((...args) => ({
...createUISlice(...args),
...createNotificationSlice(...args),
...createConfigSlice(...args),
...createConnectionSlice(...args),
...createUpdateSlice(...args),
}));
@ -274,6 +276,28 @@ export function initializeNotificationListeners(): () => void {
}
}
// Listen for SSH connection status changes from main process
if (window.electronAPI.ssh?.onStatus) {
const cleanup = window.electronAPI.ssh.onStatus((_event: unknown, status: unknown) => {
const s = status as { state: string; host: string | null; error: string | null };
useStore
.getState()
.setConnectionStatus(
s.state as 'disconnected' | 'connecting' | 'connected' | 'error',
s.host,
s.error
);
// Re-fetch data when connection state changes to connected or disconnected
if (s.state === 'connected' || s.state === 'disconnected') {
void useStore.getState().fetchProjects();
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Return cleanup function
return () => {
for (const timer of pendingSessionRefreshTimers.values()) {

View file

@ -0,0 +1,121 @@
/**
* Connection Slice - Manages SSH connection state.
*
* Tracks connection mode (local/ssh), connection state,
* and provides actions for connecting/disconnecting.
*/
import type { AppState } from '../types';
import type { SshConnectionConfig, SshConnectionState } from '@shared/types';
import type { StateCreator } from 'zustand';
// =============================================================================
// Slice Interface
// =============================================================================
export interface ConnectionSlice {
// State
connectionMode: 'local' | 'ssh';
connectionState: SshConnectionState;
connectedHost: string | null;
connectionError: string | null;
// Actions
connectSsh: (config: SshConnectionConfig) => Promise<void>;
disconnectSsh: () => Promise<void>;
testConnection: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
setConnectionStatus: (
state: SshConnectionState,
host: string | null,
error: string | null
) => void;
}
// =============================================================================
// Slice Creator
// =============================================================================
export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSlice> = (
set,
get
) => ({
// Initial state
connectionMode: 'local',
connectionState: 'disconnected',
connectedHost: null,
connectionError: null,
// Actions
connectSsh: async (config: SshConnectionConfig): Promise<void> => {
set({
connectionState: 'connecting',
connectedHost: config.host,
connectionError: null,
});
try {
const status = await window.electronAPI.ssh.connect(config);
set({
connectionMode: status.state === 'connected' ? 'ssh' : 'local',
connectionState: status.state,
connectedHost: status.host,
connectionError: status.error,
});
// Re-fetch all data when connected
if (status.state === 'connected') {
const state = get();
void state.fetchProjects();
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
set({
connectionState: 'error',
connectionError: message,
});
}
},
disconnectSsh: async (): Promise<void> => {
try {
const status = await window.electronAPI.ssh.disconnect();
set({
connectionMode: 'local',
connectionState: status.state,
connectedHost: null,
connectionError: null,
});
// Re-fetch local data
const state = get();
void state.fetchProjects();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
set({ connectionError: message });
}
},
testConnection: async (
config: SshConnectionConfig
): Promise<{ success: boolean; error?: string }> => {
try {
return await window.electronAPI.ssh.test(config);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
},
setConnectionStatus: (
state: SshConnectionState,
host: string | null,
error: string | null
): void => {
set({
connectionState: state,
connectionMode: state === 'connected' ? 'ssh' : 'local',
connectedHost: host,
connectionError: error,
});
},
});

View file

@ -4,6 +4,7 @@
*/
import type { ConfigSlice } from './slices/configSlice';
import type { ConnectionSlice } from './slices/connectionSlice';
import type { ConversationSlice } from './slices/conversationSlice';
import type { NotificationSlice } from './slices/notificationSlice';
import type { PaneSlice } from './slices/paneSlice';
@ -86,4 +87,5 @@ export type AppState = ProjectSlice &
UISlice &
NotificationSlice &
ConfigSlice &
ConnectionSlice &
UpdateSlice;

View file

@ -144,6 +144,66 @@ export interface UpdaterAPI {
onStatus: (callback: (event: unknown, status: unknown) => void) => () => void;
}
// =============================================================================
// SSH API
// =============================================================================
/**
* SSH connection state.
*/
export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
/**
* SSH authentication method.
*/
export type SshAuthMethod = 'password' | 'privateKey' | 'agent';
/**
* SSH connection configuration sent from renderer.
*/
export interface SshConnectionConfig {
host: string;
port: number;
username: string;
authMethod: SshAuthMethod;
password?: string;
privateKeyPath?: string;
}
/**
* Saved SSH connection profile (no password stored).
*/
export interface SshConnectionProfile {
id: string;
name: string;
host: string;
port: number;
username: string;
authMethod: SshAuthMethod;
privateKeyPath?: string;
}
/**
* SSH connection status returned from main process.
*/
export interface SshConnectionStatus {
state: SshConnectionState;
host: string | null;
error: string | null;
remoteProjectsPath: string | null;
}
/**
* SSH API exposed via preload.
*/
export interface SshAPI {
connect: (config: SshConnectionConfig) => Promise<SshConnectionStatus>;
disconnect: () => Promise<SshConnectionStatus>;
getState: () => Promise<SshConnectionStatus>;
test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
}
// =============================================================================
// Main Electron API
// =============================================================================
@ -225,6 +285,9 @@ export interface ElectronAPI {
// Updater API
updater: UpdaterAPI;
// SSH API
ssh: SshAPI;
}
// =============================================================================