diff --git a/db.js b/db.js index 4fabae44..45f001db 100644 --- a/db.js +++ b/db.js @@ -2,19 +2,22 @@ const Database = require('better-sqlite3'); const path = require('path'); const os = require('os'); -const DATA_DIR = path.join(os.homedir(), '.switchboard'); +// DB location is overridable via env (used by tests to point at a temp file so +// they never touch the real ~/.switchboard/switchboard.db). +const DATA_DIR = process.env.SWITCHBOARD_DATA_DIR || path.join(os.homedir(), '.switchboard'); const fs = require('fs'); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); -const DB_PATH = path.join(DATA_DIR, 'switchboard.db'); +const DB_PATH = process.env.SWITCHBOARD_DB_PATH || path.join(DATA_DIR, 'switchboard.db'); -// Migrate from old locations if needed +// Migrate from old locations if needed (skipped when an explicit DB path is set, +// so tests don't move the user's real data into a temp file). const OLD_LOCATIONS = [ path.join(os.homedir(), '.claude', 'browser', 'switchboard.db'), path.join(os.homedir(), '.claude', 'browser', 'session-browser.db'), path.join(os.homedir(), '.claude', 'session-browser.db'), ]; -if (!fs.existsSync(DB_PATH)) { +if (!process.env.SWITCHBOARD_DB_PATH && !fs.existsSync(DB_PATH)) { for (const oldPath of OLD_LOCATIONS) { if (fs.existsSync(oldPath)) { fs.renameSync(oldPath, DB_PATH); @@ -49,7 +52,8 @@ db.exec(` modified TEXT, messageCount INTEGER DEFAULT 0, slug TEXT, - aiTitle TEXT + aiTitle TEXT, + source TEXT ) `); @@ -99,6 +103,12 @@ const migrations = [ try { db.exec('DELETE FROM session_cache'); } catch {} try { db.exec('DELETE FROM cache_meta'); } catch {} }, + // v4: Add source column (NULL = local, hostId = remote host) for Phase 2 remote + // session indexing. No cache wipe — existing rows are local, and NULL means local. + (db) => { + try { db.exec('ALTER TABLE session_cache ADD COLUMN source TEXT'); } catch {} + try { db.exec('ALTER TABLE search_map ADD COLUMN source TEXT'); } catch {} + }, ]; const currentDbVersion = (() => { @@ -127,7 +137,8 @@ db.exec(` rowid INTEGER PRIMARY KEY, id TEXT NOT NULL, type TEXT NOT NULL, - folder TEXT + folder TEXT, + source TEXT ) `); @@ -152,20 +163,27 @@ const stmts = { cacheCount: db.prepare('SELECT COUNT(*) as cnt FROM session_cache'), cacheGetAll: db.prepare('SELECT * FROM session_cache'), cacheUpsert: db.prepare(` - INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO session_cache (sessionId, folder, projectPath, summary, firstPrompt, created, modified, messageCount, slug, aiTitle, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sessionId) DO UPDATE SET folder = excluded.folder, projectPath = excluded.projectPath, summary = excluded.summary, firstPrompt = excluded.firstPrompt, created = excluded.created, modified = excluded.modified, messageCount = excluded.messageCount, slug = excluded.slug, - aiTitle = excluded.aiTitle + aiTitle = excluded.aiTitle, source = excluded.source `), - cacheGetByFolder: db.prepare('SELECT sessionId, modified FROM session_cache WHERE folder = ?'), + // Local incremental refresh must not see remote rows (a remote encoded-folder + // name could collide with a local folder name), so scope to source IS NULL. + cacheGetByFolder: db.prepare('SELECT sessionId, modified FROM session_cache WHERE folder = ? AND source IS NULL'), + cacheGetBySource: db.prepare('SELECT sessionId, modified FROM session_cache WHERE source = ?'), + cacheGetIdsBySourceProject: db.prepare('SELECT sessionId FROM session_cache WHERE source = ? AND projectPath = ?'), + cacheDeleteBySourceProject: db.prepare('DELETE FROM session_cache WHERE source = ? AND projectPath = ?'), + cacheGetIdsByProjectRemote: db.prepare('SELECT sessionId FROM session_cache WHERE projectPath = ? AND source IS NOT NULL'), + cacheDeleteByProjectRemote: db.prepare('DELETE FROM session_cache WHERE projectPath = ? AND source IS NOT NULL'), cacheGetFolder: db.prepare('SELECT folder FROM session_cache WHERE sessionId = ?'), cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'), cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'), - cacheDeleteFolder: db.prepare('DELETE FROM session_cache WHERE folder = ?'), + cacheDeleteFolder: db.prepare('DELETE FROM session_cache WHERE folder = ? AND source IS NULL'), // Cache meta statements metaGet: db.prepare('SELECT * FROM cache_meta WHERE folder = ?'), metaGetAll: db.prepare('SELECT * FROM cache_meta'), @@ -179,12 +197,12 @@ const stmts = { // FTS search statements searchDeleteBySession: db.prepare('DELETE FROM search_fts WHERE rowid IN (SELECT rowid FROM search_map WHERE type = \'session\' AND id = ?)'), searchMapDeleteBySession: db.prepare('DELETE FROM search_map WHERE type = \'session\' AND id = ?'), - searchDeleteByFolder: db.prepare('DELETE FROM search_fts WHERE rowid IN (SELECT rowid FROM search_map WHERE type = \'session\' AND folder = ?)'), - searchMapDeleteByFolder: db.prepare('DELETE FROM search_map WHERE type = \'session\' AND folder = ?'), + searchDeleteByFolder: db.prepare('DELETE FROM search_fts WHERE rowid IN (SELECT rowid FROM search_map WHERE type = \'session\' AND folder = ? AND source IS NULL)'), + searchMapDeleteByFolder: db.prepare('DELETE FROM search_map WHERE type = \'session\' AND folder = ? AND source IS NULL'), searchDeleteByType: db.prepare('DELETE FROM search_fts WHERE rowid IN (SELECT rowid FROM search_map WHERE type = ?)'), searchMapDeleteByType: db.prepare('DELETE FROM search_map WHERE type = ?'), searchInsertFts: db.prepare('INSERT OR REPLACE INTO search_fts(rowid, title, body) VALUES (?, ?, ?)'), - searchInsertMap: db.prepare('INSERT OR REPLACE INTO search_map(id, type, folder) VALUES (?, ?, ?)'), + searchInsertMap: db.prepare('INSERT OR REPLACE INTO search_map(id, type, folder, source) VALUES (?, ?, ?, ?)'), searchMapLookup: db.prepare('SELECT rowid FROM search_map WHERE id = ? AND type = ?'), searchUpdateTitle: db.prepare('UPDATE search_fts SET title = ? WHERE rowid = (SELECT rowid FROM search_map WHERE id = ? AND type = ?)'), searchDeleteByRowid: db.prepare('DELETE FROM search_fts WHERE rowid = ?'), @@ -246,7 +264,7 @@ const upsertCachedSessionsBatch = db.transaction((sessions) => { stmts.cacheUpsert.run( s.sessionId, s.folder, s.projectPath, s.summary, s.firstPrompt, s.created, s.modified, s.messageCount || 0, - s.slug || null, s.aiTitle || null + s.slug || null, s.aiTitle || null, s.source || null ); } }); @@ -259,6 +277,39 @@ function getCachedByFolder(folder) { return stmts.cacheGetByFolder.all(folder); } +function getCachedBySource(source) { + return stmts.cacheGetBySource.all(source); +} + +/** Remove all cached + indexed sessions for one remote project (host + projectPath). */ +const deleteRemoteProjectCacheTx = db.transaction((source, projectPath) => { + const ids = stmts.cacheGetIdsBySourceProject.all(source, projectPath).map(r => r.sessionId); + for (const id of ids) { + stmts.searchDeleteBySession.run(id); + stmts.searchMapDeleteBySession.run(id); + } + stmts.cacheDeleteBySourceProject.run(source, projectPath); +}); + +function deleteRemoteProjectCache(source, projectPath) { + deleteRemoteProjectCacheTx(source, projectPath); +} + +// Same, but keyed only by projectPath (any remote source). Used to remove an +// auto-discovered remote project group that was never explicitly registered. +const deleteRemoteProjectCacheByPathTx = db.transaction((projectPath) => { + const ids = stmts.cacheGetIdsByProjectRemote.all(projectPath).map(r => r.sessionId); + for (const id of ids) { + stmts.searchDeleteBySession.run(id); + stmts.searchMapDeleteBySession.run(id); + } + stmts.cacheDeleteByProjectRemote.run(projectPath); +}); + +function deleteRemoteProjectCacheByPath(projectPath) { + deleteRemoteProjectCacheByPathTx(projectPath); +} + function getCachedFolder(sessionId) { const row = stmts.cacheGetFolder.get(sessionId); return row ? row.folder : null; @@ -306,7 +357,7 @@ const upsertSearchEntriesBatch = db.transaction((entries) => { stmts.searchDeleteByRowid.run(existing.rowid); stmts.searchMapDeleteByRowid.run(existing.rowid); } - const result = stmts.searchInsertMap.run(e.id, e.type, e.folder || null); + const result = stmts.searchInsertMap.run(e.id, e.type, e.folder || null, e.source || null); stmts.searchInsertFts.run(result.lastInsertRowid, e.title || '', e.body || ''); } }); @@ -376,8 +427,8 @@ function closeDb() { module.exports = { getMeta, getAllMeta, setName, toggleStar, setArchived, - isCachePopulated, getAllCached, getCachedByFolder, getCachedFolder, getCachedSession, upsertCachedSessions, - deleteCachedSession, deleteCachedFolder, + isCachePopulated, getAllCached, getCachedByFolder, getCachedBySource, getCachedFolder, getCachedSession, upsertCachedSessions, + deleteCachedSession, deleteCachedFolder, deleteRemoteProjectCache, deleteRemoteProjectCacheByPath, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, diff --git a/main.js b/main.js index 2c587b77..da908403 100644 --- a/main.js +++ b/main.js @@ -26,7 +26,9 @@ const cleanPtyEnv = Object.fromEntries( ); // Shell profiles → shell-profiles.js -const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles'); +const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, isSshProfile } = require('./shell-profiles'); +const remoteHosts = require('./remote-hosts'); +const remoteIndex = require('./remote-index'); const { startScheduler } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); @@ -61,8 +63,8 @@ if (app.isPackaged || process.env.FORCE_UPDATER) { } const { getMeta, getAllMeta, toggleStar, setName, setArchived, - isCachePopulated, getAllCached, getCachedByFolder, getCachedFolder, getCachedSession, upsertCachedSessions, - deleteCachedSession, deleteCachedFolder, + isCachePopulated, getAllCached, getCachedByFolder, getCachedBySource, getCachedFolder, getCachedSession, upsertCachedSessions, + deleteCachedSession, deleteCachedFolder, deleteRemoteProjectCache, deleteRemoteProjectCacheByPath, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated, @@ -322,6 +324,21 @@ ipcMain.handle('add-project', (_event, projectPath) => { // --- IPC: remove-project --- ipcMain.handle('remove-project', (_event, projectPath) => { try { + // Remote projects live in settings only (no local .claude/projects folder). + if (typeof projectPath === 'string' && projectPath.startsWith('ssh://')) { + const global = getSetting('global') || {}; + global.remoteProjects = (global.remoteProjects || []).filter(p => p.projectPath !== projectPath); + // Auto-discovered remote groups aren't in remoteProjects, so also hide the + // path (a re-sync would otherwise re-surface it) and drop its indexed rows. + const hidden = global.hiddenProjects || []; + if (!hidden.includes(projectPath)) hidden.push(projectPath); + global.hiddenProjects = hidden; + setSetting('global', global); + deleteSetting('project:' + projectPath); + try { deleteRemoteProjectCacheByPath(projectPath); } catch {} + notifyRendererProjectsChanged(); + return { ok: true }; + } // Add to hidden projects list const global = getSetting('global') || {}; const hidden = global.hiddenProjects || []; @@ -807,6 +824,252 @@ ipcMain.handle('delete-setting', (_event, key) => { return { ok: true }; }); +// --- IPC: remote SSH hosts (Phase 1) --- + +// Merged list of remote targets: parsed from ~/.ssh/config plus user-defined +// manual hosts persisted in global settings under `remoteHosts`. +ipcMain.handle('get-remote-targets', () => { + const manual = (getSetting('global') || {}).remoteHosts || []; + return remoteHosts.loadRemoteHosts(manual); +}); + +// Persist the manual host list (config-derived hosts are never written back). +ipcMain.handle('save-remote-hosts', (_event, hosts) => { + const global = getSetting('global') || {}; + const clean = (Array.isArray(hosts) ? hosts : []) + .filter(h => h && h.host && String(h.host).trim()) + .map(h => ({ + id: h.id || undefined, + label: (h.label || '').trim() || undefined, + host: String(h.host).trim(), + user: (h.user || '').trim() || undefined, + port: h.port ? Number(h.port) : undefined, + identityFile: (h.identityFile || '').trim() || undefined, + options: h.options, + })) + .map(remoteHosts.normalizeManualHost); + global.remoteHosts = clean; + setSetting('global', global); + return { ok: true, hosts: remoteHosts.loadRemoteHosts(clean) }; +}); + +// Interactive Connect: authenticate/verify a host inline (not in the sidebar). +// Runs `ssh -tt true` in a PTY: after auth the trivial command +// exits (code 0 = connected) while ControlPersist keeps the connection warm for +// Browse/sessions. Prompts (password/passphrase/host key) are streamed to a small +// popup terminal in the renderer so the user answers there. +const _connectProcs = new Map(); +let _connectSeq = 0; + +ipcMain.handle('remote-connect-start', (_event, hostId) => { + const manual = (getSetting('global') || {}).remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return { error: 'unknown host' }; + const sock = remoteHosts.controlSocketPath(os.tmpdir(), host.id); + const args = ['-tt', ...remoteHosts.controlArgs(sock), ...remoteHosts.hostTargetArgs(host), 'true']; + const connectId = ++_connectSeq; + let proc; + try { + proc = pty.spawn('ssh', args, { name: 'xterm-256color', cols: 80, rows: 16, cwd: os.homedir(), env: cleanPtyEnv }); + } catch (err) { + return { error: err.message }; + } + _connectProcs.set(connectId, proc); + const send = (channel, ...a) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, ...a); }; + proc.onData(d => send('remote-connect-data', connectId, d)); + proc.onExit(({ exitCode }) => { + _connectProcs.delete(connectId); + send('remote-connect-exit', connectId, exitCode); + }); + return { connectId }; +}); + +ipcMain.on('remote-connect-input', (_event, connectId, data) => { + const proc = _connectProcs.get(connectId); + if (proc) { try { proc.write(data); } catch {} } +}); + +ipcMain.handle('remote-connect-cancel', (_event, connectId) => { + const proc = _connectProcs.get(connectId); + if (proc) { try { proc.kill(); } catch {} } + _connectProcs.delete(connectId); + return { ok: true }; +}); + +// Append a host as a proper ~/.ssh/config entry (append-only, backed up once, +// skips if an entry with that alias already exists). +ipcMain.handle('write-ssh-config', (_event, host) => { + try { + if (!host || !host.host) return { error: 'host is required' }; + const h = remoteHosts.normalizeManualHost(host); + const cfgPath = remoteHosts.defaultSshConfigPath(); + const dir = path.dirname(cfgPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const existing = fs.existsSync(cfgPath) ? fs.readFileSync(cfgPath, 'utf8') : ''; + // Dedup: does a Host line already list this alias as a whole word? + const aliasRe = new RegExp('^\\s*Host\\s+(.*\\s)?' + h.label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\s.*)?$', 'mi'); + if (aliasRe.test(existing)) return { error: `Host "${h.label}" already exists in ~/.ssh/config` }; + // One-time backup before the first Switchboard-written change. + const bak = cfgPath + '.switchboard.bak'; + if (existing && !fs.existsSync(bak)) fs.writeFileSync(bak, existing, { mode: 0o600 }); + const block = (existing && !existing.endsWith('\n') ? '\n' : '') + '\n# added by Switchboard\n' + remoteHosts.buildSshConfigEntry(h) + '\n'; + fs.appendFileSync(cfgPath, block, { mode: 0o600 }); + notifyRendererProjectsChanged(); + return { ok: true, path: cfgPath, label: h.label }; + } catch (err) { + return { error: err.message }; + } +}); + +// Non-interactive connectivity probe (key/agent auth only; never prompts). +ipcMain.handle('test-remote-host', (_event, hostId) => { + const manual = (getSetting('global') || {}).remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return Promise.resolve({ ok: false, error: 'unknown host' }); + return new Promise((resolve) => { + let out = ''; + let done = false; + const finish = (result) => { if (!done) { done = true; resolve(result); } }; + let proc; + try { + proc = pty.spawn('ssh', remoteHosts.testConnectionArgs(host), { + name: 'xterm-256color', cols: 80, rows: 24, cwd: os.homedir(), env: cleanPtyEnv, + }); + } catch (err) { + return finish({ ok: false, error: err.message }); + } + const killTimer = setTimeout(() => { + try { proc.kill(); } catch {} + finish({ ok: false, status: 'unreachable', reachable: false, message: 'Timed out', output: out.trim() }); + }, 15000); + proc.onData(d => { out += d; if (out.length > 4000) out = out.slice(-4000); }); + proc.onExit(({ exitCode }) => { + clearTimeout(killTimer); + // BatchMode never prompts, so a password-auth host "fails" but is reachable. + // Classify the output so the UI reports reachability honestly. + const cls = remoteHosts.classifyConnResult(exitCode, out); + finish({ ok: cls.reachable, exitCode, ...cls, output: out.trim() }); + }); + }); +}); + +// --- IPC: remote projects (Model A — a project can be local or remote) --- + +// Register a remote project (host + remote directory) that appears in the +// sidebar alongside local projects. Persisted in global settings. +ipcMain.handle('add-remote-project', (_event, { hostId, remotePath } = {}) => { + try { + const global = getSetting('global') || {}; + const manual = global.remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return { error: 'unknown host' }; + const dir = (remotePath && String(remotePath).trim()) ? String(remotePath).trim() : '~'; + const projectPath = remoteHosts.remoteProjectPath(host.label, dir); + const list = global.remoteProjects || []; + if (!list.some(p => p.projectPath === projectPath)) { + list.push({ projectPath, hostId: host.id, hostLabel: host.label, remotePath: dir }); + global.remoteProjects = list; + setSetting('global', global); + } + notifyRendererProjectsChanged(); + return { ok: true, projectPath, hostId: host.id, hostLabel: host.label, remotePath: dir }; + } catch (err) { + return { error: err.message }; + } +}); + +// List directories at a remote path (for the remote directory browser). Uses the +// shared control socket (BatchMode = never prompts). If the host needs interactive +// auth and has no live connection yet, this fails fast with needsAuth so the UI can +// tell the user to open a session first (which authenticates and persists the master). +ipcMain.handle('remote-browse', (_event, { hostId, path: remotePath } = {}) => { + const manual = (getSetting('global') || {}).remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return Promise.resolve({ ok: false, error: 'unknown host' }); + const dir = (remotePath && String(remotePath).trim()) ? String(remotePath).trim() : '~'; + const sock = remoteHosts.controlSocketPath(os.tmpdir(), host.id); + const args = remoteHosts.browseArgs(host, sock, dir); + return new Promise((resolve) => { + let out = '', err = '', done = false; + const finish = (r) => { if (!done) { done = true; resolve(r); } }; + let proc; + try { + proc = pty.spawn('ssh', args, { name: 'xterm-256color', cols: 80, rows: 24, cwd: os.homedir(), env: cleanPtyEnv }); + } catch (e) { + return finish({ ok: false, error: e.message }); + } + const killTimer = setTimeout(() => { try { proc.kill(); } catch {} finish({ ok: false, error: 'timed out', path: dir }); }, 12000); + // pty merges stdout/stderr; classify on non-zero exit. + proc.onData(d => { out += d; if (out.length > 20000) out = out.slice(-20000); }); + proc.onExit(({ exitCode }) => { + clearTimeout(killTimer); + if (exitCode === 0) { + return finish({ ok: true, path: dir, dirs: remoteHosts.parseLsDirs(out) }); + } + const cls = remoteHosts.classifyConnResult(exitCode, out); + finish({ ok: false, path: dir, needsAuth: cls.reachable && cls.status !== 'unreachable', status: cls.status, message: cls.message, output: out.trim().slice(-500) }); + }); + }); +}); + +// Index a connected host's past remote sessions (Phase 2). Reuses the live +// ControlMaster socket; transcripts are read over SSH and never copied to disk. +// Returns { ok, indexed, deleted } or { ok:false, needsAuth } when no live master. +// `quiet` suppresses the status-bar chatter (used by the silent startup sweep). +async function runRemoteSync(hostId, { quiet = false } = {}) { + const global = getSetting('global') || {}; + const manual = global.remoteHosts || []; + const host = remoteHosts.findRemoteHost(hostId, manual); + if (!host) return { ok: false, error: 'unknown host' }; + const sock = remoteHosts.controlSocketPath(os.tmpdir(), host.id); + const label = host.label || host.alias || host.id; + if (!quiet) sendStatus('Syncing remote sessions from ' + label + '…', 'active'); + try { + const res = await remoteIndex.syncRemoteHost({ + host, sock, hostLabel: label, + db: { getCachedBySource, upsertCachedSessions, upsertSearchEntries, deleteCachedSession, deleteSearchSession, getMeta, setName }, + }); + log.info(`[remote-sync] host=${host.id} indexed=${res.indexed} deleted=${res.deleted}`); + notifyRendererProjectsChanged(); + if (!quiet) { + const msg = res.indexed > 0 + ? `Indexed ${res.indexed} remote session${res.indexed === 1 ? '' : 's'} from ${label}` + : `No new remote sessions on ${label}`; + sendStatus(msg, 'done'); + setTimeout(() => sendStatus(''), 4000); + } + return res; + } catch (err) { + const emsg = (err && err.message) || String(err); + log.info(`[remote-sync] host=${host.id} FAILED: ${emsg}`); + if (!quiet) { + sendStatus(`Couldn't sync ${label} — connect to the host first.`, 'error'); + setTimeout(() => sendStatus(''), 5000); + } + return { ok: false, error: emsg, needsAuth: true }; + } +} + +ipcMain.handle('sync-remote-host', async (_event, hostId) => { + return runRemoteSync(hostId); +}); + +// On startup, silently try to index each registered remote host. Key/agent-auth +// hosts (or ones with a still-live ControlMaster) index without any prompt via +// BatchMode; password hosts fail fast and are simply skipped until the user +// connects. Runs in the background so it never blocks the UI. +function startupRemoteSync() { + const global = getSetting('global') || {}; + // Every host the user has touched: registered remote projects + manual hosts. + const ids = [...new Set([ + ...(global.remoteProjects || []).map((p) => p.hostId), + ...(global.remoteHosts || []).map((h) => h.id), + ].filter(Boolean))]; + for (const id of ids) { + runRemoteSync(id, { quiet: true }).catch(() => {}); + } +} + // --- Scheduled tasks --- const scheduleIpc = require('./schedule-ipc'); @@ -890,7 +1153,22 @@ ipcMain.handle('rename-session', (_event, sessionId, name) => { }); // --- IPC: archive-session --- -ipcMain.handle('read-session-jsonl', (_event, sessionId) => { +ipcMain.handle('read-session-jsonl', async (_event, sessionId) => { + const cached = getCachedSession(sessionId); + // Remote (Phase 2): stream the transcript live over SSH — nothing is copied to disk. + if (cached && cached.source) { + const global = getSetting('global') || {}; + const manual = global.remoteHosts || []; + const host = remoteHosts.findRemoteHost(cached.source, manual); + if (!host) return { error: 'unknown remote host' }; + const sock = remoteHosts.controlSocketPath(os.tmpdir(), host.id); + try { + const entries = await remoteIndex.fetchRemoteSessionEntries({ host, sock, folder: cached.folder, sessionId }); + return { entries }; + } catch (err) { + return { needsConnect: true, hostLabel: host.label || host.id }; + } + } const folder = getCachedFolder(sessionId); if (!folder) return { error: 'Session not found in cache' }; const jsonlPath = path.join(PROJECTS_DIR, folder, sessionId + '.jsonl'); @@ -942,45 +1220,70 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se return { ok: true, reattached: true, mcpActive: !!session.mcpServer }; } - // Spawn new PTY - if (!fs.existsSync(projectPath)) { - return { ok: false, error: `project directory no longer exists: ${projectPath}` }; + const isPlainTerminal = sessionOptions?.type === 'terminal'; + + // Remote (SSH) session? Resolve the host + ssh profile up front so we can skip + // local-filesystem checks below. Remote sessions run over `ssh` and their + // project directory lives on the remote host, not locally. + const remoteHostId = sessionOptions?.remoteHostId || null; + let remoteHost = null; + if (remoteHostId) { + const manual = (getSetting('global') || {}).remoteHosts || []; + remoteHost = remoteHosts.findRemoteHost(remoteHostId, manual); + if (!remoteHost) return { ok: false, error: `unknown remote host: ${remoteHostId}` }; } + const isRemote = !!remoteHost; - const isPlainTerminal = sessionOptions?.type === 'terminal'; + // Spawn new PTY. Local sessions require an existing project directory; remote + // sessions do not (the directory is on the remote host). + if (!isRemote && !fs.existsSync(projectPath)) { + return { ok: false, error: `project directory no longer exists: ${projectPath}` }; + } - // Resolve shell profile from effective settings - const effectiveProfileId = (() => { - const global = getSetting('global') || {}; - const project = projectPath ? (getSetting('project:' + projectPath) || {}) : {}; - let profileId = SETTING_DEFAULTS.shellProfile; - if (global.shellProfile !== undefined && global.shellProfile !== null) profileId = global.shellProfile; - if (project.shellProfile !== undefined && project.shellProfile !== null) profileId = project.shellProfile; - return profileId; - })(); - // WSL profiles only work for plain terminals — Claude CLI sessions need the - // Windows shell because session data lives on the Windows filesystem. - const requestedProfile = resolveShell(effectiveProfileId); - const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal; - const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal) - ? resolveShell('auto') - : requestedProfile; - const shell = shellProfile.path; - const shellExtraArgs = [...(shellProfile.args || [])]; - const isWsl = isWslShell(shell); - // For WSL, convert Windows path to /mnt/ path and pass via --cd; - // the spawn cwd must remain a valid Windows path for wsl.exe itself. - if (isWsl) { - const wslCwd = windowsToWslPath(projectPath); - shellExtraArgs.unshift('--cd', wslCwd); + // Resolve the shell to spawn. + let shell, shellExtraArgs, isWsl; + if (isRemote) { + shell = 'ssh'; + // -t (PTY) + connection multiplexing (shared control socket) + target. The + // control socket lets the directory browser reuse this authenticated + // connection, so the user only authenticates once per host. + const sock = remoteHosts.controlSocketPath(os.tmpdir(), remoteHost.id); + shellExtraArgs = ['-t', ...remoteHosts.controlArgs(sock), ...remoteHosts.hostTargetArgs(remoteHost)]; + isWsl = false; + log.info(`[shell] remote host=${remoteHost.id} shell=ssh args=${JSON.stringify(shellExtraArgs)}`); + } else { + // Resolve shell profile from effective settings + const effectiveProfileId = (() => { + const global = getSetting('global') || {}; + const project = projectPath ? (getSetting('project:' + projectPath) || {}) : {}; + let profileId = SETTING_DEFAULTS.shellProfile; + if (global.shellProfile !== undefined && global.shellProfile !== null) profileId = global.shellProfile; + if (project.shellProfile !== undefined && project.shellProfile !== null) profileId = project.shellProfile; + return profileId; + })(); + // WSL profiles only work for plain terminals — Claude CLI sessions need the + // Windows shell because session data lives on the Windows filesystem. + const requestedProfile = resolveShell(effectiveProfileId); + const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal) + ? resolveShell('auto') + : requestedProfile; + shell = shellProfile.path; + shellExtraArgs = [...(shellProfile.args || [])]; + isWsl = isWslShell(shell); + // For WSL, convert Windows path to /mnt/ path and pass via --cd; + // the spawn cwd must remain a valid Windows path for wsl.exe itself. + if (isWsl) { + const wslCwd = windowsToWslPath(projectPath); + shellExtraArgs.unshift('--cd', wslCwd); + } + log.info(`[shell] profile=${shellProfile.id} shell=${shell} args=${JSON.stringify(shellExtraArgs)}`); } - log.info(`[shell] profile=${shellProfile.id} shell=${shell} args=${JSON.stringify(shellExtraArgs)}`); let knownJsonlFiles = new Set(); let sessionSlug = null; let projectFolder = null; - if (!isPlainTerminal) { + if (!isPlainTerminal && !isRemote) { // Snapshot existing .jsonl files before spawning (for new session + fork/plan detection) projectFolder = encodeProjectPath(projectPath); const claudeProjectDir = path.join(PROJECTS_DIR, projectFolder); @@ -1009,7 +1312,45 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se let ptyProcess; let mcpServer = null; try { - if (isPlainTerminal) { + if (isRemote) { + // Remote (SSH) session: run Claude (or a login shell) on the remote host. + // No claude shim and no IDE/MCP emulation — the MCP socket is local and + // the CLI on the remote can't reach it (IDE over SSH is a later phase). + const remoteMode = sessionOptions?.remoteMode === 'shell' ? 'shell' : 'claude'; + let innerCmd = null; + if (remoteMode === 'claude') { + let cc = 'claude'; + if (sessionOptions?.dangerouslySkipPermissions) { + cc += ' --dangerously-skip-permissions'; + } else if (sessionOptions?.permissionMode) { + cc += ` --permission-mode "${sessionOptions.permissionMode}"`; + } + if (sessionOptions?.addDirs) { + const dirs = sessionOptions.addDirs.split(',').map(d => d.trim()).filter(Boolean); + for (const dir of dirs) cc += ` --add-dir "${dir}"`; + } + // Resume / fork a past remote session on the host (the transcript lives + // there — same --resume the CLI uses locally, run in the session's dir). + if (sessionOptions?.forkFrom) { + cc += ` --resume "${sessionOptions.forkFrom}" --fork-session`; + } else if (sessionOptions?.resume) { + cc += ` --resume "${sessionOptions.resume}"`; + } + innerCmd = cc; + } + const remoteCmd = remoteHosts.buildRemoteCommand(remoteMode, sessionOptions?.remoteDir || '~', innerCmd); + ptyProcess = pty.spawn(shell, shellArgs(shell, remoteCmd, shellExtraArgs), { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: os.homedir(), // local cwd; the remote cd happens inside remoteCmd + env: { + ...cleanPtyEnv, + TERM: 'xterm-256color', COLORTERM: 'truecolor', + TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', + }, + }); + } else if (isPlainTerminal) { // Plain terminal: interactive login shell, no claude command // Inject a shell function to override `claude` with a helpful message const claudeShim = 'claude() { echo "\\033[33mTo start a Claude session, use the + button in the sidebar.\\033[0m"; return 1; }; export -f claude 2>/dev/null;'; @@ -1121,7 +1462,12 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se outputBuffer: [], outputBufferSize: 0, altScreen: false, projectPath, firstResize: true, projectFolder, knownJsonlFiles, sessionSlug, - isPlainTerminal, forkFrom: sessionOptions?.forkFrom || null, + // Remote sessions are treated as live "terminal" entries so they flow through + // the sidebar's active-session injection (no local .jsonl indexing yet). + isPlainTerminal: isPlainTerminal || isRemote, + remote: isRemote, remoteHost: remoteHost || null, + remoteMode: isRemote ? (sessionOptions?.remoteMode === 'shell' ? 'shell' : 'claude') : null, + forkFrom: sessionOptions?.forkFrom || null, mcpServer, _openedAt: Date.now(), }; activeSessions.set(sessionId, session); @@ -1236,6 +1582,13 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se activeSessions.delete(realId); // Clean up the original key too in case transition detection hasn't run yet activeSessions.delete(sessionId); + + // A remote Claude session just wrote/updated its transcript on the host — + // re-index that host so the past session shows up in the sidebar/search. + if (session.remote && session.remoteHost && session.remoteMode !== 'shell') { + const hid = session.remoteHost.id; + setTimeout(() => { runRemoteSync(hid).catch(() => {}); }, 1500); + } }); if (sessionOptions?.forkFrom) { @@ -1383,6 +1736,9 @@ app.whenReady().then(() => { createWindow(); startProjectsWatcher(); scheduleIpc.ensureScheduleCreatorCommand(); + // Index already-reachable remote hosts in the background (Phase 2), so past + // remote sessions show up on launch without a manual connect/refresh. + setTimeout(() => { try { startupRemoteSync(); } catch {} }, 2000); // Shared runCommand for both cron scheduler and manual "run now" const { spawn: cpSpawn } = require('child_process'); diff --git a/package.json b/package.json index 927c7a4c..6394ad67 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "main": "main.js", "scripts": { "start": "npm run bundle:codemirror && electron .", - "test": "node --test", + "test": "ELECTRON_RUN_AS_NODE=1 electron --test", "electron": "electron .", "bundle:codemirror": "esbuild public/codemirror-setup.js --bundle --outfile=public/codemirror-bundle.js --format=iife --platform=browser --minify", "generate-icons": "node scripts/generate-icons.js && node scripts/generate-dmg-background.js", diff --git a/preload.js b/preload.js index 91d8b5e5..e99a5119 100644 --- a/preload.js +++ b/preload.js @@ -32,6 +32,20 @@ contextBridge.exposeInMainWorld('api', { runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath), getShellProfiles: () => ipcRenderer.invoke('get-shell-profiles'), + // Remote SSH hosts + projects + getRemoteTargets: () => ipcRenderer.invoke('get-remote-targets'), + saveRemoteHosts: (hosts) => ipcRenderer.invoke('save-remote-hosts', hosts), + testRemoteHost: (hostId) => ipcRenderer.invoke('test-remote-host', hostId), + addRemoteProject: (opts) => ipcRenderer.invoke('add-remote-project', opts), + syncRemoteHost: (hostId) => ipcRenderer.invoke('sync-remote-host', hostId), + remoteBrowse: (opts) => ipcRenderer.invoke('remote-browse', opts), + writeSshConfig: (host) => ipcRenderer.invoke('write-ssh-config', host), + remoteConnectStart: (hostId) => ipcRenderer.invoke('remote-connect-start', hostId), + remoteConnectInput: (connectId, data) => ipcRenderer.send('remote-connect-input', connectId, data), + remoteConnectCancel: (connectId) => ipcRenderer.invoke('remote-connect-cancel', connectId), + onRemoteConnectData: (callback) => ipcRenderer.on('remote-connect-data', (_e, id, data) => callback(id, data)), + onRemoteConnectExit: (callback) => ipcRenderer.on('remote-connect-exit', (_e, id, code) => callback(id, code)), + browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), diff --git a/public/app.js b/public/app.js index 9ab01b86..d9133f75 100644 --- a/public/app.js +++ b/public/app.js @@ -788,11 +788,32 @@ async function openSession(session, customOptions) { } } + // Remote past session: resume it in a terminal on the host — same UI and + // behavior as clicking a local session (which auto-resumes via `claude --resume`). + let remoteResumeOptions = null; + if (session.remote && session.remoteMode !== 'shell') { + // Derive the remote dir from the ssh://