diff --git a/src/main/db.schema.ts b/src/main/db.schema.ts index 5ebd7797..5658a27d 100644 --- a/src/main/db.schema.ts +++ b/src/main/db.schema.ts @@ -3,9 +3,9 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" export const projects = sqliteTable("projects", { id: text("id").primaryKey(), name: text("name").notNull(), - locationKind: text("location_kind").notNull(), // "windows" | "wsl" | "posix" - locationPath: text("location_path"), // for windows/posix - locationDistro: text("location_distro"), // for wsl + locationKind: text("location_kind").notNull(), // "windows" | "wsl" | "posix" | "ssh" + locationPath: text("location_path"), // for windows/posix/ssh + locationDistro: text("location_distro"), // for wsl distro / ssh host locationLinuxPath: text("location_linux_path"), // for wsl locationUncPath: text("location_unc_path"), // for wsl lastDraftConfig: text("last_draft_config"), // JSON diff --git a/src/main/db.ts b/src/main/db.ts index 00a3c219..debea0eb 100644 --- a/src/main/db.ts +++ b/src/main/db.ts @@ -303,8 +303,9 @@ export function closeDatabase() { function locationToRow(loc: ProjectLocation) { return { locationKind: loc.kind, - locationPath: loc.kind !== "wsl" ? loc.path : null, - locationDistro: loc.kind === "wsl" ? loc.distro : null, + locationPath: + loc.kind === "windows" || loc.kind === "posix" || loc.kind === "ssh" ? loc.path : null, + locationDistro: loc.kind === "wsl" ? loc.distro : loc.kind === "ssh" ? loc.host : null, locationLinuxPath: loc.kind === "wsl" ? loc.linuxPath : null, locationUncPath: loc.kind === "wsl" ? loc.uncPath : null, }; @@ -328,6 +329,9 @@ function rowToLocation(row: { if (row.locationKind === "posix") { return { kind: "posix", path: row.locationPath! }; } + if (row.locationKind === "ssh") { + return { kind: "ssh", host: row.locationDistro!, path: row.locationPath! }; + } return { kind: "windows", path: row.locationPath! }; } diff --git a/src/renderer/actions/agentLoginActions.ts b/src/renderer/actions/agentLoginActions.ts index 1c19f9cd..121d0689 100644 --- a/src/renderer/actions/agentLoginActions.ts +++ b/src/renderer/actions/agentLoginActions.ts @@ -59,22 +59,22 @@ export function runAgentLoginCommand(input: { } const shellId = `login:${crypto.randomUUID()}`; - // On WSL the agent CLI can't reach the Windows browser on its own, so we + // In remote shells the agent CLI can't reach the Windows browser on its own, so we // suppress its opener (BROWSER=/bin/true) and watch stdout for auth URLs to // hand off via the Windows shell. Native macOS / Windows CLIs already open // their own browser, so a renderer-side watcher would just double-launch. - const suppressWslBrowser = project.location.kind === "wsl"; - const interceptWslUrls = suppressWslBrowser && !isGeminiLoginCommand(input); + const suppressRemoteBrowser = project.location.kind === "wsl" || project.location.kind === "ssh"; + const interceptRemoteUrls = suppressRemoteBrowser && !isGeminiLoginCommand(input); // Wipe the bash prompt + echoed script line that briefly appear before the // TUI takes over. `clear` (POSIX) / `Clear-Host` (PowerShell) gives the // overlay a clean canvas so the user only sees the agent's own UI. const loginCommand = buildTerminalCommand({ command: input.command, - env: suppressWslBrowser ? { BROWSER: "/bin/true", ...(input.env ?? {}) } : input.env, + env: suppressRemoteBrowser ? { BROWSER: "/bin/true", ...(input.env ?? {}) } : input.env, }); const command = project.location.kind === "windows" ? `Clear-Host; ${loginCommand}` : `clear; ${loginCommand}`; - const stopOpeningUrls = interceptWslUrls ? watchUrlsInNativeBrowser(shellId) : undefined; + const stopOpeningUrls = interceptRemoteUrls ? watchUrlsInNativeBrowser(shellId) : undefined; const completionToken = createCompletionToken(); const script = appendCompletionSignal(command, project, completionToken); diff --git a/src/renderer/actions/panelActions.ts b/src/renderer/actions/panelActions.ts index f453e0ff..57d5bba4 100644 --- a/src/renderer/actions/panelActions.ts +++ b/src/renderer/actions/panelActions.ts @@ -3,7 +3,7 @@ import { readBridge } from "@/renderer/bridge"; import { useAppStore } from "@/renderer/state/appStore"; import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { useFileEditorStore } from "@/renderer/state/fileEditorStore"; -import { usePanelStore } from "@/renderer/state/panelStore"; +import { usePanelStore, type ProjectSettingsSectionId } from "@/renderer/state/panelStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { buildFileEditorContext, resolveWorktreeBranch } from "@/renderer/utils/gitHelpers"; import { closeThreads } from "@/renderer/utils/shellUtils"; @@ -83,8 +83,11 @@ export function openUsagePanel(): void { panelStore.openUsagePanel(); } -export function openProjectSettings(projectId: string): void { - usePanelStore.getState().openProjectSettings(projectId); +export function openProjectSettings( + projectId: string, + initialSection?: ProjectSettingsSectionId, +): void { + usePanelStore.getState().openProjectSettings(projectId, initialSection); } /** Closes git/files side and right-panel content only. Does not hide the dev terminal (bottom or right). */ diff --git a/src/renderer/actions/projectActions.ts b/src/renderer/actions/projectActions.ts index 003cdfda..8416a72d 100644 --- a/src/renderer/actions/projectActions.ts +++ b/src/renderer/actions/projectActions.ts @@ -34,9 +34,24 @@ export function setProjectDisabled(projectId: string, disabled: boolean): void { if (!project) return; if ((project.disabled ?? false) === disabled) return; + const projectThreadIds = disabled + ? store.threads.filter((t) => t.projectId === projectId).map((t) => t.id) + : []; + store.setProjectDisabled(projectId, disabled); if (disabled) { + if (project.location.kind === "ssh" && projectThreadIds.length > 0) { + void Promise.allSettled( + projectThreadIds.map((threadId) => readBridge().closeThread({ threadId })), + ).then(() => { + const nextStore = useAppStore.getState(); + for (const threadId of projectThreadIds) { + nextStore.markThreadExited(threadId); + } + }); + } + void readBridge() .gitUnwatchProject({ projectId }) .catch(() => undefined); diff --git a/src/renderer/app.test.tsx b/src/renderer/app.test.tsx index 856dacd3..6d888ccb 100644 --- a/src/renderer/app.test.tsx +++ b/src/renderer/app.test.tsx @@ -13,8 +13,10 @@ const { bridge } = vi.hoisted(() => ({ pickFolder: vi.fn<() => Promise>().mockResolvedValue(null), listWslDistros: vi.fn<() => Promise>().mockResolvedValue([]), getAgentStatuses: vi - .fn<() => Promise<{ windows: unknown[]; wsl: unknown[]; fromCache: boolean }>>() - .mockResolvedValue({ windows: [], wsl: [], fromCache: false }), + .fn< + () => Promise<{ windows: unknown[]; wsl: unknown[]; ssh: unknown[]; fromCache: boolean }> + >() + .mockResolvedValue({ windows: [], wsl: [], ssh: [], fromCache: false }), getThreadSnapshots: vi.fn<() => Promise>().mockResolvedValue([]), getHomeScopeLocation: vi .fn<() => Promise<{ kind: "windows"; path: string }>>() diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 052bd08b..c7953c54 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -178,6 +178,18 @@ const unsubSupervisor = readBridge().onSupervisorEvent((event) => { store.setWslAgentStatuses(event.statuses); } } + if (event.type === "ssh-agent-statuses") { + console.log(`[renderer] event: ssh-agent-statuses (${event.statuses.length} agents)`); + const store = useAgentStatusesStore.getState(); + if (store.inFirstLaunchDiscovery && store.discoveryScope?.kind === "ssh") { + const statuses = event.statuses; + setTimeout(() => { + useAgentStatusesStore.getState().setSshAgentStatuses(statuses); + }, 1000); + } else { + store.setSshAgentStatuses(event.statuses); + } + } }); const unsubUpdate = readBridge().onUpdateStatus((status) => { diff --git a/src/renderer/components/providers/conflictResolver.launch.test.ts b/src/renderer/components/providers/conflictResolver.launch.test.ts index 406bf6a5..6052542b 100644 --- a/src/renderer/components/providers/conflictResolver.launch.test.ts +++ b/src/renderer/components/providers/conflictResolver.launch.test.ts @@ -55,6 +55,15 @@ describe("readConflictResolverSettingsForProject", () => { }); }); + it("falls back to Windows settings for SSH projects when WSL conflict resolver is unset", () => { + expect(readConflictResolverSettingsForProject("ssh", settings)).toEqual({ + provider: "cursor", + model: "composer-2.5", + effort: "", + presentationMode: "terminal", + }); + }); + it("uses WSL settings when WSL conflict resolver is configured", () => { expect( readConflictResolverSettingsForProject("wsl", { @@ -70,6 +79,22 @@ describe("readConflictResolverSettingsForProject", () => { presentationMode: "terminal", }); }); + + it("uses WSL settings for SSH projects when WSL conflict resolver is configured", () => { + expect( + readConflictResolverSettingsForProject("ssh", { + ...settings, + wslConflictResolverProvider: "cursor", + wslConflictResolverModel: "composer-2.5-fast", + wslConflictResolverPresentationMode: "terminal", + }), + ).toEqual({ + provider: "cursor", + model: "composer-2.5-fast", + effort: "", + presentationMode: "terminal", + }); + }); }); describe("resolveConflictResolverLaunchConfig", () => { diff --git a/src/renderer/components/providers/conflictResolver.ts b/src/renderer/components/providers/conflictResolver.ts index e192812d..ed8089f1 100644 --- a/src/renderer/components/providers/conflictResolver.ts +++ b/src/renderer/components/providers/conflictResolver.ts @@ -33,7 +33,7 @@ export function readConflictResolverSettingsForProject( locationKind: ProjectLocation["kind"], settings: ConflictResolverSettingsSource, ): ConflictResolverSettings { - if (locationKind !== "wsl") { + if (locationKind !== "wsl" && locationKind !== "ssh") { return { provider: settings.conflictResolverProvider, model: settings.conflictResolverModel, diff --git a/src/renderer/components/thread/AgentDiscoveryScreen.tsx b/src/renderer/components/thread/AgentDiscoveryScreen.tsx index 84346c54..383f1cfa 100644 --- a/src/renderer/components/thread/AgentDiscoveryScreen.tsx +++ b/src/renderer/components/thread/AgentDiscoveryScreen.tsx @@ -14,9 +14,11 @@ function readyBadge(status: AgentStatus): { label: string; toneClass: string } | return { label: "Ready", toneClass: "text-success" }; } -function statusLine(scopedCount: number, installedCount: number, wslDistro: string | undefined) { +function statusLine(scopedCount: number, installedCount: number, remoteKind: string | undefined) { if (scopedCount === 0) { - return wslDistro ? "Warming up WSL shell environment…" : "Warming up shell environment…"; + return remoteKind + ? `Warming up ${remoteKind} shell environment…` + : "Warming up shell environment…"; } if (installedCount === 0) return "No agents installed yet"; if (installedCount === 1) return "1 agent ready"; @@ -62,6 +64,7 @@ function statusLabel(status: AgentStatus | undefined): { label: string; toneClas export function AgentDiscoveryScreen(props: { location?: ProjectLocation; onCancel?: () => void; + sshHosts?: string[]; wslDistros?: string[]; }) { // `discoveredAgents` is already scoped by `pushDiscoveredAgent` to the active @@ -79,6 +82,7 @@ export function AgentDiscoveryScreen(props: { } const installedCount = discovered.reduce((n, s) => n + (s.installed ? 1 : 0), 0); const wslDistro = props.location?.kind === "wsl" ? props.location.distro : undefined; + const sshHost = props.location?.kind === "ssh" ? props.location.host : undefined; const scanTargets: ScanTarget[] = wslDistro !== undefined ? [ @@ -88,35 +92,55 @@ export function AgentDiscoveryScreen(props: { matches: (status) => status.envKind === "wsl" && status.envDistro === wslDistro, }, ] - : props.wslDistros !== undefined + : sshHost !== undefined ? [ { - key: "native", - label: "Windows", - matches: (status) => status.envKind !== "wsl", + key: `ssh:${sshHost}`, + label: `SSH: ${sshHost}`, + matches: (status) => status.envKind === "ssh" && status.envHost === sshHost, }, - ...props.wslDistros.map((distro) => ({ - key: `wsl:${distro}`, - label: `WSL: ${distro}`, - matches: (status: AgentStatus) => - status.envKind === "wsl" && status.envDistro === distro, - })), ] - : discoveryScope?.kind === "all" + : props.wslDistros !== undefined || props.sshHosts !== undefined ? [ { key: "native", label: "Windows", - matches: (status) => status.envKind !== "wsl", + matches: (status) => status.envKind !== "wsl" && status.envKind !== "ssh", }, - ...discoveryScope.wslDistros.map((distro) => ({ + ...(props.wslDistros ?? []).map((distro) => ({ key: `wsl:${distro}`, label: `WSL: ${distro}`, matches: (status: AgentStatus) => status.envKind === "wsl" && status.envDistro === distro, })), + ...(props.sshHosts ?? []).map((host) => ({ + key: `ssh:${host}`, + label: `SSH: ${host}`, + matches: (status: AgentStatus) => + status.envKind === "ssh" && status.envHost === host, + })), ] - : []; + : discoveryScope?.kind === "all" + ? [ + { + key: "native", + label: "Windows", + matches: (status) => status.envKind !== "wsl" && status.envKind !== "ssh", + }, + ...discoveryScope.wslDistros.map((distro) => ({ + key: `wsl:${distro}`, + label: `WSL: ${distro}`, + matches: (status: AgentStatus) => + status.envKind === "wsl" && status.envDistro === distro, + })), + ...(discoveryScope.sshHosts ?? []).map((host) => ({ + key: `ssh:${host}`, + label: `SSH: ${host}`, + matches: (status: AgentStatus) => + status.envKind === "ssh" && status.envHost === host, + })), + ] + : []; // Provider plugins self-register at module-load time; reading the registry // each render keeps this screen in sync as new agent kinds are added. const providers = getRegisteredProviders(); @@ -143,9 +167,11 @@ export function AgentDiscoveryScreen(props: {

{wslDistro ? `Scanning ${wslDistro} for installed CLIs. This usually takes a couple of seconds.` - : scanTargets.length > 1 - ? "Scanning Windows and WSL for installed CLIs. This usually takes a couple of seconds." - : "Scanning your system for installed CLIs. This usually takes a couple of seconds."} + : sshHost + ? `Scanning ${sshHost} for installed CLIs. This usually takes a couple of seconds.` + : scanTargets.length > 1 + ? "Scanning Windows and WSL for installed CLIs. This usually takes a couple of seconds." + : "Scanning your system for installed CLIs. This usually takes a couple of seconds."}

{scanTargets.length > 1 ? (
@@ -207,7 +233,11 @@ export function AgentDiscoveryScreen(props: {
{useMatrixLayout ? combinedStatusLine(discovered) - : statusLine(discovered.length, installedCount, wslDistro)} + : statusLine( + discovered.length, + installedCount, + wslDistro ? "WSL" : sshHost ? "SSH" : undefined, + )}
{props.onCancel ? ( diff --git a/src/renderer/components/thread/ChatPane/chatPathUtils.ts b/src/renderer/components/thread/ChatPane/chatPathUtils.ts index 3c41b4ea..9f435457 100644 --- a/src/renderer/components/thread/ChatPane/chatPathUtils.ts +++ b/src/renderer/components/thread/ChatPane/chatPathUtils.ts @@ -39,6 +39,7 @@ function getProjectRoots(projectLocation: ProjectLocation): string[] { return [projectLocation.path]; case "wsl": return [projectLocation.linuxPath, projectLocation.uncPath]; + case "ssh": case "posix": return [projectLocation.path]; } diff --git a/src/renderer/components/thread/ProjectSwitchMenu.tsx b/src/renderer/components/thread/ProjectSwitchMenu.tsx index 8f1d67a2..cda8b450 100644 --- a/src/renderer/components/thread/ProjectSwitchMenu.tsx +++ b/src/renderer/components/thread/ProjectSwitchMenu.tsx @@ -1,5 +1,5 @@ import { startTransition } from "react"; -import { ChevronDown, FolderOpen, House, Monitor } from "lucide-react"; +import { ChevronDown, FolderOpen, House, Monitor, Server } from "lucide-react"; import { Dropdown, Label } from "@heroui/react"; import { useShallow } from "zustand/shallow"; import type { Project } from "@/shared/contracts"; @@ -20,6 +20,9 @@ function LocationIcon(props: { kind: Project["location"]["kind"]; className?: st if (props.kind === "windows") { return ; } + if (props.kind === "ssh") { + return ; + } return ; } diff --git a/src/renderer/components/thread/ThreadAgentUpdateDock.tsx b/src/renderer/components/thread/ThreadAgentUpdateDock.tsx index 42e6f9be..b7de8147 100644 --- a/src/renderer/components/thread/ThreadAgentUpdateDock.tsx +++ b/src/renderer/components/thread/ThreadAgentUpdateDock.tsx @@ -43,9 +43,11 @@ export function ThreadAgentUpdateDock(props: { >(undefined); const [pending, setPending] = useState(false); + const isSshStatus = agentStatus.envKind === "ssh"; const registryAgentId = acpGenericInstanceId(agentStatus.kind); useEffect(() => { + if (isSshStatus) return; if (registryAgentId) return; if (!agentStatus.installed || !agentStatus.version) return; let cancelled = false; @@ -65,7 +67,7 @@ export function ThreadAgentUpdateDock(props: { return () => { cancelled = true; }; - }, [agentStatus.kind, agentStatus.installed, agentStatus.version, registryAgentId]); + }, [agentStatus.kind, agentStatus.installed, agentStatus.version, isSshStatus, registryAgentId]); // Identity-gated read so a stale entry from a previous agent never matches // the current one (avoids one-frame flash on agent switch). @@ -82,15 +84,19 @@ export function ThreadAgentUpdateDock(props: { installedVersion !== undefined && isNewerVersion(resolvedLatest, installedVersion); - if (!isOutdated || !resolvedLatest || !installedVersion) { + if (isSshStatus || !isOutdated || !resolvedLatest || !installedVersion) { return null; } const scope = statusUpdateScope(agentStatus); + if (scope.envKind === "ssh") { + return null; + } + const updateEnvKind = scope.envKind; const previewCommand = resolveSharedUpdateCommand({ update: agentStatus.update, executablePath: agentStatus.executablePath, - envKind: scope.envKind, + envKind: updateEnvKind, }); const previewCommandLine = previewCommand ? formatUpdateCommandLine(previewCommand) : undefined; @@ -103,7 +109,7 @@ export function ThreadAgentUpdateDock(props: { try { const result = await readBridge().updateAgentBinary({ agentKind: agentStatus.kind, - envKind: scope.envKind, + envKind: updateEnvKind, ...(scope.wslDistro ? { wslDistro: scope.wslDistro } : {}), }); if (result.ok) { diff --git a/src/renderer/components/thread/ThreadAuthRequiredDock.tsx b/src/renderer/components/thread/ThreadAuthRequiredDock.tsx index 31206ffd..918ef161 100644 --- a/src/renderer/components/thread/ThreadAuthRequiredDock.tsx +++ b/src/renderer/components/thread/ThreadAuthRequiredDock.tsx @@ -4,10 +4,11 @@ import { KeyRound, LogIn, RefreshCw, Settings } from "lucide-react"; import type { AgentStatus, Project } from "@/shared/contracts"; import { readBridge } from "@/renderer/bridge"; import { runAgentLoginCommand } from "@/renderer/actions/agentLoginActions"; -import { openSettings } from "@/renderer/actions/panelActions"; +import { openProjectSettings, openSettings } from "@/renderer/actions/panelActions"; import { Button } from "@/renderer/components/common"; import { agentAuthTarget, + currentSshProjects, currentWslDistros, findAgentAuthMethodForStatus, findTerminalAuthMethodForStatus, @@ -16,10 +17,14 @@ import { import { ThreadDockHeader, ThreadDockSection } from "./ThreadDockUI"; async function refreshAgentStatus(status: AgentStatus): Promise { - await readBridge().refreshAgentStatuses(currentWslDistros(), { - agentKinds: [status.kind], - envs: [scopeEnvForStatus(status)], - }); + await readBridge().refreshAgentStatuses( + currentWslDistros(), + { + agentKinds: [status.kind], + envs: [scopeEnvForStatus(status)], + }, + currentSshProjects(), + ); } /** @@ -42,7 +47,7 @@ export function ThreadAuthRequiredDock(props: { agentStatus: AgentStatus; projec const [pendingAction, setPendingAction] = useState<"login" | "refresh" | undefined>(); const agentAuthMethod = findAgentAuthMethodForStatus(agentStatus); const terminalAuthMethod = findTerminalAuthMethodForStatus(agentStatus); - const canUseAgentAuth = agentAuthMethod !== undefined; + const canUseAgentAuth = agentStatus.envKind !== "ssh" && agentAuthMethod !== undefined; const canUseTerminalLogin = Boolean(agentStatus.loginCommand); const hasDirectLogin = canUseAgentAuth || canUseTerminalLogin; const preferTerminalLogin = shouldPreferTerminalLogin(agentStatus); @@ -52,6 +57,13 @@ export function ThreadAuthRequiredDock(props: { agentStatus: AgentStatus; projec : agentAuthMethod ? `Complete ${agentAuthMethod.name} sign-in before this thread can run.` : "Add credentials before this thread can run."; + const openAuthSettings = () => { + if (agentStatus.envKind === "ssh" && project) { + openProjectSettings(project.id, "agents"); + return; + } + openSettings(); + }; async function handleLogin() { if (pendingAction) return; @@ -147,7 +159,7 @@ export function ThreadAuthRequiredDock(props: { agentStatus: AgentStatus; projec variant="ghost" className="h-6 min-w-0 px-2 text-xs text-foreground" onMouseDown={preventFocusSteal} - onPress={openSettings} + onPress={openAuthSettings} > Settings diff --git a/src/renderer/hooks/uiSelectors.ts b/src/renderer/hooks/uiSelectors.ts index 410342b8..a4c8a493 100644 --- a/src/renderer/hooks/uiSelectors.ts +++ b/src/renderer/hooks/uiSelectors.ts @@ -183,7 +183,12 @@ export function useProjectAgentStatuses( return useAgentStatusesStore( useShallow((s) => projectLocation - ? getProjectAgentStatuses(projectLocation, s.agentStatuses, s.wslAgentStatuses) + ? getProjectAgentStatuses( + projectLocation, + s.agentStatuses, + s.wslAgentStatuses, + s.sshAgentStatuses, + ) : [], ), ); diff --git a/src/renderer/hooks/useGitRefresh.ts b/src/renderer/hooks/useGitRefresh.ts index d101cf7e..bdc98aea 100644 --- a/src/renderer/hooks/useGitRefresh.ts +++ b/src/renderer/hooks/useGitRefresh.ts @@ -21,7 +21,7 @@ import { GIT_FETCH_BACKGROUND_INTERVAL_MS, } from "@/renderer/utils/gitHelpers"; -const WSL_STATUS_POLL_INTERVAL_MS = 3_000; +const REMOTE_STATUS_POLL_INTERVAL_MS = 3_000; /** * Subscribe to a store and invoke `onChange` whenever any of the selected @@ -220,12 +220,12 @@ export function useGitRefresh(storeHydrated: boolean) { ); } - function pollPriorityWslStatus() { + function pollPriorityRemoteStatus() { if (!isActive) return; if (typeof document !== "undefined" && !document.hasFocus()) return; const priorityProjectIds = getPriorityProjectIds(); for (const project of activeProjects) { - if (project.location.kind !== "wsl") continue; + if (project.location.kind !== "wsl" && project.location.kind !== "ssh") continue; if (!priorityProjectIds.has(project.id)) continue; void refreshGitProject(project, "poll", "status", { isActive: isActiveCheck }); } @@ -241,13 +241,16 @@ export function useGitRefresh(storeHydrated: boolean) { () => void fetchRemotes(), Math.min(GIT_FETCH_PRIORITY_INTERVAL_MS, GIT_FETCH_BACKGROUND_INTERVAL_MS), ); - const wslStatusPollIntervalId = setInterval(pollPriorityWslStatus, WSL_STATUS_POLL_INTERVAL_MS); + const remoteStatusPollIntervalId = setInterval( + pollPriorityRemoteStatus, + REMOTE_STATUS_POLL_INTERVAL_MS, + ); return () => { isActive = false; clearTimeout(initialFetchTimer); clearInterval(fetchIntervalId); - clearInterval(wslStatusPollIntervalId); + clearInterval(remoteStatusPollIntervalId); for (const timer of watcherDebounceTimers.values()) clearTimeout(timer); watcherDebounceTimers.clear(); unsubPendingPrRefresh(); diff --git a/src/renderer/state/agentStatusesStore.test.ts b/src/renderer/state/agentStatusesStore.test.ts index 5b42bcfe..4ccc6cb7 100644 --- a/src/renderer/state/agentStatusesStore.test.ts +++ b/src/renderer/state/agentStatusesStore.test.ts @@ -34,8 +34,10 @@ function reset() { useAgentStatusesStore.setState({ agentStatuses: [], wslAgentStatuses: [], + sshAgentStatuses: [], windowsLoaded: false, wslLoaded: false, + sshLoaded: false, inFirstLaunchDiscovery: false, discoveryScope: undefined, discoveredAgents: [], @@ -121,12 +123,15 @@ describe("hydrateFromCache", () => { useAgentStatusesStore.getState().hydrateFromCache({ windows: [makeStatus({ kind: "claude" })], wsl: [makeStatus({ kind: "gemini", envKind: "wsl", envDistro: "Ubuntu" })], + ssh: [makeStatus({ kind: "codex", envKind: "ssh", envHost: "devbox" })], }); const state = useAgentStatusesStore.getState(); expect(state.agentStatuses).toHaveLength(1); expect(state.wslAgentStatuses).toHaveLength(1); + expect(state.sshAgentStatuses).toHaveLength(1); expect(state.windowsLoaded).toBe(true); expect(state.wslLoaded).toBe(true); + expect(state.sshLoaded).toBe(true); expect(state.inFirstLaunchDiscovery).toBe(false); expect(state.discoveryScope).toBeUndefined(); }); @@ -160,13 +165,17 @@ describe("beginFirstLaunchDiscovery", () => { }); it("can start combined discovery after statuses are already loaded", () => { - useAgentStatusesStore.setState({ windowsLoaded: true, wslLoaded: true }); + useAgentStatusesStore.setState({ windowsLoaded: true, wslLoaded: true, sshLoaded: true }); useAgentStatusesStore .getState() - .beginFirstLaunchDiscovery({ kind: "all", wslDistros: ["Ubuntu"] }); + .beginFirstLaunchDiscovery({ kind: "all", wslDistros: ["Ubuntu"], sshHosts: ["devbox"] }); const state = useAgentStatusesStore.getState(); expect(state.inFirstLaunchDiscovery).toBe(true); - expect(state.discoveryScope).toEqual({ kind: "all", wslDistros: ["Ubuntu"] }); + expect(state.discoveryScope).toEqual({ + kind: "all", + wslDistros: ["Ubuntu"], + sshHosts: ["devbox"], + }); }); }); @@ -201,17 +210,20 @@ describe("pushDiscoveredAgent", () => { it("appends native and matching WSL agents during combined discovery", () => { useAgentStatusesStore .getState() - .beginFirstLaunchDiscovery({ kind: "all", wslDistros: ["Ubuntu"] }); + .beginFirstLaunchDiscovery({ kind: "all", wslDistros: ["Ubuntu"], sshHosts: ["devbox"] }); useAgentStatusesStore.getState().pushDiscoveredAgent(makeStatus({ kind: "codex" })); useAgentStatusesStore .getState() .pushDiscoveredAgent(makeStatus({ kind: "codex", envKind: "wsl", envDistro: "Ubuntu" })); + useAgentStatusesStore + .getState() + .pushDiscoveredAgent(makeStatus({ kind: "claude", envKind: "ssh", envHost: "devbox" })); useAgentStatusesStore .getState() .pushDiscoveredAgent(makeStatus({ kind: "gemini", envKind: "wsl", envDistro: "Debian" })); expect( useAgentStatusesStore.getState().discoveredAgents.map((status) => status.envKind), - ).toEqual([undefined, "wsl"]); + ).toEqual([undefined, "wsl", "ssh"]); }); }); @@ -263,6 +275,15 @@ describe("mergeAgentStatus", () => { expect(state.wslLoaded).toBe(true); }); + it("routes SSH statuses into sshAgentStatuses and keeps native list untouched", () => { + const ssh = makeStatus({ kind: "gemini", envKind: "ssh", envHost: "devbox" }); + useAgentStatusesStore.getState().mergeAgentStatus(ssh); + const state = useAgentStatusesStore.getState(); + expect(state.sshAgentStatuses).toHaveLength(1); + expect(state.agentStatuses).toHaveLength(0); + expect(state.sshLoaded).toBe(true); + }); + it("treats different envDistro values as distinct entries", () => { useAgentStatusesStore .getState() @@ -299,6 +320,19 @@ describe("isDetectingAgentsForLocation", () => { false, ); }); + + it("uses sshLoaded for an SSH location", () => { + const loc: ProjectLocation = { kind: "ssh", host: "devbox", path: "/repo" }; + expect( + isDetectingAgentsForLocation({ windowsLoaded: true, wslLoaded: true, sshLoaded: false }, loc), + ).toBe(true); + expect( + isDetectingAgentsForLocation( + { windowsLoaded: false, wslLoaded: false, sshLoaded: true }, + loc, + ), + ).toBe(false); + }); }); describe("isDiscoveryActiveForLocation", () => { @@ -337,7 +371,10 @@ describe("isDiscoveryActiveForLocation", () => { const loc: ProjectLocation = { kind: "windows", path: "C:\\tmp" }; expect( isDiscoveryActiveForLocation( - { inFirstLaunchDiscovery: true, discoveryScope: { kind: "all", wslDistros: ["Ubuntu"] } }, + { + inFirstLaunchDiscovery: true, + discoveryScope: { kind: "all", wslDistros: ["Ubuntu"], sshHosts: ["devbox"] }, + }, loc, ), ).toBe(true); diff --git a/src/renderer/state/agentStatusesStore.ts b/src/renderer/state/agentStatusesStore.ts index bdd55456..92772609 100644 --- a/src/renderer/state/agentStatusesStore.ts +++ b/src/renderer/state/agentStatusesStore.ts @@ -10,11 +10,13 @@ import { export type AgentDiscoveryScope = | { kind: "native" } | { kind: "wsl"; distro: string } - | { kind: "all"; wslDistros: string[] }; + | { kind: "ssh"; host: string } + | { kind: "all"; wslDistros: string[]; sshHosts?: string[] }; interface AgentStatusesStore { agentStatuses: AgentStatus[]; wslAgentStatuses: AgentStatus[]; + sshAgentStatuses: AgentStatus[]; /** * True once a windows-agent-statuses event (or cache) has been applied. * Used by UI to distinguish "detecting…" from "detected nothing". @@ -22,6 +24,8 @@ interface AgentStatusesStore { windowsLoaded: boolean; /** True once a wsl-agent-statuses event (or cache) has been applied. */ wslLoaded: boolean; + /** True once ssh-agent-statuses has been applied for all requested SSH hosts. */ + sshLoaded: boolean; /** * On first launch (no cache) we render a discovery screen that reveals * agent tiles as the supervisor streams `agent-detected` events. Once the @@ -35,12 +39,17 @@ interface AgentStatusesStore { discoveredAgents: AgentStatus[]; setAgentStatuses: (statuses: AgentStatus[]) => void; setWslAgentStatuses: (statuses: AgentStatus[]) => void; + setSshAgentStatuses: (statuses: AgentStatus[]) => void; /** * Hydrate both scopes at once from an RPC cache read. Called before the * main UI mounts so ThreadDraft renders with the cached agents instead of * the empty initial state. */ - hydrateFromCache: (cached: { windows: AgentStatus[]; wsl: AgentStatus[] }) => void; + hydrateFromCache: (cached: { + windows: AgentStatus[]; + wsl: AgentStatus[]; + ssh?: AgentStatus[]; + }) => void; beginFirstLaunchDiscovery: (scope?: AgentDiscoveryScope) => void; resetDiscoveredAgents: () => void; pushDiscoveredAgent: (status: AgentStatus) => void; @@ -79,6 +88,9 @@ function statusesEqual(a: AgentStatus[], b: AgentStatus[]): boolean { x.version === b[i]!.version && x.authState === b[i]!.authState && x.loginCommand === b[i]!.loginCommand && + x.envKind === b[i]!.envKind && + x.envDistro === b[i]!.envDistro && + x.envHost === b[i]!.envHost && JSON.stringify(x.authMethods ?? []) === JSON.stringify(b[i]!.authMethods ?? []) && areAgentProviderMetadataEqual(x.providerMetadata, b[i]!.providerMetadata) && capabilitiesEqual(x.capabilities, b[i]!.capabilities), @@ -90,28 +102,40 @@ function isStatusInDiscoveryScope( scope: AgentDiscoveryScope | undefined, ): boolean { if (scope?.kind === "all") { - return status.envKind !== "wsl" || scope.wslDistros.includes(status.envDistro ?? ""); + if (status.envKind === "wsl") return scope.wslDistros.includes(status.envDistro ?? ""); + if (status.envKind === "ssh") return scope.sshHosts?.includes(status.envHost ?? "") ?? false; + return true; } if (scope?.kind === "wsl") { return status.envKind === "wsl" && status.envDistro === scope.distro; } - return status.envKind !== "wsl"; + if (scope?.kind === "ssh") { + return status.envKind === "ssh" && status.envHost === scope.host; + } + return status.envKind !== "wsl" && status.envKind !== "ssh"; } function buildStatusUpdatePatch( prev: AgentStatusesStore, incoming: AgentStatus[], - scope: "native" | "wsl", + scope: "native" | "wsl" | "ssh", ): Partial { const isWsl = scope === "wsl"; - const currentList = isWsl ? prev.wslAgentStatuses : prev.agentStatuses; - const currentLoaded = isWsl ? prev.wslLoaded : prev.windowsLoaded; + const isSsh = scope === "ssh"; + const currentList = isSsh + ? prev.sshAgentStatuses + : isWsl + ? prev.wslAgentStatuses + : prev.agentStatuses; + const currentLoaded = isSsh ? prev.sshLoaded : isWsl ? prev.wslLoaded : prev.windowsLoaded; const equal = statusesEqual(currentList, incoming); const endsDiscovery = prev.inFirstLaunchDiscovery && - (isWsl - ? prev.discoveryScope?.kind === "wsl" - : prev.discoveryScope?.kind === undefined || prev.discoveryScope.kind === "native"); + (isSsh + ? prev.discoveryScope?.kind === "ssh" + : isWsl + ? prev.discoveryScope?.kind === "wsl" + : prev.discoveryScope?.kind === undefined || prev.discoveryScope.kind === "native"); const discoveryPatch: Partial = endsDiscovery ? { inFirstLaunchDiscovery: false, discoveryScope: undefined } : {}; @@ -120,12 +144,16 @@ function buildStatusUpdatePatch( } const listPatch: Partial = equal ? {} + : isSsh + ? { sshAgentStatuses: incoming } + : isWsl + ? { wslAgentStatuses: incoming } + : { agentStatuses: incoming }; + const loadedPatch: Partial = isSsh + ? { sshLoaded: true } : isWsl - ? { wslAgentStatuses: incoming } - : { agentStatuses: incoming }; - const loadedPatch: Partial = isWsl - ? { wslLoaded: true } - : { windowsLoaded: true }; + ? { wslLoaded: true } + : { windowsLoaded: true }; return { ...listPatch, ...loadedPatch, ...discoveryPatch }; } @@ -134,8 +162,10 @@ export const useAgentStatusesStore = create()( (set) => ({ agentStatuses: [], wslAgentStatuses: [], + sshAgentStatuses: [], windowsLoaded: false, wslLoaded: false, + sshLoaded: false, inFirstLaunchDiscovery: false, discoveryScope: undefined, discoveredAgents: [], @@ -143,12 +173,16 @@ export const useAgentStatusesStore = create()( set((prev) => buildStatusUpdatePatch(prev, incoming, "native")), setWslAgentStatuses: (incoming) => set((prev) => buildStatusUpdatePatch(prev, incoming, "wsl")), - hydrateFromCache: ({ windows, wsl }) => + setSshAgentStatuses: (incoming) => + set((prev) => buildStatusUpdatePatch(prev, incoming, "ssh")), + hydrateFromCache: ({ windows, wsl, ssh }) => set(() => ({ agentStatuses: windows, wslAgentStatuses: wsl, + sshAgentStatuses: ssh ?? [], windowsLoaded: true, wslLoaded: true, + sshLoaded: true, inFirstLaunchDiscovery: false, discoveryScope: undefined, })), @@ -163,6 +197,7 @@ export const useAgentStatusesStore = create()( discoveryScope: nextScope, discoveredAgents: [], ...(nextScope.kind === "wsl" ? { wslLoaded: false } : {}), + ...(nextScope.kind === "ssh" ? { sshLoaded: false } : {}), }; }), resetDiscoveredAgents: () => @@ -183,7 +218,8 @@ export const useAgentStatusesStore = create()( (existing) => existing.kind === status.kind && existing.envKind === status.envKind && - existing.envDistro === status.envDistro, + existing.envDistro === status.envDistro && + existing.envHost === status.envHost, ) ) { return prev; @@ -195,7 +231,16 @@ export const useAgentStatusesStore = create()( const matches = (entry: AgentStatus) => entry.kind === status.kind && entry.envKind === status.envKind && - entry.envDistro === status.envDistro; + entry.envDistro === status.envDistro && + entry.envHost === status.envHost; + if (status.envKind === "ssh") { + const idx = prev.sshAgentStatuses.findIndex(matches); + const next = + idx === -1 + ? [...prev.sshAgentStatuses, status] + : prev.sshAgentStatuses.map((entry, i) => (i === idx ? status : entry)); + return { sshAgentStatuses: next, sshLoaded: true }; + } if (status.envKind === "wsl") { const idx = prev.wslAgentStatuses.findIndex(matches); const next = @@ -227,8 +272,10 @@ export const useAgentStatusesStore = create()( partialize: (state) => ({ agentStatuses: state.agentStatuses, wslAgentStatuses: state.wslAgentStatuses, + sshAgentStatuses: state.sshAgentStatuses, windowsLoaded: state.windowsLoaded, wslLoaded: state.wslLoaded, + sshLoaded: state.sshLoaded, }), }, ), @@ -241,9 +288,10 @@ export const useAgentStatusesStore = create()( * shows the install-agents prompt. */ export function isDetectingAgentsForLocation( - state: { windowsLoaded: boolean; wslLoaded: boolean }, + state: { windowsLoaded: boolean; wslLoaded: boolean; sshLoaded?: boolean }, location: ProjectLocation, ): boolean { + if (location.kind === "ssh") return state.sshLoaded !== true; return location.kind === "wsl" ? !state.wslLoaded : !state.windowsLoaded; } @@ -256,5 +304,8 @@ export function isDiscoveryActiveForLocation( if (location.kind === "wsl") { return state.discoveryScope?.kind === "wsl" && state.discoveryScope.distro === location.distro; } - return state.discoveryScope?.kind !== "wsl"; + if (location.kind === "ssh") { + return state.discoveryScope?.kind === "ssh" && state.discoveryScope.host === location.host; + } + return state.discoveryScope?.kind !== "wsl" && state.discoveryScope?.kind !== "ssh"; } diff --git a/src/renderer/state/panelStore.test.ts b/src/renderer/state/panelStore.test.ts index 70449c70..1b9a82f7 100644 --- a/src/renderer/state/panelStore.test.ts +++ b/src/renderer/state/panelStore.test.ts @@ -17,6 +17,7 @@ function resetPanelStore() { browserOverlayOpen: false, settingsOpen: false, projectSettingsId: null, + projectSettingsInitialSection: null, threadSearchOpen: false, }); } @@ -52,6 +53,17 @@ describe("selectAnyObstructingOverlayOpen", () => { expect(selectAnyObstructingOverlayOpen()).toBe(true); }); + it("stores and clears the project settings initial section", () => { + const { openProjectSettings, closeProjectSettings } = usePanelStore.getState(); + openProjectSettings("proj-1", "agents"); + expect(usePanelStore.getState().projectSettingsId).toBe("proj-1"); + expect(usePanelStore.getState().projectSettingsInitialSection).toBe("agents"); + + closeProjectSettings(); + expect(usePanelStore.getState().projectSettingsId).toBeNull(); + expect(usePanelStore.getState().projectSettingsInitialSection).toBeNull(); + }); + it("returns true when the git review overlay is open", () => { usePanelStore.setState({ gitOverlayOpen: true }); expect(selectAnyObstructingOverlayOpen()).toBe(true); diff --git a/src/renderer/state/panelStore.ts b/src/renderer/state/panelStore.ts index 17ec7a42..c477da5f 100644 --- a/src/renderer/state/panelStore.ts +++ b/src/renderer/state/panelStore.ts @@ -21,6 +21,7 @@ export interface FilesPanelContext { } export type RightPanelTab = "git" | "files" | "terminal" | "browser" | "usage"; +export type ProjectSettingsSectionId = "general" | "worktrees" | "actions" | "search" | "agents"; interface PanelState { gitReviewContext: GitReviewContext | null; @@ -38,6 +39,7 @@ interface PanelState { /** When the overlay is opened deep-linked to a section (e.g. "usage"); else null. */ settingsSection: string | null; projectSettingsId: string | null; + projectSettingsInitialSection: ProjectSettingsSectionId | null; threadSortMode: ThreadSortMode; threadSearchOpen: boolean; setGitReviewContext: (ctx: GitReviewContext | null) => void; @@ -58,7 +60,7 @@ interface PanelState { openSettingsSection: (section: string) => void; clearSettingsSection: () => void; closeSettings: () => void; - openProjectSettings: (projectId: string) => void; + openProjectSettings: (projectId: string, initialSection?: ProjectSettingsSectionId) => void; closeProjectSettings: () => void; openThreadSearch: () => void; closeThreadSearch: () => void; @@ -111,6 +113,7 @@ export const usePanelStore = create((set) => ({ settingsOpen: false, settingsSection: null, projectSettingsId: null, + projectSettingsInitialSection: null, threadSortMode: "updated", threadSearchOpen: false, @@ -230,10 +233,19 @@ export const usePanelStore = create((set) => ({ clearSettingsSection: () => set((state) => (state.settingsSection === null ? {} : { settingsSection: null })), closeSettings: () => set((state) => (state.settingsOpen ? { settingsOpen: false } : {})), - openProjectSettings: (projectId) => - set((state) => (state.projectSettingsId === projectId ? {} : { projectSettingsId: projectId })), + openProjectSettings: (projectId, initialSection) => + set((state) => + state.projectSettingsId === projectId && + state.projectSettingsInitialSection === (initialSection ?? null) + ? {} + : { projectSettingsId: projectId, projectSettingsInitialSection: initialSection ?? null }, + ), closeProjectSettings: () => - set((state) => (state.projectSettingsId === null ? {} : { projectSettingsId: null })), + set((state) => + state.projectSettingsId === null && state.projectSettingsInitialSection === null + ? {} + : { projectSettingsId: null, projectSettingsInitialSection: null }, + ), openThreadSearch: () => set((state) => (state.threadSearchOpen ? {} : { threadSearchOpen: true })), closeThreadSearch: () => diff --git a/src/renderer/state/projectKeys.ts b/src/renderer/state/projectKeys.ts index 7084bb39..d68fc9e2 100644 --- a/src/renderer/state/projectKeys.ts +++ b/src/renderer/state/projectKeys.ts @@ -7,6 +7,8 @@ function buildProjectLocationKey(location: ProjectLocation): string { return `${location.kind}:${location.path}`; case "wsl": return `${location.kind}:${location.distro}:${location.linuxPath}:${location.uncPath}`; + case "ssh": + return `${location.kind}:${location.host}:${location.path}`; } } @@ -33,3 +35,34 @@ export function buildWslProjectDistrosKey(projects: readonly Project[]): string export function parseWslProjectDistrosKey(key: string): string[] { return key ? key.split("\0") : []; } + +export function buildSshProjectLocationsKey(projects: readonly Project[]): string { + return JSON.stringify( + projects + .flatMap((project) => + project.location.kind === "ssh" && !project.disabled + ? [{ kind: "ssh" as const, host: project.location.host, path: project.location.path }] + : [], + ) + .sort((a, b) => `${a.host}:${a.path}`.localeCompare(`${b.host}:${b.path}`)), + ); +} + +export function parseSshProjectLocationsKey( + key: string, +): Extract[] { + try { + const parsed = JSON.parse(key) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (entry): entry is Extract => + entry != null && + typeof entry === "object" && + (entry as { kind?: unknown }).kind === "ssh" && + typeof (entry as { host?: unknown }).host === "string" && + typeof (entry as { path?: unknown }).path === "string", + ); + } catch { + return []; + } +} diff --git a/src/renderer/state/slices/projectSlice.ts b/src/renderer/state/slices/projectSlice.ts index 039c84ba..49e29e0b 100644 --- a/src/renderer/state/slices/projectSlice.ts +++ b/src/renderer/state/slices/projectSlice.ts @@ -243,5 +243,14 @@ function projectLocationsEqual(a: ProjectLocation, b: ProjectLocation): boolean if (a.kind === "wsl" && b.kind === "wsl") { return a.distro === b.distro && a.linuxPath === b.linuxPath && a.uncPath === b.uncPath; } - return a.kind !== "wsl" && b.kind !== "wsl" && a.path === b.path; + if (a.kind === "ssh" && b.kind === "ssh") { + return a.host === b.host && a.path === b.path; + } + return ( + a.kind !== "wsl" && + a.kind !== "ssh" && + b.kind !== "wsl" && + b.kind !== "ssh" && + a.path === b.path + ); } diff --git a/src/renderer/utils/acpRegistryAuth.ts b/src/renderer/utils/acpRegistryAuth.ts index 1eafb833..18a38ff9 100644 --- a/src/renderer/utils/acpRegistryAuth.ts +++ b/src/renderer/utils/acpRegistryAuth.ts @@ -7,7 +7,12 @@ import type { RefreshAgentScopeEnv, } from "@/shared/contracts"; import { useAppStore } from "@/renderer/state/appStore"; -import { buildWslProjectDistrosKey, parseWslProjectDistrosKey } from "@/renderer/state/projectKeys"; +import { + buildSshProjectLocationsKey, + buildWslProjectDistrosKey, + parseSshProjectLocationsKey, + parseWslProjectDistrosKey, +} from "@/renderer/state/projectKeys"; const ACP_GENERIC_PREFIX = "acp-generic:"; @@ -80,34 +85,44 @@ export function findTerminalAuthMethodInStatuses( } export function agentAuthTarget(status: AgentStatus): { - envKind?: AgentStatus["envKind"]; + envKind?: Exclude; wslDistro?: string; } { return { - ...(status.envKind ? { envKind: status.envKind } : {}), + ...(status.envKind && status.envKind !== "ssh" ? { envKind: status.envKind } : {}), ...(status.envDistro ? { wslDistro: status.envDistro } : {}), }; } export function scopeEnvForStatus(status: AgentStatus): RefreshAgentScopeEnv { - return status.envKind === "wsl" && status.envDistro - ? { kind: "wsl", distro: status.envDistro } - : { kind: "native" }; + if (status.envKind === "wsl" && status.envDistro) { + return { kind: "wsl", distro: status.envDistro }; + } + if (status.envKind === "ssh" && status.envHost) { + const project = currentSshProjects().find((candidate) => candidate.host === status.envHost); + if (project) return { kind: "ssh", host: project.host, path: project.path }; + } + return { kind: "native" }; } export function statusUpdateScope(status: AgentStatus): { - envKind: "windows" | "wsl" | "posix"; + envKind: "windows" | "wsl" | "posix" | "ssh"; wslDistro?: string; + sshHost?: string; } { if (status.envKind === "wsl" && status.envDistro) { return { envKind: "wsl", wslDistro: status.envDistro }; } + if (status.envKind === "ssh" && status.envHost) { + return { envKind: "ssh", sshHost: status.envHost }; + } if (status.envKind === "windows") return { envKind: "windows" }; return { envKind: "posix" }; } export function envLabelForStatus(status: AgentStatus): string { if (status.envKind === "wsl") return status.envDistro ? `WSL (${status.envDistro})` : "WSL"; + if (status.envKind === "ssh") return status.envHost ? `SSH (${status.envHost})` : "SSH"; if (status.envKind === "windows") return "Windows"; return ""; } @@ -116,6 +131,10 @@ export function currentWslDistros(): string[] { return parseWslProjectDistrosKey(buildWslProjectDistrosKey(useAppStore.getState().projects)); } +export function currentSshProjects(): Extract[] { + return parseSshProjectLocationsKey(buildSshProjectLocationsKey(useAppStore.getState().projects)); +} + export function findProjectForStatus( status: AgentStatus | undefined, projects: readonly Project[], @@ -129,5 +148,10 @@ export function findProjectForStatus( if (status.envKind === "windows") { return projects.find((project) => project.location.kind === "windows"); } + if (status.envKind === "ssh" && status.envHost) { + return projects.find( + (project) => project.location.kind === "ssh" && project.location.host === status.envHost, + ); + } return undefined; } diff --git a/src/renderer/utils/titleGen.ts b/src/renderer/utils/titleGen.ts index 774c6f38..4f13ba0a 100644 --- a/src/renderer/utils/titleGen.ts +++ b/src/renderer/utils/titleGen.ts @@ -11,12 +11,12 @@ export function generateTitleAsync( prompt: string, ): void { const settings = useSharedSettings.getState(); - const isWsl = projectLocation.kind === "wsl"; - const provider = isWsl ? settings.wslTitleGenProvider : settings.titleGenProvider; + const isRemote = projectLocation.kind === "wsl" || projectLocation.kind === "ssh"; + const provider = isRemote ? settings.wslTitleGenProvider : settings.titleGenProvider; if (provider === "disabled") return; - const model = isWsl ? settings.wslTitleGenModel : settings.titleGenModel; - const effort = isWsl ? settings.wslTitleGenEffort : settings.titleGenEffort; + const model = isRemote ? settings.wslTitleGenModel : settings.titleGenModel; + const effort = isRemote ? settings.wslTitleGenEffort : settings.titleGenEffort; void generateTitleWithFallback({ projectLocation, diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx index 515d213d..420dd6a1 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx @@ -91,9 +91,10 @@ export function GitReviewSidebar(props: { }); const agentStatuses = useAgentStatusesStore((s) => s.agentStatuses); const wslAgentStatuses = useAgentStatusesStore((s) => s.wslAgentStatuses); - const isWsl = project.location.kind === "wsl"; + const sshAgentStatuses = useAgentStatusesStore((s) => s.sshAgentStatuses); + const isRemote = project.location.kind === "wsl" || project.location.kind === "ssh"; const commitGenProvider = useSharedSettings((s) => - isWsl ? s.wslCommitGenProvider : s.commitGenProvider, + isRemote ? s.wslCommitGenProvider : s.commitGenProvider, ); // Treat "unknown" as "might be GitHub" — covers SSH host aliases where the @@ -184,6 +185,7 @@ export function GitReviewSidebar(props: { project.location, agentStatuses, wslAgentStatuses, + sshAgentStatuses, ); const canGenerateMessage = getCommitGenCandidates(projectAgentStatuses, commitGenProvider).length > 0; diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts index 256eb846..16fbfa08 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useConflictResolver.ts @@ -31,6 +31,7 @@ export function useConflictResolver(params: { const agentStatuses = useAgentStatusesStore((s) => s.agentStatuses); const wslAgentStatuses = useAgentStatusesStore((s) => s.wslAgentStatuses); + const sshAgentStatuses = useAgentStatusesStore((s) => s.sshAgentStatuses); // useShallow is required: this selector builds a fresh object each call, and // zustand v5's useSyncExternalStore does not memoize selector results. Without // it the snapshot reference changes every render -> forceStoreRerender loops -> @@ -57,6 +58,7 @@ export function useConflictResolver(params: { project.location, agentStatuses, wslAgentStatuses, + sshAgentStatuses, ); const canResolveWithAgent = diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts index ced50414..f34437f8 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useGitReviewActions.ts @@ -82,21 +82,25 @@ export function useGitReviewActions(args: UseGitReviewActionsArgs) { else store.setStatus(storeKey, next); } - const isWsl = project.location.kind === "wsl"; + const isRemote = project.location.kind === "wsl" || project.location.kind === "ssh"; const commitGenProvider = useSharedSettings((s) => - isWsl ? s.wslCommitGenProvider : s.commitGenProvider, + isRemote ? s.wslCommitGenProvider : s.commitGenProvider, + ); + const commitGenModel = useSharedSettings((s) => + isRemote ? s.wslCommitGenModel : s.commitGenModel, ); - const commitGenModel = useSharedSettings((s) => (isWsl ? s.wslCommitGenModel : s.commitGenModel)); const commitGenEffort = useSharedSettings((s) => - isWsl ? s.wslCommitGenEffort : s.commitGenEffort, + isRemote ? s.wslCommitGenEffort : s.commitGenEffort, ); const agentStatuses = useAgentStatusesStore((s) => s.agentStatuses); const wslAgentStatuses = useAgentStatusesStore((s) => s.wslAgentStatuses); + const sshAgentStatuses = useAgentStatusesStore((s) => s.sshAgentStatuses); const projectAgentStatuses = getProjectAgentStatuses( project.location, agentStatuses, wslAgentStatuses, + sshAgentStatuses, ); const [commitMessage, setCommitMessage] = useState(""); diff --git a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useSourceBranchData.ts b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useSourceBranchData.ts index 07f62133..31f5e3cd 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useSourceBranchData.ts +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/parts/useSourceBranchData.ts @@ -30,6 +30,7 @@ export function useSourceBranchData(params: { const projectLocationPath = getProjectPosixPath(project.location); const projectLocationDistro = project.location.kind === "wsl" ? project.location.distro : null; const projectLocationUncPath = project.location.kind === "wsl" ? project.location.uncPath : null; + const projectLocationHost = project.location.kind === "ssh" ? project.location.host : null; const [sourceBranchLoading, setSourceBranchLoading] = useState(false); @@ -49,7 +50,9 @@ export function useSourceBranchData(params: { } : projectLocationKind === "posix" ? { kind: "posix", path: projectLocationPath } - : { kind: "windows", path: projectLocationPath }; + : projectLocationKind === "ssh" + ? { kind: "ssh", host: projectLocationHost!, path: projectLocationPath } + : { kind: "windows", path: projectLocationPath }; setSourceBranchLoading(true); readBridge() .gitGetWorktreeSourceBranch({ @@ -86,6 +89,7 @@ export function useSourceBranchData(params: { effectivePrKey, preferredSourceBranch, projectLocationDistro, + projectLocationHost, projectLocationKind, projectLocationPath, projectLocationUncPath, diff --git a/src/renderer/views/MainView/MainView.tsx b/src/renderer/views/MainView/MainView.tsx index 9c9c90b5..ba707f53 100644 --- a/src/renderer/views/MainView/MainView.tsx +++ b/src/renderer/views/MainView/MainView.tsx @@ -6,7 +6,12 @@ import { ensureHomeScopeProject } from "@/renderer/actions/projectActions"; import { useAgentStatusesStore } from "@/renderer/state/agentStatusesStore"; import { useAppStore } from "@/renderer/state/appStore"; -import { buildWslProjectDistrosKey, parseWslProjectDistrosKey } from "@/renderer/state/projectKeys"; +import { + buildSshProjectLocationsKey, + buildWslProjectDistrosKey, + parseSshProjectLocationsKey, + parseWslProjectDistrosKey, +} from "@/renderer/state/projectKeys"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { AppDndProvider } from "@/renderer/dnd"; @@ -30,11 +35,24 @@ function findMissingWslDistro(distros: readonly string[], statuses: readonly Age return distros.find((distro) => !cachedDistros.has(distro)); } +function findMissingSshHost( + hosts: readonly string[], + statuses: readonly AgentStatus[], +): string | undefined { + const cachedHosts = new Set( + statuses.flatMap((status) => (status.envHost ? [status.envHost] : [])), + ); + return hosts.find((host) => !cachedHosts.has(host)); +} + export function MainView(props: { storeHydrated: boolean; loadT0: number }) { const { storeHydrated, loadT0 } = props; const view = useAppStore((state) => state.view); const openHome = useAppStore((state) => state.openHome); const wslProjectDistrosKey = useAppStore((state) => buildWslProjectDistrosKey(state.projects)); + const sshProjectLocationsKey = useAppStore((state) => + buildSshProjectLocationsKey(state.projects), + ); const homeScopeEnabled = useSharedSettings((state) => state.homeScopeEnabled); const sharedSettingsHydrated = useSharedSettings((state) => state.sharedSettingsHydrated); @@ -65,32 +83,44 @@ export function MainView(props: { storeHydrated: boolean; loadT0: number }) { // Fresh detection results still arrive via events // (windows-agent-statuses, wsl-agent-statuses). const wslDistros = parseWslProjectDistrosKey(wslProjectDistrosKey); + const sshProjects = parseSshProjectLocationsKey(sshProjectLocationsKey); + const sshHosts = [...new Set(sshProjects.map((project) => project.host))]; void readBridge() - .getAgentStatuses(wslDistros) + .getAgentStatuses(wslDistros, sshProjects) .then((response) => { const missingWslDistro = findMissingWslDistro(wslDistros, response.wsl); + const missingSshHost = findMissingSshHost(sshHosts, response.ssh); if (response.fromCache) { useAgentStatusesStore.getState().hydrateFromCache({ windows: response.windows, wsl: response.wsl, + ssh: response.ssh, }); - if (!missingWslDistro) { + if (!missingWslDistro && !missingSshHost) { return; } useAgentStatusesStore .getState() - .beginFirstLaunchDiscovery({ kind: "wsl", distro: missingWslDistro }); + .beginFirstLaunchDiscovery( + missingWslDistro + ? { kind: "wsl", distro: missingWslDistro } + : { kind: "ssh", host: missingSshHost! }, + ); return; } useAgentStatusesStore .getState() .beginFirstLaunchDiscovery( - missingWslDistro ? { kind: "wsl", distro: missingWslDistro } : undefined, + missingWslDistro + ? { kind: "wsl", distro: missingWslDistro } + : missingSshHost + ? { kind: "ssh", host: missingSshHost } + : undefined, ); }) .catch(() => undefined); - }, [storeHydrated, wslProjectDistrosKey]); + }, [storeHydrated, wslProjectDistrosKey, sshProjectLocationsKey]); console.log(`[renderer] +${Date.now() - loadT0}ms: rendering main UI`); return ( diff --git a/src/renderer/views/MainView/parts/AppContent/AppContent.tsx b/src/renderer/views/MainView/parts/AppContent/AppContent.tsx index 681fbc98..89ef0a72 100644 --- a/src/renderer/views/MainView/parts/AppContent/AppContent.tsx +++ b/src/renderer/views/MainView/parts/AppContent/AppContent.tsx @@ -119,11 +119,12 @@ export function AppContent() { } } - const { agentStatuses, wslAgentStatuses } = useAgentStatusesStore.getState(); + const { agentStatuses, wslAgentStatuses, sshAgentStatuses } = useAgentStatusesStore.getState(); const projectAgentStatuses = getProjectAgentStatuses( project.location, agentStatuses, wslAgentStatuses, + sshAgentStatuses, ); const titlePrompt = segments ? segments @@ -257,8 +258,13 @@ export function AppContent() { useAppStore.getState().openThreadSideBySide(thread.id); } - const { agentStatuses, wslAgentStatuses } = useAgentStatusesStore.getState(); - const agents = getProjectAgentStatuses(project.location, agentStatuses, wslAgentStatuses); + const { agentStatuses, wslAgentStatuses, sshAgentStatuses } = useAgentStatusesStore.getState(); + const agents = getProjectAgentStatuses( + project.location, + agentStatuses, + wslAgentStatuses, + sshAgentStatuses, + ); generateTitleAsync(thread.id, project.location, agents, prompt); const targetLabel = agents.find((a) => a.kind === targetAgentKind)?.label ?? targetAgentKind; @@ -487,7 +493,12 @@ function DraftViewContent(props: { const { project, lastDraftConfig, onStart } = props; const projectAgentStatuses = useAgentStatusesStore( useShallow((s) => - getProjectAgentStatuses(project.location, s.agentStatuses, s.wslAgentStatuses), + getProjectAgentStatuses( + project.location, + s.agentStatuses, + s.wslAgentStatuses, + s.sshAgentStatuses, + ), ), ); const isDetectingAgents = useAgentStatusesStore((s) => diff --git a/src/renderer/views/MainView/parts/AppContent/parts/DraftPane.tsx b/src/renderer/views/MainView/parts/AppContent/parts/DraftPane.tsx index 1387e5e3..a0448e83 100644 --- a/src/renderer/views/MainView/parts/AppContent/parts/DraftPane.tsx +++ b/src/renderer/views/MainView/parts/AppContent/parts/DraftPane.tsx @@ -40,7 +40,14 @@ export function DraftPane(props: { const initialLastDraftConfig = useInitialProjectDraftConfig(props.projectId); const projectAgentStatuses = useAgentStatusesStore( useShallow((s) => - project ? getProjectAgentStatuses(project.location, s.agentStatuses, s.wslAgentStatuses) : [], + project + ? getProjectAgentStatuses( + project.location, + s.agentStatuses, + s.wslAgentStatuses, + s.sshAgentStatuses, + ) + : [], ), ); const isDetectingAgents = useAgentStatusesStore((s) => diff --git a/src/renderer/views/MainView/parts/AppContent/parts/ThreadPane.tsx b/src/renderer/views/MainView/parts/AppContent/parts/ThreadPane.tsx index 327293a4..75eac852 100644 --- a/src/renderer/views/MainView/parts/AppContent/parts/ThreadPane.tsx +++ b/src/renderer/views/MainView/parts/AppContent/parts/ThreadPane.tsx @@ -80,6 +80,10 @@ export function ThreadPane(props: { const projectLocation = thread.worktreePath ? buildWorktreeLocation(project.location, thread.worktreePath) : project.location; + const providerInstalledAgents = + project.location.kind === "ssh" + ? projectAgentStatuses.filter((status) => status.installed) + : installedAgents; return ( { diff --git a/src/renderer/views/MainView/parts/AppOverlays.tsx b/src/renderer/views/MainView/parts/AppOverlays.tsx index 8cc67298..24c8f35e 100644 --- a/src/renderer/views/MainView/parts/AppOverlays.tsx +++ b/src/renderer/views/MainView/parts/AppOverlays.tsx @@ -42,6 +42,7 @@ export function AppOverlays() { const projects = useAppStore((s) => s.projects); const settingsOpen = usePanelStore((s) => s.settingsOpen); const projectSettingsId = usePanelStore((s) => s.projectSettingsId); + const projectSettingsInitialSection = usePanelStore((s) => s.projectSettingsInitialSection); const gitOverlayOpen = usePanelStore((s) => s.gitOverlayOpen); const gitReviewContext = usePanelStore((s) => s.gitReviewContext); const gitReviewAsPanel = usePanelStore((s) => s.gitReviewAsPanel); @@ -72,6 +73,9 @@ export function AppOverlays() { {projectSettingsId && ( usePanelStore.getState().closeProjectSettings()} /> )} diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectHeader.tsx b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectHeader.tsx index 12083229..4b6685df 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectHeader.tsx +++ b/src/renderer/views/MainView/parts/Sidebar/parts/SidebarProjectHeader.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { ChevronRight, FileDiff, @@ -7,6 +8,7 @@ import { Power, PowerOff, RefreshCw, + Server, Settings2, Trash2, } from "lucide-react"; @@ -21,6 +23,7 @@ import { } from "@/renderer/actions/panelActions"; import { gitSync } from "@/renderer/actions/gitActions"; import { openTerminal, runProjectAction } from "@/renderer/actions/terminalActions"; +import { readBridge } from "@/renderer/bridge"; import { useIsProjectFilesPanelActive, useIsProjectGitPanelActive, @@ -36,6 +39,8 @@ import { GitBadge } from "./GitBadge"; import { SidebarPanelDragButton } from "./SidebarPanelDragButton"; import { SyncBadge } from "./SyncBadge"; +type SshProjectStatus = "checking" | "connected" | "error" | "disconnected"; + export function SidebarProjectHeader(props: { project: Project; isCollapsed: boolean; @@ -50,7 +55,106 @@ export function SidebarProjectHeader(props: { const isActiveFilesPanel = useIsProjectFilesPanelActive(project.id); const projectLocation = formatProjectLocation(project); const isDisabled = !!project.disabled; + const [sshStatus, setSshStatus] = useState( + isDisabled ? "disconnected" : "checking", + ); + const [sshStatusMessage, setSshStatusMessage] = useState(""); + const isSshProject = project.location.kind === "ssh"; + const sshHost = project.location.kind === "ssh" ? project.location.host : undefined; + const sshPath = project.location.kind === "ssh" ? project.location.path : undefined; + const shouldReconnect = isSshProject && (isDisabled || sshStatus === "error"); + const toggleDisabledIconIsPower = isSshProject ? shouldReconnect : isDisabled; + const toggleDisabledLabel = isSshProject + ? shouldReconnect + ? "Reconnect" + : "Disconnect" + : isDisabled + ? "Enable Project" + : "Disable Project"; const showBody = !isCollapsed && !isDisabled; + const sshIconClass = + sshStatus === "connected" + ? "text-success" + : sshStatus === "error" + ? "text-danger" + : sshStatus === "checking" + ? "text-accent" + : "text-muted/40"; + const sshTooltip = + sshStatus === "connected" + ? "Connected" + : sshStatus === "checking" + ? "Checking connection" + : sshStatus === "error" + ? sshStatusMessage || "Connection failed" + : "Disconnected"; + + async function checkSshConnection(): Promise { + if (!isSshProject) return true; + setSshStatus("checking"); + setSshStatusMessage(""); + try { + const result = await readBridge().checkSshProjectConnection({ + projectLocation: project.location, + }); + setSshStatus(result.ok ? "connected" : "error"); + setSshStatusMessage(result.message ?? ""); + return result.ok; + } catch (error) { + setSshStatus("error"); + setSshStatusMessage(error instanceof Error ? error.message : String(error)); + return false; + } + } + + useEffect(() => { + // Key off the SSH host/path primitives rather than the `project.location` + // object: the projects array (and the derived project object) is rebuilt on + // unrelated store updates, so depending on the object reference would + // re-spawn an `ssh` probe and flicker the badge on every such render. + if (sshHost === undefined || sshPath === undefined) return; + if (isDisabled) { + setSshStatus("disconnected"); + setSshStatusMessage(""); + return; + } + let cancelled = false; + setSshStatus("checking"); + setSshStatusMessage(""); + void readBridge() + .checkSshProjectConnection({ + projectLocation: { kind: "ssh", host: sshHost, path: sshPath }, + }) + .then((result) => { + if (cancelled) return; + setSshStatus(result.ok ? "connected" : "error"); + setSshStatusMessage(result.message ?? ""); + }) + .catch((error: unknown) => { + if (cancelled) return; + setSshStatus("error"); + setSshStatusMessage(error instanceof Error ? error.message : String(error)); + }); + return () => { + cancelled = true; + }; + }, [isDisabled, sshHost, sshPath]); + + function handleToggleDisabled() { + if (!isSshProject) { + setProjectDisabled(project.id, !isDisabled); + return; + } + if (!shouldReconnect) { + setSshStatus("disconnected"); + setSshStatusMessage(""); + setProjectDisabled(project.id, true); + return; + } + void checkSshConnection().then((connected) => { + if (connected) setProjectDisabled(project.id, false); + }); + } return ( : , + label: toggleDisabledLabel, + icon: toggleDisabledIconIsPower ? ( + + ) : ( + + ), }, { id: "remove-project", @@ -112,7 +220,7 @@ export function SidebarProjectHeader(props: { onAction={(key) => { if (key === "project-settings") openProjectSettings(project.id); if (key === "remove-project") deleteProject(project.id); - if (key === "toggle-disabled") setProjectDisabled(project.id, !isDisabled); + if (key === "toggle-disabled") handleToggleDisabled(); if (key === "git-review") openGitReview(project.id); if (key === "git-sync") gitSync(project.id); if (key.startsWith("action:")) { @@ -134,9 +242,16 @@ export function SidebarProjectHeader(props: { {project.location.kind === "wsl" && ( )} + {isSshProject && } } - tooltip={isDisabled ? `${projectLocation} (disabled)` : projectLocation} + tooltip={ + isSshProject + ? `${projectLocation} (${sshTooltip})` + : isDisabled + ? `${projectLocation} (disabled)` + : projectLocation + } className={`lightcode-sidebar-project-nudge !pl-1${isDragging ? " opacity-60" : ""}${ isDisabled ? " opacity-50" : "" }`} diff --git a/src/renderer/views/MainView/parts/Sidebar/parts/formatProjectLocation.ts b/src/renderer/views/MainView/parts/Sidebar/parts/formatProjectLocation.ts index f6f761bc..91697adf 100644 --- a/src/renderer/views/MainView/parts/Sidebar/parts/formatProjectLocation.ts +++ b/src/renderer/views/MainView/parts/Sidebar/parts/formatProjectLocation.ts @@ -4,5 +4,8 @@ export function formatProjectLocation(project: Project): string { if (project.location.kind === "wsl") { return `${project.location.distro}:${project.location.linuxPath}`; } + if (project.location.kind === "ssh") { + return `${project.location.host}:${project.location.path}`; + } return project.location.path; } diff --git a/src/renderer/views/MainView/parts/SidebarHeaderControls.tsx b/src/renderer/views/MainView/parts/SidebarHeaderControls.tsx index 5bdf908d..b1dfa528 100644 --- a/src/renderer/views/MainView/parts/SidebarHeaderControls.tsx +++ b/src/renderer/views/MainView/parts/SidebarHeaderControls.tsx @@ -1,7 +1,8 @@ -import { startTransition } from "react"; -import { FolderPlus, Globe, Monitor, Search } from "lucide-react"; -import { Button, Dropdown, Label, Tooltip } from "@heroui/react"; -import { TuxIcon } from "@/renderer/components/common"; +import { startTransition, useState } from "react"; +import { FolderPlus, Globe, Monitor, Search, Server } from "lucide-react"; +import { Button, Dropdown, Label, Modal, Tooltip } from "@heroui/react"; +import { Input, TuxIcon } from "@/renderer/components/common"; +import { parseSshProjectSpec } from "@/shared/ssh"; import { parseWslUncPath } from "@/shared/wsl"; import { isWindows, readBridge } from "@/renderer/bridge"; import { useAppStore } from "@/renderer/state/appStore"; @@ -22,168 +23,283 @@ export function SidebarHeaderControls(props: { wslAvailable: boolean }) { const browserPanelOpen = usePanelStore((s) => s.browserPanelOpen); const rightPanelTab = usePanelStore((s) => s.rightPanelTab); const browserVisible = browserPanelOpen && rightPanelTab === "browser"; + const [sshDialogOpen, setSshDialogOpen] = useState(false); + const [sshSpec, setSshSpec] = useState(""); + const [sshError, setSshError] = useState(""); + const [sshPending, setSshPending] = useState(false); + + async function addSshProject() { + if (sshPending) return; + const location = parseSshProjectSpec(sshSpec); + if (!location) { + setSshError("Use user@host:/absolute/path or ssh://user@host/absolute/path."); + return; + } + setSshPending(true); + setSshError(""); + try { + const result = await readBridge().checkSshProjectConnection({ projectLocation: location }); + if (!result.ok) { + setSshError(result.message ?? "Unable to connect."); + return; + } + setSshDialogOpen(false); + setSshSpec(""); + startTransition(() => { + const project = addProject(location); + autoDetectSetupScript(project); + openDraft(project.id); + }); + } catch (error) { + setSshError(error instanceof Error ? error.message : "Unable to connect."); + } finally { + setSshPending(false); + } + } return ( -
- - - - - Search - - {isWindows() ? ( + <> +
+ + + + + Search + + {isWindows() ? ( + + + + { + if (key === "windows") { + void readBridge() + .pickFolder() + .then((path) => { + if (!path) return; + startTransition(() => { + const project = addProject({ kind: "windows", path }); + autoDetectSetupScript(project); + openDraft(project.id); + }); + }); + } + if (key === "wsl") { + void readBridge() + .listWslDistros() + .then((distros) => { + const distro = distros[0]; + const defaultPath = distro + ? `\\\\wsl.localhost\\${distro}\\home` + : undefined; + return readBridge().pickFolder(defaultPath); + }) + .then((selectedPath) => { + if (!selectedPath) return; + const parsed = parseWslUncPath(selectedPath); + if (!parsed) return; + startTransition(() => { + const project = addProject({ + kind: "wsl", + distro: parsed.distro, + linuxPath: parsed.linuxPath, + uncPath: selectedPath, + }); + autoDetectSetupScript(project); + openDraft(project.id); + }); + }); + } + if (key === "ssh") { + setSshDialogOpen(true); + } + }} + > + + + + + + + + + + + + + + + + ) : ( + + + + { + if (key === "local") { + void readBridge() + .pickFolder() + .then((path) => { + if (!path) return; + startTransition(() => { + const project = addProject({ kind: "posix", path }); + autoDetectSetupScript(project); + openDraft(project.id); + }); + }); + } + if (key === "ssh") { + setSshDialogOpen(true); + } + }} + > + + + + + + + + + + + + )} { - if (key === "windows") { - void readBridge() - .pickFolder() - .then((path) => { - if (!path) return; - startTransition(() => { - const project = addProject({ kind: "windows", path }); - autoDetectSetupScript(project); - openDraft(project.id); - }); - }); - } - if (key === "wsl") { - void readBridge() - .listWslDistros() - .then((distros) => { - const distro = distros[0]; - const defaultPath = distro ? `\\\\wsl.localhost\\${distro}\\home` : undefined; - return readBridge().pickFolder(defaultPath); - }) - .then((selectedPath) => { - if (!selectedPath) return; - const parsed = parseWslUncPath(selectedPath); - if (!parsed) return; - startTransition(() => { - const project = addProject({ - kind: "wsl", - distro: parsed.distro, - linuxPath: parsed.linuxPath, - uncPath: selectedPath, - }); - autoDetectSetupScript(project); - openDraft(project.id); - }); - }); - } + usePanelStore.getState().setThreadSortMode(key as ThreadSortMode); }} > - - - - - - - - + {sortModeOrder.map((mode) => { + const Icon = sortModeIcon[mode]; + return ( + + + + + ); + })} - ) : ( - - )} - - - - { - usePanelStore.getState().setThreadSortMode(key as ThreadSortMode); - }} - > - {sortModeOrder.map((mode) => { - const Icon = sortModeIcon[mode]; - return ( - - - - - ); - })} - - - - - - - - Browser - -
+ + + + + Browser + +
+ { + setSshDialogOpen(open); + if (!open) setSshError(""); + }} + > + + + + + Add SSH Project + +
{ + event.preventDefault(); + void addSshProject(); + }} + > + + { + setSshSpec(event.target.value); + if (sshError) setSshError(""); + }} + /> + {sshError && ( +

+ {sshError} +

+ )} +
+ + + + +
+
+
+
+ ); } diff --git a/src/renderer/views/ProjectSettingsOverlay/ProjectSettingsOverlay.tsx b/src/renderer/views/ProjectSettingsOverlay/ProjectSettingsOverlay.tsx index 7544f41c..dd30516b 100644 --- a/src/renderer/views/ProjectSettingsOverlay/ProjectSettingsOverlay.tsx +++ b/src/renderer/views/ProjectSettingsOverlay/ProjectSettingsOverlay.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useAppStore } from "@/renderer/state/appStore"; import { PageLayout } from "@/renderer/components/layout/PageLayout"; import { SettingsSidebar } from "./parts/SettingsSidebar"; @@ -6,16 +6,30 @@ import { GeneralSection } from "./parts/GeneralSection"; import { ScriptsSection } from "./parts/ScriptsSection"; import { ActionsSection } from "./parts/ActionsSection"; import { SearchSection } from "./parts/SearchSection"; +import { AgentsSection } from "./parts/AgentsSection"; import type { ProjectSettingsSection } from "./parts/types"; export { resolveActionIcon } from "@/renderer/utils/actionIcons"; -export function ProjectSettingsOverlay(props: { projectId: string; onClose: () => void }) { +export function ProjectSettingsOverlay(props: { + projectId: string; + initialSection?: ProjectSettingsSection; + onClose: () => void; +}) { const { projectId, onClose } = props; - const projectName = useAppStore( - (s) => s.projects.find((p) => p.id === projectId)?.name ?? "Project", - ); - const [activeSection, setActiveSection] = useState("general"); + const project = useAppStore((s) => s.projects.find((p) => p.id === projectId)); + const projectName = project?.name ?? "Project"; + const showAgents = project?.location.kind === "ssh"; + const resolvedInitialSection = + props.initialSection === "agents" && !showAgents + ? "general" + : (props.initialSection ?? "general"); + const [activeSection, setActiveSection] = + useState(resolvedInitialSection); + + useEffect(() => { + setActiveSection(resolvedInitialSection); + }, [resolvedInitialSection]); return ( } content={ @@ -36,6 +51,8 @@ export function ProjectSettingsOverlay(props: { projectId: string; onClose: () = ) : activeSection === "search" ? ( + ) : activeSection === "agents" && showAgents ? ( + ) : null } /> diff --git a/src/renderer/views/ProjectSettingsOverlay/parts/AgentsSection.tsx b/src/renderer/views/ProjectSettingsOverlay/parts/AgentsSection.tsx new file mode 100644 index 00000000..563042c2 --- /dev/null +++ b/src/renderer/views/ProjectSettingsOverlay/parts/AgentsSection.tsx @@ -0,0 +1,241 @@ +import { useState } from "react"; +import { toast } from "@heroui/react"; +import { + AlertTriangle, + CheckCircle2, + CircleDashed, + LogIn, + RefreshCw, + type LucideIcon, +} from "lucide-react"; +import { useShallow } from "zustand/shallow"; +import type { AgentStatus, ProjectLocation, RefreshAgentScope } from "@/shared/contracts"; +import { getProjectAgentStatuses } from "@/shared/agentStatus"; +import { readBridge } from "@/renderer/bridge"; +import { runAgentLoginCommand } from "@/renderer/actions/agentLoginActions"; +import { Button, PixelLoader } from "@/renderer/components/common"; +import { getRegisteredProviders, ProviderIcon } from "@/renderer/components/providers/ProviderIcon"; +import { + isDetectingAgentsForLocation, + useAgentStatusesStore, +} from "@/renderer/state/agentStatusesStore"; +import { useProject } from "@/renderer/state/useThread"; +import { findTerminalAuthMethodForStatus } from "@/renderer/utils/acpRegistryAuth"; + +type SshLocation = Extract; + +function statusRank(status: AgentStatus): number { + if (!status.installed) return 2; + return status.authState === "missing" ? 0 : 1; +} + +function statusBadge(status: AgentStatus): { + label: string; + className: string; + icon: LucideIcon; +} { + if (!status.installed) { + return { + label: "Not found", + className: "border-border text-muted", + icon: CircleDashed, + }; + } + if (status.authState === "missing") { + return { + label: "Sign in needed", + className: "border-warning/40 text-warning", + icon: AlertTriangle, + }; + } + return { + label: "Ready", + className: "border-success/40 text-success", + icon: CheckCircle2, + }; +} + +function statusDetail(status: AgentStatus): string { + const parts: string[] = []; + if (status.version) parts.push(`v${status.version}`); + const modelCount = status.capabilities.models.length; + if (modelCount > 0) parts.push(`${modelCount} model${modelCount === 1 ? "" : "s"}`); + if (status.providerMetadata?.authenticatedAs) { + parts.push(status.providerMetadata.authenticatedAs); + } + if (parts.length > 0) return parts.join(" - "); + return status.installed ? "Detected on remote host" : "CLI not found on remote host"; +} + +function refreshScope(location: SshLocation, agentKinds: string[]): RefreshAgentScope { + return { + agentKinds, + envs: [{ kind: "ssh", host: location.host, path: location.path }], + }; +} + +export function AgentsSection(props: { projectId: string }) { + const project = useProject(props.projectId); + const statuses = useAgentStatusesStore( + useShallow((s) => + project + ? getProjectAgentStatuses( + project.location, + s.agentStatuses, + s.wslAgentStatuses, + s.sshAgentStatuses, + ) + : [], + ), + ); + const isDetecting = useAgentStatusesStore((s) => + project ? isDetectingAgentsForLocation(s, project.location) : false, + ); + const [pendingRefreshKey, setPendingRefreshKey] = useState(); + const [pendingLoginKind, setPendingLoginKind] = useState(); + + if (!project || project.location.kind !== "ssh") return null; + + const sshProject = project; + const location = project.location; + const registeredKinds = getRegisteredProviders().map((provider) => provider.kind); + const allAgentKinds = [ + ...new Set([...registeredKinds, ...statuses.map((status) => status.kind)]), + ]; + const sortedStatuses = [...statuses].sort((left, right) => { + const rank = statusRank(left) - statusRank(right); + if (rank !== 0) return rank; + return left.label.localeCompare(right.label); + }); + + async function refreshAgents(agentKinds = allAgentKinds): Promise { + if (agentKinds.length === 0) return; + const key = agentKinds.length === 1 ? agentKinds[0]! : "all"; + setPendingRefreshKey(key); + try { + await readBridge().refreshAgentStatuses([], refreshScope(location, agentKinds), [location]); + } catch (error) { + toast.danger(error instanceof Error ? error.message : "Unable to refresh SSH agents."); + } finally { + setPendingRefreshKey(undefined); + } + } + + function runLogin(status: AgentStatus): void { + if (!status.loginCommand) return; + const terminalMethod = findTerminalAuthMethodForStatus(status); + setPendingLoginKind(status.kind); + const opened = runAgentLoginCommand({ + label: status.label, + command: status.loginCommand, + ...(terminalMethod?.env ? { env: terminalMethod.env } : {}), + project: sshProject, + onCommandComplete: (exitCode) => { + setPendingLoginKind(undefined); + void refreshAgents([status.kind]).then(() => { + if (exitCode === 0) toast.success(`${status.label} authenticated.`); + }); + }, + }); + if (!opened) setPendingLoginKind(undefined); + } + + return ( +
+
+
+
+

Agents

+

+ {location.host}:{location.path} +

+
+ +
+ + {sortedStatuses.length === 0 ? ( +
+ {isDetecting ? : } + {isDetecting ? "Detecting agents..." : "No SSH agent statuses yet."} +
+ ) : ( +
+ {sortedStatuses.map((status) => { + const badge = statusBadge(status); + const BadgeIcon = badge.icon; + const canLogin = status.authState === "missing" && Boolean(status.loginCommand); + const refreshKey = status.kind; + return ( +
+
+ +
+

{status.label}

+

{statusDetail(status)}

+
+
+
+ + + {badge.label} + + {canLogin ? ( + + ) : null} + +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/renderer/views/ProjectSettingsOverlay/parts/SettingsSidebar.tsx b/src/renderer/views/ProjectSettingsOverlay/parts/SettingsSidebar.tsx index cdc26014..2a5dd9c7 100644 --- a/src/renderer/views/ProjectSettingsOverlay/parts/SettingsSidebar.tsx +++ b/src/renderer/views/ProjectSettingsOverlay/parts/SettingsSidebar.tsx @@ -1,5 +1,6 @@ import { ArrowLeft, + Bot, GitFork, PanelLeft, PanelLeftClose, @@ -22,12 +23,16 @@ export function SettingsSidebar(props: { activeSection: ProjectSettingsSection; onSectionChange: (section: ProjectSettingsSection) => void; onClose: () => void; + showAgents?: boolean; }) { - const { activeSection, onSectionChange, onClose } = props; + const { activeSection, onSectionChange, onClose, showAgents } = props; const { isCollapsed, collapse, expand } = useSidebar(); const sections: { id: ProjectSettingsSection; icon: React.ReactNode; label: string }[] = [ { id: "general", icon: , label: "General" }, + ...(showAgents + ? [{ id: "agents" as const, icon: , label: "Agents" }] + : []), { id: "worktrees", icon: , label: "Worktrees" }, { id: "actions", icon: , label: "Actions" }, { id: "search", icon: , label: "Search" }, diff --git a/src/renderer/views/ProjectSettingsOverlay/parts/types.ts b/src/renderer/views/ProjectSettingsOverlay/parts/types.ts index c92e4c05..82cbdbaf 100644 --- a/src/renderer/views/ProjectSettingsOverlay/parts/types.ts +++ b/src/renderer/views/ProjectSettingsOverlay/parts/types.ts @@ -1 +1 @@ -export type ProjectSettingsSection = "general" | "worktrees" | "actions" | "search"; +export type ProjectSettingsSection = "general" | "worktrees" | "actions" | "search" | "agents"; diff --git a/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx b/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx index e9dbfc8e..6da4e43c 100644 --- a/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx +++ b/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx @@ -180,7 +180,12 @@ const registry: AcpRegistryListResult = { ], }; -const emptyStatusesResponse: AgentStatusesResponse = { windows: [], wsl: [], fromCache: false }; +const emptyStatusesResponse: AgentStatusesResponse = { + windows: [], + wsl: [], + ssh: [], + fromCache: false, +}; function installedRecord(input: { id: string; diff --git a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx index 8d2f2a17..c5f3256a 100644 --- a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx @@ -432,6 +432,7 @@ function findTerminalLoginStatus(statuses: readonly AgentStatus[]): AgentStatus } function statusEnvKey(status: AgentStatus): string { + if (status.envKind === "ssh" && status.envHost) return `ssh:${status.envHost}`; return status.envKind === "wsl" && status.envDistro ? `wsl:${status.envDistro}` : "native"; } @@ -544,13 +545,14 @@ function AgentEnvironmentRow(props: { targetVersion !== undefined; const previewScope = statusUpdateScope(status); - const previewCommand = showUpdateButton - ? resolveSharedUpdateCommand({ - update: status.update, - executablePath: status.executablePath, - envKind: previewScope.envKind, - }) - : undefined; + const previewCommand = + showUpdateButton && previewScope.envKind !== "ssh" + ? resolveSharedUpdateCommand({ + update: status.update, + executablePath: status.executablePath, + envKind: previewScope.envKind, + }) + : undefined; const previewCommandLine = previewCommand ? formatUpdateCommandLine(previewCommand) : undefined; const description = isMissing @@ -1245,6 +1247,8 @@ export function SingleAgentSettings(props: { agentKind: string }) { const performBinaryUpdate = (status: AgentStatus) => { const scope = statusUpdateScope(status); + if (scope.envKind === "ssh") return; + const updateEnvKind = scope.envKind; const envKey = statusEnvKey(status); const envSuffix = envLabel(status) ? ` (${envLabel(status)})` : ""; const previousVersion = status.version; @@ -1252,7 +1256,7 @@ export function SingleAgentSettings(props: { agentKind: string }) { readBridge() .updateAgentBinary({ agentKind: props.agentKind, - envKind: scope.envKind, + envKind: updateEnvKind, ...(scope.wslDistro ? { wslDistro: scope.wslDistro } : {}), }) .then(async (result) => { diff --git a/src/shared/agentStatus.test.ts b/src/shared/agentStatus.test.ts index f46a1b20..73660306 100644 --- a/src/shared/agentStatus.test.ts +++ b/src/shared/agentStatus.test.ts @@ -68,6 +68,22 @@ describe("getProjectAgentStatuses", () => { expect(getProjectAgentStatuses(location, [], legacyStatuses)).toEqual(legacyStatuses); }); + + it("returns only statuses for the matching SSH host", () => { + const location: ProjectLocation = { kind: "ssh", host: "devbox", path: "/repo" }; + + expect( + getProjectAgentStatuses( + location, + [makeStatus("codex", { envKind: "windows" })], + [], + [ + makeStatus("claude", { envKind: "ssh", envHost: "devbox" }), + makeStatus("gemini", { envKind: "ssh", envHost: "other" }), + ], + ), + ).toEqual([makeStatus("claude", { envKind: "ssh", envHost: "devbox" })]); + }); }); describe("getSettingsInstalledAgents", () => { diff --git a/src/shared/agentStatus.ts b/src/shared/agentStatus.ts index c8a67949..57a88bf7 100644 --- a/src/shared/agentStatus.ts +++ b/src/shared/agentStatus.ts @@ -4,7 +4,12 @@ export function getProjectAgentStatuses( location: ProjectLocation, windowsStatuses: AgentStatus[], wslStatuses: AgentStatus[], + sshStatuses: AgentStatus[] = [], ): AgentStatus[] { + if (location.kind === "ssh") { + return sshStatuses.filter((status) => status.envHost === location.host); + } + if (location.kind !== "wsl") { return windowsStatuses; } diff --git a/src/shared/contracts/agent.ts b/src/shared/contracts/agent.ts index d754948d..3acc457d 100644 --- a/src/shared/contracts/agent.ts +++ b/src/shared/contracts/agent.ts @@ -231,14 +231,16 @@ export const agentStatusSchema = z.object({ authMethods: z.array(agentAuthMethodSchema).optional(), authLogoutSupported: z.boolean().optional(), capabilities: agentCapabilitySchema, - envKind: z.enum(["windows", "wsl", "posix"]).optional(), + envKind: z.enum(["windows", "wsl", "posix", "ssh"]).optional(), envDistro: z.string().optional(), + envHost: z.string().optional(), }); export type AgentStatus = z.infer; export const refreshAgentScopeEnvSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("native") }), z.object({ kind: z.literal("wsl"), distro: z.string().min(1) }), + z.object({ kind: z.literal("ssh"), host: z.string().min(1), path: z.string().min(1) }), ]); export type RefreshAgentScopeEnv = z.infer; @@ -250,6 +252,9 @@ export type RefreshAgentScope = z.infer; export const getAgentStatusesPayloadSchema = z.object({ wslDistros: z.array(z.string().min(1)).default([]), + sshProjects: z + .array(z.object({ kind: z.literal("ssh"), host: z.string().min(1), path: z.string().min(1) })) + .optional(), scope: refreshAgentScopeSchema.optional(), }); export type GetAgentStatusesPayload = z.infer; @@ -257,6 +262,7 @@ export type GetAgentStatusesPayload = z.infer; diff --git a/src/shared/contracts/projectTree.ts b/src/shared/contracts/projectTree.ts index 2ccdddbf..a4335450 100644 --- a/src/shared/contracts/projectTree.ts +++ b/src/shared/contracts/projectTree.ts @@ -195,3 +195,16 @@ export type DetectSetupScriptPayload = z.infer; + +export interface CheckSshProjectConnectionResult { + ok: boolean; + message?: string; + latencyMs?: number; +} diff --git a/src/shared/ipc/events.ts b/src/shared/ipc/events.ts index f291a93f..8d25622a 100644 --- a/src/shared/ipc/events.ts +++ b/src/shared/ipc/events.ts @@ -70,6 +70,7 @@ export type SupervisorEvent = } | { type: "windows-agent-statuses"; statuses: AgentStatus[] } | { type: "wsl-agent-statuses"; statuses: AgentStatus[] } + | { type: "ssh-agent-statuses"; statuses: AgentStatus[] } | { type: "agent-detected"; status: AgentStatus } | { type: "agent-status-updated"; status: AgentStatus } | { type: "provider-usage"; snapshot: UsageSnapshot } diff --git a/src/shared/ipc/procedures/projectTree.ts b/src/shared/ipc/procedures/projectTree.ts index ce01b560..d28a9409 100644 --- a/src/shared/ipc/procedures/projectTree.ts +++ b/src/shared/ipc/procedures/projectTree.ts @@ -1,5 +1,6 @@ import { createProjectEntryPayloadSchema, + checkSshProjectConnectionPayloadSchema, deleteProjectEntryPayloadSchema, detectSetupScriptPayloadSchema, listProjectTreePayloadSchema, @@ -38,6 +39,8 @@ import type { WriteExternalFileResult, WriteProjectFilePayload, WriteProjectFileResult, + CheckSshProjectConnectionPayload, + CheckSshProjectConnectionResult, } from "../../contracts"; import { definePayloadProcedure } from "../core"; @@ -112,4 +115,9 @@ export const projectTreeProcedures = { DetectSetupScriptResult, "supervisor" >("detectSetupScript", "supervisor", detectSetupScriptPayloadSchema), + checkSshProjectConnection: definePayloadProcedure< + CheckSshProjectConnectionPayload, + CheckSshProjectConnectionResult, + "supervisor" + >("checkSshProjectConnection", "supervisor", checkSshProjectConnectionPayloadSchema), } as const; diff --git a/src/shared/ipc/procedures/thread.ts b/src/shared/ipc/procedures/thread.ts index b03394fd..d5ae65bf 100644 --- a/src/shared/ipc/procedures/thread.ts +++ b/src/shared/ipc/procedures/thread.ts @@ -40,6 +40,7 @@ import type { InstallAcpRegistryAgentPayload, InterruptThreadPayload, LogoutAcpAgentPayload, + ProjectLocation, RefreshAgentScope, RemoveAcpRegistryAgentPayload, ResizeTerminalPayload, @@ -72,23 +73,31 @@ import { export const threadProcedures = { getAgentStatuses: defineIpcProcedure< - [string[]?], + [string[]?, Extract[]?], GetAgentStatusesPayload, AgentStatusesResponse, "supervisor" - >("getAgentStatuses", "supervisor", getAgentStatusesPayloadSchema, (wslDistros) => - getAgentStatusesPayloadSchema.parse({ wslDistros: wslDistros ?? [] }), + >("getAgentStatuses", "supervisor", getAgentStatusesPayloadSchema, (wslDistros, sshProjects) => + getAgentStatusesPayloadSchema.parse({ + wslDistros: wslDistros ?? [], + sshProjects: sshProjects ?? [], + }), ), refreshAgentStatuses: defineIpcProcedure< - [string[]?, RefreshAgentScope?], + [string[]?, RefreshAgentScope?, Extract[]?], GetAgentStatusesPayload, AgentStatusesResponse, "supervisor" - >("refreshAgentStatuses", "supervisor", getAgentStatusesPayloadSchema, (wslDistros, scope) => - getAgentStatusesPayloadSchema.parse({ - wslDistros: wslDistros ?? [], - ...(scope ? { scope } : {}), - }), + >( + "refreshAgentStatuses", + "supervisor", + getAgentStatusesPayloadSchema, + (wslDistros, scope, sshProjects) => + getAgentStatusesPayloadSchema.parse({ + wslDistros: wslDistros ?? [], + sshProjects: sshProjects ?? [], + ...(scope ? { scope } : {}), + }), ), getAgentHookPluginStatuses: definePayloadProcedure< GetAgentHookPluginStatusesPayload, diff --git a/src/shared/ssh.ts b/src/shared/ssh.ts new file mode 100644 index 00000000..28df5350 --- /dev/null +++ b/src/shared/ssh.ts @@ -0,0 +1,47 @@ +import type { ProjectLocation } from "./contracts"; + +export type SshProjectLocation = Extract; + +export function isSafeSshHost(host: string): boolean { + return ( + host.length > 0 && + !host.startsWith("-") && + !/\s/.test(host) && + !host.includes(String.fromCharCode(0)) + ); +} + +export function formatSshProjectLocation(location: SshProjectLocation): string { + return `${location.host}:${location.path}`; +} + +export function parseSshProjectSpec(input: string): SshProjectLocation | null { + const raw = input.trim(); + if (!raw) return null; + + if (raw.startsWith("ssh://")) { + try { + const url = new URL(raw); + if (url.port) return null; + const username = url.username ? `${decodeURIComponent(url.username)}@` : ""; + const host = `${username}${url.hostname}`; + const path = decodeURIComponent(url.pathname); + if (!isSafeSshHost(host) || !path.startsWith("/")) return null; + return { kind: "ssh", host, path: normalizeSshPath(path) }; + } catch { + return null; + } + } + + const separator = raw.indexOf(":"); + if (separator <= 0) return null; + const host = raw.slice(0, separator).trim(); + const path = raw.slice(separator + 1).trim(); + if (!isSafeSshHost(host) || !path.startsWith("/")) return null; + return { kind: "ssh", host, path: normalizeSshPath(path) }; +} + +function normalizeSshPath(path: string): string { + const normalized = path.replace(/\/+$/g, ""); + return normalized || "/"; +} diff --git a/src/shared/worktree.test.ts b/src/shared/worktree.test.ts index 6b971d99..d80b5272 100644 --- a/src/shared/worktree.test.ts +++ b/src/shared/worktree.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { sanitizeWorktreeBranchName, sanitizeWorktreePathSegment } from "./worktree"; +import { parseSshProjectSpec } from "./ssh"; +import { + buildWorktreeLocation, + sanitizeWorktreeBranchName, + sanitizeWorktreePathSegment, +} from "./worktree"; describe("worktree helpers", () => { it("sanitizes branch names into stable directory segments", () => { @@ -19,4 +24,37 @@ describe("worktree helpers", () => { it("falls back to a default path segment when input becomes empty", () => { expect(sanitizeWorktreePathSegment(" ")).toBe("project"); }); + + it("builds SSH worktree locations on the same host", () => { + expect( + buildWorktreeLocation( + { kind: "ssh", host: "devbox", path: "/home/demo/repo" }, + "/home/demo/.lightcode/worktrees/repo/feature", + ), + ).toEqual({ + kind: "ssh", + host: "devbox", + path: "/home/demo/.lightcode/worktrees/repo/feature", + }); + }); +}); + +describe("SSH project specs", () => { + it("parses host:path and ssh URLs", () => { + expect(parseSshProjectSpec("devbox:/srv/app")).toEqual({ + kind: "ssh", + host: "devbox", + path: "/srv/app", + }); + expect(parseSshProjectSpec("ssh://demo@example.com/home/demo/app/")).toEqual({ + kind: "ssh", + host: "demo@example.com", + path: "/home/demo/app", + }); + }); + + it("rejects unsafe hosts and relative paths", () => { + expect(parseSshProjectSpec("-oProxyCommand=bad:/srv/app")).toBeNull(); + expect(parseSshProjectSpec("devbox:relative/path")).toBeNull(); + }); }); diff --git a/src/shared/worktree.ts b/src/shared/worktree.ts index e1db8631..9bf18174 100644 --- a/src/shared/worktree.ts +++ b/src/shared/worktree.ts @@ -41,5 +41,8 @@ export function buildWorktreeLocation( if (original.kind === "posix") { return { kind: "posix", path: worktreePath }; } + if (original.kind === "ssh") { + return { kind: "ssh", host: original.host, path: worktreePath }; + } return { kind: "windows", path: worktreePath }; } diff --git a/src/shared/wsl.ts b/src/shared/wsl.ts index 31efce9f..73953fbc 100644 --- a/src/shared/wsl.ts +++ b/src/shared/wsl.ts @@ -1,4 +1,5 @@ import type { ProjectLocation } from "./contracts"; +import { formatSshProjectLocation } from "./ssh"; export function stripNulChars(value: string): string { return value.split("\0").join(""); @@ -26,6 +27,7 @@ export function parseWslUncPath(uncPath: string): { distro: string; linuxPath: s export function getProjectDisplayPath(location: ProjectLocation): string { if (location.kind === "wsl") return `${location.distro}:${location.linuxPath}`; + if (location.kind === "ssh") return formatSshProjectLocation(location); return location.path; } @@ -43,9 +45,13 @@ export function getProjectName(location: ProjectLocation): string { * - windows → `location.path` * - wsl → `location.uncPath` (e.g. `\\wsl.localhost\Ubuntu\home\user\repo`) * - posix → `location.path` + * - ssh → not available through the local filesystem */ export function getProjectFsPath(location: ProjectLocation): string { if (location.kind === "wsl") return location.uncPath; + if (location.kind === "ssh") { + throw new Error("SSH projects do not have a local filesystem path."); + } return location.path; } @@ -58,9 +64,11 @@ export function getProjectFsPath(location: ProjectLocation): string { * in-distro commands) * - wsl → `location.linuxPath` (e.g. `/home/user/repo`) * - posix → `location.path` + * - ssh → `location.path` */ export function getProjectPosixPath(location: ProjectLocation): string { if (location.kind === "wsl") return location.linuxPath; + if (location.kind === "ssh") return location.path; return location.path; } diff --git a/src/supervisor/agents/acp/session.ts b/src/supervisor/agents/acp/session.ts index 3ba81e7b..dcf7ff4f 100644 --- a/src/supervisor/agents/acp/session.ts +++ b/src/supervisor/agents/acp/session.ts @@ -51,7 +51,6 @@ import { } from "@agentclientprotocol/sdk"; import type { AgentSlashCommand, - ProjectLocation, PromptSegment, RuntimeEvent, SessionRef, @@ -88,6 +87,7 @@ import { type AgentLaunchOptions, type CommandSpec, type CreateStructuredSessionInput, + type NonSshProjectLocation, type StartTurnOptions, type StructuredSessionHandle, type StructuredSessionListener, @@ -124,7 +124,7 @@ export { resolveAcpReadableHostFsPath, resolveAcpResourcePath, toAcpResourceUri */ async function segmentsToContentBlocks( prompt: string, - location: ProjectLocation, + location: NonSshProjectLocation, segments?: PromptSegment[], promptCapabilities?: PromptCapabilities, ): Promise { @@ -260,7 +260,7 @@ function processEnvRecord(): Record { return env; } -function buildAcpTerminalEnv(location: ProjectLocation): Record { +function buildAcpTerminalEnv(location: NonSshProjectLocation): Record { const env = processEnvRecord(); if (location.kind === "windows") { return { @@ -306,14 +306,14 @@ function acpTerminalEnvEntries( return env; } -function resolveAcpTerminalCwd(location: ProjectLocation, cwd: string): string { +function resolveAcpTerminalCwd(location: NonSshProjectLocation, cwd: string): string { return location.kind === "wsl" ? resolveAcpProjectPath(location, cwd) : resolveAcpHostFsPath(location, cwd); } function buildAcpTerminalLaunch( - location: ProjectLocation, + location: NonSshProjectLocation, cwd: string, command: string, args: string[], @@ -458,7 +458,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { private readonly child: ChildProcess; private readonly connection: ClientSideConnection; private readonly cwd: string; - private readonly projectLocation: ProjectLocation; + private readonly projectLocation: NonSshProjectLocation; private readonly browserMcp: BrowserMcpHttpConfig | undefined; /** Lightcode thread id (stable identifier we report in RuntimeEvents). */ private readonly threadId: string; @@ -527,7 +527,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { private constructor( child: ChildProcess, connection: ClientSideConnection, - projectLocation: ProjectLocation, + projectLocation: NonSshProjectLocation, cwd: string, threadId: string, options?: AcpStructuredSessionOptions, @@ -742,7 +742,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { */ static create( command: CommandSpec, - projectLocation: ProjectLocation, + projectLocation: NonSshProjectLocation, threadId: string, options?: AcpStructuredSessionOptions, ): AcpStructuredSession { diff --git a/src/supervisor/agents/acp/sessionPaths.ts b/src/supervisor/agents/acp/sessionPaths.ts index 16507839..8ea345ed 100644 --- a/src/supervisor/agents/acp/sessionPaths.ts +++ b/src/supervisor/agents/acp/sessionPaths.ts @@ -1,11 +1,11 @@ import { posix, win32 } from "node:path"; import { pathToFileURL } from "node:url"; import { RequestError } from "@agentclientprotocol/sdk"; -import type { ProjectLocation } from "@/shared/contracts"; import { toWslUncPath } from "@/shared/wsl"; +import type { NonSshProjectLocation } from "../base"; /** CWD to pass into the ACP session (the agent's working directory). */ -export function resolveSessionCwd(location: ProjectLocation): string { +export function resolveSessionCwd(location: NonSshProjectLocation): string { switch (location.kind) { case "windows": return location.path; @@ -17,14 +17,14 @@ export function resolveSessionCwd(location: ProjectLocation): string { } /** CWD for the spawned process on the host OS (must be a valid native path). */ -export function resolveSpawnCwd(location: ProjectLocation): string | undefined { +export function resolveSpawnCwd(location: NonSshProjectLocation): string | undefined { // WSL projects launch wsl.exe from Windows — the linux path doesn't exist // on the host FS. wsl.exe receives its cwd via --cd, so no spawn cwd needed. if (location.kind === "wsl") return undefined; return location.path; } -export function basenameForProjectPath(location: ProjectLocation, filePath: string): string { +export function basenameForProjectPath(location: NonSshProjectLocation, filePath: string): string { switch (location.kind) { case "windows": return win32.basename(filePath); @@ -38,7 +38,7 @@ export function isWindowsAbsolutePath(filePath: string): boolean { return /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\"); } -export function resolveAcpResourcePath(location: ProjectLocation, rawPath: string): string { +export function resolveAcpResourcePath(location: NonSshProjectLocation, rawPath: string): string { if (isWindowsAbsolutePath(rawPath)) { return rawPath; } @@ -52,7 +52,7 @@ export function resolveAcpResourcePath(location: ProjectLocation, rawPath: strin } } -function isProjectRelativePath(location: ProjectLocation, absolutePath: string): boolean { +function isProjectRelativePath(location: NonSshProjectLocation, absolutePath: string): boolean { switch (location.kind) { case "windows": { const relative = win32.relative(location.path, absolutePath); @@ -69,7 +69,7 @@ function isProjectRelativePath(location: ProjectLocation, absolutePath: string): } } -export function resolveAcpProjectPath(location: ProjectLocation, rawPath: string): string { +export function resolveAcpProjectPath(location: NonSshProjectLocation, rawPath: string): string { const absolutePath = resolveAcpResourcePath(location, rawPath); if (!isProjectRelativePath(location, absolutePath)) { throw RequestError.invalidParams({ message: `Path is outside the project: ${rawPath}` }); @@ -77,7 +77,7 @@ export function resolveAcpProjectPath(location: ProjectLocation, rawPath: string return absolutePath; } -export function resolveAcpHostFsPath(location: ProjectLocation, rawPath: string): string { +export function resolveAcpHostFsPath(location: NonSshProjectLocation, rawPath: string): string { const absolutePath = resolveAcpProjectPath(location, rawPath); if (location.kind !== "wsl" || isWindowsAbsolutePath(absolutePath)) { return absolutePath; @@ -88,7 +88,10 @@ export function resolveAcpHostFsPath(location: ProjectLocation, rawPath: string) : win32.join(location.uncPath, ...relative.split("/").filter(Boolean)); } -export function resolveAcpReadableHostFsPath(location: ProjectLocation, rawPath: string): string { +export function resolveAcpReadableHostFsPath( + location: NonSshProjectLocation, + rawPath: string, +): string { const absolutePath = resolveAcpResourcePath(location, rawPath); if (isProjectRelativePath(location, absolutePath)) { return resolveAcpHostFsPath(location, rawPath); @@ -103,7 +106,7 @@ export function resolveAcpReadableHostFsPath(location: ProjectLocation, rawPath: return normalizedPath; } -export function toAcpResourceUri(location: ProjectLocation, rawPath: string): string { +export function toAcpResourceUri(location: NonSshProjectLocation, rawPath: string): string { const absolutePath = resolveAcpResourcePath(location, rawPath); if (isWindowsAbsolutePath(absolutePath)) { // Emit the legacy "file://C:/..." (two-slash) form for Windows absolute @@ -159,7 +162,7 @@ export function sliceTextFileContent( return selected.join("\n"); } -function isAgentSkillReadPath(location: ProjectLocation, absolutePath: string): boolean { +function isAgentSkillReadPath(location: NonSshProjectLocation, absolutePath: string): boolean { switch (location.kind) { case "windows": { const match = @@ -184,7 +187,7 @@ function isAgentSkillReadPath(location: ProjectLocation, absolutePath: string): } } -function normalizeAcpPath(location: ProjectLocation, absolutePath: string): string { +function normalizeAcpPath(location: NonSshProjectLocation, absolutePath: string): string { if (isWindowsAbsolutePath(absolutePath)) return win32.normalize(absolutePath); return location.kind === "windows" ? win32.normalize(absolutePath) diff --git a/src/supervisor/agents/base.test.ts b/src/supervisor/agents/base.test.ts index 6347042b..6c20249f 100644 --- a/src/supervisor/agents/base.test.ts +++ b/src/supervisor/agents/base.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vitest"; import type { ProjectLocation } from "@/shared/contracts"; -import { getWslCommand, injectWslEnv, wrapWslCommand } from "./base"; +import { + buildAgentCommand, + getWslCommand, + injectWslEnv, + resolveLaunchSpec, + wrapWslCommand, +} from "./base"; const wslProject: ProjectLocation = { kind: "wsl", @@ -92,3 +98,32 @@ describe("injectWslEnv", () => { expect(result).toBe(original); }); }); + +describe("buildAgentCommand", () => { + it("launches SSH agent commands through ssh in the remote project path", () => { + const spec = buildAgentCommand( + { kind: "ssh", host: "devbox", path: "/home/demo/repo" }, + "codex", + ["--version"], + undefined, + { LIGHTCODE: "1" }, + ); + + expect(spec.command).toBe("ssh"); + expect(spec.args).toEqual(expect.arrayContaining(["-o", "BatchMode=yes", "-T", "devbox"])); + expect(spec.args.at(-1)).toContain("/home/demo/repo"); + expect(spec.args.at(-1)).toContain("LIGHTCODE"); + expect(spec.args.at(-1)).toContain("codex"); + expect(spec.args.at(-1)).toContain("--version"); + }); + + it("uses a tty for SSH terminal launch specs", () => { + const spec = resolveLaunchSpec( + { kind: "ssh", host: "devbox", path: "/home/demo/repo" }, + { binary: "codex", args: ["--version"] }, + ); + + expect(spec.command).toBe("ssh"); + expect(spec.args).toEqual(expect.arrayContaining(["-o", "BatchMode=no", "-tt", "devbox"])); + }); +}); diff --git a/src/supervisor/agents/base/index.ts b/src/supervisor/agents/base/index.ts index 241912f7..b6f11126 100644 --- a/src/supervisor/agents/base/index.ts +++ b/src/supervisor/agents/base/index.ts @@ -38,6 +38,7 @@ import type { AgentLaunchOptions, AgentLauncher, AgentMetadata, + NonSshProjectLocation, AgentOneShotRunner, AgentPromptFormatter, AgentSessionTracker, @@ -45,6 +46,7 @@ import type { AgentUpdater, AgentUpdaterCommand, AuthProbe, + CapabilitiesProbeCtx, CapabilitiesProbeResult, CommandSpec, CreateStructuredSessionInput, @@ -61,12 +63,14 @@ import type { ThreadHistory, ThreadHistoryEntry, } from "./types"; +import { buildSshCommand, buildSshPtyCommand, readSshCommandOutput, runSshScript } from "../../ssh"; export type { AcpSessionUpdateTransform, AgentAcpAuth, AgentAdapter, AgentArgvSpec, + AgentCliHookEnvContext, AgentCliHookPluginSupport, AgentDetector, AgentEnvContext, @@ -80,6 +84,7 @@ export type { AgentUpdater, AgentUpdaterCommand, AuthProbe, + CapabilitiesProbeCtx, CapabilitiesProbeResult, CommandSpec, CreateStructuredSessionInput, @@ -90,6 +95,7 @@ export type { ResolveExecutablePath, RunOneShotInput, StartTurnOptions, + NonSshProjectLocation, StatusProbe, StatusProbeResult, StructuredSessionHandle, @@ -262,6 +268,7 @@ function buildPosixCommand(cwd: string, command: string, args: string[]): Comman * Handles: * - "windows" → PowerShell or cmd.exe * - "wsl" → wsl.exe with Linux shell + * - "ssh" → ssh with remote POSIX shell * - "posix" → macOS/Linux with $SHELL or /bin/bash */ export function buildAgentCommand( @@ -270,6 +277,7 @@ export function buildAgentCommand( args: string[], resolvedExecPath?: string, env?: Record, + options?: { sshTty?: boolean; sshBatchMode?: "yes" | "no" }, ): CommandSpec { if (location.kind === "wsl") { // Always launch the agent through `bash -l -i -c` so the user's rc files @@ -299,6 +307,15 @@ export function buildAgentCommand( }; } + if (location.kind === "ssh") { + return options?.sshTty + ? buildSshPtyCommand(location, resolvedExecPath ?? command, args, env) + : buildSshCommand(location, resolvedExecPath ?? command, args, env, { + ...(options?.sshBatchMode ? { batchMode: options.sshBatchMode } : {}), + tty: false, + }); + } + if (location.kind === "windows") { const commandPath = resolvedExecPath ?? command; const shim = resolveWindowsNodeCmdShim(commandPath); @@ -331,13 +348,23 @@ export function resolveLaunchSpec(location: ProjectLocation, argv: AgentArgvSpec argv.preferShell && location.kind === "posix" ? undefined : resolveAgentBinaryPath(location, argv.binary); - const spec = buildAgentCommand(location, argv.binary, argv.args, resolvedExecPath, argv.env); + const spec = buildAgentCommand(location, argv.binary, argv.args, resolvedExecPath, argv.env, { + sshBatchMode: "no", + sshTty: true, + }); if (argv.sessionRef) { spec.sessionRef = argv.sessionRef; } return spec; } +export function isSessionTrackingLocation( + location: ProjectLocation, + launchOptions?: AgentLaunchOptions, +): location is NonSshProjectLocation { + return launchOptions?.sessionTrackingEnabled !== false && location.kind !== "ssh"; +} + // ── Install-detection engine ─────────────────────────────────────── /** @@ -356,6 +383,15 @@ export function envVarAuthProbe(names: string[]): AuthProbe { const any = results.some((r) => r.ok && r.stdout.trim().length > 0); return any ? "authenticated" : "unknown"; } + if (ctx.location.kind === "ssh") { + const result = await readSshCommandOutput( + ctx.location, + "sh", + ["-lc", names.map((name) => `printf %s "$${name}"`).join("; ")], + { timeout: 10_000 }, + ).catch(() => undefined); + return result?.stdout.trim() ? "authenticated" : "unknown"; + } const any = names.some((n) => { const value = process.env[n]; return typeof value === "string" && value.trim().length > 0; @@ -374,6 +410,7 @@ export function configFileAuthProbe( resolvePath: (location: ProjectLocation) => string | undefined, ): AuthProbe { return async (ctx) => { + if (ctx.location.kind === "ssh") return undefined; const path = resolvePath(ctx.location); if (!path) return undefined; return existsSync(path) ? "authenticated" : "missing"; @@ -398,6 +435,7 @@ export function cliSubcommandAuthProbe(args: string[]): AuthProbe { } const PROBE_WSL_LINUX_PATH = "/tmp"; +const PROBE_SSH_PATH = "/tmp"; export function detectProbeLocation(ctx: AgentEnvContext | undefined): ProjectLocation { if (ctx?.envKind === "wsl" && ctx.wslDistro) { @@ -408,6 +446,9 @@ export function detectProbeLocation(ctx: AgentEnvContext | undefined): ProjectLo uncPath: "\\\\wsl$", }; } + if (ctx?.envKind === "ssh" && ctx.sshHost) { + return { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? PROBE_SSH_PATH }; + } if (process.platform === "win32") { return { kind: "windows", path: homedir() }; } @@ -443,6 +484,17 @@ async function resolveDetectedBinary( primeAgentBinaryPath(ctx.wslDistro, binary, path); return path; } + if (ctx?.envKind === "ssh" && ctx.sshHost) { + const location: Extract = { + kind: "ssh", + host: ctx.sshHost, + path: ctx.sshPath ?? PROBE_SSH_PATH, + }; + const result = await runSshScript(location, `command -v ${quotePosixShellArg(binary)}`, { + timeout: 10_000, + }).catch(() => undefined); + return result?.stdout.trim() || undefined; + } return resolveExecutablePathAsync(binary); } @@ -536,10 +588,10 @@ export function resolveAgentHomeSubpath( location: ProjectLocation, subpath: string, ): string | undefined { + const trimmed = subpath.replace(/^[\\/]+/, ""); if (location.kind === "wsl") { const home = getCachedWslHomeDirectory(location.distro); if (!home) return undefined; - const trimmed = subpath.replace(/^[\\/]+/, ""); return toWslUncPath(location.distro, `${home}/${trimmed}`); } return join(homedir(), ...subpath.split(/[\\/]/).filter((s) => s.length > 0)); @@ -616,8 +668,12 @@ export async function detectAgentInstall( let probedProviderMetadata: AgentProviderMetadata | undefined; if (executablePath) { const probeCtx: DetectProbeCtx = { location, executablePath, version }; + const capabilitiesProbeCtx: CapabilitiesProbeCtx | undefined = + location.kind === "ssh" ? undefined : { location, executablePath, version }; const [capabilityPartial, nextStatusProbeResult] = await Promise.all([ - spec.capabilitiesProbe ? spec.capabilitiesProbe(probeCtx) : Promise.resolve(undefined), + spec.capabilitiesProbe && capabilitiesProbeCtx + ? spec.capabilitiesProbe(capabilitiesProbeCtx) + : Promise.resolve(undefined), spec.statusProbe ? spec.statusProbe(probeCtx) : Promise.resolve(undefined), ]); if (capabilityPartial) { diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index ddd7b219..b8460855 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -34,8 +34,10 @@ export interface CommandSpec { } export interface AgentEnvContext { - envKind: "windows" | "wsl" | "posix"; + envKind: "windows" | "wsl" | "posix" | "ssh"; wslDistro?: string; + sshHost?: string; + sshPath?: string; /** * Lightcode data base dir for native (non-WSL) plugin staging. Populated by * the supervisor so dev runs (`~/.lightcode-dev`) stage plugins separately @@ -47,8 +49,15 @@ export interface AgentEnvContext { browserMcp?: BrowserMcpHttpConfig; } +export type AgentCliHookEnvContext = Omit & { + envKind: "windows" | "wsl" | "posix"; +}; + +export type NonSshProjectLocation = Exclude; + export interface AgentLaunchOptions { suppressResumeConfigOverrides?: boolean; + sessionTrackingEnabled?: boolean; resumeThreadId?: string; agentSettings?: Record; browserMcp?: BrowserMcpHttpConfig; @@ -110,7 +119,7 @@ export type ResolveExecutablePath = (command: string) => string | undefined; export interface CreateStructuredSessionInput { threadId: string; - projectLocation: ProjectLocation; + projectLocation: NonSshProjectLocation; config: ThreadConfig; agentSettings?: Record; browserMcp?: BrowserMcpHttpConfig; @@ -144,6 +153,10 @@ export interface DetectProbeCtx { version?: string | undefined; } +export type CapabilitiesProbeCtx = Omit & { + location: NonSshProjectLocation; +}; + export type AuthProbe = (ctx: DetectProbeCtx) => Promise; export interface StatusProbeResult { @@ -182,7 +195,7 @@ export interface DetectionSpec { versionArgs?: string[]; statusProbe?: StatusProbe; authProbes?: AuthProbe[]; - capabilitiesProbe?: (ctx: DetectProbeCtx) => Promise; + capabilitiesProbe?: (ctx: CapabilitiesProbeCtx) => Promise; } export interface AgentMetadata { @@ -262,13 +275,16 @@ export interface AgentSessionTracker { createStructuredSession?( input: CreateStructuredSessionInput, ): Promise; - discoverSessionRef?(location: ProjectLocation): Promise; + discoverSessionRef?(location: NonSshProjectLocation): Promise; initialSessionRefDiscoveryDelayMs?: number; - watchSessionRef?(location: ProjectLocation, onChanged: () => void): (() => void) | undefined; + watchSessionRef?( + location: NonSshProjectLocation, + onChanged: () => void, + ): (() => void) | undefined; } export interface RunOneShotInput { - location: ProjectLocation; + location: NonSshProjectLocation; model: string; effort?: string | undefined; prompt: string; @@ -337,14 +353,14 @@ export interface AgentCliHookPluginSupport { readonly pluginVersion: string; readonly minProtocolVersion: number; readonly partialL1?: boolean; - isPluginSupported?(ctx: AgentEnvContext): Promise; - isPluginInstalled(ctx: AgentEnvContext): Promise<{ installed: boolean; version?: string }>; + isPluginSupported?(ctx: AgentCliHookEnvContext): Promise; + isPluginInstalled(ctx: AgentCliHookEnvContext): Promise<{ installed: boolean; version?: string }>; installPlugin( - ctx: AgentEnvContext, + ctx: AgentCliHookEnvContext, ): Promise<{ ok: true; version: string } | { ok: false; reason: string }>; - uninstallPlugin?(ctx: AgentEnvContext): Promise; + uninstallPlugin?(ctx: AgentCliHookEnvContext): Promise; pluginLaunchExtras?( - ctx: AgentEnvContext, + ctx: AgentCliHookEnvContext, ): Promise<{ args?: string[]; env?: Record } | undefined>; } diff --git a/src/supervisor/agents/binaryResolver.ts b/src/supervisor/agents/binaryResolver.ts index 8323bfe6..f6acf930 100644 --- a/src/supervisor/agents/binaryResolver.ts +++ b/src/supervisor/agents/binaryResolver.ts @@ -34,6 +34,9 @@ export function resolveAgentBinaryPath( const key = keyOf(location.distro, binary); return cache.get(key); } + if (location.kind === "ssh") { + return undefined; + } // posix: piggy-back on the shared exec-path cache populated by // primeExecutablePathCache during agent detection. The cached path may come // from a temporary login shell (e.g. fnm multishell) that has since been diff --git a/src/supervisor/agents/browserMcp/index.ts b/src/supervisor/agents/browserMcp/index.ts index a1ab9bd6..faef66fe 100644 --- a/src/supervisor/agents/browserMcp/index.ts +++ b/src/supervisor/agents/browserMcp/index.ts @@ -63,6 +63,7 @@ export function resolveBrowserMcpHttpConfig( ): BrowserMcpHttpConfig | null { const env = readBrowserMcpEnv(); if (!env) return null; + if (location.kind === "ssh") return null; if (location.kind === "wsl") return null; const url = env.url; // Append `/mcp` so the agent hits the Streamable-HTTP endpoint directly. @@ -80,6 +81,7 @@ export async function resolveBrowserMcpHttpConfigForLaunch( bridge?: BrowserMcpBridge, ): Promise { if (!enabled) return undefined; + if (location.kind === "ssh") return undefined; if (location.kind === "wsl") { if (!bridge) return undefined; const env = readBrowserMcpEnv(); diff --git a/src/supervisor/agents/browserMcp/providers.test.ts b/src/supervisor/agents/browserMcp/providers.test.ts index 3f0e00ce..8c00e333 100644 --- a/src/supervisor/agents/browserMcp/providers.test.ts +++ b/src/supervisor/agents/browserMcp/providers.test.ts @@ -5,8 +5,10 @@ import { buildCodexBrowserMcpArgs, buildCodexBrowserMcpEnv } from "../codex/mcpB import { buildGeminiBrowserMcpServers } from "../gemini/mcpBrowser"; import { buildOpenCodeBrowserMcp } from "../opencode/mcpBrowser"; import { resolveBrowserMcpHttpConfigForLaunch, type BrowserMcpHttpConfig } from "./index"; +import type { ProjectLocation } from "@/shared/contracts"; const wslLocation = { kind: "wsl", distro: "Ubuntu" } as const; +const sshLocation: ProjectLocation = { kind: "ssh", host: "devbox", path: "/repo" }; const bridgeMcp: BrowserMcpHttpConfig = { url: "http://127.0.0.1:45678/mcp", token: "bridge-secret", @@ -82,4 +84,18 @@ describe("WSL Browser MCP provider configs", () => { expect(buildGeminiBrowserMcpServers(wslLocation)).toBeUndefined(); expect(buildOpenCodeBrowserMcp(wslLocation)).toBeUndefined(); }); + + it("does not pass the host Browser MCP endpoint into SSH launches", async () => { + process.env.LIGHTCODE_BROWSER_MCP_URL = "http://127.0.0.1:65093"; + process.env.LIGHTCODE_BROWSER_MCP_TOKEN = "host-token"; + const ensureBridge = vi.fn<() => Promise<{ baseUrl: string; secret: string }>>(async () => ({ + baseUrl: "http://127.0.0.1:45678", + secret: "bridge-secret", + })); + + await expect( + resolveBrowserMcpHttpConfigForLaunch(sshLocation, true, { ensureBridge }), + ).resolves.toBeUndefined(); + expect(ensureBridge).not.toHaveBeenCalled(); + }); }); diff --git a/src/supervisor/agents/claude/sdkSession.ts b/src/supervisor/agents/claude/sdkSession.ts index 4b16220e..2ff32e98 100644 --- a/src/supervisor/agents/claude/sdkSession.ts +++ b/src/supervisor/agents/claude/sdkSession.ts @@ -38,6 +38,7 @@ import { resolveExecutablePathAsync, type AgentLaunchOptions, type CreateStructuredSessionInput, + type NonSshProjectLocation, type StartTurnOptions, type StructuredSessionHandle, type StructuredSessionListener, @@ -97,7 +98,7 @@ type CompletedClaudeTurn = { resumeSessionAt: string | undefined; }; -function projectCwd(location: ProjectLocation): string { +function projectCwd(location: NonSshProjectLocation): string { switch (location.kind) { case "wsl": return location.linuxPath; diff --git a/src/supervisor/agents/codex/index.ts b/src/supervisor/agents/codex/index.ts index 1beb3429..50c8fb63 100644 --- a/src/supervisor/agents/codex/index.ts +++ b/src/supervisor/agents/codex/index.ts @@ -8,6 +8,7 @@ import { detectAgentInstall, detectProbeLocation, getOscNotificationText, + isSessionTrackingLocation, watchSessionPaths, type AgentAdapter, type CreateStructuredSessionInput, @@ -175,7 +176,7 @@ export function createCodexAdapter(): AgentAdapter { }, buildLaunchArgv(location: ProjectLocation, config, prompt, sessionRef, launchOptions) { preSpawnStartedAt = Date.now(); - if (location.kind === "wsl") { + if (location.kind === "wsl" || !isSessionTrackingLocation(location, launchOptions)) { preSpawnRolloutIds = new Set(); } else { const sessions = readCodexSessionIndexForLocation(location); diff --git a/src/supervisor/agents/codex/session.ts b/src/supervisor/agents/codex/session.ts index 9311d6c8..e1fc15d0 100644 --- a/src/supervisor/agents/codex/session.ts +++ b/src/supervisor/agents/codex/session.ts @@ -1,13 +1,13 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import type { ProjectLocation } from "@/shared/contracts"; import { resolveLightcodePaths } from "@/shared/lightcodePaths"; import { findSessionFiles, getCachedWslHomeDirectory, readSessionFileText, resolveWslHomeDirectoryAsync, + type NonSshProjectLocation, } from "../base"; import { parseCodexRolloutIdFromPath, @@ -59,7 +59,7 @@ function dedupeSessionIndex( return dedupeById(sessions, (s) => s.updatedAt).sort((a, b) => a.updatedAt - b.updatedAt); } -export function describeCodexLocation(location: ProjectLocation): string { +export function describeCodexLocation(location: NonSshProjectLocation): string { switch (location.kind) { case "windows": return `windows:${location.path}`; @@ -70,7 +70,7 @@ export function describeCodexLocation(location: ProjectLocation): string { } } -export function readCodexSessionIndexForLocation(location: ProjectLocation) { +export function readCodexSessionIndexForLocation(location: NonSshProjectLocation) { if (location.kind === "wsl") { return []; } @@ -93,7 +93,7 @@ export function readCodexSessionIndexForLocation(location: ProjectLocation) { * `buildLaunchArgv` where the call site itself is sync. */ export async function readCodexSessionIndexForLocationAsync( - location: ProjectLocation, + location: NonSshProjectLocation, ): Promise> { if (location.kind !== "wsl") { return readCodexSessionIndexForLocation(location); @@ -113,7 +113,7 @@ export async function readCodexSessionIndexForLocationAsync( export function isInteractiveCodexRollout( rollout: CodexRolloutMeta, - location: ProjectLocation, + location: NonSshProjectLocation, ): boolean { if (rollout.originator !== "codex-tui" || rollout.source !== "cli") { return false; @@ -133,7 +133,7 @@ export function isInteractiveCodexRollout( } } -export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRolloutMeta[] { +export function readCodexRolloutsForLocation(location: NonSshProjectLocation): CodexRolloutMeta[] { if (location.kind === "wsl") { return []; } @@ -184,7 +184,7 @@ export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRo } export function readCodexRolloutMetaForLocation( - location: ProjectLocation, + location: NonSshProjectLocation, rollout: CodexRolloutMeta, ): CodexRolloutMeta | undefined { if (location.kind === "wsl") { @@ -200,7 +200,7 @@ export function readCodexRolloutMetaForLocation( } export async function readCodexRolloutMetaForLocationAsync( - location: ProjectLocation, + location: NonSshProjectLocation, rollout: CodexRolloutMeta, ): Promise { if (location.kind !== "wsl") { @@ -213,7 +213,7 @@ export async function readCodexRolloutMetaForLocationAsync( } export async function readCodexRolloutsForLocationAsync( - location: ProjectLocation, + location: NonSshProjectLocation, ): Promise { if (location.kind !== "wsl") { return readCodexRolloutsForLocation(location); @@ -253,7 +253,7 @@ export async function readCodexRolloutsForLocationAsync( * paths for windows/posix; Linux paths inside the distro for WSL (consumed * by the in-distro bridge watch subscription, NOT UNC `\\wsl.localhost\…`). */ -export function resolveCodexSessionWatchPaths(location: ProjectLocation): string[] { +export function resolveCodexSessionWatchPaths(location: NonSshProjectLocation): string[] { if (location.kind === "wsl") { const home = getCachedWslHomeDirectory(location.distro); const privateHome = wslPrivateCodexHome(location.distro); diff --git a/src/supervisor/agents/opencode/sdkClient.ts b/src/supervisor/agents/opencode/sdkClient.ts index 29016eeb..30a347bf 100644 --- a/src/supervisor/agents/opencode/sdkClient.ts +++ b/src/supervisor/agents/opencode/sdkClient.ts @@ -1,6 +1,6 @@ import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; -import type { ProjectLocation } from "@/shared/contracts"; import type { BrowserMcpHttpConfig } from "@/supervisor/agents/browserMcp"; +import type { NonSshProjectLocation } from "../base"; import { resolveAgentBinaryPath } from "../binaryResolver"; import { BROWSER_MCP_SERVER_NAME } from "../browserMcp"; import { buildOpenCodeServerCommand } from "./argv"; @@ -13,7 +13,7 @@ import { } from "./sdkServer"; /** Agent-side cwd that the SDK passes through to the server's session config. */ -export function resolveOpenCodeSessionDirectory(location: ProjectLocation): string { +export function resolveOpenCodeSessionDirectory(location: NonSshProjectLocation): string { switch (location.kind) { case "windows": return location.path; @@ -24,7 +24,7 @@ export function resolveOpenCodeSessionDirectory(location: ProjectLocation): stri } } -function poolKey(location: ProjectLocation): string { +function poolKey(location: NonSshProjectLocation): string { switch (location.kind) { case "windows": return `windows:${location.path}`; @@ -115,7 +115,7 @@ async function waitForOpenCodeReachable(baseUrl: string): Promise { } } -async function spawnAndWire(projectLocation: ProjectLocation): Promise { +async function spawnAndWire(projectLocation: NonSshProjectLocation): Promise { const resolvedExecPath = resolveAgentBinaryPath(projectLocation, "opencode"); const command = buildOpenCodeServerCommand(projectLocation, resolvedExecPath); const handle = spawnOpenCodeServer(command); @@ -153,7 +153,7 @@ async function spawnAndWire(projectLocation: ProjectLocation): Promise { diff --git a/src/supervisor/agents/opencode/sdkProbe.ts b/src/supervisor/agents/opencode/sdkProbe.ts index 4be3fb62..37d91ba9 100644 --- a/src/supervisor/agents/opencode/sdkProbe.ts +++ b/src/supervisor/agents/opencode/sdkProbe.ts @@ -21,7 +21,7 @@ * etc. The caller logs the failure mode and degrades gracefully. */ -import type { ProjectLocation } from "@/shared/contracts"; +import type { NonSshProjectLocation } from "../base"; import { resolveAgentBinaryPath } from "../binaryResolver"; import { buildOpenCodeServerCommand } from "./argv"; import { resolveOpenCodeSessionDirectory } from "./sdkClient"; @@ -159,7 +159,7 @@ function normalizeAgentsResponse(raw: unknown): OpenCodeSdkAgent[] { * callers are expected to fall back to the CLI parser. */ export async function probeOpenCodeInventoryViaSdk( - location: ProjectLocation, + location: NonSshProjectLocation, executablePath: string, ): Promise { const resolvedExecPath = resolveAgentBinaryPath(location, executablePath); diff --git a/src/supervisor/agents/updateAgent.ts b/src/supervisor/agents/updateAgent.ts index 4d13f699..3a14aa37 100644 --- a/src/supervisor/agents/updateAgent.ts +++ b/src/supervisor/agents/updateAgent.ts @@ -28,6 +28,9 @@ export function resolveUpdateCommand( envContext: AgentEnvContext, options?: { skipBuiltIn?: boolean }, ): AgentUpdaterCommand | undefined { + if (envContext.envKind === "ssh") { + return undefined; + } const fromAdapter = adapter.buildUpdateCommand?.(envContext, status); if (fromAdapter && !(options?.skipBuiltIn && fromAdapter.strategy === "built-in")) { return fromAdapter; diff --git a/src/supervisor/git/checkpointService.ts b/src/supervisor/git/checkpointService.ts index 4d998d1c..40c811cf 100644 --- a/src/supervisor/git/checkpointService.ts +++ b/src/supervisor/git/checkpointService.ts @@ -7,6 +7,7 @@ import type { FileCheckpointTurn, ProjectLocation, } from "@/shared/contracts"; +import { readSshCommandOutput } from "../ssh"; import type { WslBridgeClient } from "../wsl/bridge/client"; import { execGit, removeWslPathViaBridge } from "./exec"; @@ -236,6 +237,10 @@ async function removeTempIndex(projectLocation: ProjectLocation, tempIndex: stri await removeWslPathViaBridge(projectLocation, tempIndex, { force: true }); return; } + if (projectLocation.kind === "ssh") { + await readSshCommandOutput({ ...projectLocation, path: "/" }, "rm", ["-f", "--", tempIndex]); + return; + } const resolved = isAbsolute(tempIndex) ? tempIndex : join(projectLocation.path, tempIndex); await rm(resolved, { force: true }); } diff --git a/src/supervisor/git/exec.ts b/src/supervisor/git/exec.ts index b6f7aa44..b2152624 100644 --- a/src/supervisor/git/exec.ts +++ b/src/supervisor/git/exec.ts @@ -8,6 +8,7 @@ import { resolveLightcodePaths } from "@/shared/lightcodePaths"; import { attachErrorDetails, errorDetail, msg } from "@/shared/messages"; import { getProjectName } from "@/shared/wsl"; import { sanitizeWorktreeBranchName, sanitizeWorktreePathSegment } from "@/shared/worktree"; +import { readSshCommandOutput, resolveSshHomeDirectoryAsync } from "../ssh"; import type { WslBridgeClient, WslGitExecResult } from "../wsl/bridge/client"; import { mkdir } from "node:fs/promises"; @@ -86,6 +87,18 @@ export async function execGit( throw gitBridgeResultToError(bridgeResult); } + if (location.kind === "ssh") { + const { stdout } = await readSshCommandOutput(location, "git", args, { + timeout, + maxBuffer, + env: { + GIT_OPTIONAL_LOCKS: "0", + ...(options?.env ?? {}), + }, + }); + return stdout; + } + const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...(options?.env ?? {}) }; const { stdout } = await execFileAsync("git", args, { cwd: location.path, @@ -166,7 +179,7 @@ export function toForwardSlash(path: string): string { } export function normalizeWorktreePath(location: ProjectLocation, path: string): string { - if (location.kind === "wsl") { + if (location.kind === "wsl" || location.kind === "ssh") { return path; } return normalize(path).replace(/\\/g, "/").toLowerCase(); @@ -179,6 +192,9 @@ export function getLocationIdentity(location: ProjectLocation): string { if (location.kind === "windows") { return `windows:${toForwardSlash(location.path).toLowerCase()}`; } + if (location.kind === "ssh") { + return `ssh:${location.host}:${location.path}`; + } return `posix:${location.path}`; } @@ -214,6 +230,16 @@ export async function computeDefaultWorktreePath( const homePath = await resolveWslHomeDirectory(location.distro); return posix.join(homePath, ".lightcode", "worktrees", repoDir, branchDir); } + if (location.kind === "ssh") { + // `path: "/"` so the cached resolver `cd`s into a directory that always + // exists (the project path may be a not-yet-created worktree). The result + // is memoized per host in ssh.ts. + const homePath = await resolveSshHomeDirectoryAsync({ ...location, path: "/" }); + if (!homePath) { + throw new Error(`Unable to resolve home directory for ${location.host}.`); + } + return posix.join(homePath, ".lightcode", "worktrees", repoDir, branchDir); + } return join( resolveLightcodePaths(join(homedir(), ".lightcode")).worktreesDir, repoDir, @@ -236,6 +262,14 @@ export async function ensureWorktreeParentExists( return; } + if (location.kind === "ssh") { + await readSshCommandOutput({ ...location, path: "/" }, "mkdir", [ + "-p", + posix.dirname(worktreePath), + ]); + return; + } + await mkdir(dirname(worktreePath), { recursive: true }); } diff --git a/src/supervisor/git/mergeService.ts b/src/supervisor/git/mergeService.ts index faa9bf42..2874f22a 100644 --- a/src/supervisor/git/mergeService.ts +++ b/src/supervisor/git/mergeService.ts @@ -5,7 +5,7 @@ import { type ProjectLocation, } from "@/shared/contracts"; import { errorDetail, msg } from "@/shared/messages"; -import { getProjectFsPath } from "@/shared/wsl"; +import { getProjectDisplayPath } from "@/shared/wsl"; import { computeDefaultWorktreePath, ensureWorktreeParentExists, @@ -230,7 +230,10 @@ export class GitMergeService { const status = await execGit(location, ["status", "--porcelain"]); if (!status.trim()) return; throw new Error( - msg("git.worktree.dirtySource", { branch: sourceBranch, path: getProjectFsPath(location) }), + msg("git.worktree.dirtySource", { + branch: sourceBranch, + path: getProjectDisplayPath(location), + }), ); } diff --git a/src/supervisor/git/statusService.ts b/src/supervisor/git/statusService.ts index d5a47266..78535480 100644 --- a/src/supervisor/git/statusService.ts +++ b/src/supervisor/git/statusService.ts @@ -10,6 +10,7 @@ import { type ProjectLocation, } from "@/shared/contracts"; import { getProjectFsPath, toWslUncPath } from "@/shared/wsl"; +import { readSshCommandOutput } from "../ssh"; import { execGit, execGitBatchWslBridge, @@ -712,10 +713,18 @@ export class GitStatusService { return { oldContent, newContent }; } - const repoPath = getProjectFsPath(location); + const newContentPromise = + location.kind === "ssh" + ? readSshCommandOutput(location, "cat", ["--", filePath], { + timeout: GIT_DIFF_TIMEOUT, + maxBuffer: 50 * 1024 * 1024, + }) + .then((result) => result.stdout) + .catch(() => "") + : readFile(join(getProjectFsPath(location), filePath), "utf-8").catch(() => ""); const [oldContent, newContent] = await Promise.all([ execGit(location, ["show", `:${filePath}`], { timeout: GIT_DIFF_TIMEOUT }).catch(() => ""), - readFile(join(repoPath, filePath), "utf-8").catch(() => ""), + newContentPromise, ]); return { oldContent, newContent }; } @@ -773,6 +782,16 @@ export class GitStatusService { location: ProjectLocation, filePath: string, ): Promise { + if (location.kind === "ssh") { + const result = await readSshCommandOutput( + location, + "sh", + ["-c", 'test -f "$1" || exit 0; wc -l < "$1"', "sh", filePath], + { timeout: GIT_STATUS_TIMEOUT, maxBuffer: 1024 }, + ).catch(() => ({ stdout: "" })); + return Number.parseInt(result.stdout.trim(), 10) || 0; + } + const absolutePath = join(getProjectFsPath(location), filePath); try { const stats = await stat(absolutePath); diff --git a/src/supervisor/github.ts b/src/supervisor/github.ts index 3b2c71ac..b6c32d70 100644 --- a/src/supervisor/github.ts +++ b/src/supervisor/github.ts @@ -24,6 +24,7 @@ import { type GhGetPrDiffResult, } from "@/shared/contracts"; import { buildAgentCommand } from "./agents/base"; +import { readSshCommandOutput } from "./ssh"; import type { WslBridgeClient, WslProcessExecResult } from "./wsl/bridge/client"; const execFileAsync = promisify(execFile); @@ -110,7 +111,9 @@ async function runGh( } const spec = buildAgentCommand(location, "gh", args); - const cwd = spec.cwd ?? location.path; + const cwd = + spec.cwd ?? + (location.kind === "windows" || location.kind === "posix" ? location.path : undefined); const { stdout } = await execFileAsync(spec.command, spec.args, { windowsHide: true, timeout: GH_TIMEOUT, @@ -157,6 +160,30 @@ async function createGhBodyFile( body: string, wslClient?: WslBridgeClient, ): Promise<{ cliPath: string; cleanup(): Promise }> { + if (location.kind === "ssh") { + const dir = ( + await readSshCommandOutput({ ...location, path: "/" }, "mktemp", [ + "-d", + `/tmp/${prefix}-XXXXXX`, + ]) + ).stdout.trim(); + const filename = "body.md"; + const cliPath = `${dir}/${filename}`; + const encoded = Buffer.from(body, "utf8").toString("base64"); + await readSshCommandOutput({ ...location, path: "/" }, "sh", [ + "-c", + `base64 -d > "$1" <<'__LIGHTCODE_GH_BODY__'\n${encoded}\n__LIGHTCODE_GH_BODY__`, + "sh", + cliPath, + ]); + return { + cliPath, + cleanup: async () => { + await readSshCommandOutput({ ...location, path: "/" }, "rm", ["-rf", "--", dir]); + }, + }; + } + if (location.kind === "wsl") { if (!wslClient) { throw new Error(`WSL bridge unavailable for GitHub CLI in distro "${location.distro}"`); @@ -406,6 +433,7 @@ function mapPrDetails(raw: Record): PrDetails { /** Stable cache key for {@link GitHubService.viewerLoginCache}. */ function locationKey(location: ProjectLocation): string { if (location.kind === "wsl") return `wsl:${location.distro}:${location.linuxPath}`; + if (location.kind === "ssh") return `ssh:${location.host}:${location.path}`; return `${location.kind}:${location.path}`; } diff --git a/src/supervisor/ipcHandlers.ts b/src/supervisor/ipcHandlers.ts index 0b7e482c..d44911fb 100644 --- a/src/supervisor/ipcHandlers.ts +++ b/src/supervisor/ipcHandlers.ts @@ -94,6 +94,7 @@ export function createSupervisorIpcHandlers(runtime: SupervisorRuntime): Supervi moveProjectEntry: (payload) => runtime.moveProjectEntry(payload), deleteProjectEntry: (payload) => runtime.deleteProjectEntry(payload), detectSetupScript: (payload) => runtime.detectSetupScript(payload), + checkSshProjectConnection: (payload) => runtime.checkSshProjectConnection(payload), ghCheckAvailable: (payload) => runtime.ghCheckAvailable(payload), ghCreatePr: (payload) => runtime.ghCreatePr(payload), ghGetPrForBranch: (payload) => runtime.ghGetPrForBranch(payload), diff --git a/src/supervisor/lsp/serverInstance.ts b/src/supervisor/lsp/serverInstance.ts index da5db19d..275b8122 100644 --- a/src/supervisor/lsp/serverInstance.ts +++ b/src/supervisor/lsp/serverInstance.ts @@ -63,7 +63,10 @@ export class ServerInstance { if (this.disposed) return; this.onStatus("starting"); - const projectRoot = getProjectFsPath(this.projectLocation); + const projectRoot = + this.projectLocation.kind === "ssh" + ? this.projectLocation.path + : getProjectFsPath(this.projectLocation); // Prime the user's interactive-shell env so node-based language servers // (typescript-language-server, vscode-eslint, etc.) launch with the // project-pinned node from fnm/asdf/mise rather than launchd's PATH. @@ -77,7 +80,9 @@ export class ServerInstance { const command = this.projectLocation.kind === "wsl" ? resolveWslCommand(candidate.command, this.projectLocation.linuxPath) - : resolveNativeCommand(candidate.command, projectRoot); + : this.projectLocation.kind === "ssh" + ? resolveWslCommand(candidate.command, this.projectLocation.path) + : resolveNativeCommand(candidate.command, projectRoot); const spec = buildAgentCommand(this.projectLocation, command, candidate.args); const proc = spawn(spec.command, spec.args, { ...(spec.cwd ? { cwd: spec.cwd } : {}), diff --git a/src/supervisor/oneShotPromptRunner.test.ts b/src/supervisor/oneShotPromptRunner.test.ts index ddbdf63d..5a34d29d 100644 --- a/src/supervisor/oneShotPromptRunner.test.ts +++ b/src/supervisor/oneShotPromptRunner.test.ts @@ -51,6 +51,12 @@ const windowsProject: ProjectLocation = { path: "C:\\Users\\demo\\project", }; +const sshProject: ProjectLocation = { + kind: "ssh", + host: "devbox", + path: "/repo", +}; + // Adapter that embeds the prompt directly in argv (mirrors Claude/Gemini/Copilot). function argvProneAdapter(): AgentAdapter { return { @@ -134,6 +140,48 @@ describe("runOneShotPromptWithFallback", () => { expect(args).toEqual(["-p", "hello world", "--model", "haiku"]); }); + it("uses the command fallback for SSH even when the adapter supports runOneShot", async () => { + const child = createMockChildProcess(); + spawnMock.mockReturnValueOnce(child); + buildAgentCommandMock.mockReturnValueOnce({ + command: "ssh", + args: ["devbox", "remote claude"], + }); + const runOneShot = vi.fn>().mockResolvedValue("sdk"); + const adapter = { + ...argvProneAdapter(), + runOneShot, + } as AgentAdapter; + + const pending = runOneShotPromptWithFallback({ + location: sshProject, + adapter, + model: "haiku", + effort: undefined, + timeoutMs: 10_000, + logTag: "test", + attempts: [{ level: "full", buildPrompt: () => "remote prompt" }], + }); + await flushPromises(); + + child.stdout.emit("data", Buffer.from("remote ok")); + child.emit("close", 0); + + await expect(pending).resolves.toBe("remote ok"); + expect(runOneShot).not.toHaveBeenCalled(); + expect(buildAgentCommandMock).toHaveBeenCalledWith(sshProject, "claude", [ + "-p", + "remote prompt", + "--model", + "haiku", + ]); + expect(spawnMock).toHaveBeenCalledWith( + "ssh", + ["devbox", "remote claude"], + expect.objectContaining({ stdio: ["pipe", "pipe", "pipe"], windowsHide: true }), + ); + }); + it("proactively skips an attempt whose built argv exceeds the platform budget", async () => { const child = createMockChildProcess(); spawnMock.mockReturnValueOnce(child); diff --git a/src/supervisor/oneShotPromptRunner.ts b/src/supervisor/oneShotPromptRunner.ts index 2e8f562b..7c481972 100644 --- a/src/supervisor/oneShotPromptRunner.ts +++ b/src/supervisor/oneShotPromptRunner.ts @@ -97,7 +97,8 @@ export async function runOneShotPromptWithFallback( throw new Error(`${options.adapter.label} does not support one-shot generation`); } - const useSdkPath = typeof options.adapter.runOneShot === "function"; + const sdkLocation = options.location.kind === "ssh" ? undefined : options.location; + const useSdkPath = sdkLocation !== undefined && typeof options.adapter.runOneShot === "function"; let lastError: unknown; for (let i = 0; i < options.attempts.length; i++) { @@ -105,14 +106,14 @@ export async function runOneShotPromptWithFallback( const prompt = attempt.buildPrompt(); const hasNextAttempt = i < options.attempts.length - 1; - if (useSdkPath) { + if (useSdkPath && sdkLocation) { console.log( `[${options.logTag}] sdk one-shot ${attempt.level} (prompt ${prompt.length} chars)`, ); const signal = wrapTimeoutSignal(options.signal, options.timeoutMs); try { return await options.adapter.runOneShot!({ - location: options.location, + location: sdkLocation, model: options.model, effort: options.effort, prompt, diff --git a/src/supervisor/projectTree.ts b/src/supervisor/projectTree.ts index f0731595..02e58b22 100644 --- a/src/supervisor/projectTree.ts +++ b/src/supervisor/projectTree.ts @@ -27,6 +27,7 @@ import type { } from "@/shared/contracts"; import { getProjectFsPath, joinProjectPosixPath } from "@/shared/wsl"; import { execGit, getLocationIdentity } from "./git"; +import { readSshCommandOutput } from "./ssh"; import type { WslBridgeClient } from "./wsl/bridge/client"; const BOM = Buffer.from([0xef, 0xbb, 0xbf]); @@ -43,6 +44,7 @@ interface CachedSearchIndex { type RawFileRead = | { kind: "tooLarge"; modifiedAtMs: number } | { kind: "ok"; buffer: Buffer; modifiedAtMs: number }; +type SshProjectLocation = Extract; function normalizeRelativePath(input: string): string { const normalized = input.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); @@ -148,6 +150,9 @@ export class ProjectTreeService { this.requireWslClient(), ); } + if (payload.projectLocation.kind === "ssh") { + return this.listProjectTreeSsh(payload.projectLocation, directoryPath); + } const fullPath = this.resolveEntryPath(payload.projectLocation, directoryPath); const entries = await readdir(fullPath, { withFileTypes: true }); @@ -211,6 +216,47 @@ export class ProjectTreeService { return { directoryPath, entries: sortEntries(mapped) }; } + private async listProjectTreeSsh( + location: SshProjectLocation, + directoryPath: string, + ): Promise { + const absolute = joinProjectPosixPath(location, directoryPath); + const script = ` +dir=$1 +for entry in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do + [ -e "$entry" ] || [ -L "$entry" ] || continue + name=\${entry##*/} + [ "$name" = ".git" ] && continue + if [ -d "$entry" ]; then + if find "$entry" -mindepth 1 -maxdepth 1 ! -name .git -print -quit | grep -q .; then + has=1 + else + has=0 + fi + printf 'directory\\0%s\\0%s\\0' "$has" "$name" + else + printf 'file\\0\\0%s\\0' "$name" + fi +done +`; + const result = await readSshCommandOutput(location, "sh", ["-c", script, "sh", absolute]); + const parts = result.stdout.split("\0"); + const entries: ProjectTreeEntry[] = []; + for (let i = 0; i + 2 < parts.length; i += 3) { + const type = parts[i]; + const hasChildren = parts[i + 1] === "1"; + const name = parts[i + 2]; + if (!type || !name) continue; + const path = joinRelativePath(directoryPath, name); + if (type === "directory") { + entries.push({ path, name, type: "directory", hasChildren }); + } else { + entries.push({ path, name, type: "file" }); + } + } + return { directoryPath, entries: sortEntries(entries) }; + } + async searchProjectTree(payload: SearchProjectTreePayload): Promise { const query = payload.query.trim().toLowerCase(); if (!query) return { entries: [] }; @@ -232,7 +278,9 @@ export class ProjectTreeService { path, this.requireWslClient(), ) - : await this.readProjectFileBufferNative(payload.projectLocation, path); + : payload.projectLocation.kind === "ssh" + ? await this.readProjectFileBufferSsh(payload.projectLocation, path) + : await this.readProjectFileBufferNative(payload.projectLocation, path); if (raw.kind === "tooLarge") { return { path, status: "too_large", modifiedAtMs: raw.modifiedAtMs }; @@ -289,6 +337,11 @@ export class ProjectTreeService { linuxPath, this.wslClient, ); + } else if (payload.projectLocation.kind === "ssh") { + raw = await this.readAbsoluteFileBufferSsh( + payload.projectLocation, + this.resolveProjectSshReadPath(payload.projectLocation, payload.absolutePath), + ); } else { raw = await this.readAbsoluteFileBufferNative( this.resolveProjectNativeReadPath(payload.projectLocation, payload.absolutePath), @@ -339,7 +392,9 @@ export class ProjectTreeService { payload.absolutePath, this.requireWslClient(), ) - : await this.readAbsoluteFileBufferNative(payload.absolutePath); + : payload.projectLocation.kind === "ssh" + ? await this.readAbsoluteFileBufferSsh(payload.projectLocation, payload.absolutePath) + : await this.readAbsoluteFileBufferNative(payload.absolutePath); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { return { path: payload.absolutePath, status: "missing", modifiedAtMs: 0 }; @@ -387,6 +442,9 @@ export class ProjectTreeService { if (payload.projectLocation.kind === "wsl") { return this.writeExternalFileWsl(payload.projectLocation, payload, this.requireWslClient()); } + if (payload.projectLocation.kind === "ssh") { + return this.writeExternalFileSsh(payload.projectLocation, payload); + } const fileStat = await stat(payload.absolutePath); if (!fileStat.isFile()) { @@ -436,6 +494,24 @@ export class ProjectTreeService { return { modifiedAtMs: result.mtimeMs }; } + private async writeExternalFileSsh( + location: SshProjectLocation, + payload: WriteExternalFilePayload, + ): Promise { + const existing = await this.readAbsoluteFileBufferSsh(location, payload.absolutePath); + if (existing.kind === "tooLarge") { + throw new Error("This file is too large to save from the editor."); + } + return this.writeSshFile( + location, + payload.absolutePath, + existing.buffer, + payload.content, + payload.baseModifiedAtMs, + existing.modifiedAtMs, + ); + } + /** * External reads/writes are intentionally NOT confined to the project root, * but the WSL bridge still requires every target to sit within a declared @@ -479,6 +555,11 @@ export class ProjectTreeService { return path.startsWith("/") ? posix.resolve(path) : posix.resolve(root, path); } + private resolveProjectSshReadPath(location: SshProjectLocation, path: string): string { + const root = posix.resolve(location.path); + return path.startsWith("/") ? posix.resolve(path) : posix.resolve(root, path); + } + private async readAbsoluteFileBufferNative(absolutePath: string): Promise { if (!isAbsolute(absolutePath)) throw new Error("Path must be absolute."); const fileStat = await stat(absolutePath); @@ -508,6 +589,58 @@ export class ProjectTreeService { }; } + private async readAbsoluteFileBufferSsh( + location: SshProjectLocation, + absolutePath: string, + ): Promise { + const script = ` +path=$1 +max=$2 +if [ ! -e "$path" ]; then + printf 'missing\\n' + exit 0 +fi +if [ ! -f "$path" ]; then + printf 'not_file\\n' + exit 0 +fi +size=$(wc -c < "$path" | tr -d '[:space:]') +mtime=$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null || printf 0) +if [ "$size" -gt "$max" ]; then + printf 'too_large\\t%s\\n' "$mtime" + exit 0 +fi +printf 'ok\\t%s\\n' "$mtime" +base64 "$path" +`; + const result = await readSshCommandOutput( + location, + "sh", + ["-c", script, "sh", absolutePath, String(MAX_EDITABLE_FILE_SIZE)], + { maxBuffer: MAX_EDITABLE_FILE_SIZE * 2 }, + ); + const headerEnd = result.stdout.indexOf("\n"); + const header = headerEnd === -1 ? result.stdout.trim() : result.stdout.slice(0, headerEnd); + const [status, mtimeRaw] = header.split("\t"); + if (status === "missing") { + throw Object.assign(new Error(`ENOENT: no such file or directory, open '${absolutePath}'`), { + code: "ENOENT", + }); + } + if (status === "not_file") { + throw new Error("Only files can be read."); + } + const modifiedAtMs = (Number.parseFloat(mtimeRaw ?? "0") || 0) * 1000; + if (status === "too_large") { + return { kind: "tooLarge", modifiedAtMs }; + } + if (status !== "ok" || headerEnd === -1) { + throw new Error("Unable to read SSH file."); + } + const encoded = result.stdout.slice(headerEnd + 1).replace(/\s/g, ""); + return { kind: "ok", buffer: Buffer.from(encoded, "base64"), modifiedAtMs }; + } + private async readProjectFileBufferNative( location: ProjectLocation, relativePath: string, @@ -540,6 +673,13 @@ export class ProjectTreeService { }; } + private async readProjectFileBufferSsh( + location: SshProjectLocation, + relativePath: string, + ): Promise { + return this.readAbsoluteFileBufferSsh(location, joinProjectPosixPath(location, relativePath)); + } + async writeProjectFile(payload: WriteProjectFilePayload): Promise { const path = normalizeRelativePath(payload.path); @@ -551,6 +691,9 @@ export class ProjectTreeService { this.requireWslClient(), ); } + if (payload.projectLocation.kind === "ssh") { + return this.writeProjectFileSsh(payload.projectLocation, path, payload); + } const { fullPath, fileStat } = await this.statFollowingWslSymlinks( payload.projectLocation, @@ -606,6 +749,76 @@ export class ProjectTreeService { return { modifiedAtMs: result.mtimeMs }; } + private async writeProjectFileSsh( + location: SshProjectLocation, + relativePath: string, + payload: WriteProjectFilePayload, + ): Promise { + const absolute = joinProjectPosixPath(location, relativePath); + const existing = await this.readAbsoluteFileBufferSsh(location, absolute); + if (existing.kind === "tooLarge") { + throw new Error("This file is too large to save from the editor."); + } + const result = await this.writeSshFile( + location, + absolute, + existing.buffer, + payload.content, + payload.baseModifiedAtMs, + existing.modifiedAtMs, + ); + this.invalidateCaches(location); + return result; + } + + private async writeSshFile( + location: SshProjectLocation, + absolutePath: string, + existingBuffer: Buffer, + content: string, + baseModifiedAtMs: number, + existingModifiedAtMs: number, + ): Promise { + if (Math.abs(existingModifiedAtMs - baseModifiedAtMs) > 1) { + throw new Error("The file changed on disk. Reload it before saving."); + } + if (isBinaryBuffer(existingBuffer)) { + throw new Error("Binary files cannot be saved from the editor."); + } + + const nextBuffer = buildWriteBuffer(existingBuffer, content); + const encoded = nextBuffer.toString("base64"); + const expectedMtime = String(Math.round(existingModifiedAtMs / 1000)); + const script = ` +target=$1 +expected=$2 +current=$(stat -c %Y "$target" 2>/dev/null || stat -f %m "$target" 2>/dev/null || printf 0) +if [ "$current" != "$expected" ]; then + echo "The file changed on disk. Reload it before saving." >&2 + exit 3 +fi +parent=\${target%/*} +if [ -n "$parent" ] && [ "$parent" != "$target" ]; then + mkdir -p "$parent" +fi +tmp="$target.lightcode.$$" +trap 'rm -f "$tmp"' EXIT +base64 -d > "$tmp" <<'__LIGHTCODE_FILE__' +${encoded} +__LIGHTCODE_FILE__ +mv "$tmp" "$target" +stat -c %Y "$target" 2>/dev/null || stat -f %m "$target" 2>/dev/null || printf 0 +`; + const result = await readSshCommandOutput(location, "sh", [ + "-c", + script, + "sh", + absolutePath, + expectedMtime, + ]); + return { modifiedAtMs: (Number.parseFloat(result.stdout.trim()) || 0) * 1000 }; + } + async createProjectEntry(payload: CreateProjectEntryPayload): Promise { const path = normalizeRelativePath(payload.path); if (!path) { @@ -627,6 +840,18 @@ export class ProjectTreeService { this.invalidateCaches(payload.projectLocation); return; } + if (payload.projectLocation.kind === "ssh") { + const absolute = joinProjectPosixPath(payload.projectLocation, path); + await readSshCommandOutput(payload.projectLocation, "sh", [ + "-c", + 'parent=${1%/*}; [ -n "$parent" ] && [ "$parent" != "$1" ] && mkdir -p "$parent"; [ ! -e "$1" ] || exit 1; if [ "$2" = directory ]; then mkdir "$1"; else : > "$1"; fi', + "sh", + absolute, + payload.type, + ]); + this.invalidateCaches(payload.projectLocation); + return; + } const fullPath = this.resolveEntryPath(payload.projectLocation, path); await mkdir(dirname(fullPath), { recursive: true }); @@ -653,6 +878,15 @@ export class ProjectTreeService { this.invalidateCaches(payload.projectLocation); return; } + if (payload.projectLocation.kind === "ssh") { + await readSshCommandOutput(payload.projectLocation, "mv", [ + "--", + joinProjectPosixPath(payload.projectLocation, path), + joinProjectPosixPath(payload.projectLocation, nextPath), + ]); + this.invalidateCaches(payload.projectLocation); + return; + } await rename( this.resolveEntryPath(payload.projectLocation, path), @@ -694,6 +928,24 @@ export class ProjectTreeService { this.invalidateCaches(payload.projectLocation); return; } + if (payload.projectLocation.kind === "ssh") { + const sourcePath = joinProjectPosixPath(payload.projectLocation, path); + const targetPath = joinProjectPosixPath(payload.projectLocation, nextPath); + if (nextParentPath === path || nextParentPath.startsWith(`${path}/`)) { + const sourceType = await readSshCommandOutput( + payload.projectLocation, + "sh", + ["-c", 'test -d "$1" && printf d || printf f', "sh", sourcePath], + { maxBuffer: 16 }, + ).catch(() => ({ stdout: "f" })); + if (sourceType.stdout.trim() === "d") { + throw new Error("Folders cannot be moved into themselves."); + } + } + await readSshCommandOutput(payload.projectLocation, "mv", ["--", sourcePath, targetPath]); + this.invalidateCaches(payload.projectLocation); + return; + } const { fullPath: sourceFullPath, fileStat: entryStat } = await this.statFollowingWslSymlinks( payload.projectLocation, @@ -722,6 +974,15 @@ export class ProjectTreeService { this.invalidateCaches(payload.projectLocation); return; } + if (payload.projectLocation.kind === "ssh") { + await readSshCommandOutput(payload.projectLocation, "rm", [ + "-r", + "--", + joinProjectPosixPath(payload.projectLocation, path), + ]); + this.invalidateCaches(payload.projectLocation); + return; + } await rm(this.resolveEntryPath(payload.projectLocation, path), { recursive: true, @@ -840,6 +1101,9 @@ export class ProjectTreeService { return { path: entry.path, name: entry.name, type: "file" }; }); } + if (location.kind === "ssh") { + return this.buildIndexFromWalkSsh(location, ignoreSet); + } const rootPath = getProjectFsPath(location); const stack = [""]; @@ -865,6 +1129,47 @@ export class ProjectTreeService { return results; } + private async buildIndexFromWalkSsh( + location: SshProjectLocation, + ignoreSet: Set, + ): Promise { + // Prune the heavy build/dependency directories at the `find` level so the + // 10MB buffer is not exhausted enumerating (e.g.) node_modules before the + // real source files are reached. `pathHitsIgnoredName` below still applies + // the project's full ignore set to whatever survives. + const pruneExpr = [ + ".git", + "node_modules", + ".next", + "dist", + "build", + ".turbo", + "__pycache__", + ".venv", + ] + .map((name) => `-name '${name}'`) + .join(" -o "); + const script = `find "$1" -mindepth 1 \\( ${pruneExpr} \\) -prune -o -printf '%y\\0%P\\0'`; + const result = await readSshCommandOutput(location, "sh", ["-c", script, "sh", location.path], { + maxBuffer: 10 * 1024 * 1024, + }).catch(() => ({ stdout: "" })); + const parts = result.stdout.split("\0"); + const entries: ProjectTreeEntry[] = []; + for (let i = 0; i + 1 < parts.length && entries.length < MAX_SEARCH_INDEX_SIZE; i += 2) { + const type = parts[i]; + const path = parts[i + 1]?.replace(/\\/g, "/"); + if (!type || !path || pathHitsIgnoredName(path, ignoreSet)) continue; + const slash = path.lastIndexOf("/"); + const name = slash >= 0 ? path.slice(slash + 1) : path; + if (type === "d") { + entries.push({ path, name, type: "directory", hasChildren: true }); + } else { + entries.push({ path, name, type: "file" }); + } + } + return entries; + } + private rankEntries( entries: ProjectTreeEntry[], query: string, diff --git a/src/supervisor/projectWatcher.test.ts b/src/supervisor/projectWatcher.test.ts index 8128f9d9..7fef74be 100644 --- a/src/supervisor/projectWatcher.test.ts +++ b/src/supervisor/projectWatcher.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ProjectWatcher } from "./projectWatcher"; +import { readSshCommandOutput } from "./ssh"; import type { WslBridgeClient, WslLocation } from "./wsl/bridge/client"; +vi.mock("./ssh", () => ({ + readSshCommandOutput: vi.fn<() => Promise<{ stdout: string; stderr: string }>>(), +})); + function makeLocation(linuxPath: string): WslLocation { return { kind: "wsl", @@ -167,3 +172,46 @@ describe("ProjectWatcher WSL worktrees", () => { await watcher.dispose(); }); }); + +describe("ProjectWatcher SSH polling", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("emits tree changes when the remote tree signature changes", async () => { + vi.useFakeTimers(); + const readSsh = vi.mocked(readSshCommandOutput); + readSsh + .mockResolvedValueOnce({ stdout: "sig-a\n", stderr: "" }) + .mockResolvedValueOnce({ stdout: "sig-b\n", stderr: "" }); + const onTreeChanged = vi.fn<(projectId: string) => void>(); + const watcher = new ProjectWatcher({ + onGitChanged: vi.fn<(projectId: string) => void>(), + onTreeChanged, + }); + + watcher.watch("project-ssh", { + kind: "ssh", + host: "dev.example.com", + path: "/home/demo/repo", + }); + + await vi.advanceTimersByTimeAsync(SSH_POLL_MS_FOR_TEST); + expect(onTreeChanged).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(SSH_POLL_MS_FOR_TEST); + await vi.advanceTimersByTimeAsync(300); + + expect(readSsh).toHaveBeenCalledWith( + { kind: "ssh", host: "dev.example.com", path: "/home/demo/repo" }, + "sh", + expect.any(Array), + expect.objectContaining({ timeout: 10_000 }), + ); + expect(onTreeChanged).toHaveBeenCalledWith("project-ssh"); + await watcher.dispose(); + }); +}); + +const SSH_POLL_MS_FOR_TEST = 3_000; diff --git a/src/supervisor/projectWatcher.ts b/src/supervisor/projectWatcher.ts index bec99ad4..5641dc30 100644 --- a/src/supervisor/projectWatcher.ts +++ b/src/supervisor/projectWatcher.ts @@ -2,9 +2,11 @@ import { watch, type FSWatcher } from "node:fs"; import { join } from "node:path"; import type { ProjectLocation } from "@/shared/contracts"; import { toWslUncPath } from "@/shared/wsl"; +import { readSshCommandOutput, type SshLocation } from "./ssh"; import type { WslBridgeClient, WslLocation } from "./wsl/bridge/client"; const DEBOUNCE_MS = 300; +const SSH_POLL_MS = 3_000; export interface ProjectWatcherCallbacks { /** Fires when `.git` metadata (refs, index, HEAD, worktrees/*) changes. */ @@ -22,6 +24,8 @@ interface WatcherEntry { wslUnsubscribe: WslUnsubscribe | null; gitDebounceTimer: ReturnType | null; treeDebounceTimer: ReturnType | null; + sshPollTimer: ReturnType | null; + sshTreeSignature?: string; projectId: string; location: ProjectLocation; wslSchedule?: WslSchedule; @@ -32,6 +36,8 @@ interface WorktreeWatcherEntry { wslUnsubscribe: WslUnsubscribe | null; gitDebounceTimer: ReturnType | null; treeDebounceTimer: ReturnType | null; + sshPollTimer: ReturnType | null; + sshTreeSignature?: string; projectId: string; wslLocation?: WslLocation; wslLinuxPath?: string; @@ -119,6 +125,33 @@ async function readGitignoreTopLevelDirs( } } +async function readSshTreeSignature(location: SshLocation): Promise { + const prune = [ + "./.git", + "./node_modules", + "./.next", + "./dist", + "./build", + "./.turbo", + "./__pycache__", + "./.venv", + ] + .map((path) => `-path '${path}'`) + .join(" -o "); + const script = [ + `find . \\( ${prune} \\) -prune -o -type f -printf '%T@ %s %p\\n' 2>/dev/null`, + "sort", + "sha256sum", + "awk '{print $1}'", + ].join(" | "); + const out = await readSshCommandOutput(location, "sh", ["-lc", script], { + timeout: 10_000, + maxBuffer: 1024 * 1024, + }).catch(() => undefined); + const signature = out?.stdout.trim().split(/\s+/)[0]; + return signature || undefined; +} + /** * Watches a project for filesystem changes and emits debounced notifications * on two channels: `.git` metadata and the working tree. @@ -153,6 +186,7 @@ export class ProjectWatcher { wslUnsubscribe: null, gitDebounceTimer: null, treeDebounceTimer: null, + sshPollTimer: null, projectId, location, }; @@ -179,6 +213,12 @@ export class ProjectWatcher { return; } + if (location.kind === "ssh") { + this.watchers.set(projectId, entry); + this.startSshTreePolling(entry, location, location.path, scheduleTreeNotify); + return; + } + const repoPath = location.path; const gitDir = join(repoPath, ".git"); @@ -243,6 +283,7 @@ export class ProjectWatcher { wslUnsubscribe: null, gitDebounceTimer: null, treeDebounceTimer: null, + sshPollTimer: null, projectId, }; @@ -270,6 +311,12 @@ export class ProjectWatcher { continue; } + if (location?.kind === "ssh") { + this.worktreeWatchers.set(wtPath, entry); + this.startSshWorktreePolling(entry, location, wtPath, scheduleTreeNotify); + continue; + } + try { entry.watcher = watch(wtPath, { recursive: true }, (_eventType, filename) => { if (filename) { @@ -290,12 +337,62 @@ export class ProjectWatcher { } } + private startSshTreePolling( + entry: WatcherEntry, + location: Extract, + path: string, + scheduleTreeNotify: () => void, + ): void { + const poll = async () => { + const signature = await readSshTreeSignature({ kind: "ssh", host: location.host, path }); + // Identity check (not just `.has`): if this project was unwatched and + // re-watched while we awaited, a new entry replaced ours — stop here so + // the stale poll loop doesn't re-arm itself and leak forever. + if (this.watchers.get(entry.projectId) !== entry) return; + if (signature) { + if (entry.sshTreeSignature === undefined) { + entry.sshTreeSignature = signature; + } else if (entry.sshTreeSignature !== signature) { + entry.sshTreeSignature = signature; + scheduleTreeNotify(); + } + } + entry.sshPollTimer = setTimeout(poll, SSH_POLL_MS); + }; + entry.sshPollTimer = setTimeout(poll, SSH_POLL_MS); + } + + private startSshWorktreePolling( + entry: WorktreeWatcherEntry, + location: Extract, + path: string, + scheduleTreeNotify: () => void, + ): void { + const poll = async () => { + const signature = await readSshTreeSignature({ kind: "ssh", host: location.host, path }); + // Identity check (not just `.has`): a re-watch may have replaced this + // worktree entry while we awaited; bail so the stale loop stops re-arming. + if (this.worktreeWatchers.get(path) !== entry) return; + if (signature) { + if (entry.sshTreeSignature === undefined) { + entry.sshTreeSignature = signature; + } else if (entry.sshTreeSignature !== signature) { + entry.sshTreeSignature = signature; + scheduleTreeNotify(); + } + } + entry.sshPollTimer = setTimeout(poll, SSH_POLL_MS); + }; + entry.sshPollTimer = setTimeout(poll, SSH_POLL_MS); + } + /** Stop watching a project and its worktrees. */ async unwatch(projectId: string): Promise { const entry = this.watchers.get(projectId); if (entry) { if (entry.gitDebounceTimer) clearTimeout(entry.gitDebounceTimer); if (entry.treeDebounceTimer) clearTimeout(entry.treeDebounceTimer); + if (entry.sshPollTimer) clearTimeout(entry.sshPollTimer); entry.gitWatcher?.close(); entry.workTreeWatcher?.close(); if (entry.wslUnsubscribe) { @@ -370,6 +467,7 @@ export class ProjectWatcher { if (!entry) return; if (entry.gitDebounceTimer) clearTimeout(entry.gitDebounceTimer); if (entry.treeDebounceTimer) clearTimeout(entry.treeDebounceTimer); + if (entry.sshPollTimer) clearTimeout(entry.sshPollTimer); entry.watcher?.close(); if (entry.wslUnsubscribe) { await entry.wslUnsubscribe().catch(() => undefined); diff --git a/src/supervisor/runtime.ts b/src/supervisor/runtime.ts index 31022f06..2abada13 100644 --- a/src/supervisor/runtime.ts +++ b/src/supervisor/runtime.ts @@ -18,6 +18,8 @@ import type { DeleteProjectEntryPayload, DetectSetupScriptPayload, DetectSetupScriptResult, + CheckSshProjectConnectionPayload, + CheckSshProjectConnectionResult, ExtractContextPayload, ExtractContextResult, FinalizeFileCheckpointPayload, @@ -194,6 +196,7 @@ import { LanguageServerManager } from "./lsp"; import { ProjectTreeService } from "./projectTree"; import { generatePrSummary } from "./prSummaryGenerator"; import { detectWindowsShell, type WindowsShellPreference } from "./shellPreference"; +import { readSshCommandOutput } from "./ssh"; import { generateTitle } from "./titleGenerator"; import { AgentStatusService, detectWslAgentStatuses } from "./runtime/agentStatusService"; import { createLocalUsageCollectors } from "./runtime/localUsageCollectors"; @@ -397,7 +400,10 @@ export class SupervisorRuntime { readDisableCliHookPlugin: () => this.sharedSettingsCache.read().disableCliHookPlugin, adapters: this.adapters, windowsShell: this.windowsShell, - ...(this.wslHookBridge ? { wslBridge: this.wslHookBridge } : {}), + browserMcpBridge: { + ensureBridge: (distro) => + this.wslHookBridge?.ensureBridge(distro) ?? Promise.resolve(undefined), + }, resolvePluginEnvForSpawn: (input) => this.cliHookPluginCoordinator.resolvePluginEnvForSpawn(input), }); @@ -1340,6 +1346,19 @@ export class SupervisorRuntime { return {}; } + if (location.kind === "ssh") { + for (const candidate of candidates) { + const found = await readSshCommandOutput(location, "test", [ + "-f", + joinProjectPosixPath(location, candidate.file), + ]) + .then(() => true) + .catch(() => false); + if (found) return { setupScript: candidate.command }; + } + return {}; + } + const dir = location.path; for (const candidate of candidates) { if (existsSync(join(dir, candidate.file))) { @@ -1349,6 +1368,31 @@ export class SupervisorRuntime { return {}; } + async checkSshProjectConnection( + payload: CheckSshProjectConnectionPayload, + ): Promise { + const location = payload.projectLocation; + if (location.kind !== "ssh") { + return { ok: false, message: "Project is not an SSH project." }; + } + const startedAt = Date.now(); + try { + await readSshCommandOutput(location, "sh", ["-lc", 'test -d "$PWD" && printf %s "$PWD"']); + return { + ok: true, + message: "Connected", + latencyMs: Date.now() - startedAt, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + message: message || "Unable to connect.", + latencyMs: Date.now() - startedAt, + }; + } + } + async lspStart(payload: LspStartPayload): Promise { await this.lspManager.start(payload); } diff --git a/src/supervisor/runtime/agentStatusCache.test.ts b/src/supervisor/runtime/agentStatusCache.test.ts index 8c3fa817..c3f30c8f 100644 --- a/src/supervisor/runtime/agentStatusCache.test.ts +++ b/src/supervisor/runtime/agentStatusCache.test.ts @@ -103,6 +103,7 @@ describe("agent status cache", () => { readCachedStatuses: (wslDistros: readonly string[]) => { windows: unknown[]; wsl: unknown[]; + ssh: unknown[]; fromCache: boolean; }; } @@ -166,6 +167,7 @@ describe("agent status cache", () => { }, ], wsl: [], + ssh: [], }); }); @@ -210,6 +212,7 @@ describe("agent status cache", () => { readCachedStatuses: (wslDistros: readonly string[]) => { windows: AgentStatus[]; wsl: AgentStatus[]; + ssh: AgentStatus[]; fromCache: boolean; }; } @@ -237,27 +240,7 @@ describe("detectWslAgentStatuses", () => { }; it("detects statuses for every adapter in every distro", async () => { - const detectInstall = vi.fn< - (ctx?: { envKind: "windows" | "wsl"; wslDistro?: string }) => Promise<{ - kind: "codex"; - label: string; - installed: boolean; - authState: "unknown"; - capabilities: { - models: []; - efforts: []; - modelEfforts: {}; - modes: []; - approvalPolicies: []; - sandboxModes: []; - supportsResume: true; - supportsDirectInput: true; - liveInputMode: "server"; - presentationMode: "terminal"; - settingDefs: []; - }; - }> - >(async (ctx?: { envKind: "windows" | "wsl"; wslDistro?: string }) => ({ + const detectInstall = vi.fn(async (ctx) => ({ kind: "codex" as const, label: `Codex ${ctx?.wslDistro ?? "windows"}`, installed: ctx?.wslDistro === "Ubuntu", diff --git a/src/supervisor/runtime/agentStatusService.test.ts b/src/supervisor/runtime/agentStatusService.test.ts index 70dca6b9..85db1b35 100644 --- a/src/supervisor/runtime/agentStatusService.test.ts +++ b/src/supervisor/runtime/agentStatusService.test.ts @@ -270,4 +270,68 @@ HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\{333} expect.objectContaining({ kind: "codex", envKind: "wsl", envDistro: "Ubuntu" }), ); }); + + it("streams SSH agent detection events during full detection", async () => { + const detectInstall = vi.fn().mockResolvedValue(makeStatus()); + const adapter = makeAdapter("codex", "Codex", detectInstall); + const { service, emit } = makeMultiAdapterService([adapter]); + + await service.refreshAgentStatuses({ + wslDistros: [], + sshProjects: [{ kind: "ssh", host: "devbox", path: "/repo" }], + }); + + expect(detectInstall).toHaveBeenCalledWith({ + envKind: "ssh", + sshHost: "devbox", + sshPath: "/repo", + }); + const detected = emit.mock.calls + .map(([event]) => event) + .filter((event) => event.type === "agent-detected") + .map((event) => event.status); + expect(detected).toContainEqual( + expect.objectContaining({ kind: "codex", envKind: "ssh", envHost: "devbox" }), + ); + const sshStatus = detected.find((status) => status.envKind === "ssh"); + expect(sshStatus?.capabilities).toMatchObject({ + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal"], + supportsResume: false, + }); + }); + + it("scoped refresh preserves cached agents for environments outside the scope", async () => { + const detectInstall = vi.fn().mockResolvedValue(makeStatus()); + const adapter = makeAdapter("codex", "Codex", detectInstall); + const { service } = makeMultiAdapterService([adapter]); + + const sshProjects = [{ kind: "ssh" as const, host: "devbox", path: "/repo" }]; + + // Seed a full detection covering native + WSL + SSH so the cache has all + // three environment buckets populated. + await service.refreshAgentStatuses({ wslDistros: ["Ubuntu"], sshProjects }); + const seeded = await service.getAgentStatuses({ wslDistros: ["Ubuntu"], sshProjects }); + expect(seeded.ssh.some((s) => s.envHost === "devbox")).toBe(true); + expect(seeded.wsl.some((s) => s.envDistro === "Ubuntu")).toBe(true); + + // A scoped refresh that omits sshProjects must not drop the cached SSH agents. + await service.refreshAgentStatuses({ + wslDistros: ["Ubuntu"], + scope: { agentKinds: ["codex"] }, + }); + const afterWslScope = await service.getAgentStatuses({ wslDistros: ["Ubuntu"], sshProjects }); + expect(afterWslScope.ssh.some((s) => s.envHost === "devbox")).toBe(true); + + // A scoped refresh that omits wslDistros must not drop the cached WSL agents. + await service.refreshAgentStatuses({ + wslDistros: [], + sshProjects, + scope: { agentKinds: ["codex"] }, + }); + const afterSshScope = await service.getAgentStatuses({ wslDistros: ["Ubuntu"], sshProjects }); + expect(afterSshScope.wsl.some((s) => s.envDistro === "Ubuntu")).toBe(true); + expect(afterSshScope.ssh.some((s) => s.envHost === "devbox")).toBe(true); + }); }); diff --git a/src/supervisor/runtime/agentStatusService.ts b/src/supervisor/runtime/agentStatusService.ts index 8a85d137..d0ccf8d9 100644 --- a/src/supervisor/runtime/agentStatusService.ts +++ b/src/supervisor/runtime/agentStatusService.ts @@ -10,6 +10,7 @@ import { type AgentStatus, type AgentStatusesResponse, type GetAgentStatusesPayload, + type ProjectLocation, type RefreshAgentScope, type RefreshAgentScopeEnv, } from "@/shared/contracts"; @@ -35,6 +36,7 @@ const execFileAsync = promisify(execFile); */ export const STATUS_CACHE_VERSION = 3; const WSL_AGENT_DETECTION_TIMEOUT_MS = 60_000; +const SSH_AGENT_DETECTION_TIMEOUT_MS = 60_000; const WSL_LXSS_REGISTRY_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"; function migrateSettingDef(definition: Record): Record { @@ -110,30 +112,71 @@ function filterWslStatusesForDistros( ); } +function uniqueSshProjects( + locations: readonly Extract[], +): Extract[] { + const byHost = new Map>(); + for (const location of locations) { + if (!byHost.has(location.host)) byHost.set(location.host, location); + } + return [...byHost.values()]; +} + +function filterSshStatusesForHosts( + statuses: readonly AgentStatus[], + locations: readonly Extract[], +): AgentStatus[] { + if (locations.length === 0) { + return []; + } + const hosts = new Set(locations.map((location) => location.host)); + return statuses.filter((status) => status.envHost !== undefined && hosts.has(status.envHost)); +} + +function asSshTerminalStatus(status: AgentStatus, host: string): AgentStatus { + return { + ...status, + envKind: "ssh", + envHost: host, + capabilities: { + ...status.capabilities, + liveInputMode: "terminal", + presentationMode: "terminal", + presentationModes: ["terminal"], + supportsResume: false, + }, + }; +} + function statusEnvKey(status: AgentStatus): string { - return `${status.kind}|${status.envKind ?? ""}|${status.envDistro ?? ""}`; + return `${status.kind}|${status.envKind ?? ""}|${status.envDistro ?? ""}|${status.envHost ?? ""}`; } function mergeScopedStatuses( existingWindows: readonly AgentStatus[], existingWsl: readonly AgentStatus[], + existingSsh: readonly AgentStatus[], probed: readonly AgentStatus[], -): { windows: AgentStatus[]; wsl: AgentStatus[] } { +): { windows: AgentStatus[]; wsl: AgentStatus[]; ssh: AgentStatus[] } { const byKey = new Map(); for (const status of existingWindows) byKey.set(statusEnvKey(status), status); for (const status of existingWsl) byKey.set(statusEnvKey(status), status); + for (const status of existingSsh) byKey.set(statusEnvKey(status), status); for (const status of probed) byKey.set(statusEnvKey(status), status); const windows: AgentStatus[] = []; const wsl: AgentStatus[] = []; + const ssh: AgentStatus[] = []; for (const status of byKey.values()) { if (status.envKind === "wsl") { wsl.push(status); + } else if (status.envKind === "ssh") { + ssh.push(status); } else { windows.push(status); } } - return { windows, wsl }; + return { windows, wsl, ssh }; } export async function detectWslAgentStatuses( @@ -206,6 +249,82 @@ export async function detectWslAgentStatuses( return statuses.flat(); } +export async function detectSshAgentStatuses( + adapters: Iterable, + locations: readonly Extract[], + disabled?: ReadonlySet, + onStatus?: (status: AgentStatus) => void, +): Promise { + const adapterList = [...adapters]; + const statuses = await Promise.all( + uniqueSshProjects(locations).map(async (location) => { + const ctx: AgentEnvContext = { + envKind: "ssh", + sshHost: location.host, + sshPath: location.path, + }; + return Promise.all( + adapterList.map(async (adapter) => { + let status: AgentStatus; + if (disabled?.has(adapter.kind)) { + status = asSshTerminalStatus( + { + kind: adapter.kind, + label: adapter.label, + installed: true, + authState: "unknown" as const, + capabilities: adapter.capabilities, + ...(adapter.update ? { update: adapter.update } : {}), + }, + location.host, + ); + } else { + try { + let timeout: NodeJS.Timeout | undefined; + const detected = await Promise.race([ + adapter.detectInstall(ctx), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject( + new Error( + `detectInstall(${adapter.kind}, ssh:${location.host}) timed out after ${SSH_AGENT_DETECTION_TIMEOUT_MS}ms`, + ), + ); + }, SSH_AGENT_DETECTION_TIMEOUT_MS); + if (typeof timeout.unref === "function") timeout.unref(); + }), + ]).finally(() => { + if (timeout) clearTimeout(timeout); + }); + status = asSshTerminalStatus(detected, location.host); + } catch (error) { + console.error( + `[supervisor] detectInstall(${adapter.kind}, ssh:${location.host}) failed`, + error, + ); + status = asSshTerminalStatus( + { + kind: adapter.kind, + label: adapter.label, + installed: false, + authState: "unknown" as const, + capabilities: adapter.capabilities, + ...(adapter.update ? { update: adapter.update } : {}), + }, + location.host, + ); + } + } + onStatus?.(status); + return status; + }), + ); + }), + ); + + return statuses.flat(); +} + export interface AgentStatusServiceOptions { adapters: Map; settingsPath: string; @@ -216,6 +335,7 @@ export interface AgentStatusServiceOptions { interface DetectionResults { windows: AgentStatus[]; wsl: AgentStatus[]; + ssh: AgentStatus[]; } export function parseWslRegistryDistributionNames(stdout: string): string[] { @@ -235,6 +355,7 @@ export class AgentStatusService { private pendingDetection: Promise | undefined; private startupDetectionLaunched = false; private startupDetectionWslDistros = new Set(); + private startupDetectionSshHosts = new Set(); constructor(private readonly options: AgentStatusServiceOptions) {} @@ -261,13 +382,15 @@ export class AgentStatusService { async getAgentStatuses(payload: GetAgentStatusesPayload): Promise { const wslDistros = [...new Set(payload.wslDistros)]; - const cached = this.readCachedStatuses(wslDistros); - this.detectStartupAgentStatusesBackground(wslDistros); + const sshProjects = uniqueSshProjects(payload.sshProjects ?? []); + const cached = this.readCachedStatuses(wslDistros, sshProjects); + this.detectStartupAgentStatusesBackground(wslDistros, sshProjects); return cached; } async refreshAgentStatuses(payload: GetAgentStatusesPayload): Promise { const wslDistros = [...new Set(payload.wslDistros)]; + const sshProjects = uniqueSshProjects(payload.sshProjects ?? []); // An explicit refresh is the signal that something changed on disk (an // install/update just ran), so bypass the binary-path TTL cache and re-read // PATH (including the registry-backed Windows user/machine PATH) fresh. @@ -276,18 +399,20 @@ export class AgentStatusService { // since enabled/disabled it); the next capabilities probe repopulates it. void clearFastModeCache(); if (payload.scope) { - return this.runScopedDetection(wslDistros, payload.scope); + return this.runScopedDetection(wslDistros, sshProjects, payload.scope); } this.startupDetectionLaunched = true; for (const distro of wslDistros) { this.startupDetectionWslDistros.add(distro); } + for (const project of sshProjects) { + this.startupDetectionSshHosts.add(project.host); + } const previousDetection = this.pendingDetection; const fresh = await this.runDetectionTask(async () => { - if (previousDetection) { - await previousDetection.catch(() => ({ windows: [], wsl: [] })); - } - return this.runDetection(wslDistros); + if (previousDetection) + await previousDetection.catch(() => ({ windows: [], wsl: [], ssh: [] })); + return this.runDetection(wslDistros, sshProjects); }); return { ...fresh, fromCache: false }; } @@ -305,15 +430,16 @@ export class AgentStatusService { */ private async runScopedDetection( wslDistros: readonly string[], + sshProjects: readonly Extract[], scope: RefreshAgentScope, ): Promise { - const existing = this.readCachedStatuses(wslDistros); + const existing = this.readCachedStatuses(wslDistros, sshProjects); // Without a baseline cache we have no merge target — fall back to a full // detection so the renderer ends up with a complete list. Callers // typically hit this path well after startup, so this is rare. if (!existing.fromCache) { this.startupDetectionLaunched = true; - const fresh = await this.runDetectionTask(() => this.runDetection(wslDistros)); + const fresh = await this.runDetectionTask(() => this.runDetection(wslDistros, sshProjects)); return { ...fresh, fromCache: false }; } @@ -323,7 +449,7 @@ export class AgentStatusService { .map((kind) => adapterByKind.get(kind)) .filter((adapter): adapter is AgentAdapter => adapter !== undefined); - const targetEnvs = this.resolveScopedEnvs(scope.envs, wslDistros); + const targetEnvs = this.resolveScopedEnvs(scope.envs, wslDistros, sshProjects); const disabled = this.readDisabledAgents(); const probed = await Promise.all( @@ -336,14 +462,53 @@ export class AgentStatusService { this.options.emit({ type: "agent-status-updated", status }); } - const merged = mergeScopedStatuses(existing.windows, existing.wsl, probed); - this.writeDiskCache(merged.windows, merged.wsl); + const merged = mergeScopedStatuses(existing.windows, existing.wsl, existing.ssh, probed); + + // Persist into the FULL on-disk cache, not the scope-filtered `existing`. + // `readCachedStatuses` filters `wsl`/`ssh` down to the requested + // distros/hosts, so callers that pass a partial list (e.g. an SSH-only + // refresh with `wslDistros: []`, or a WSL/native refresh that omits + // `sshProjects`) would otherwise rewrite the other environment's bucket as + // empty and wipe its cached agents. Merging the probe into the unfiltered + // cache keeps environments outside this scope intact. + const fullCache = this.readRawCachedStatuses() ?? { windows: [], wsl: [], ssh: [] }; + const persisted = mergeScopedStatuses(fullCache.windows, fullCache.wsl, fullCache.ssh, probed); + this.writeDiskCache(persisted.windows, persisted.wsl, persisted.ssh); return { ...merged, fromCache: false }; } + /** + * Reads and parses the raw on-disk cache lists without filtering by the + * currently-requested distros/hosts. Used as the merge base when persisting + * a scoped detection so unrelated environments are preserved. Returns + * `undefined` when no cache exists or its version is stale. + */ + private readRawCachedStatuses(): DetectionResults | undefined { + try { + const raw = readFileSync(this.options.statusCachePath, "utf8"); + const cache = JSON.parse(raw) as { + version?: number; + windows?: unknown[]; + wsl?: unknown[]; + ssh?: unknown[]; + }; + if (cache.version !== STATUS_CACHE_VERSION) { + return undefined; + } + return { + windows: parseCachedStatuses(cache.windows), + wsl: parseCachedStatuses(cache.wsl), + ssh: parseCachedStatuses(cache.ssh), + }; + } catch { + return undefined; + } + } + private resolveScopedEnvs( envs: RefreshAgentScope["envs"], wslDistros: readonly string[], + sshProjects: readonly Extract[], ): RefreshAgentScopeEnv[] { if (envs && envs.length > 0) { return envs; @@ -352,6 +517,11 @@ export class AgentStatusService { return [ nativeEnv, ...wslDistros.map((distro) => ({ kind: "wsl", distro })), + ...sshProjects.map((project) => ({ + kind: "ssh", + host: project.host, + path: project.path, + })), ]; } @@ -361,12 +531,18 @@ export class AgentStatusService { disabled: ReadonlySet, ): Promise { const isWsl = env.kind === "wsl"; + const isSsh = env.kind === "ssh"; const nativeEnvKind: "windows" | "posix" = process.platform === "win32" ? "windows" : "posix"; - const envKind: "windows" | "posix" | "wsl" = isWsl ? "wsl" : nativeEnvKind; + const envKind: "windows" | "posix" | "wsl" | "ssh" = isSsh + ? "ssh" + : isWsl + ? "wsl" + : nativeEnvKind; const envDistro = isWsl ? env.distro : undefined; + const envHost = isSsh ? env.host : undefined; if (disabled.has(adapter.kind)) { - return { + const status: AgentStatus = { kind: adapter.kind, label: adapter.label, installed: true, @@ -375,22 +551,28 @@ export class AgentStatusService { ...(adapter.update ? { update: adapter.update } : {}), envKind, ...(envDistro ? { envDistro } : {}), + ...(envHost ? { envHost } : {}), }; + return isSsh && envHost ? asSshTerminalStatus(status, envHost) : status; } - const ctx: AgentEnvContext | undefined = isWsl - ? { envKind: "wsl", wslDistro: env.distro } - : undefined; + const ctx: AgentEnvContext | undefined = isSsh + ? { envKind: "ssh", sshHost: env.host, sshPath: env.path } + : isWsl + ? { envKind: "wsl", wslDistro: env.distro } + : undefined; try { const detected = ctx ? await adapter.detectInstall(ctx) : await adapter.detectInstall(); - return { + const status: AgentStatus = { ...detected, envKind, ...(envDistro ? { envDistro } : {}), + ...(envHost ? { envHost } : {}), }; + return isSsh && envHost ? asSshTerminalStatus(status, envHost) : status; } catch (error) { - const where = isWsl ? `wsl:${env.distro}` : "native"; + const where = isSsh ? `ssh:${env.host}` : isWsl ? `wsl:${env.distro}` : "native"; console.error(`[supervisor] scoped detectInstall(${adapter.kind}, ${where}) failed`, error); - return { + const status: AgentStatus = { kind: adapter.kind, label: adapter.label, installed: false, @@ -399,7 +581,9 @@ export class AgentStatusService { ...(adapter.update ? { update: adapter.update } : {}), envKind, ...(envDistro ? { envDistro } : {}), + ...(envHost ? { envHost } : {}), }; + return isSsh && envHost ? asSshTerminalStatus(status, envHost) : status; } } @@ -413,13 +597,17 @@ export class AgentStatusService { * event) avoids a startup race where the ThreadDraft renders "No supported * agents detected" before the cache event is received. */ - private readCachedStatuses(wslDistros: readonly string[]): AgentStatusesResponse { + private readCachedStatuses( + wslDistros: readonly string[], + sshProjects: readonly Extract[] = [], + ): AgentStatusesResponse { try { const raw = readFileSync(this.options.statusCachePath, "utf8"); const cache = JSON.parse(raw) as { version?: number; windows?: unknown[]; wsl?: unknown[]; + ssh?: unknown[]; }; // Cache version is bumped whenever derived fields like `loginCommand` @@ -428,7 +616,7 @@ export class AgentStatusService { // hand back pre-bump values that no longer match what fresh detection // would compute. if (cache.version !== STATUS_CACHE_VERSION) { - return { windows: [], wsl: [], fromCache: false }; + return { windows: [], wsl: [], ssh: [], fromCache: false }; } const windows = parseCachedStatuses(cache.windows) @@ -437,10 +625,13 @@ export class AgentStatusService { const wsl = filterWslStatusesForDistros(parseCachedStatuses(cache.wsl), wslDistros).map( (status) => this.withCachedCapabilityDefaults(status), ); + const ssh = filterSshStatusesForHosts(parseCachedStatuses(cache.ssh), sshProjects).map( + (status) => this.withCachedCapabilityDefaults(status), + ); - return { windows, wsl, fromCache: true }; + return { windows, wsl, ssh, fromCache: true }; } catch { - return { windows: [], wsl: [], fromCache: false }; + return { windows: [], wsl: [], ssh: [], fromCache: false }; } } @@ -466,7 +657,7 @@ export class AgentStatusService { }; } - private writeDiskCache(windows: AgentStatus[], wsl: AgentStatus[]): void { + private writeDiskCache(windows: AgentStatus[], wsl: AgentStatus[], ssh: AgentStatus[]): void { try { writeFileSync( this.options.statusCachePath, @@ -474,6 +665,7 @@ export class AgentStatusService { version: STATUS_CACHE_VERSION, windows, wsl, + ssh, savedAt: new Date().toISOString(), }), "utf8", @@ -503,28 +695,45 @@ export class AgentStatusService { return pending; } - private detectStartupAgentStatusesBackground(wslDistros: readonly string[]): void { + private detectStartupAgentStatusesBackground( + wslDistros: readonly string[], + sshProjects: readonly Extract[], + ): void { const newWslDistros = wslDistros.filter( (distro) => !this.startupDetectionWslDistros.has(distro), ); - if (this.startupDetectionLaunched && newWslDistros.length === 0) { + const newSshProjects = sshProjects.filter( + (project) => !this.startupDetectionSshHosts.has(project.host), + ); + if ( + this.startupDetectionLaunched && + newWslDistros.length === 0 && + newSshProjects.length === 0 + ) { return; } this.startupDetectionLaunched = true; for (const distro of newWslDistros) { this.startupDetectionWslDistros.add(distro); } + for (const project of newSshProjects) { + this.startupDetectionSshHosts.add(project.host); + } const detectionWslDistros = [...this.startupDetectionWslDistros]; + const detectionSshProjects = uniqueSshProjects([...sshProjects]); const previousDetection = this.pendingDetection; void this.runDetectionTask(async () => { if (previousDetection) { - await previousDetection.catch(() => ({ windows: [], wsl: [] })); + await previousDetection.catch(() => ({ windows: [], wsl: [], ssh: [] })); } - return this.runDetection(detectionWslDistros); + return this.runDetection(detectionWslDistros, detectionSshProjects); }); } - private async runDetection(wslDistros: readonly string[]): Promise { + private async runDetection( + wslDistros: readonly string[], + sshProjects: readonly Extract[], + ): Promise { const adapters = [...this.options.adapters.values()]; const disabled = this.readDisabledAgents(); @@ -599,9 +808,27 @@ export class AgentStatusService { return [] as AgentStatus[]; }); - const [nativeResult, wslResult] = await Promise.allSettled([nativePromise, wslPromise]); + const sshPromise = detectSshAgentStatuses(adapters, sshProjects, disabled, (status) => { + this.options.emit({ type: "agent-detected", status }); + }) + .then((statuses) => { + this.options.emit({ type: "ssh-agent-statuses", statuses }); + return statuses; + }) + .catch((error) => { + console.error("[supervisor] detectSshAgentStatuses failed", error); + this.options.emit({ type: "ssh-agent-statuses", statuses: [] }); + return [] as AgentStatus[]; + }); + + const [nativeResult, wslResult, sshResult] = await Promise.allSettled([ + nativePromise, + wslPromise, + sshPromise, + ]); const nativeStatuses = nativeResult.status === "fulfilled" ? nativeResult.value : []; const wslStatuses = wslResult.status === "fulfilled" ? wslResult.value : []; + const sshStatuses = sshResult.status === "fulfilled" ? sshResult.value : []; // Native detection may have thrown before emitting — ensure the renderer // always gets a terminal windows-agent-statuses event. @@ -613,8 +840,11 @@ export class AgentStatusService { if (wslDistros.length === 0) { this.options.emit({ type: "wsl-agent-statuses", statuses: [] }); } + if (sshProjects.length === 0) { + this.options.emit({ type: "ssh-agent-statuses", statuses: [] }); + } - this.writeDiskCache(nativeStatuses, wslStatuses); - return { windows: nativeStatuses, wsl: wslStatuses }; + this.writeDiskCache(nativeStatuses, wslStatuses, sshStatuses); + return { windows: nativeStatuses, wsl: wslStatuses, ssh: sshStatuses }; } } diff --git a/src/supervisor/runtime/cliHookPluginCoordinator.test.ts b/src/supervisor/runtime/cliHookPluginCoordinator.test.ts index 15bbf50d..7a25c985 100644 --- a/src/supervisor/runtime/cliHookPluginCoordinator.test.ts +++ b/src/supervisor/runtime/cliHookPluginCoordinator.test.ts @@ -715,6 +715,30 @@ describe("CliHookPluginCoordinator install cache", () => { expect(resolved).toBeUndefined(); }); + it("skips CLI hook plugins for SSH launches", async () => { + const stub = makeStubAdapter("claude"); + stub.isPluginInstalled.mockResolvedValue({ installed: true, version: "1.0.0" }); + + coordinator = new CliHookPluginCoordinator( + { + adapters: new Map([["claude", stub.adapter]]), + settingsPath, + envContext: () => ({ envKind: "ssh", sshHost: "devbox", sshPath: "/repo" }), + }, + () => undefined, + ); + + const resolved = await coordinator.resolvePluginEnvForSpawn({ + threadId: "t-ssh", + agentKind: "claude", + projectLocation: { kind: "ssh", host: "devbox", path: "/repo" }, + }); + + expect(resolved).toBeUndefined(); + expect(stub.isPluginInstalled).not.toHaveBeenCalled(); + expect(stub.installPlugin).not.toHaveBeenCalled(); + }); + it("explicitly installs codex and writes a cache entry", async () => { const stub = makeStubAdapter("codex"); stub.isPluginInstalled.mockResolvedValue({ installed: true, version: "1.0.0" }); diff --git a/src/supervisor/runtime/cliHookPluginCoordinator.ts b/src/supervisor/runtime/cliHookPluginCoordinator.ts index e512c8b6..b8572f16 100644 --- a/src/supervisor/runtime/cliHookPluginCoordinator.ts +++ b/src/supervisor/runtime/cliHookPluginCoordinator.ts @@ -15,6 +15,7 @@ import type { } from "@/shared/contracts"; import { type AgentAdapter, + type AgentCliHookEnvContext, type AgentEnvContext, type AgentCliHookPluginSupport, resolveWslHomeDirectoryAsync, @@ -208,8 +209,13 @@ export class CliHookPluginCoordinator { if (!isTerminalLiveInput(adapter)) { return undefined; } - - const ctx = this.envContext(input.agentKind, input.projectLocation); + if (input.projectLocation?.kind === "ssh") { + return undefined; + } + const ctx = toCliHookEnvContext(this.envContext(input.agentKind, input.projectLocation)); + if (!ctx) { + return undefined; + } if (input.browserMcpEnabled !== undefined) ctx.browserMcpEnabled = input.browserMcpEnabled; if (input.browserMcp) ctx.browserMcp = input.browserMcp; const outcome = await this.ensureInstalledOrUpdated(adapter, slice, ctx); @@ -249,7 +255,8 @@ export class CliHookPluginCoordinator { // in `resolvePluginEnvForSpawn`. Installing their plugin would be // wasted I/O because the runtime never injects the env/args for them. if (!isTerminalLiveInput(adapter)) return; - const ctx = this.envContext(adapter.kind); + const ctx = toCliHookEnvContext(this.envContext(adapter.kind)); + if (!ctx) return; await this.ensureInstalledOrUpdated(adapter, slice, ctx); }); await Promise.allSettled(tasks); @@ -311,7 +318,7 @@ export class CliHookPluginCoordinator { } private async resolveTransport( - ctx: AgentEnvContext, + ctx: AgentCliHookEnvContext, ): Promise<{ url: string; secret: string; protocolVersion: number } | undefined> { if (ctx.envKind === "wsl") { if (!this.wslHookBridge || !ctx.wslDistro) return undefined; @@ -328,7 +335,7 @@ export class CliHookPluginCoordinator { private async ensureInstalledOrUpdated( adapter: AgentAdapter, slice: AgentCliHookPluginSupport, - ctx: AgentEnvContext, + ctx: AgentCliHookEnvContext, ): Promise { const key = composeCacheKey(adapter.kind, ctx); const existing = this.installPromises.get(key); @@ -351,7 +358,7 @@ export class CliHookPluginCoordinator { private async runInstall( adapter: AgentAdapter, slice: AgentCliHookPluginSupport, - ctx: AgentEnvContext, + ctx: AgentCliHookEnvContext, cacheKey: string, ): Promise { const supported = (await slice.isPluginSupported?.(ctx)) ?? true; @@ -496,9 +503,9 @@ export class CliHookPluginCoordinator { } } - private contextForEnv(env: AgentHookPluginEnv): AgentEnvContext { + private contextForEnv(env: AgentHookPluginEnv): AgentCliHookEnvContext { const nativeKind = process.platform === "win32" ? "windows" : "posix"; - const ctx: AgentEnvContext = + const ctx: AgentCliHookEnvContext = env.kind === "wsl" ? { envKind: "wsl", wslDistro: env.distro } : { envKind: nativeKind }; const baseDir = this.options.baseDir; if (ctx.baseDir !== undefined || baseDir === undefined) return ctx; @@ -584,6 +591,23 @@ async function warmWslHomeCache(ctx: AgentEnvContext): Promise { await resolveWslHomeDirectoryAsync(ctx.wslDistro); } +function toCliHookEnvContext(ctx: AgentEnvContext): AgentCliHookEnvContext | undefined { + if (ctx.envKind === "ssh") return undefined; + const common = { + ...(ctx.baseDir !== undefined ? { baseDir: ctx.baseDir } : {}), + ...(ctx.browserMcpEnabled !== undefined ? { browserMcpEnabled: ctx.browserMcpEnabled } : {}), + ...(ctx.browserMcp !== undefined ? { browserMcp: ctx.browserMcp } : {}), + }; + if (ctx.envKind === "wsl") { + return { + envKind: "wsl", + ...(ctx.wslDistro !== undefined ? { wslDistro: ctx.wslDistro } : {}), + ...common, + }; + } + return { envKind: ctx.envKind, ...common }; +} + function defaultEnvContext( _agentKind: AgentKind, projectLocation?: ProjectLocation, @@ -597,6 +621,13 @@ function defaultEnvContext( if (projectLocation?.kind === "posix") { return { envKind: "posix" }; } + if (projectLocation?.kind === "ssh") { + return { + envKind: "ssh", + sshHost: projectLocation.host, + sshPath: projectLocation.path, + }; + } return process.platform === "win32" ? { envKind: "windows" } : { envKind: "posix" }; } diff --git a/src/supervisor/runtime/threadAttachments.ts b/src/supervisor/runtime/threadAttachments.ts index 1ec88c8a..cb6aff42 100644 --- a/src/supervisor/runtime/threadAttachments.ts +++ b/src/supervisor/runtime/threadAttachments.ts @@ -1,9 +1,14 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { basename } from "node:path"; import type { PromptSegment, ProjectLocation } from "@/shared/contracts"; +import { isSafeSshHost } from "@/shared/ssh"; +import { quotePosixShellArg } from "../agents/base/shellBasics"; import type { WslBridgeClient, WslLocation } from "../wsl/bridge/client"; const wslAttachmentDirCache = new Map(); +const sshAttachmentDirCache = new Map(); let wslAttachmentBridgeClient: WslBridgeClient | undefined; export function setWslAttachmentBridgeClient(client: WslBridgeClient | undefined): void { @@ -47,6 +52,61 @@ async function resolveWslAttachmentDirs( return entry; } +function resolveSshAttachmentDir(location: Extract): string { + const cached = sshAttachmentDirCache.get(location.host); + if (cached) { + return cached; + } + if (!isSafeSshHost(location.host)) { + throw new Error("Invalid SSH host."); + } + const result = spawnSync( + "ssh", + [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-T", + location.host, + "sh", + "-lc", + 'mkdir -p "$HOME/.lightcode/attachments" && printf %s "$HOME/.lightcode/attachments"', + ], + { encoding: "utf8", timeout: 5000, windowsHide: true }, + ); + if (result.status !== 0) { + throw new Error(result.stderr?.trim() || "Unable to prepare SSH attachments directory."); + } + const remoteDir = result.stdout.trim(); + sshAttachmentDirCache.set(location.host, remoteDir); + return remoteDir; +} + +function copyFileToSsh( + location: Extract, + sourcePath: string, + remotePath: string, +): boolean { + const payload = readFileSync(sourcePath).toString("base64"); + const result = spawnSync( + "ssh", + [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-T", + location.host, + "sh", + "-lc", + `base64 -d > ${quotePosixShellArg(remotePath)}`, + ], + { input: payload, encoding: "utf8", timeout: 15_000, windowsHide: true }, + ); + return result.status === 0; +} + function isImageAttachmentSegment(segment: PromptSegment): boolean { if (segment.kind !== "attachment") { return false; @@ -62,14 +122,15 @@ export async function rewriteSegmentsForWsl( location: ProjectLocation, options?: { preserveImageAttachments?: boolean }, ): Promise { - if (location.kind !== "wsl") { + if (location.kind !== "wsl" && location.kind !== "ssh") { return segments; } const client = wslAttachmentBridgeClient; - if (!client) return segments; + if (location.kind === "wsl" && !client) return segments; let dirs: { home: string; linuxDir: string } | undefined; + let sshDir: string | undefined; const rewritten: PromptSegment[] = []; for (const segment of segments) { if ((segment.kind !== "attachment" && segment.kind !== "file") || !segment.path) { @@ -80,6 +141,39 @@ export async function rewriteSegmentsForWsl( rewritten.push(segment); continue; } + + if (location.kind === "ssh") { + // Copy any file that exists on THIS machine to the remote. The local host + // may be Windows (drive paths) or macOS/Linux (POSIX paths), so we can't + // gate on a Windows-drive shape here; an already-remote path simply won't + // exist locally and is left untouched. + if (!existsSync(segment.path)) { + rewritten.push(segment); + continue; + } + sshDir ??= resolveSshAttachmentDir(location); + const fileName = basename(segment.path); + const destination = `${sshDir}/${fileName}`; + try { + rewritten.push( + copyFileToSsh(location, segment.path, destination) + ? { ...segment, path: destination } + : segment, + ); + } catch (error) { + console.warn(`[ssh-attach] failed to copy ${segment.path} -> ${destination}:`, error); + rewritten.push(segment); + } + continue; + } + + if (!client) { + rewritten.push(segment); + continue; + } + + // WSL runs only on Windows, so local attachments are always drive paths; + // anything else is already a WSL/UNC path that must not be rewritten. if (!/^[A-Za-z]:[\\/]/.test(segment.path)) { rewritten.push(segment); continue; diff --git a/src/supervisor/runtime/threadSession/helpers.ts b/src/supervisor/runtime/threadSession/helpers.ts index 69f1ebdf..3fcd7e3d 100644 --- a/src/supervisor/runtime/threadSession/helpers.ts +++ b/src/supervisor/runtime/threadSession/helpers.ts @@ -8,6 +8,8 @@ export function hookDebugProjectLabel(loc: ProjectLocation): string { return `wsl:${loc.distro}`; case "windows": return `windows:${loc.path}`; + case "ssh": + return `ssh:${loc.host}:${loc.path}`; case "posix": return `posix:${loc.path}`; } diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index b29a43e7..dc3cd404 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -36,6 +36,7 @@ import { buildPromptContentBlocks } from "@/shared/promptContent"; import { terminateProcessTree } from "@/shared/processTree"; import { resolveBrowserMcpHttpConfigForLaunch, + type BrowserMcpBridge, type BrowserMcpHttpConfig, } from "@/supervisor/agents/browserMcp"; import { @@ -55,6 +56,7 @@ import { prepareClaudeMergedSettingsFile } from "../agents/claude/mergedSettings import { captureSupervisorException } from "../diagnostics/sentry"; import { ensureNodePtySpawnHelperExecutable } from "../nodePty"; import type { WindowsShellPreference } from "../shellPreference"; +import { buildSshShellCommand } from "../ssh"; import { BufferedLogWriter } from "./bufferedLogWriter"; import { hookDebugSpawn } from "./hookDebug"; import type { @@ -104,9 +106,7 @@ export interface ThreadSessionManagerOptions { browserMcpEnabled?: boolean; browserMcp?: BrowserMcpHttpConfig; }): Promise<{ env: Record; extraArgs: string[] } | undefined>; - wslBridge?: { - ensureBridge(distro: string): Promise<{ baseUrl: string; secret: string } | undefined>; - }; + browserMcpBridge?: BrowserMcpBridge; } function shouldPrimeNativeProjectShellEnv( @@ -1356,7 +1356,10 @@ export class ThreadSessionManager { adapter, structuredSession?.launchOptions, ); - const launchOptionsWithBrowserMcp = this.launchOptionsWithBrowserMcp(launchOptions, browserMcp); + const launchOptionsWithBrowserMcp = this.launchOptionsWithSessionTracking( + payload.projectLocation, + this.launchOptionsWithBrowserMcp(launchOptions, browserMcp), + ); const argv = payload.sessionRef ? adapter.buildResumeArgv( payload.projectLocation, @@ -1462,6 +1465,9 @@ export class ThreadSessionManager { if (!adapter.createStructuredSession) { return undefined; } + if (projectLocation.kind === "ssh") { + return undefined; + } try { return await adapter.createStructuredSession({ threadId, @@ -1629,6 +1635,7 @@ export class ThreadSessionManager { this.outputPipeline.emitState(session); if ( pty && + input.projectLocation.kind !== "ssh" && !session.sessionRef && !session.sessionRefDiscoveryStarted && input.adapter.discoverSessionRef @@ -1802,6 +1809,10 @@ export class ThreadSessionManager { } private pollSessionRefDiscovery(session: SessionRuntime): void { + if (session.projectLocation.kind === "ssh") { + return; + } + const projectLocation = session.projectLocation; let attempt = 0; let polling = false; const existingIds = new Set(); @@ -1823,7 +1834,7 @@ export class ThreadSessionManager { attempt += 1; } try { - const ref = await session.adapter.discoverSessionRef?.(session.projectLocation); + const ref = await session.adapter.discoverSessionRef?.(projectLocation); if (ref && !session.sessionRef && !existingIds.has(ref.providerSessionId)) { session.sessionRef = ref; session.canResumeWithConfig = true; @@ -1844,7 +1855,7 @@ export class ThreadSessionManager { }; session.stopSessionRefWatcher = session.adapter.watchSessionRef?.( - session.projectLocation, + projectLocation, () => void poll(true), ); const initialDelay = session.adapter.initialSessionRefDiscoveryDelayMs ?? 0; @@ -1987,9 +1998,12 @@ export class ThreadSessionManager { config, launchPrompt, session.sessionRef, - this.launchOptionsWithBrowserMcp( - this.launchOptionsWithAgentSettings(session.adapter, structuredSession?.launchOptions), - browserMcp, + this.launchOptionsWithSessionTracking( + session.projectLocation, + this.launchOptionsWithBrowserMcp( + this.launchOptionsWithAgentSettings(session.adapter, structuredSession?.launchOptions), + browserMcp, + ), ), ); if (cliHookExtras.extraArgs.length > 0) { @@ -2087,9 +2101,12 @@ export class ThreadSessionManager { session.config, session.launchPrompt, undefined, - this.launchOptionsWithBrowserMcp( - this.launchOptionsWithAgentSettings(session.adapter), - browserMcp, + this.launchOptionsWithSessionTracking( + session.projectLocation, + this.launchOptionsWithBrowserMcp( + this.launchOptionsWithAgentSettings(session.adapter), + browserMcp, + ), ), ); if (cliHookExtras.extraArgs.length > 0) { @@ -2243,6 +2260,10 @@ export class ThreadSessionManager { }; } + if (location.kind === "ssh") { + return buildSshShellCommand(location, { startInHome }); + } + if (process.platform === "win32") { return { command: this.options.windowsShell.shell, @@ -2298,6 +2319,15 @@ export class ThreadSessionManager { }; } + private launchOptionsWithSessionTracking( + location: ProjectLocation, + launchOptions: AgentLaunchOptions, + ): AgentLaunchOptions { + return location.kind === "ssh" + ? { ...launchOptions, sessionTrackingEnabled: false } + : launchOptions; + } + private isBrowserMcpEnabledForLaunch( adapter: AgentAdapter | undefined, config: ThreadConfig, @@ -2317,7 +2347,7 @@ export class ThreadSessionManager { const cfg = await resolveBrowserMcpHttpConfigForLaunch( location, enabled, - this.options.wslBridge, + this.options.browserMcpBridge, ); return cfg; } diff --git a/src/supervisor/ssh.ts b/src/supervisor/ssh.ts new file mode 100644 index 00000000..12fb9d4a --- /dev/null +++ b/src/supervisor/ssh.ts @@ -0,0 +1,196 @@ +import { spawn } from "node:child_process"; +import type { ProjectLocation } from "@/shared/contracts"; +import { isSafeSshHost } from "@/shared/ssh"; +import type { CommandSpec } from "./agents/base"; +import { buildPosixExportPrefix, quotePosixShellArg } from "./agents/base/shellBasics"; + +export const SSH_DEFAULT_TIMEOUT = 15_000; + +export interface SshCommandOutput { + stdout: string; + stderr: string; +} + +export type SshLocation = Extract; +type SshCommandError = Error & { stdout?: string; stderr?: string; code?: number | null }; +const sshHomeCache = new Map(); + +function assertSshLocation(location: SshLocation): void { + if (!isSafeSshHost(location.host)) { + throw new Error("Invalid SSH host."); + } +} + +function baseSshArgs( + location: SshLocation, + options: { batchMode: "yes" | "no"; tty: boolean }, +): string[] { + assertSshLocation(location); + return [...baseSshOptions(options), location.host]; +} + +function baseSshOptions(options: { batchMode: "yes" | "no"; tty: boolean }): string[] { + return [ + "-o", + `BatchMode=${options.batchMode}`, + "-o", + "ConnectTimeout=10", + options.tty ? "-tt" : "-T", + ]; +} + +function remoteScriptForCommand( + location: SshLocation, + command: string, + args: string[], + env?: Record, +): string { + const exports = buildPosixExportPrefix(env); + const argv = [command, ...args].map(quotePosixShellArg).join(" "); + const inner = `${exports}exec ${argv}`; + return `cd ${quotePosixShellArg(location.path)} && exec "\${SHELL:-/bin/sh}" -l -c ${quotePosixShellArg(inner)}`; +} + +export function buildSshPtyCommand( + location: SshLocation, + command: string, + args: string[], + env?: Record, +): CommandSpec { + return buildSshCommand(location, command, args, env, { batchMode: "no", tty: true }); +} + +export function buildSshCommand( + location: SshLocation, + command: string, + args: string[], + env?: Record, + options?: { batchMode?: "yes" | "no"; tty?: boolean }, +): CommandSpec { + const script = remoteScriptForCommand(location, command, args, env); + return { + command: "ssh", + args: [ + ...baseSshArgs(location, { + batchMode: options?.batchMode ?? "yes", + tty: options?.tty ?? false, + }), + `sh -lc ${quotePosixShellArg(script)}`, + ], + }; +} + +export function buildSshShellCommand( + location: SshLocation, + options?: { startInHome?: boolean }, +): CommandSpec { + const cd = options?.startInHome ? "" : `cd ${quotePosixShellArg(location.path)} && `; + const script = `${cd}exec "\${SHELL:-/bin/sh}" -l`; + return { + command: "ssh", + args: [ + ...baseSshArgs(location, { batchMode: "no", tty: true }), + `sh -lc ${quotePosixShellArg(script)}`, + ], + }; +} + +export async function readSshCommandOutput( + location: SshLocation, + command: string, + args: string[], + options?: { timeout?: number; maxBuffer?: number; env?: Record }, +): Promise { + return runSshScript( + location, + `${buildPosixExportPrefix(options?.env)}exec ${[command, ...args].map(quotePosixShellArg).join(" ")}`, + options, + ); +} + +export function runSshScript( + location: SshLocation, + script: string, + options?: { timeout?: number; maxBuffer?: number }, +): Promise { + assertSshLocation(location); + const timeout = options?.timeout ?? SSH_DEFAULT_TIMEOUT; + const maxBuffer = options?.maxBuffer ?? 10 * 1024 * 1024; + const wrapped = `set -e\ncd ${quotePosixShellArg(location.path)}\n${script}\n`; + + return new Promise((resolve, reject) => { + const child = spawn( + "ssh", + [...baseSshArgs(location, { batchMode: "yes", tty: false }), "sh", "-s"], + { + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + let settled = false; + const timer = setTimeout(() => { + settled = true; + child.kill(); + reject(new Error(`SSH command timed out after ${timeout}ms.`)); + }, timeout); + if (typeof timer.unref === "function") timer.unref(); + + // Overflow has to settle the promise itself: otherwise the kill triggers a + // `close` with a null exit code and the handler below rejects with a + // misleading "SSH command exited null" instead of an explicit cap error. + const failOverflow = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + reject(new Error(`SSH command output exceeded ${maxBuffer} bytes.`)); + }; + + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + if (stdout.length > maxBuffer) failOverflow(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + if (stderr.length > maxBuffer) failOverflow(); + }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + const error = new Error(stderr.trim() || `SSH command exited ${code}.`) as SshCommandError; + error.stdout = stdout; + error.stderr = stderr; + error.code = code; + reject(error); + }); + child.stdin.end(wrapped); + }); +} + +export async function resolveSshHomeDirectoryAsync( + location: SshLocation, +): Promise { + assertSshLocation(location); + const cached = sshHomeCache.get(location.host); + if (cached) return cached; + const result = await readSshCommandOutput(location, "sh", ["-lc", 'printf %s "$HOME"'], { + timeout: 5_000, + }).catch(() => undefined); + const home = result?.stdout.trim() ?? ""; + if (!home) return undefined; + sshHomeCache.set(location.host, home); + return home; +} diff --git a/src/supervisor/titleGenerator.ts b/src/supervisor/titleGenerator.ts index 28fa99ab..3048036f 100644 --- a/src/supervisor/titleGenerator.ts +++ b/src/supervisor/titleGenerator.ts @@ -78,15 +78,17 @@ export async function generateTitle( // Prefer the SDK / structured-runtime path: no cold-start cost, no argv // length limit. Fall back to spawning the CLI when the adapter only // exposes `buildOneShotCommand`. - const raw = adapter.runOneShot - ? await adapter.runOneShot({ - location, - model: effectiveModel, - effort, - prompt: finalPrompt, - signal: timeoutSignal(TITLE_GEN_TIMEOUT_MS), - }) - : await runViaCli(location, adapter, effectiveModel, effort, finalPrompt); + const sdkLocation = location.kind === "ssh" ? undefined : location; + const raw = + adapter.runOneShot && sdkLocation + ? await adapter.runOneShot({ + location: sdkLocation, + model: effectiveModel, + effort, + prompt: finalPrompt, + signal: timeoutSignal(TITLE_GEN_TIMEOUT_MS), + }) + : await runViaCli(location, adapter, effectiveModel, effort, finalPrompt); const title = cleanTitle(raw); if (!title) { diff --git a/src/supervisor/workflows/transcriptReader.ts b/src/supervisor/workflows/transcriptReader.ts index cd493e8d..c6c35604 100644 --- a/src/supervisor/workflows/transcriptReader.ts +++ b/src/supervisor/workflows/transcriptReader.ts @@ -8,6 +8,7 @@ import type { WorkflowRun, WorkflowRunStatus, } from "@/shared/contracts"; +import { listSessionDir, readSessionFileText } from "../agents/base"; /** * The on-disk manifest at `/workflows/.json` carries @@ -90,7 +91,13 @@ async function readJournalAgents(input: ReadWorkflowRunInput): Promise entry.name) ?? []; + } else { + entries = await readdir(dirPath); + } } catch (err) { if (isNotFoundError(err)) return []; throw err; @@ -520,6 +532,17 @@ async function readManifestBytes(input: ReadWorkflowRunInput): Promise { ); return readFile(uncPath, "utf8"); } + if (input.location.kind === "ssh") { + const text = await readSessionFileText(input.location, input.manifestPath); + if (text === undefined) { + const error = new Error( + `ENOENT: no such file, open '${input.manifestPath}'`, + ) as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } + return text; + } return readFile(input.manifestPath, "utf8"); }