From cf49b946c67702ac6d7eb9690cb2d35f8a0cd102 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Wed, 3 Jun 2026 02:36:28 -0700 Subject: [PATCH 1/3] feat(projects): add SSH remote project support - Extend project locations, DB rows, worktrees, and IPC with ssh kind - Run git, file tree, watchers, and sessions over SSH in the supervisor - Teach agent adapters, plugins, and status service SSH detection and envHost - Add SSH project picker, sidebar connection UI, and scoped discovery polling - Deep-link project settings agents section; refresh docks and git review for SSH --- src/main/db.schema.ts | 6 +- src/main/db.ts | 8 +- src/renderer/actions/agentLoginActions.ts | 10 +- src/renderer/actions/panelActions.ts | 9 +- src/renderer/actions/projectActions.ts | 15 + src/renderer/app.test.tsx | 6 +- src/renderer/app.tsx | 12 + .../providers/conflictResolver.launch.test.ts | 25 ++ .../components/providers/conflictResolver.ts | 2 +- .../thread/AgentDiscoveryScreen.tsx | 70 ++- .../thread/ChatPane/chatPathUtils.ts | 1 + .../components/thread/ProjectSwitchMenu.tsx | 5 +- .../thread/ThreadAgentUpdateDock.tsx | 14 +- .../thread/ThreadAuthRequiredDock.tsx | 26 +- src/renderer/hooks/uiSelectors.ts | 7 +- src/renderer/hooks/useGitRefresh.ts | 13 +- src/renderer/state/agentStatusesStore.test.ts | 49 ++- src/renderer/state/agentStatusesStore.ts | 91 +++- src/renderer/state/panelStore.test.ts | 12 + src/renderer/state/panelStore.ts | 20 +- src/renderer/state/projectKeys.ts | 33 ++ src/renderer/state/slices/projectSlice.ts | 11 +- src/renderer/utils/acpRegistryAuth.ts | 38 +- src/renderer/utils/titleGen.ts | 8 +- .../GitReviewSidebar/GitReviewSidebar.tsx | 6 +- .../parts/useConflictResolver.ts | 2 + .../parts/useGitReviewActions.ts | 12 +- .../parts/useSourceBranchData.ts | 6 +- src/renderer/views/MainView/MainView.tsx | 42 +- .../MainView/parts/AppContent/AppContent.tsx | 19 +- .../parts/AppContent/parts/DraftPane.tsx | 9 +- .../parts/AppContent/parts/ThreadPane.tsx | 6 +- .../views/MainView/parts/AppOverlays.tsx | 4 + .../Sidebar/parts/SidebarProjectHeader.tsx | 123 +++++- .../Sidebar/parts/formatProjectLocation.ts | 3 + .../MainView/parts/SidebarHeaderControls.tsx | 414 +++++++++++------- .../ProjectSettingsOverlay.tsx | 29 +- .../parts/SettingsSidebar.tsx | 7 +- .../ProjectSettingsOverlay/parts/types.ts | 2 +- .../parts/AcpRegistrySettings.test.tsx | 7 +- .../parts/SingleAgentSettings.tsx | 20 +- src/shared/agentStatus.test.ts | 16 + src/shared/agentStatus.ts | 5 + src/shared/contracts/agent.ts | 8 +- src/shared/contracts/common.ts | 5 + src/shared/contracts/projectTree.ts | 13 + src/shared/ipc/events.ts | 1 + src/shared/ipc/procedures/projectTree.ts | 8 + src/shared/ipc/procedures/thread.ts | 27 +- src/shared/worktree.test.ts | 40 +- src/shared/worktree.ts | 3 + src/shared/wsl.ts | 8 + src/supervisor/agents/acp-generic/index.ts | 3 + src/supervisor/agents/acp/session.ts | 42 +- src/supervisor/agents/acp/sessionPaths.ts | 7 + src/supervisor/agents/antigravity/session.ts | 64 ++- src/supervisor/agents/base.test.ts | 38 +- src/supervisor/agents/base/index.ts | 61 ++- src/supervisor/agents/base/sessionFs.ts | 176 +++++++- src/supervisor/agents/base/types.ts | 4 +- src/supervisor/agents/binaryResolver.ts | 3 + src/supervisor/agents/browserMcp/index.ts | 18 + .../agents/browserMcp/providers.test.ts | 42 ++ .../agents/claude/plugin/install.ts | 65 +++ src/supervisor/agents/claude/sdkSession.ts | 38 +- src/supervisor/agents/codex/index.ts | 43 +- src/supervisor/agents/codex/plugin/install.ts | 146 ++++++ src/supervisor/agents/codex/session.ts | 105 ++++- .../agents/copilot/plugin/install.ts | 99 +++++ .../agents/cursor/plugin/install.ts | 117 +++++ src/supervisor/agents/gemini/detection.ts | 46 +- .../agents/gemini/plugin/install.ts | 106 +++++ src/supervisor/agents/grok/detection.ts | 8 + src/supervisor/agents/grok/plugin/install.ts | 126 ++++++ src/supervisor/agents/grok/sessionFiles.ts | 33 +- src/supervisor/agents/opencode/argv.ts | 24 + .../agents/opencode/plugin/install.ts | 192 ++++++++ src/supervisor/agents/opencode/sdkClient.ts | 3 + src/supervisor/agents/opencode/sdkServer.ts | 6 +- .../agents/plugin/installerBase.test.ts | 11 +- src/supervisor/agents/plugin/installerBase.ts | 195 ++++++++- src/supervisor/agents/updateAgent.ts | 3 + src/supervisor/git/checkpointService.ts | 5 + src/supervisor/git/exec.ts | 36 +- src/supervisor/git/mergeService.ts | 7 +- src/supervisor/git/statusService.ts | 23 +- src/supervisor/github.ts | 30 +- src/supervisor/ipcHandlers.ts | 1 + src/supervisor/lsp/serverInstance.ts | 9 +- src/supervisor/projectTree.ts | 309 ++++++++++++- src/supervisor/projectWatcher.test.ts | 48 ++ src/supervisor/projectWatcher.ts | 98 +++++ src/supervisor/runtime.ts | 49 ++- .../runtime/agentStatusCache.test.ts | 25 +- .../runtime/agentStatusService.test.ts | 57 +++ src/supervisor/runtime/agentStatusService.ts | 276 ++++++++++-- .../runtime/cliHookPluginCoordinator.test.ts | 61 +++ .../runtime/cliHookPluginCoordinator.ts | 30 +- src/supervisor/runtime/threadAttachments.ts | 87 +++- .../runtime/threadSession/helpers.ts | 2 + .../runtime/threadSessionManager.ts | 23 +- src/supervisor/workflows/transcriptReader.ts | 27 +- 102 files changed, 3941 insertions(+), 422 deletions(-) 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 30be5f46..4055f503 100644 --- a/src/main/db.ts +++ b/src/main/db.ts @@ -290,8 +290,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, }; @@ -315,6 +316,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 9d0560fd..865d3027 100644 --- a/src/renderer/app.test.tsx +++ b/src/renderer/app.test.tsx @@ -11,8 +11,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 af10603e..f4e30769 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 a732fbdf..67b3483d 100644 --- a/src/renderer/hooks/useGitRefresh.ts +++ b/src/renderer/hooks/useGitRefresh.ts @@ -17,7 +17,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; export function useGitRefresh(storeHydrated: boolean) { // Subscribe only to the active-project identity/location key so draft config @@ -172,12 +172,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 }); } @@ -193,13 +193,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 511eb91a..ea56a5fc 100644 --- a/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx +++ b/src/renderer/views/GitReviewOverlay/parts/GitReviewSidebar/GitReviewSidebar.tsx @@ -84,9 +84,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 @@ -177,6 +178,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 73f8ea24..d9919ec0 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; @@ -497,7 +503,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 e08662db..4e373529 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/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 4e373044..8263a95c 100644 --- a/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx +++ b/src/renderer/views/SettingsOverlay/parts/AcpRegistrySettings.test.tsx @@ -178,7 +178,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 c3645bf3..0ccf3b9e 100644 --- a/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SingleAgentSettings.tsx @@ -426,6 +426,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"; } @@ -538,13 +539,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 @@ -1239,6 +1241,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; @@ -1246,7 +1250,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/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-generic/index.ts b/src/supervisor/agents/acp-generic/index.ts index cb1b0518..5c916470 100644 --- a/src/supervisor/agents/acp-generic/index.ts +++ b/src/supervisor/agents/acp-generic/index.ts @@ -208,6 +208,9 @@ function detectProbeLocation(ctx: AgentEnvContext | undefined): ProjectLocation uncPath: "\\\\wsl$", }; } + if (ctx?.envKind === "ssh" && ctx.sshHost) { + return { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }; + } if (process.platform === "win32") { return { kind: "windows", path: homedir() }; } diff --git a/src/supervisor/agents/acp/session.ts b/src/supervisor/agents/acp/session.ts index 3ba81e7b..b89ca593 100644 --- a/src/supervisor/agents/acp/session.ts +++ b/src/supervisor/agents/acp/session.ts @@ -74,6 +74,7 @@ import { import { terminateChildProcessTree } from "@/shared/processTree"; import { ensureNodePtySpawnHelperExecutable } from "@/supervisor/nodePty"; import { + buildAgentCommand, buildPosixExportPrefix, createKnownSessionRef, detectShell, @@ -84,7 +85,9 @@ import { getWslCommand, quotePosixShellArg, quotePowerShellLiteral, + readSessionFileText, resolveWslShellPath, + runSshScript, type AgentLaunchOptions, type CommandSpec, type CreateStructuredSessionInput, @@ -364,6 +367,22 @@ function buildAcpTerminalLaunch( }; } + if (location.kind === "ssh") { + const spec = buildAgentCommand( + { ...location, path: cwd }, + command, + args, + undefined, + { TERM: "xterm-256color", ...requestEnv }, + { sshBatchMode: "yes", sshTty: false }, + ); + return { + command: spec.command, + args: spec.args, + env: processEnvRecord(), + }; + } + if (args.length === 0) { return { command: process.env.SHELL || "/bin/bash", @@ -1210,7 +1229,13 @@ export class AcpStructuredSession implements StructuredSessionHandle { private async handleReadTextFile(params: ReadTextFileRequest): Promise { this.assertRequestSession(params.sessionId); const path = resolveAcpReadableHostFsPath(this.projectLocation, params.path); - const fullContent = await readFile(path, "utf8"); + const fullContent = + this.projectLocation.kind === "ssh" + ? await readSessionFileText(this.projectLocation, path) + : await readFile(path, "utf8"); + if (fullContent === undefined) { + throw RequestError.invalidParams({ message: `File not found: ${params.path}` }); + } const content = sliceTextFileContent(fullContent, params.line, params.limit); return { content }; } @@ -1218,7 +1243,20 @@ export class AcpStructuredSession implements StructuredSessionHandle { private async handleWriteTextFile(params: WriteTextFileRequest): Promise { this.assertRequestSession(params.sessionId); const path = resolveAcpHostFsPath(this.projectLocation, params.path); - await writeFile(path, params.content, "utf8"); + if (this.projectLocation.kind === "ssh") { + const encoded = Buffer.from(params.content, "utf8").toString("base64"); + await runSshScript( + this.projectLocation, + [ + `path=${quotePosixShellArg(path)}`, + `dir=\${path%/*}`, + `mkdir -p "$dir"`, + `printf %s ${quotePosixShellArg(encoded)} | base64 -d > "$path"`, + ].join("\n"), + ); + } else { + await writeFile(path, params.content, "utf8"); + } return {}; } diff --git a/src/supervisor/agents/acp/sessionPaths.ts b/src/supervisor/agents/acp/sessionPaths.ts index 16507839..fa54fce1 100644 --- a/src/supervisor/agents/acp/sessionPaths.ts +++ b/src/supervisor/agents/acp/sessionPaths.ts @@ -11,6 +11,7 @@ export function resolveSessionCwd(location: ProjectLocation): string { return location.path; case "wsl": return location.linuxPath; + case "ssh": case "posix": return location.path; } @@ -21,6 +22,7 @@ export function resolveSpawnCwd(location: ProjectLocation): 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; + if (location.kind === "ssh") return undefined; return location.path; } @@ -29,6 +31,7 @@ export function basenameForProjectPath(location: ProjectLocation, filePath: stri case "windows": return win32.basename(filePath); case "wsl": + case "ssh": case "posix": return posix.basename(filePath); } @@ -47,6 +50,7 @@ export function resolveAcpResourcePath(location: ProjectLocation, rawPath: strin return win32.join(location.path, rawPath); case "wsl": return rawPath.startsWith("/") ? rawPath : posix.join(location.linuxPath, rawPath); + case "ssh": case "posix": return rawPath.startsWith("/") ? rawPath : posix.join(location.path, rawPath); } @@ -62,6 +66,7 @@ function isProjectRelativePath(location: ProjectLocation, absolutePath: string): const relative = posix.relative(location.linuxPath, absolutePath); return relative === "" || (!relative.startsWith("..") && !posix.isAbsolute(relative)); } + case "ssh": case "posix": { const relative = posix.relative(location.path, absolutePath); return relative === "" || (!relative.startsWith("..") && !posix.isAbsolute(relative)); @@ -118,6 +123,7 @@ export function toAcpResourceUri(location: ProjectLocation, rawPath: string): st case "windows": return pathToFileURL(absolutePath).href; case "wsl": + case "ssh": case "posix": return new URL(`file://${absolutePath.replace(/\\/g, "/")}`).href; } @@ -172,6 +178,7 @@ function isAgentSkillReadPath(location: ProjectLocation, absolutePath: string): return relative !== "" && !relative.startsWith("..") && !win32.isAbsolute(relative); } case "wsl": + case "ssh": case "posix": { const match = /^(\/(?:home\/[^/]+|Users\/[^/]+|root)\/\.agents\/skills)(?:\/.*)?$/.exec( absolutePath, diff --git a/src/supervisor/agents/antigravity/session.ts b/src/supervisor/agents/antigravity/session.ts index f68ea31a..c5fbc068 100644 --- a/src/supervisor/agents/antigravity/session.ts +++ b/src/supervisor/agents/antigravity/session.ts @@ -2,7 +2,13 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { ProjectLocation } from "@/shared/contracts"; -import { resolveAgentHomeSubpath, resolveWslHomeDirectory } from "../base"; +import { + readSshCommandOutputSync, + resolveAgentHomeSubpath, + resolveSshHomeDirectory, + resolveWslHomeDirectory, + runSshScriptSync, +} from "../base"; const INVALID_SESSION_RE = /not\s+found|invalid\s+conversation|no\s+such\s+conversation/i; @@ -25,7 +31,12 @@ export function resolveAntigravityConfigDir(location: ProjectLocation): string | export function antigravityConfigDirExists(location: ProjectLocation): boolean { const dir = resolveAntigravityConfigDir(location); - return Boolean(dir && existsSync(dir)); + if (!dir) return false; + if (location.kind === "ssh") { + const result = runSshScriptSync(location, `[ -d ${shellQuote(dir)} ]`, { timeout: 5_000 }); + return result.ok; + } + return existsSync(dir); } interface AntigravityConversationFile { @@ -39,11 +50,46 @@ interface AntigravityConversationFile { // open database must be excluded — only the base file names a conversation. const CONVERSATION_FILE_RE = /\.(pb|db)$/; +function shellQuote(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + function readAntigravityConversationFiles( location: ProjectLocation, ): AntigravityConversationFile[] { const dir = resolveAntigravityConversationsDir(location); - if (!dir || !existsSync(dir)) return []; + if (!dir) return []; + if (location.kind === "ssh") { + const result = runSshScriptSync( + location, + [ + `dir=${shellQuote(dir)}`, + `[ -d "$dir" ] || exit 0`, + `find "$dir" -maxdepth 1 -type f -name '*.pb' 2>/dev/null | while IFS= read -r path; do`, + ` mtime=$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null || printf 0)`, + ` printf '%s\\t%s\\n' "$mtime" "\${path##*/}"`, + `done`, + ].join("\n"), + { timeout: 10_000 }, + ); + if (!result.ok) return []; + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + const [mtimeRaw, name] = line.split("\t"); + if (!name?.endsWith(".pb")) return []; + return [ + { + id: name.replace(/\.pb$/, ""), + path: `${dir}/${name}`, + mtimeMs: Number(mtimeRaw) * 1000, + }, + ]; + }); + } + if (!existsSync(dir)) return []; try { return readdirSync(dir) .filter((file) => CONVERSATION_FILE_RE.test(file)) @@ -176,7 +222,11 @@ export function readAntigravityLastConversationForCwd( const path = resolveAgentHomeSubpath(location, LAST_CONVERSATIONS_SUBPATH); if (!path) return undefined; try { - const map = JSON.parse(readFileSync(path, "utf8")) as Record; + const raw = + location.kind === "ssh" + ? readSshCommandOutputSync(location, "cat", [path], { timeout: 10_000 }).stdout + : readFileSync(path, "utf8"); + const map = JSON.parse(raw) as Record; const value = map[cwd]; return typeof value === "string" && value.length > 0 ? value : undefined; } catch { @@ -202,6 +252,11 @@ export function resolveAntigravityWatchPaths(location: ProjectLocation): string[ if (!home) return []; return [`${home}/${ANTIGRAVITY_CONFIG_SUBPATH}`, `${home}/${ANTIGRAVITY_PARENT_SUBPATH}`]; } + if (location.kind === "ssh") { + const home = resolveSshHomeDirectory(location); + if (!home) return []; + return [`${home}/${ANTIGRAVITY_CONFIG_SUBPATH}`, `${home}/${ANTIGRAVITY_PARENT_SUBPATH}`]; + } const home = homedir(); const paths = [ join(home, ...ANTIGRAVITY_CONFIG_SUBPATH.split("/")), @@ -211,5 +266,6 @@ export function resolveAntigravityWatchPaths(location: ProjectLocation): string[ } export function describeAntigravityLocation(location: ProjectLocation): string { + if (location.kind === "ssh") return `ssh:${location.host}`; return location.kind === "wsl" ? `wsl:${location.distro}` : location.kind; } diff --git a/src/supervisor/agents/base.test.ts b/src/supervisor/agents/base.test.ts index ca48d153..9f7ac72f 100644 --- a/src/supervisor/agents/base.test.ts +++ b/src/supervisor/agents/base.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; import type { ProjectLocation } from "@/shared/contracts"; -import { buildBatchWslScript, getWslCommand, injectWslEnv, wrapWslCommand } from "./base"; +import { + buildAgentCommand, + buildBatchWslScript, + getWslCommand, + injectWslEnv, + resolveLaunchSpec, + wrapWslCommand, +} from "./base"; const wslProject: ProjectLocation = { kind: "wsl", @@ -100,3 +107,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 8f23c3dc..ae6bb96a 100644 --- a/src/supervisor/agents/base/index.ts +++ b/src/supervisor/agents/base/index.ts @@ -60,6 +60,13 @@ import type { ThreadHistory, ThreadHistoryEntry, } from "./types"; +import { + buildSshCommand, + buildSshPtyCommand, + readSshCommandOutput, + resolveSshHomeDirectory, + runSshScript, +} from "../../ssh"; export type { AcpSessionUpdateTransform, @@ -261,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( @@ -269,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 @@ -298,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); @@ -327,7 +345,10 @@ export function buildAgentCommand( */ export function resolveLaunchSpec(location: ProjectLocation, argv: AgentArgvSpec): CommandSpec { const resolvedExecPath = 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; } @@ -352,6 +373,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; @@ -370,6 +400,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"; @@ -394,6 +425,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) { @@ -404,6 +436,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() }; } @@ -436,6 +471,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); } @@ -528,15 +574,26 @@ export function resolveAgentHomeSubpath( location: ProjectLocation, subpath: string, ): string | undefined { + const trimmed = subpath.replace(/^[\\/]+/, ""); if (location.kind === "wsl") { const home = resolveWslHomeDirectory(location.distro); if (!home) return undefined; - const trimmed = subpath.replace(/^[\\/]+/, ""); return toWslUncPath(location.distro, `${home}/${trimmed}`); } + if (location.kind === "ssh") { + const home = resolveSshHomeDirectory(location); + return home ? `${home}/${trimmed}` : undefined; + } return join(homedir(), ...subpath.split(/[\\/]/).filter((s) => s.length > 0)); } +export { + readSshCommandOutputSync, + resolveSshHomeDirectory, + runSshScript, + runSshScriptSync, +} from "../../ssh"; + /** * Recursive `fs.watch` wrapper with uniform error-swallow / cleanup semantics. * Returns an undo handle or `undefined` when the watcher could not be diff --git a/src/supervisor/agents/base/sessionFs.ts b/src/supervisor/agents/base/sessionFs.ts index c1f88482..1d48d005 100644 --- a/src/supervisor/agents/base/sessionFs.ts +++ b/src/supervisor/agents/base/sessionFs.ts @@ -1,9 +1,11 @@ import { existsSync, readFileSync, readdirSync, statSync, watch as fsWatch } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import type { ProjectLocation } from "@/shared/contracts"; import { toWslUncPath } from "@/shared/wsl"; import type { WslBridgeClient, WslLocation } from "../../wsl/bridge/client"; +import { runSshScript } from "../../ssh"; import { resolveWslHomeDirectory } from "./processRuntime"; +import { quotePosixShellArg } from "./shellBasics"; /** * Shared filesystem helpers for agent session discovery. Routes WSL reads @@ -38,10 +40,50 @@ export interface SessionDirEntry { type: "file" | "directory" | "symlink" | "other"; } +function parseSshDirEntry(line: string): SessionDirEntry | undefined { + const tab = line.indexOf("\t"); + if (tab <= 0) return undefined; + const name = line.slice(0, tab); + const rawType = line.slice(tab + 1); + const type = + rawType === "file" || rawType === "directory" || rawType === "symlink" || rawType === "other" + ? rawType + : "other"; + return { name, type }; +} + +async function listSshSessionDir( + location: Extract, + absolutePath: string, +): Promise { + const dir = quotePosixShellArg(absolutePath); + const result = await runSshScript( + location, + [ + `dir=${dir}`, + `[ -d "$dir" ] || exit 0`, + `for p in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do`, + ` [ -e "$p" ] || [ -L "$p" ] || continue`, + ` name=\${p##*/}`, + ` if [ -f "$p" ]; then type=file; elif [ -d "$p" ]; then type=directory; elif [ -L "$p" ]; then type=symlink; else type=other; fi`, + ` printf '%s\\t%s\\n' "$name" "$type"`, + `done`, + ].join("\n"), + ).catch(() => undefined); + if (!result) return undefined; + return result.stdout + .split(/\r?\n/) + .map((line) => parseSshDirEntry(line)) + .filter((entry): entry is SessionDirEntry => entry !== undefined); +} + export async function listSessionDir( location: ProjectLocation, absolutePath: string, ): Promise { + if (location.kind === "ssh") { + return listSshSessionDir(location, absolutePath); + } if (location.kind !== "wsl") { try { return readdirSync(absolutePath, { withFileTypes: true }).map((d) => ({ @@ -77,11 +119,59 @@ export interface SessionStat { isFile?: boolean; } +async function statSshSessionPaths( + location: Extract, + paths: string[], +): Promise> { + const lines = [ + `mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || printf 0; }`, + ...paths.map((path, index) => { + const p = quotePosixShellArg(path); + return [ + `p=${p}`, + `if [ -e "$p" ] || [ -L "$p" ]; then`, + ` is_file=0; is_dir=0`, + ` [ -f "$p" ] && is_file=1`, + ` [ -d "$p" ] && is_dir=1`, + ` printf '${index}\\t1\\t%s\\t%s\\t%s\\n' "$(mtime "$p")" "$is_file" "$is_dir"`, + `else`, + ` printf '${index}\\t0\\t0\\t0\\t0\\n'`, + `fi`, + ].join("\n"); + }), + ]; + const result = await runSshScript(location, lines.join("\n")).catch(() => undefined); + const map = new Map(paths.map((p) => [p, { exists: false }])); + if (!result) return map; + for (const line of result.stdout.split(/\r?\n/)) { + if (!line.trim()) continue; + const [indexRaw, existsRaw, mtimeRaw, fileRaw, dirRaw] = line.split("\t"); + const index = Number(indexRaw); + const path = paths[index]; + if (!Number.isInteger(index) || !path) continue; + if (existsRaw !== "1") { + map.set(path, { exists: false }); + continue; + } + const mtimeSeconds = Number(mtimeRaw); + map.set(path, { + exists: true, + ...(Number.isFinite(mtimeSeconds) ? { mtimeMs: mtimeSeconds * 1000 } : {}), + isFile: fileRaw === "1", + isDirectory: dirRaw === "1", + }); + } + return map; +} + export async function statSessionPaths( location: ProjectLocation, paths: string[], ): Promise> { if (paths.length === 0) return new Map(); + if (location.kind === "ssh") { + return statSshSessionPaths(location, paths); + } if (location.kind !== "wsl") { const map = new Map(); for (const p of paths) { @@ -131,6 +221,21 @@ export async function readSessionFileText( absolutePath: string, maxBytes = 0, ): Promise { + if (location.kind === "ssh") { + const path = quotePosixShellArg(absolutePath); + const sizeGuard = + maxBytes > 0 + ? `size=$(wc -c < ${path} 2>/dev/null || printf 0); [ "$size" -le ${maxBytes} ] || exit 3` + : ""; + const result = await runSshScript( + location, + [`[ -f ${path} ] || exit 3`, sizeGuard, `cat -- ${path}`].filter(Boolean).join("\n"), + { + ...(maxBytes > 0 ? { maxBuffer: maxBytes + 1024 } : {}), + }, + ).catch(() => undefined); + return result?.stdout; + } if (location.kind !== "wsl") { try { const raw = readFileSync(absolutePath); @@ -171,6 +276,41 @@ export interface FoundSessionFile { mtimeMs?: number; } +async function findSshSessionFiles( + location: Extract, + opts: FindSessionFilesOptions, +): Promise { + const ignoreExpr = + opts.ignore && opts.ignore.length > 0 + ? `\\( ${opts.ignore.map((name) => `-name ${quotePosixShellArg(name)}`).join(" -o ")} \\) -prune -o ` + : ""; + const result = await runSshScript( + location, + [ + `root=${quotePosixShellArg(opts.root)}`, + `[ -d "$root" ] || exit 0`, + `find "$root" ${ignoreExpr}-type f -print 2>/dev/null | sed -n '1,${opts.maxEntries ?? 10_000}p'`, + ].join("\n"), + ).catch(() => undefined); + if (!result) return []; + const acceptFile = opts.acceptFile ?? (() => true); + const matches = result.stdout + .split(/\r?\n/) + .map((path) => path.trim()) + .filter((path) => path.length > 0) + .map((path) => ({ path, name: basename(path) })) + .filter((entry) => acceptFile(entry.name)); + if (!opts.includeMtime || matches.length === 0) return matches; + const stats = await statSshSessionPaths( + location, + matches.map((match) => match.path), + ); + return matches.map((match) => { + const mtimeMs = stats.get(match.path)?.mtimeMs; + return typeof mtimeMs === "number" ? { ...match, mtimeMs } : match; + }); +} + export async function findSessionFiles( location: ProjectLocation, opts: FindSessionFilesOptions, @@ -179,6 +319,10 @@ export async function findSessionFiles( const maxEntries = opts.maxEntries ?? 10_000; const ignore = opts.ignore ?? []; + if (location.kind === "ssh") { + return findSshSessionFiles(location, opts); + } + if (location.kind !== "wsl") { const out: FoundSessionFile[] = []; const walk = (dir: string): void => { @@ -255,6 +399,36 @@ export function watchSessionPaths( label: string, ): () => void { if (paths.length === 0) return () => undefined; + if (location.kind === "ssh") { + let disposed = false; + let timer: NodeJS.Timeout | undefined; + let previousKey: string | undefined; + const poll = async () => { + const stats = await statSshSessionPaths(location, paths); + const nextKey = paths + .map((p) => { + const stat = stats.get(p); + return `${p}:${stat?.exists ? "1" : "0"}:${stat?.mtimeMs ?? 0}`; + }) + .join("|"); + if (previousKey !== undefined && nextKey !== previousKey && !disposed) onChanged(); + previousKey = nextKey; + if (disposed) return; + timer = setTimeout(() => void poll(), 2_000); + if (typeof timer.unref === "function") timer.unref(); + }; + void poll().catch((err) => { + console.log( + "[%s] ssh session watcher failed: %s", + label, + err instanceof Error ? err.message : String(err), + ); + }); + return () => { + disposed = true; + if (timer) clearTimeout(timer); + }; + } if (location.kind !== "wsl") { const watchers: Array<() => void> = []; for (const p of paths) { diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index 68025ad0..62a384f1 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 diff --git a/src/supervisor/agents/binaryResolver.ts b/src/supervisor/agents/binaryResolver.ts index 5a4c8656..594253f4 100644 --- a/src/supervisor/agents/binaryResolver.ts +++ b/src/supervisor/agents/binaryResolver.ts @@ -39,6 +39,9 @@ export function resolveAgentBinaryPath( cache.set(key, resolved); return resolved; } + 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 b1126bce..2a3d69a4 100644 --- a/src/supervisor/agents/browserMcp/index.ts +++ b/src/supervisor/agents/browserMcp/index.ts @@ -47,6 +47,10 @@ export interface BrowserMcpHttpConfig { export interface BrowserMcpBridge { ensureBridge(distro: string): Promise<{ baseUrl: string; secret: string } | undefined>; + ensureSshBridge?( + location: Extract, + upstream: BrowserMcpEnv, + ): Promise<{ baseUrl: string; secret: string } | undefined>; } /** @@ -64,6 +68,7 @@ export function resolveBrowserMcpHttpConfig( ): BrowserMcpHttpConfig | null { const env = readBrowserMcpEnv(); if (!env) return null; + if (location.kind === "ssh") return null; const url = location.kind === "wsl" ? rewriteUrlForWsl(env.url, location.distro) : env.url; // Append `/mcp` so the agent hits the Streamable-HTTP endpoint directly. const mcpUrl = `${url.replace(/\/$/, "")}/mcp`; @@ -93,5 +98,18 @@ export async function resolveBrowserMcpHttpConfigForLaunch( headers: { Authorization: `Bearer ${handle.secret}` }, }; } + if (location.kind === "ssh") { + if (!bridge?.ensureSshBridge) return undefined; + const env = readBrowserMcpEnv(); + if (!env) return undefined; + const handle = await bridge.ensureSshBridge(location, env); + if (!handle) return undefined; + const url = `${handle.baseUrl.replace(/\/$/, "")}/mcp`; + return { + url, + token: handle.secret, + headers: { Authorization: `Bearer ${handle.secret}` }, + }; + } return resolveBrowserMcpHttpConfig(location) ?? undefined; } diff --git a/src/supervisor/agents/browserMcp/providers.test.ts b/src/supervisor/agents/browserMcp/providers.test.ts index 3f0e00ce..44389369 100644 --- a/src/supervisor/agents/browserMcp/providers.test.ts +++ b/src/supervisor/agents/browserMcp/providers.test.ts @@ -7,6 +7,7 @@ import { buildOpenCodeBrowserMcp } from "../opencode/mcpBrowser"; import { resolveBrowserMcpHttpConfigForLaunch, type BrowserMcpHttpConfig } from "./index"; const wslLocation = { kind: "wsl", distro: "Ubuntu" } as const; +const sshLocation = { kind: "ssh", host: "devbox", path: "/repo" } as const; const bridgeMcp: BrowserMcpHttpConfig = { url: "http://127.0.0.1:45678/mcp", token: "bridge-secret", @@ -82,4 +83,45 @@ describe("WSL Browser MCP provider configs", () => { expect(buildGeminiBrowserMcpServers(wslLocation)).toBeUndefined(); expect(buildOpenCodeBrowserMcp(wslLocation)).toBeUndefined(); }); + + it("resolves SSH Browser MCP through a reverse SSH bridge", async () => { + process.env.LIGHTCODE_BROWSER_MCP_URL = "http://127.0.0.1:65093"; + process.env.LIGHTCODE_BROWSER_MCP_TOKEN = "host-token"; + const ensureSshBridge = vi.fn< + ( + location: typeof sshLocation, + upstream: { url: string; token: string }, + ) => Promise<{ baseUrl: string; secret: string }> + >(async () => ({ + baseUrl: "http://127.0.0.1:45678", + secret: "host-token", + })); + const ensureBridge = vi.fn<() => Promise<{ baseUrl: string; secret: string } | undefined>>(); + + await expect( + resolveBrowserMcpHttpConfigForLaunch(sshLocation, true, { + ensureBridge, + ensureSshBridge, + }), + ).resolves.toEqual({ + url: "http://127.0.0.1:45678/mcp", + token: "host-token", + headers: { Authorization: "Bearer host-token" }, + }); + expect(ensureSshBridge).toHaveBeenCalledWith(sshLocation, { + url: "http://127.0.0.1:65093", + token: "host-token", + }); + }); + + it("does not fall back to local loopback MCP for SSH when no SSH bridge exists", () => { + process.env.LIGHTCODE_BROWSER_MCP_URL = "http://127.0.0.1:65093"; + process.env.LIGHTCODE_BROWSER_MCP_TOKEN = "host-token"; + + expect(buildAcpBrowserMcpServers(sshLocation, true)).toEqual([]); + expect(buildClaudeBrowserMcpServers(sshLocation, true)).toBeUndefined(); + expect(buildCodexBrowserMcpArgs(sshLocation, true)).toEqual([]); + expect(buildGeminiBrowserMcpServers(sshLocation)).toBeUndefined(); + expect(buildOpenCodeBrowserMcp(sshLocation)).toBeUndefined(); + }); }); diff --git a/src/supervisor/agents/claude/plugin/install.ts b/src/supervisor/agents/claude/plugin/install.ts index 9b7012c8..04dedaaf 100644 --- a/src/supervisor/agents/claude/plugin/install.ts +++ b/src/supervisor/agents/claude/plugin/install.ts @@ -13,14 +13,19 @@ import { ctxCacheKey, getNativeHookWrapperFilename, getNativePluginBaseDir, + getSshPluginBaseDirs, getWslPluginBaseDirs, hasNativeHookWrapper, + isSshPluginContext, isWslPluginContext, memoByCtx, readBundledPluginVersion, readPluginManifest, removeStagedPluginDir, + stagePluginAssetsToSsh, stagePluginAssetsToWsl, + verifySshStagedPlugin, + writeSshJsonFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; @@ -83,6 +88,15 @@ export function readBundledClaudePluginVersion(): string { } function computeClaudePluginPaths(ctx?: AgentEnvContext): ClaudePluginPaths { + if (isSshPluginContext(ctx)) { + const ssh = getSshPluginBaseDirs(ctx, "claude"); + if (!ssh) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; + return { + pluginDir: ssh.linuxBase, + settingsPath: `${ssh.linuxBase}/settings.json`, + version: "0.0.0", + }; + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "claude"); if (!wsl) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; @@ -172,6 +186,15 @@ export function installClaudePlugin( } return installClaudePluginWsl(ctx.wslDistro, sourceDir, manifest, options.resolvedNodePath); } + if (isSshPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: "SSH Claude plugin install requires a resolved node path on the remote host.", + }; + } + return installClaudePluginSsh(ctx, sourceDir, manifest, options.resolvedNodePath); + } const pluginDir = getNativePluginBaseDir("claude", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -200,6 +223,37 @@ export function installClaudePlugin( }; } +function installClaudePluginSsh( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, +): { ok: true; paths: ClaudePluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToSsh(ctx, sourceDir, "claude", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const linuxPluginDir = staged.linuxPluginDir; + const settingsPath = `${linuxPluginDir}/settings.json`; + const headExpression = buildWslHookCommandHead(resolvedNodePath, `${linuxPluginDir}/forward.mjs`); + const settings = renderClaudeSettings(headExpression); + const settingsResult = writeSshJsonFile(ctx, settingsPath, settings); + if (!settingsResult.ok) return settingsResult; + const hooksResult = writeSshJsonFile(ctx, `${linuxPluginDir}/hooks/hooks.json`, settings); + if (!hooksResult.ok) return hooksResult; + + console.log( + `[supervisor] Claude hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost} at ${linuxPluginDir} (forward.mjs, settings.json, hooks/hooks.json) using node=${resolvedNodePath}`, + ); + + return { + ok: true, + version: manifest.version, + paths: { pluginDir: linuxPluginDir, settingsPath, version: manifest.version }, + }; +} + function installClaudePluginWsl( distro: string, sourceDir: string, @@ -256,6 +310,17 @@ export function isClaudePluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { + if (isSshPluginContext(ctx)) { + return verifySshStagedPlugin(ctx, "claude", { + assets: [ + "plugin.json", + "forward.mjs", + FORWARD_RUNTIME_FILE, + "settings.json", + "hooks/hooks.json", + ], + }); + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "claude"); if (!wsl) return { installed: false }; diff --git a/src/supervisor/agents/claude/sdkSession.ts b/src/supervisor/agents/claude/sdkSession.ts index 81b0b6b6..a55aafba 100644 --- a/src/supervisor/agents/claude/sdkSession.ts +++ b/src/supervisor/agents/claude/sdkSession.ts @@ -28,6 +28,7 @@ import type { import { areAgentSlashCommandsEqual } from "@/shared/contracts"; import { buildClaudeBrowserMcpServers } from "./mcpBrowser"; import { + buildAgentCommand, createKnownSessionRef, getWslCommand, getPrimedPosixEnv, @@ -101,6 +102,7 @@ function projectCwd(location: ProjectLocation): string { switch (location.kind) { case "wsl": return location.linuxPath; + case "ssh": case "windows": case "posix": return location.path; @@ -212,6 +214,26 @@ function spawnClaudeNative(location: ProjectLocation, options: SpawnOptions): Sp }) as unknown as SpawnedProcess; } +function spawnClaudeInSsh(location: ProjectLocation, options: SpawnOptions): SpawnedProcess { + if (location.kind !== "ssh") { + throw new Error("spawnClaudeInSsh called for a non-SSH project."); + } + const command = options.command || "claude"; + const spec = buildAgentCommand( + location, + command, + options.args, + undefined, + filteredEnv(options.env), + ); + return spawn(spec.command, spec.args, { + env: process.env, + signal: options.signal, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }) as unknown as SpawnedProcess; +} + function isImageAttachment(segment: PromptSegment): boolean { return ( segment.kind === "attachment" && @@ -824,7 +846,7 @@ export class ClaudeSdkSession implements StructuredSessionHandle { (process.env as Record)) : undefined; const env = - this.input.projectLocation.kind === "wsl" + this.input.projectLocation.kind === "wsl" || this.input.projectLocation.kind === "ssh" ? { CLAUDE_AGENT_SDK_CLIENT_APP: "lightcode", BROWSER: "/bin/true" } : { ...(posixEnv ?? process.env), CLAUDE_AGENT_SDK_CLIENT_APP: "lightcode" }; // Posix builds ship without the SDK's bundled `claude` SEA binary @@ -862,6 +884,9 @@ export class ClaudeSdkSession implements StructuredSessionHandle { claudeExecutablePath = resolveAgentBinaryPath(this.input.projectLocation, "claude") ?? "claude"; break; + case "ssh": + claudeExecutablePath = "claude"; + break; default: { const _exhaustive: never = this.input.projectLocation; void _exhaustive; @@ -908,12 +933,17 @@ export class ClaudeSdkSession implements StructuredSessionHandle { spawnClaudeCodeProcess: (spawnOptions) => spawnClaudeInWsl(this.input.projectLocation, spawnOptions), } - : this.input.projectLocation.kind === "windows" + : this.input.projectLocation.kind === "ssh" ? { spawnClaudeCodeProcess: (spawnOptions) => - spawnClaudeNative(this.input.projectLocation, spawnOptions), + spawnClaudeInSsh(this.input.projectLocation, spawnOptions), } - : {}), + : this.input.projectLocation.kind === "windows" + ? { + spawnClaudeCodeProcess: (spawnOptions) => + spawnClaudeNative(this.input.projectLocation, spawnOptions), + } + : {}), }; this.queryRuntime = query({ prompt: this.promptQueue, options }); diff --git a/src/supervisor/agents/codex/index.ts b/src/supervisor/agents/codex/index.ts index 45be2ad8..8a3255c5 100644 --- a/src/supervisor/agents/codex/index.ts +++ b/src/supervisor/agents/codex/index.ts @@ -13,6 +13,7 @@ import { type TerminalStatusHint, } from "../base"; import { resolveAgentBinaryPath } from "../binaryResolver"; +import { readSshCommandOutput } from "../../ssh"; import { CodexStructuredSession } from "./acp"; import { buildCodexArgvFor } from "./argv"; import { codexDefaultCapabilities, codexDetectionSpec } from "./detection"; @@ -85,8 +86,10 @@ function codexOscHint(notification: OscNotification): TerminalStatusHint | null } async function resolveCodexHooksFeatureFlag(ctx: { - envKind: "windows" | "wsl" | "posix"; + envKind: "windows" | "wsl" | "posix" | "ssh"; wslDistro?: string; + sshHost?: string; + sshPath?: string; }): Promise { if (ctx.envKind === "wsl" && ctx.wslDistro) { const [verOut] = await batchWslCommandsAsync(ctx.wslDistro, ["codex --version"]); @@ -97,6 +100,20 @@ async function resolveCodexHooksFeatureFlag(ctx: { .find((line) => line.length > 0) ?? ""; return codexHooksFeatureFlagForSemver(parseCodexVersionLine(versionLine)); } + if (ctx.envKind === "ssh" && ctx.sshHost) { + const out = await readSshCommandOutput( + { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, + "codex", + ["--version"], + { timeout: 8_000 }, + ).catch(() => undefined); + const versionLine = + out?.stdout + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; + return codexHooksFeatureFlagForSemver(parseCodexVersionLine(versionLine)); + } return codexHooksFeatureFlagForSemver(probeCodexCliSemver()); } @@ -139,6 +156,30 @@ export function createCodexAdapter(): AgentAdapter { } return true; } + if (ctx.envKind === "ssh" && ctx.sshHost) { + const out = await readSshCommandOutput( + { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, + "codex", + ["--version"], + { timeout: 8_000 }, + ).catch(() => undefined); + const versionLine = + out?.stdout + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? ""; + const v = parseCodexVersionLine(versionLine); + if (!isCodexSemverSupportedForHooks(v)) { + console.warn( + `[codex] SSH hook plugin unsupported on host ${ctx.sshHost}: ` + + `need codex-cli >= ${CODEX_MIN_HOOKS_VERSION_LABEL}, got ${ + versionLine || "(unparseable `codex --version` output)" + }`, + ); + return false; + } + return true; + } return isCodexVersionSupportedForHooks(); }, isPluginInstalled(ctx) { diff --git a/src/supervisor/agents/codex/plugin/install.ts b/src/supervisor/agents/codex/plugin/install.ts index fd3fd2a5..752c5abd 100644 --- a/src/supervisor/agents/codex/plugin/install.ts +++ b/src/supervisor/agents/codex/plugin/install.ts @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { toWslUncPath } from "@/shared/wsl"; import type { AgentEnvContext } from "../../base"; import { batchWslCommands, quotePosixShellArg } from "../../base"; +import { runSshScriptSync } from "../../../ssh"; import { FORWARD_RUNTIME_FILE, buildNativeHookCommandHeads, @@ -21,15 +22,23 @@ import { ctxCacheKey, ensureNativeStateLink, getNativePluginBaseDir, + getSshPluginBaseDirs, getWslPluginBaseDirs, hasNativeHookWrapper, + isSshPluginContext, isWslPluginContext, memoByCtx, parseExistingHooksJson, + parseExistingSshJson, readBundledPluginVersion, readPluginManifest, + readSshTextFile, removeStagedPluginDir, + stagePluginAssetsToSsh, stagePluginAssetsToWsl, + sshPathExists, + verifySshStagedPlugin, + writeSshJsonFile, writeHooksJsonFile, writeNativeHookWrapper, type PluginManifest, @@ -78,6 +87,18 @@ export function readBundledCodexPluginVersion(): string { } function computeCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths { + if (isSshPluginContext(ctx)) { + const ssh = getSshPluginBaseDirs(ctx, "codex"); + if (!ssh) { + return { pluginDir: "", codexHomeDir: "", codexHooksPath: "", version: "0.0.0" }; + } + return { + pluginDir: ssh.linuxBase, + codexHomeDir: `${ssh.linuxBase}/home`, + codexHooksPath: `${ssh.linuxBase}/home/hooks.json`, + version: "0.0.0", + }; + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "codex"); if (!wsl) { @@ -245,6 +266,36 @@ function seedWslCodexHome(distro: string, home: string, linuxCodexHome: string): batchWslCommands(distro, [script]); } +function seedSshCodexHome( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + home: string, + linuxCodexHome: string, +): void { + const globalCodexHome = `${home}/.codex`; + const linkExists = (path: string) => + `[ -e ${quotePosixShellArg(path)} ] || [ -L ${quotePosixShellArg(path)} ]`; + const linkLine = (name: string, kind: "dir" | "file") => { + const target = quotePosixShellArg(`${linuxCodexHome}/${name}`); + const source = quotePosixShellArg(`${globalCodexHome}/${name}`); + const attempts = [ + linkExists(`${linuxCodexHome}/${name}`), + `ln -s ${source} ${target}`, + ...(kind === "file" ? [`ln ${source} ${target}`, `cp ${source} ${target}`] : []), + ]; + return attempts.join(" || "); + }; + const script = [ + [ + "mkdir -p", + quotePosixShellArg(linuxCodexHome), + quotePosixShellArg(`${globalCodexHome}/sessions`), + ].join(" "), + `touch ${quotePosixShellArg(`${globalCodexHome}/session_index.jsonl`)}`, + ...CODEX_LINK_TARGETS.map(({ name, kind }) => linkLine(name, kind)), + ].join("\n"); + runSshScriptSync({ kind: "ssh", host: ctx.sshHost, path: "/" }, script); +} + const MIN_CODEX_SEMVER = [0, 122, 0] as const; const CODEX_HOOKS_FEATURE_RENAME_SEMVER = [0, 130, 0] as const; const CODEX_GOALS_FEATURE_SEMVER = [0, 130, 0] as const; @@ -342,6 +393,15 @@ export function installCodexPlugin( } return installCodexPluginWsl(ctx.wslDistro, sourceDir, manifest, options.resolvedNodePath); } + if (isSshPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: "SSH Codex plugin install requires a resolved node path on the remote host.", + }; + } + return installCodexPluginSsh(ctx, sourceDir, manifest, options.resolvedNodePath); + } const pluginDir = getNativePluginBaseDir("codex", ctx?.baseDir); const codexHomeDir = join(pluginDir, "home"); @@ -396,6 +456,58 @@ export function installCodexPlugin( }; } +function installCodexPluginSsh( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, +): { ok: true; paths: CodexPluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToSsh(ctx, sourceDir, "codex", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const linuxCodexHome = `${staged.linuxPluginDir}/home`; + seedSshCodexHome(ctx, staged.deploy.home, linuxCodexHome); + const hooksPath = `${linuxCodexHome}/hooks.json`; + const existing = parseExistingSshJson(ctx, hooksPath); + if (existing === null && sshPathExists(ctx, hooksPath)) { + return { + ok: false, + reason: `malformed private Codex hooks.json on ssh host ${ctx.sshHost}`, + }; + } + + const commandHead = `${JSON.stringify(resolvedNodePath)} ${JSON.stringify(`${staged.linuxPluginDir}/forward.mjs`)}`; + const merged = mergeCodexHooksDocument(existing, commandHead); + const writeResult = writeSshJsonFile(ctx, hooksPath, merged); + if (!writeResult.ok) { + return { + ok: false, + reason: `failed to write hooks.json on ssh host ${ctx.sshHost}: ${writeResult.reason}`, + }; + } + + console.log( + [ + `[supervisor] Codex hook plugin staged v${manifest.version} (ssh:${ctx.sshHost})`, + ` pluginDir: ${staged.linuxPluginDir}`, + ` CODEX_HOME: ${linuxCodexHome}`, + ].join("\n"), + ); + + return { + ok: true, + version: manifest.version, + paths: { + pluginDir: staged.linuxPluginDir, + codexHomeDir: linuxCodexHome, + codexHooksPath: hooksPath, + version: manifest.version, + }, + }; +} + function installCodexPluginWsl( distro: string, sourceDir: string, @@ -461,6 +573,15 @@ function installCodexPluginWsl( export function isCodexPluginInstalled( ctx?: AgentEnvContext, ): Promise<{ installed: boolean; version?: string }> { + if (isSshPluginContext(ctx)) { + const paths = getCodexPluginPaths(ctx); + return Promise.resolve( + verifySshStagedPlugin(ctx, "codex", { + assets: ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE, "home/hooks.json"], + extraCheck: () => codexHooksHaveLightcodeEntry(readSshTextFile(ctx, paths.codexHooksPath)), + }), + ); + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "codex"); if (!wsl) return Promise.resolve({ installed: false }); @@ -514,3 +635,28 @@ function verifyCodexInstallAt( return { installed: false }; } } + +function codexHooksHaveLightcodeEntry(raw: string | undefined): boolean { + if (!raw) return false; + try { + const doc = JSON.parse(raw) as { hooks?: Record }; + if (!doc.hooks) return false; + for (const event of CODEX_HOOK_EVENTS) { + const groups = doc.hooks[event]; + if (!Array.isArray(groups)) continue; + for (const g of groups) { + if (!g || typeof g !== "object") continue; + const hooks = (g as { hooks?: unknown }).hooks; + if (!Array.isArray(hooks)) continue; + for (const h of hooks) { + if (!h || typeof h !== "object") continue; + const cmd = (h as { command?: string }).command; + if (typeof cmd === "string" && LIGHTCODE_FORWARD_RE.test(cmd)) return true; + } + } + } + return false; + } catch { + return false; + } +} diff --git a/src/supervisor/agents/codex/session.ts b/src/supervisor/agents/codex/session.ts index c23d9e98..92907c0d 100644 --- a/src/supervisor/agents/codex/session.ts +++ b/src/supervisor/agents/codex/session.ts @@ -7,9 +7,12 @@ import { findSessionFiles, quotePosixShellArg, readSessionFileText, + readSshCommandOutputSync, readWslCommandOutput, + resolveSshHomeDirectory, resolveWslHomeDirectory, resolveWslShellPath, + runSshScriptSync, } from "../base"; import { parseCodexRolloutIdFromPath, @@ -36,6 +39,12 @@ function wslPrivateCodexHome(distro: string): string | undefined { return home ? `${home}/.lightcode/agent-plugins/codex/home` : undefined; } +function sshCodexHomeCandidates(location: Extract): string[] { + const home = resolveSshHomeDirectory(location); + if (!home) return []; + return [`${home}/.codex`, `${home}/.lightcode/agent-plugins/codex/home`]; +} + function dedupeById(items: T[], getUpdatedAt: (item: T) => number): T[] { const byId = new Map(); for (const item of items) { @@ -63,6 +72,8 @@ export function describeCodexLocation(location: ProjectLocation): string { return `windows:${location.path}`; case "wsl": return `wsl:${location.distro}:${location.linuxPath}`; + case "ssh": + return `ssh:${location.host}:${location.path}`; case "posix": return `posix:${location.path}`; } @@ -84,6 +95,16 @@ export function readCodexSessionIndexForLocation(location: ProjectLocation) { return parseCodexSessionIndex(result.stdout); } + if (location.kind === "ssh") { + const commands = sshCodexHomeCandidates(location).map( + (home) => `cat ${quotePosixShellArg(`${home}/session_index.jsonl`)} 2>/dev/null || true`, + ); + if (commands.length === 0) return []; + const result = runSshScriptSync(location, commands.join("\n"), { timeout: 10_000 }); + if (!result.ok || result.stdout.length === 0) return []; + return dedupeSessionIndex(parseCodexSessionIndex(result.stdout)); + } + const sessions = readCodexSessionIndex(); const privateIndexPath = join(nativePrivateCodexHome(), "session_index.jsonl"); let privateRaw: string; @@ -104,6 +125,13 @@ export function readCodexSessionIndexForLocation(location: ProjectLocation) { export async function readCodexSessionIndexForLocationAsync( location: ProjectLocation, ): Promise> { + if (location.kind === "ssh") { + const paths = sshCodexHomeCandidates(location).map((home) => `${home}/session_index.jsonl`); + const reads = await Promise.all(paths.map((p) => readSessionFileText(location, p))); + const parts = reads.filter((r): r is string => typeof r === "string" && r.length > 0); + if (parts.length === 0) return []; + return dedupeSessionIndex(parts.flatMap((raw) => parseCodexSessionIndex(raw))); + } if (location.kind !== "wsl") { return readCodexSessionIndexForLocation(location); } @@ -137,6 +165,8 @@ export function isInteractiveCodexRollout( return rollout.cwd === location.path; case "posix": return rollout.cwd === location.path; + case "ssh": + return rollout.cwd === location.path; case "wsl": return rollout.cwd === location.linuxPath || rollout.cwd === location.uncPath; } @@ -188,6 +218,45 @@ export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRo ); } + if (location.kind === "ssh") { + const roots = sshCodexHomeCandidates(location) + .map((home) => quotePosixShellArg(`${home}/sessions`)) + .join(" "); + if (!roots) return []; + const result = runSshScriptSync( + location, + [ + `find ${roots} -type f -name 'rollout-*.jsonl' 2>/dev/null | while IFS= read -r path; do`, + ` mtime=$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null || printf 0)`, + ` printf '%s\\t%s\\n' "$mtime" "$path"`, + `done`, + ].join("\n"), + { timeout: 15_000 }, + ); + if (!result.ok || result.stdout.length === 0) return []; + return dedupeRollouts( + result.stdout + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + const [mtimeRaw, path] = line.split("\t"); + if (!path) return []; + const updatedAt = Number.isFinite(Number(mtimeRaw)) + ? Math.round(Number(mtimeRaw) * 1000) + : undefined; + const id = parseCodexRolloutIdFromPath(path); + if (!id) return []; + const meta: CodexRolloutMeta = { + id, + path, + ...(updatedAt !== undefined ? { updatedAt } : {}), + }; + return [meta]; + }), + ); + } + const rollouts: CodexRolloutMeta[] = []; const walk = (dir: string) => { let entries: import("node:fs").Dirent[]; @@ -251,6 +320,14 @@ export function readCodexRolloutMetaForLocation( return parseCodexRolloutMeta(rollout.path, result.stdout, rollout.updatedAt) ?? rollout; } + if (location.kind === "ssh") { + const result = readSshCommandOutputSync(location, "head", ["-n", "1", "--", rollout.path], { + timeout: 10_000, + }); + if (!result.ok || result.stdout.length === 0) return rollout; + return parseCodexRolloutMeta(rollout.path, result.stdout, rollout.updatedAt) ?? rollout; + } + try { const firstLine = readFileSync(rollout.path, "utf8").split(/\r?\n/g)[0] ?? ""; return parseCodexRolloutMeta(rollout.path, firstLine, rollout.updatedAt) ?? rollout; @@ -263,6 +340,12 @@ export async function readCodexRolloutMetaForLocationAsync( location: ProjectLocation, rollout: CodexRolloutMeta, ): Promise { + if (location.kind === "ssh") { + const text = await readSessionFileText(location, rollout.path); + if (!text) return rollout; + const firstLine = text.split(/\r?\n/g)[0] ?? ""; + return parseCodexRolloutMeta(rollout.path, firstLine, rollout.updatedAt) ?? rollout; + } if (location.kind !== "wsl") { return readCodexRolloutMetaForLocation(location, rollout); } @@ -275,15 +358,20 @@ export async function readCodexRolloutMetaForLocationAsync( export async function readCodexRolloutsForLocationAsync( location: ProjectLocation, ): Promise { - if (location.kind !== "wsl") { + if (location.kind !== "wsl" && location.kind !== "ssh") { return readCodexRolloutsForLocation(location); } - const home = resolveWslHomeDirectory(location.distro); - const privateHome = wslPrivateCodexHome(location.distro); - const roots = [ - home ? `${home}/.codex/sessions` : undefined, - privateHome ? `${privateHome}/sessions` : undefined, - ].filter((r): r is string => Boolean(r)); + let roots: string[]; + if (location.kind === "ssh") { + roots = sshCodexHomeCandidates(location).map((home) => `${home}/sessions`); + } else { + const home = resolveWslHomeDirectory(location.distro); + const privateHome = wslPrivateCodexHome(location.distro); + roots = [ + home ? `${home}/.codex/sessions` : undefined, + privateHome ? `${privateHome}/sessions` : undefined, + ].filter((r): r is string => Boolean(r)); + } const accept = (name: string): boolean => name.startsWith("rollout-") && name.endsWith(".jsonl"); const found = ( @@ -322,6 +410,9 @@ export function resolveCodexSessionWatchPaths(location: ProjectLocation): string privateHome ? `${privateHome}/sessions` : undefined, ].filter((p): p is string => Boolean(p)); } + if (location.kind === "ssh") { + return sshCodexHomeCandidates(location).map((home) => `${home}/sessions`); + } const paths: string[] = []; const publicSessions = join(homedir(), ".codex", "sessions"); if (existsSync(publicSessions)) paths.push(publicSessions); diff --git a/src/supervisor/agents/copilot/plugin/install.ts b/src/supervisor/agents/copilot/plugin/install.ts index d54d72f6..ff9d4578 100644 --- a/src/supervisor/agents/copilot/plugin/install.ts +++ b/src/supervisor/agents/copilot/plugin/install.ts @@ -13,14 +13,21 @@ import { createPluginSourceResolver, ctxCacheKey, getNativePluginBaseDir, + getSshPluginBaseDirs, getWslPluginBaseDirs, + isSshPluginContext, isWslPluginContext, memoByCtx, readBundledPluginVersion, readPluginManifest, + readSshTextFile, + removeSshFile, removeStagedPluginDir, + stagePluginAssetsToSsh, stagePluginAssetsToWsl, verifyStagedPluginAt, + verifySshStagedPlugin, + writeSshTextFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; @@ -106,6 +113,15 @@ function wslGlobalCopilotDir(distro: string): string { } function computeCopilotPluginPaths(ctx?: AgentEnvContext): CopilotPluginPaths { + if (isSshPluginContext(ctx)) { + const ssh = getSshPluginBaseDirs(ctx, "copilot"); + if (!ssh) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; + return { + pluginDir: ssh.linuxBase, + globalHookFilePath: `${ssh.home}/.copilot/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`, + version: "0.0.0", + }; + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "copilot"); if (!wsl) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; @@ -197,6 +213,21 @@ export function installCopilotPlugin( options.globalCopilotDirOverride, ); } + if (isSshPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: "SSH Copilot plugin install requires a resolved node path on the remote host.", + }; + } + return installCopilotPluginSsh( + ctx, + sourceDir, + manifest, + options.resolvedNodePath, + options.globalCopilotDirOverride, + ); + } const pluginDir = getNativePluginBaseDir("copilot", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -234,6 +265,49 @@ export function installCopilotPlugin( }; } +function installCopilotPluginSsh( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, + globalCopilotDirOverride: string | undefined, +): { ok: true; paths: CopilotPluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToSsh(ctx, sourceDir, "copilot", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const linuxForward = `${staged.linuxPluginDir}/forward.mjs`; + const linuxCopilotDir = globalCopilotDirOverride ?? `${staged.deploy.home}/.copilot`; + const hookFilePath = `${linuxCopilotDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`; + const bashCommand = buildWslHookCommandHead(resolvedNodePath, linuxForward); + const writeResult = writeSshCopilotHookFileIfChanged(ctx, hookFilePath, { bashCommand }); + if (!writeResult.ok) { + return { + ok: false, + reason: `failed to write Copilot hook file at ${hookFilePath} on ssh host ${ctx.sshHost}: ${writeResult.reason}`, + }; + } + + console.log( + [ + `[supervisor] Copilot hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost}`, + ` pluginDir: ${staged.linuxPluginDir}`, + ` hookFile: ${hookFilePath}`, + ].join("\n"), + ); + + return { + ok: true, + version: manifest.version, + paths: { + pluginDir: staged.linuxPluginDir, + globalHookFilePath: hookFilePath, + version: manifest.version, + }, + }; +} + function installCopilotPluginWsl( distro: string, sourceDir: string, @@ -286,6 +360,13 @@ export function isCopilotPluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { + if (isSshPluginContext(ctx)) { + const paths = getCopilotPluginPaths(ctx); + return verifySshStagedPlugin(ctx, "copilot", { + assets: COPILOT_VERIFY_ASSETS, + extraCheck: () => readSshTextFile(ctx, paths.globalHookFilePath) !== undefined, + }); + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "copilot"); if (!wsl) return { installed: false }; @@ -306,6 +387,14 @@ export function isCopilotPluginInstalled(ctx?: AgentEnvContext): { } export function uninstallCopilotPlugin(ctx?: AgentEnvContext): void { + if (isSshPluginContext(ctx)) { + const paths = getCopilotPluginPaths(ctx); + if (readSshTextFile(ctx, paths.globalHookFilePath) !== undefined) { + removeSshFile(ctx, paths.globalHookFilePath); + } + removeStagedPluginDir("copilot", ctx); + return; + } const hookFile = isWslPluginContext(ctx) ? toWslUncPath( ctx.wslDistro, @@ -382,5 +471,15 @@ function writeCopilotHookFileIfChanged( } } +function writeSshCopilotHookFileIfChanged( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + hookFilePath: string, + input: { bashCommand: string; powershellCommand?: string }, +): { ok: true } | { ok: false; reason: string } { + const serialized = `${JSON.stringify(renderCopilotHookConfig(input), null, 2)}\n`; + if (readSshTextFile(ctx, hookFilePath) === serialized) return { ok: true }; + return writeSshTextFile(ctx, hookFilePath, serialized); +} + /** Exposed for tests. */ export { COPILOT_HOOK_EVENTS, GLOBAL_HOOK_FILENAME, GLOBAL_HOOK_DIR_NAME }; diff --git a/src/supervisor/agents/cursor/plugin/install.ts b/src/supervisor/agents/cursor/plugin/install.ts index b565d54c..8ff668c0 100644 --- a/src/supervisor/agents/cursor/plugin/install.ts +++ b/src/supervisor/agents/cursor/plugin/install.ts @@ -14,14 +14,22 @@ import { createPluginSourceResolver, ctxCacheKey, getNativePluginBaseDir, + getSshPluginBaseDirs, getWslPluginBaseDirs, + isSshPluginContext, isWslPluginContext, memoByCtx, parseExistingHooksJson, + parseExistingSshJson, readBundledPluginVersion, readPluginManifest, + readSshTextFile, removeStagedPluginDir, + stagePluginAssetsToSsh, stagePluginAssetsToWsl, + sshPathExists, + verifySshStagedPlugin, + writeSshJsonFile, verifyStagedPluginAt, writeHooksJsonFile, writeNativeHookWrapper, @@ -90,6 +98,14 @@ function wslGlobalCursorHooksPath(distro: string): string { } function computeCursorPluginPaths(ctx?: AgentEnvContext): CursorPluginPaths { + if (isSshPluginContext(ctx)) { + const ssh = getSshPluginBaseDirs(ctx, "cursor"); + if (!ssh) return { pluginDir: "", globalHooksPath: "" }; + return { + pluginDir: ssh.linuxBase, + globalHooksPath: `${ssh.home}/.cursor/hooks.json`, + }; + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "cursor"); if (!wsl) return { pluginDir: "", globalHooksPath: "" }; @@ -237,6 +253,21 @@ export function installCursorPlugin( options.globalCursorDirOverride, ); } + if (isSshPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: "SSH Cursor plugin install requires a resolved node path on the remote host.", + }; + } + return installCursorPluginSsh( + ctx, + sourceDir, + manifest, + options.resolvedNodePath, + options.globalCursorDirOverride, + ); + } const pluginDir = getNativePluginBaseDir("cursor", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -278,6 +309,53 @@ export function installCursorPlugin( }; } +function installCursorPluginSsh( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, + globalCursorDirOverride: string | undefined, +): { ok: true; paths: CursorPluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToSsh(ctx, sourceDir, "cursor", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const hooksPath = globalCursorDirOverride + ? `${globalCursorDirOverride}/hooks.json` + : `${staged.deploy.home}/.cursor/hooks.json`; + const existing = parseExistingSshJson(ctx, hooksPath); + if (existing === null && sshPathExists(ctx, hooksPath)) { + return { + ok: false, + reason: `malformed Cursor hooks.json at ${hooksPath} on ssh host ${ctx.sshHost}`, + }; + } + + const commandHead = buildWslHookCommandHead( + resolvedNodePath, + `${staged.linuxPluginDir}/forward.mjs`, + ); + const merged = mergeCursorHooksDocument(existing, commandHead); + const writeResult = writeSshJsonFile(ctx, hooksPath, merged); + if (!writeResult.ok) { + return { + ok: false, + reason: `failed to write hooks.json at ${hooksPath} on ssh host ${ctx.sshHost}: ${writeResult.reason}`, + }; + } + + console.log( + `[supervisor] Cursor hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost} at ${staged.linuxPluginDir}; merged hooks into ${hooksPath}`, + ); + + return { + ok: true, + version: manifest.version, + paths: { pluginDir: staged.linuxPluginDir, globalHooksPath: hooksPath }, + }; +} + function installCursorPluginWsl( distro: string, sourceDir: string, @@ -332,6 +410,16 @@ function installCursorPluginWsl( export function isCursorPluginInstalled( ctx?: AgentEnvContext, ): Promise<{ installed: boolean; version?: string }> { + if (isSshPluginContext(ctx)) { + const paths = getCursorPluginPaths(ctx); + return Promise.resolve( + verifySshStagedPlugin(ctx, "cursor", { + assets: CURSOR_VERIFY_ASSETS, + extraCheck: () => + hooksJsonTextHasLightcodeEntry(readSshTextFile(ctx, paths.globalHooksPath)), + }), + ); + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "cursor"); if (!wsl) return Promise.resolve({ installed: false }); @@ -345,6 +433,15 @@ export function isCursorPluginInstalled( } export function uninstallCursorPlugin(ctx?: AgentEnvContext): void { + if (isSshPluginContext(ctx)) { + const paths = getCursorPluginPaths(ctx); + const existing = parseExistingSshJson(ctx, paths.globalHooksPath); + if (existing !== null || sshPathExists(ctx, paths.globalHooksPath)) { + void writeSshJsonFile(ctx, paths.globalHooksPath, removeCursorHooksDocument(existing)); + } + removeStagedPluginDir("cursor", ctx); + return; + } const hooksPath = isWslPluginContext(ctx) ? toWslUncPath(ctx.wslDistro, wslGlobalCursorHooksPath(ctx.wslDistro)) : join(nativeGlobalCursorDir(), "hooks.json"); @@ -375,6 +472,26 @@ function hooksJsonHasLightcodeEntry(hooksPath: string): boolean { } } +function hooksJsonTextHasLightcodeEntry(raw: string | undefined): boolean { + if (!raw) return false; + try { + const doc = JSON.parse(raw) as { hooks?: Record }; + if (!doc.hooks) return false; + for (const spec of CURSOR_HOOK_SPECS) { + const entries = doc.hooks[spec.event]; + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const cmd = (entry as { command?: string }).command; + if (typeof cmd === "string" && LIGHTCODE_FORWARD_RE.test(cmd)) return true; + } + } + return false; + } catch { + return false; + } +} + const CURSOR_VERIFY_ASSETS = ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE] as const; function verifyCursorInstallAt( diff --git a/src/supervisor/agents/gemini/detection.ts b/src/supervisor/agents/gemini/detection.ts index aa39be31..b89b00c3 100644 --- a/src/supervisor/agents/gemini/detection.ts +++ b/src/supervisor/agents/gemini/detection.ts @@ -13,6 +13,7 @@ import { } from "../base"; import { buildContextSizeCapabilities } from "../contextWindowLabel"; import { getAgentProbeCwd } from "../probeCwd"; +import { runSshScript } from "../../ssh"; // Gemini's ACP probe reports the selectable model ids/names, but not token // limits. Keep this as an exact documented allowlist so new ids do not inherit @@ -55,6 +56,12 @@ export const defaultGeminiCapabilities: AgentCapability = { // Gemini stores a config dir at ~/.gemini after first login; treat its // presence as authenticated even without GEMINI_API_KEY set. const configDirAuthProbe: AuthProbe = async (ctx) => { + if (ctx.location.kind === "ssh") { + const result = await runSshScript(ctx.location, "test -d ~/.gemini && echo yes").catch( + () => undefined, + ); + return result?.stdout.trim() === "yes" ? "authenticated" : "unknown"; + } if (ctx.location.kind !== "wsl") { return existsSync(join(homedir(), ".gemini")) ? "authenticated" : "unknown"; } @@ -82,20 +89,33 @@ export function parseGeminiGoogleAccountsJson(raw: string): string | undefined { } async function probeGeminiMetadata(ctx: Parameters>[0]) { - if (ctx.location.kind === "wsl") { - const [apiKeyResult, configDirResult, accountsResult] = await batchWslCommandsAsync( - ctx.location.distro, - [ - 'printf %s "$GEMINI_API_KEY"', - "test -d ~/.gemini && echo yes", - 'cat ~/.gemini/google_accounts.json 2>/dev/null || printf ""', - ], - ); - const apiKeySet = !!(apiKeyResult?.ok && apiKeyResult.stdout.trim().length > 0); - const configDirPresent = !!(configDirResult?.ok && configDirResult.stdout.trim() === "yes"); + if (ctx.location.kind === "wsl" || ctx.location.kind === "ssh") { + const commands = [ + 'printf %s "$GEMINI_API_KEY"', + "test -d ~/.gemini && echo yes", + 'cat ~/.gemini/google_accounts.json 2>/dev/null || printf ""', + ]; + let outputs: string[]; + if (ctx.location.kind === "wsl") { + outputs = (await batchWslCommandsAsync(ctx.location.distro, commands)).map((result) => + result?.ok ? result.stdout : "", + ); + } else { + const sshLocation = ctx.location; + outputs = await Promise.all( + commands.map((command) => + runSshScript(sshLocation, command) + .then((result) => result.stdout) + .catch(() => ""), + ), + ); + } + const [apiKeyStdout, configDirStdout, accountsStdout] = outputs; + const apiKeySet = !!apiKeyStdout?.trim(); + const configDirPresent = configDirStdout?.trim() === "yes"; const activeAccount = - !apiKeySet && accountsResult?.ok && accountsResult.stdout.length > 0 - ? parseGeminiGoogleAccountsJson(accountsResult.stdout) + !apiKeySet && accountsStdout && accountsStdout.length > 0 + ? parseGeminiGoogleAccountsJson(accountsStdout) : undefined; const providerMetadata = compactAgentProviderMetadata({ ...(activeAccount ? { authenticatedAs: activeAccount } : {}), diff --git a/src/supervisor/agents/gemini/plugin/install.ts b/src/supervisor/agents/gemini/plugin/install.ts index 75462673..2042225f 100644 --- a/src/supervisor/agents/gemini/plugin/install.ts +++ b/src/supervisor/agents/gemini/plugin/install.ts @@ -15,14 +15,20 @@ import { ctxCacheKey, getNativeHookWrapperFilename, getNativePluginBaseDir, + getSshPluginBaseDirs, getWslPluginBaseDirs, hasNativeHookWrapper, + isSshPluginContext, isWslPluginContext, memoByCtx, readBundledPluginVersion, readPluginManifest, + readSshTextFile, removeStagedPluginDir, + stagePluginAssetsToSsh, stagePluginAssetsToWsl, + verifySshStagedPlugin, + writeSshJsonFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; @@ -100,6 +106,15 @@ export function readBundledGeminiPluginVersion(): string { } function computeGeminiPluginPaths(ctx?: AgentEnvContext): GeminiPluginPaths { + if (isSshPluginContext(ctx)) { + const ssh = getSshPluginBaseDirs(ctx, "gemini"); + if (!ssh) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; + return { + pluginDir: ssh.linuxBase, + settingsPath: `${ssh.linuxBase}/settings.json`, + version: "0.0.0", + }; + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "gemini"); if (!wsl) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; @@ -146,6 +161,26 @@ export function syncGeminiBrowserMcpSettings( if (ctx?.browserMcpEnabled === undefined) return; const paths = getGeminiPluginPaths(ctx); if (!paths.settingsPath) return; + if (isSshPluginContext(ctx)) { + try { + const raw = readSshTextFile(ctx, paths.settingsPath); + if (!raw) return; + const settings = JSON.parse(raw) as GeminiSettings; + if (ctx.browserMcpEnabled && browserMcp) { + const servers = buildGeminiBrowserMcpServers( + { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, + browserMcp, + ); + if (servers) settings.mcpServers = servers; + } else { + delete settings.mcpServers; + } + void writeSshJsonFile(ctx, paths.settingsPath, settings); + } catch { + // Best-effort; stale settings should not block thread launch. + } + return; + } const settingsPath = resolveSettingsWritePath(ctx, paths.settingsPath); try { const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as GeminiSettings; @@ -212,6 +247,21 @@ export function installGeminiPlugin( ctx.browserMcp, ); } + if (isSshPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: "SSH Gemini plugin install requires a resolved node path on the remote host.", + }; + } + return installGeminiPluginSsh( + ctx, + sourceDir, + manifest, + options.resolvedNodePath, + ctx.browserMcp, + ); + } const pluginDir = getNativePluginBaseDir("gemini", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -241,6 +291,45 @@ export function installGeminiPlugin( }; } +function installGeminiPluginSsh( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, + browserMcp?: BrowserMcpHttpConfig, +): { ok: true; paths: GeminiPluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToSsh(ctx, sourceDir, "gemini", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const settingsPath = `${staged.linuxPluginDir}/settings.json`; + const headExpression = buildWslHookCommandHead( + resolvedNodePath, + `${staged.linuxPluginDir}/forward.mjs`, + ); + const browserMcpServers = buildGeminiBrowserMcpServers( + { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, + browserMcp, + ); + const settings = renderGeminiSettings({ + headExpression, + ...(browserMcpServers ? { mcpServers: browserMcpServers } : {}), + }); + const writeResult = writeSshJsonFile(ctx, settingsPath, settings); + if (!writeResult.ok) return writeResult; + + console.log( + `[supervisor] Gemini hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost} at ${staged.linuxPluginDir} (forward.mjs, settings.json)`, + ); + + return { + ok: true, + version: manifest.version, + paths: { pluginDir: staged.linuxPluginDir, settingsPath, version: manifest.version }, + }; +} + function installGeminiPluginWsl( distro: string, sourceDir: string, @@ -295,6 +384,13 @@ export function isGeminiPluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { + if (isSshPluginContext(ctx)) { + const paths = getGeminiPluginPaths(ctx); + return verifySshStagedPlugin(ctx, "gemini", { + assets: ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE, "settings.json"], + extraCheck: () => hasGeminiHooksFromRaw(readSshTextFile(ctx, paths.settingsPath)), + }); + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "gemini"); if (!wsl) return { installed: false }; @@ -355,6 +451,16 @@ function hasGeminiHooks(hooks: Record | undefined): boolean { return true; } +function hasGeminiHooksFromRaw(raw: string | undefined): boolean { + if (!raw) return false; + try { + const settings = JSON.parse(raw) as { hooks?: Record }; + return hasGeminiHooks(settings.hooks); + } catch { + return false; + } +} + export interface RenderGeminiSettingsOptions { headExpression: string; mcpServers?: GeminiSettings["mcpServers"]; diff --git a/src/supervisor/agents/grok/detection.ts b/src/supervisor/agents/grok/detection.ts index 3675c96d..6f5c39dc 100644 --- a/src/supervisor/agents/grok/detection.ts +++ b/src/supervisor/agents/grok/detection.ts @@ -17,6 +17,7 @@ import { } from "../base"; import { buildContextSizeCapabilities } from "../contextWindowLabel"; import { getAgentProbeCwd, resolveProbeSpawnCwd } from "../probeCwd"; +import { runSshScript } from "../../ssh"; // Approval policies surfaced to Lightcode. Grok only honors `--always-approve` // (bypass) at launch — `--permission-mode ` is headless-only and is @@ -146,6 +147,13 @@ async function grokAuthFileProbe( if (existsSync(join(home, ".grok", "auth.json"))) return "authenticated"; return "unknown"; }; + if (ctx.location.kind === "ssh") { + const result = await runSshScript( + ctx.location, + "test -f ~/.grok/auth.json && echo yes || echo no", + ).catch(() => undefined); + return result?.stdout.trim() === "yes" ? "authenticated" : "unknown"; + } if (ctx.location.kind !== "wsl") { return check(homedir()); } diff --git a/src/supervisor/agents/grok/plugin/install.ts b/src/supervisor/agents/grok/plugin/install.ts index 7ab76669..1b8851e0 100644 --- a/src/supervisor/agents/grok/plugin/install.ts +++ b/src/supervisor/agents/grok/plugin/install.ts @@ -13,14 +13,21 @@ import { createPluginSourceResolver, ctxCacheKey, getNativePluginBaseDir, + getSshPluginBaseDirs, getWslPluginBaseDirs, + isSshPluginContext, isWslPluginContext, memoByCtx, readBundledPluginVersion, readPluginManifest, + readSshTextFile, + removeSshFile, removeStagedPluginDir, + stagePluginAssetsToSsh, stagePluginAssetsToWsl, verifyStagedPluginAt, + verifySshStagedPlugin, + writeSshTextFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; @@ -85,6 +92,15 @@ function wslGlobalGrokDir(distro: string): string { } function computeGrokPluginPaths(ctx?: AgentEnvContext): GrokPluginPaths { + if (isSshPluginContext(ctx)) { + const ssh = getSshPluginBaseDirs(ctx, "grok"); + if (!ssh) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; + return { + pluginDir: ssh.linuxBase, + globalHookFilePath: `${ssh.home}/${GLOBAL_GROK_DIR_NAME}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`, + version: "0.0.0", + }; + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "grok"); if (!wsl) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; @@ -175,6 +191,21 @@ export function installGrokPlugin( options.globalGrokDirOverride, ); } + if (isSshPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: "SSH Grok plugin install requires a resolved node path on the remote host.", + }; + } + return installGrokPluginSsh( + ctx, + sourceDir, + manifest, + options.resolvedNodePath, + options.globalGrokDirOverride, + ); + } const pluginDir = getNativePluginBaseDir("grok", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -211,6 +242,49 @@ export function installGrokPlugin( }; } +function installGrokPluginSsh( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, + globalGrokDirOverride: string | undefined, +): { ok: true; paths: GrokPluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToSsh(ctx, sourceDir, "grok", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const linuxForward = `${staged.linuxPluginDir}/forward.mjs`; + const linuxGrokDir = globalGrokDirOverride ?? `${staged.deploy.home}/${GLOBAL_GROK_DIR_NAME}`; + const hookFilePath = `${linuxGrokDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`; + const command = buildWslHookCommandHead(resolvedNodePath, linuxForward); + const writeResult = writeSshGrokHookFileIfChanged(ctx, hookFilePath, { command }); + if (!writeResult.ok) { + return { + ok: false, + reason: `failed to write Grok hook file at ${hookFilePath} on ssh host ${ctx.sshHost}: ${writeResult.reason}`, + }; + } + + console.log( + [ + `[supervisor] Grok hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost}`, + ` pluginDir: ${staged.linuxPluginDir}`, + ` hookFile: ${hookFilePath}`, + ].join("\n"), + ); + + return { + ok: true, + version: manifest.version, + paths: { + pluginDir: staged.linuxPluginDir, + globalHookFilePath: hookFilePath, + version: manifest.version, + }, + }; +} + function installGrokPluginWsl( distro: string, sourceDir: string, @@ -263,6 +337,14 @@ export function isGrokPluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { + if (isSshPluginContext(ctx)) { + const paths = getGrokPluginPaths(ctx); + return verifySshStagedPlugin(ctx, "grok", { + assets: GROK_VERIFY_ASSETS, + extraCheck: () => + hookFileTextMatchesLightcode(readSshTextFile(ctx, paths.globalHookFilePath)), + }); + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "grok"); if (!wsl) return { installed: false }; @@ -283,6 +365,14 @@ export function isGrokPluginInstalled(ctx?: AgentEnvContext): { } export function uninstallGrokPlugin(ctx?: AgentEnvContext): void { + if (isSshPluginContext(ctx)) { + const paths = getGrokPluginPaths(ctx); + if (hookFileTextMatchesLightcode(readSshTextFile(ctx, paths.globalHookFilePath))) { + removeSshFile(ctx, paths.globalHookFilePath); + } + removeStagedPluginDir("grok", ctx); + return; + } const hookFile = isWslPluginContext(ctx) ? toWslUncPath( ctx.wslDistro, @@ -333,6 +423,32 @@ function hookFileMatchesLightcode(path: string): boolean { } } +function hookFileTextMatchesLightcode(raw: string | undefined): boolean { + if (!raw) return false; + try { + const parsed = JSON.parse(raw) as { hooks?: Record }; + if (!parsed.hooks || typeof parsed.hooks !== "object") return false; + for (const event of GROK_HOOK_EVENTS) { + const groups = parsed.hooks[event]; + if (!Array.isArray(groups) || groups.length === 0) return false; + const found = groups.some((group) => { + if (!group || typeof group !== "object") return false; + const hookEntries = (group as { hooks?: unknown }).hooks; + if (!Array.isArray(hookEntries)) return false; + return hookEntries.some((hook) => { + if (!hook || typeof hook !== "object") return false; + const command = (hook as { command?: unknown }).command; + return typeof command === "string" && LIGHTCODE_GROK_HOOK_RE.test(command); + }); + }); + if (!found) return false; + } + return true; + } catch { + return false; + } +} + // ── Hook config rendering / write ───────────────────────────────────────── interface GrokHookCommand { @@ -396,4 +512,14 @@ function writeGrokHookFileIfChanged( } } +function writeSshGrokHookFileIfChanged( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + hookFilePath: string, + input: { command: string }, +): { ok: true } | { ok: false; reason: string } { + const serialized = `${JSON.stringify(renderGrokHookConfig(input), null, 2)}\n`; + if (readSshTextFile(ctx, hookFilePath) === serialized) return { ok: true }; + return writeSshTextFile(ctx, hookFilePath, serialized); +} + export { GROK_HOOK_EVENTS, GLOBAL_HOOK_FILENAME, GLOBAL_HOOK_DIR_NAME }; diff --git a/src/supervisor/agents/grok/sessionFiles.ts b/src/supervisor/agents/grok/sessionFiles.ts index 1333e8b3..386b1895 100644 --- a/src/supervisor/agents/grok/sessionFiles.ts +++ b/src/supervisor/agents/grok/sessionFiles.ts @@ -6,7 +6,9 @@ import type { ProjectLocation, SessionRef } from "@/shared/contracts"; import { createKnownSessionRef, listSessionDir, + readSshCommandOutputSync, readWslCommandOutput, + resolveSshHomeDirectory, resolveWslHomeDirectory, statSessionPaths, watchSessionPaths, @@ -32,6 +34,10 @@ function getGrokCwdSessionsDir(location: ProjectLocation, cwd: string): string | if (!home) return null; return `${home}/.grok/sessions/${encodeCwdKey(cwd)}`; } + if (location.kind === "ssh") { + const home = resolveSshHomeDirectory(location); + return home ? `${home}/.grok/sessions/${encodeCwdKey(cwd)}` : null; + } return join(GROK_SESSIONS_ROOT, encodeCwdKey(cwd)); } @@ -40,6 +46,10 @@ function getGrokSessionsRoot(location: ProjectLocation): string | null { const home = resolveWslHomeDirectory(location.distro); return home ? `${home}/.grok/sessions` : null; } + if (location.kind === "ssh") { + const home = resolveSshHomeDirectory(location); + return home ? `${home}/.grok/sessions` : null; + } return GROK_SESSIONS_ROOT; } @@ -73,6 +83,20 @@ export function snapshotGrokPreSpawnSessions(location: ProjectLocation, cwd: str return; } + if (location.kind === "ssh") { + const result = readSshCommandOutputSync(location, "sh", [ + "-lc", + `[ -d ${shellQuote(dir)} ] && ls -1 -- ${shellQuote(dir)} 2>/dev/null || true`, + ]); + if (!result.ok) return; + preSpawnCwdKey = encodeCwdKey(cwd); + for (const name of result.stdout.split(/\r?\n/)) { + const trimmed = name.trim(); + if (isUuid(trimmed)) preSpawnSessionIds.add(trimmed); + } + return; + } + if (!existsSync(dir)) return; preSpawnCwdKey = encodeCwdKey(cwd); @@ -150,7 +174,7 @@ export function makeGrokDiscoverSessionRef() { */ export function resolveGrokSessionsWatchPaths(location: ProjectLocation, cwd: string): string[] { const dir = getGrokCwdSessionsDir(location, cwd); - if (location.kind === "wsl") { + if (location.kind === "wsl" || location.kind === "ssh") { const root = getGrokSessionsRoot(location); return [dir ?? undefined, root ?? undefined].filter((p): p is string => Boolean(p)); } @@ -247,15 +271,16 @@ for (const r of reqs) p.stdin.write(JSON.stringify(r) + "\\n"); * Returns `undefined` on any failure (timeout, auth error, JSON parse, etc.). * Callers must treat undefined as "no minted ID" and proceed without `-r`. * - * WSL is skipped — minting would require routing the ACP child through - * `wsl.exe`, which is out of scope here. + * Remote environments skip this local pre-mint path. The remote PTY launch + * still snapshots sessions before spawn and discovers the created session + * afterward, matching the fallback path used when local minting fails. */ export function mintGrokSessionIdViaAcpSync( location: ProjectLocation, timeoutMs = 4500, extraLaunchArgs: string[] = [], ): string | undefined { - if (location.kind === "wsl") return undefined; + if (location.kind === "wsl" || location.kind === "ssh") return undefined; // Use the absolute path resolved during detection. Packaged Electron apps // start with a minimal PATH that may exclude Homebrew/asdf/etc., so a bare diff --git a/src/supervisor/agents/opencode/argv.ts b/src/supervisor/agents/opencode/argv.ts index ef0d0909..663c883a 100644 --- a/src/supervisor/agents/opencode/argv.ts +++ b/src/supervisor/agents/opencode/argv.ts @@ -1,8 +1,11 @@ +import { randomInt } from "node:crypto"; import { dirname as posixDirname } from "node:path/posix"; import type { ProjectLocation, ThreadConfig } from "@/shared/contracts"; import { buildAgentCommand, getWslCommand, type CommandSpec } from "../base"; +import { buildSshForwardedCommand } from "../../ssh"; const DEFAULT_WSL_EXEC_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +export const OPENCODE_LOCAL_BASE_URL_ENV = "LIGHTCODE_OPENCODE_LOCAL_BASE_URL"; // `opencode` (default TUI) only accepts `[project]` as a positional, so the // initial prompt must go through `--prompt` rather than a trailing arg. @@ -68,5 +71,26 @@ export function buildOpenCodeServerCommand( ], }; } + if (location.kind === "ssh") { + const localPort = randomInt(40_000, 60_000); + const remotePort = randomInt(40_000, 60_000); + const spec = buildSshForwardedCommand( + location, + resolvedExecPath ?? "opencode", + ["serve", "--hostname=127.0.0.1", `--port=${remotePort}`, "--print-logs"], + undefined, + [ + { + localHost: "127.0.0.1", + localPort, + remoteHost: "127.0.0.1", + remotePort, + }, + ], + { batchMode: "yes", tty: false }, + ); + spec.env = { [OPENCODE_LOCAL_BASE_URL_ENV]: `http://127.0.0.1:${localPort}` }; + return spec; + } return buildAgentCommand(location, "opencode", args, resolvedExecPath); } diff --git a/src/supervisor/agents/opencode/plugin/install.ts b/src/supervisor/agents/opencode/plugin/install.ts index 01d82437..3332a6a9 100644 --- a/src/supervisor/agents/opencode/plugin/install.ts +++ b/src/supervisor/agents/opencode/plugin/install.ts @@ -18,14 +18,22 @@ import { resolveWslHomeDirectory } from "../../base"; import { BROWSER_MCP_SERVER_NAME } from "../../browserMcp"; import { copyPluginAssetsIfStale, + copySshFile, createPluginSourceResolver, getNativePluginBaseDir, + getSshPluginBaseDirs, getWslPluginBaseDirs, + isSshPluginContext, isWslPluginContext, readBundledPluginVersion, readPluginManifest, + readSshTextFile, + removeSshFile, removeStagedPluginDir, + stagePluginAssetsToSsh, stagePluginAssetsToWsl, + verifySshStagedPlugin, + writeSshTextFile, type PluginManifest, } from "../../plugin/installerBase"; import { buildOpenCodeBrowserMcp } from "../mcpBrowser"; @@ -158,7 +166,36 @@ function resolveOpenCodeWslPluginsDir( }; } +function resolveOpenCodeSshConfigDir( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, +): { linuxDir: string } | undefined { + const ssh = getSshPluginBaseDirs(ctx, "opencode"); + if (!ssh) return undefined; + return { linuxDir: `${ssh.home}/.config/opencode` }; +} + +function resolveOpenCodeSshPluginsDir( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, +): { linuxDir: string } | undefined { + const cfg = resolveOpenCodeSshConfigDir(ctx); + return cfg ? { linuxDir: `${cfg.linuxDir}/plugins` } : undefined; +} + export function getOpenCodePluginPaths(ctx?: AgentEnvContext): OpenCodePluginPaths { + if (isSshPluginContext(ctx)) { + const ssh = getSshPluginBaseDirs(ctx, "opencode"); + if (!ssh) { + return { pluginDir: "", opencodePluginFile: "", version: "0.0.0" }; + } + const opencodeDir = resolveOpenCodeSshPluginsDir(ctx); + return { + pluginDir: ssh.linuxBase, + opencodePluginFile: opencodeDir + ? `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_FILE_NAME}` + : "", + version: "0.0.0", + }; + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "opencode"); if (!wsl) { @@ -213,6 +250,9 @@ export function installOpenCodePlugin( if (isWslPluginContext(ctx)) { return installOpenCodePluginWsl(ctx.wslDistro, sourceDir, manifest); } + if (isSshPluginContext(ctx)) { + return installOpenCodePluginSsh(ctx, sourceDir, manifest); + } const pluginDir = getNativePluginBaseDir("opencode", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -253,6 +293,49 @@ export function installOpenCodePlugin( }; } +function installOpenCodePluginSsh( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + sourceDir: string, + manifest: PluginManifest, +): { ok: true; paths: OpenCodePluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToSsh(ctx, sourceDir, "opencode", OPENCODE_PLUGIN_ASSET_FILES); + if (!staged.ok) return staged; + + const opencodeDir = resolveOpenCodeSshPluginsDir(ctx); + if (!opencodeDir) { + return { + ok: false, + reason: `failed to resolve OpenCode plugins dir on ssh host ${ctx.sshHost}`, + }; + } + const opencodePluginFile = `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_FILE_NAME}`; + const opencodeManifestFile = `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_MANIFEST_NAME}`; + const pluginCopy = copySshFile(ctx, join(sourceDir, "lightcode-status.mjs"), opencodePluginFile); + if (!pluginCopy.ok) return pluginCopy; + const manifestCopy = copySshFile(ctx, join(sourceDir, "plugin.json"), opencodeManifestFile); + if (!manifestCopy.ok) return manifestCopy; + cleanupSshLegacyDrops(ctx, opencodeDir.linuxDir); + + const cfgDir = resolveOpenCodeSshConfigDir(ctx); + if (cfgDir) { + updateOpenCodeSshConfigFile(ctx, `${cfgDir.linuxDir}/${OPENCODE_CONFIG_FILE_NAME}`, undefined); + } + + console.log( + `[supervisor] OpenCode hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost} at ${staged.linuxPluginDir} → ${opencodePluginFile}`, + ); + + return { + ok: true, + version: manifest.version, + paths: { + pluginDir: staged.linuxPluginDir, + opencodePluginFile, + version: manifest.version, + }, + }; +} + function installOpenCodePluginWsl( distro: string, sourceDir: string, @@ -316,6 +399,32 @@ export function isOpenCodePluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { + if (isSshPluginContext(ctx)) { + const paths = getOpenCodePluginPaths(ctx); + const opencodeDir = resolveOpenCodeSshPluginsDir(ctx); + if (!opencodeDir) return { installed: false }; + return verifySshStagedPlugin(ctx, "opencode", { + assets: OPENCODE_PLUGIN_ASSET_FILES, + extraCheck: () => { + const stagedPlugin = readSshTextFile(ctx, `${paths.pluginDir}/lightcode-status.mjs`); + const droppedPlugin = readSshTextFile( + ctx, + `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_FILE_NAME}`, + ); + const stagedManifest = readSshTextFile(ctx, `${paths.pluginDir}/plugin.json`); + const droppedManifest = readSshTextFile( + ctx, + `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_MANIFEST_NAME}`, + ); + return ( + stagedPlugin !== undefined && + stagedPlugin === droppedPlugin && + stagedManifest !== undefined && + stagedManifest === droppedManifest + ); + }, + }); + } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "opencode"); if (!wsl) return { installed: false }; @@ -477,6 +586,17 @@ export function syncOpenCodeBrowserMcpConfigFile( enabled: boolean, browserMcp?: BrowserMcpHttpConfig, ): void { + if (location.kind === "ssh") { + const ctx = { envKind: "ssh", sshHost: location.host, sshPath: location.path } as const; + const cfgDir = resolveOpenCodeSshConfigDir(ctx); + if (!cfgDir) return; + updateOpenCodeSshConfigFile( + ctx, + `${cfgDir.linuxDir}/${OPENCODE_CONFIG_FILE_NAME}`, + enabled ? buildOpenCodeBrowserMcp(location, browserMcp) : undefined, + ); + return; + } if (location.kind === "wsl") { const cfgDir = resolveOpenCodeWslConfigDir(location.distro); if (!cfgDir) return; @@ -500,6 +620,24 @@ export function syncOpenCodeBrowserMcpConfigFile( * Best-effort: missing files / unreachable distros are swallowed. */ export function uninstallOpenCodePlugin(ctx?: AgentEnvContext): void { + if (isSshPluginContext(ctx)) { + const opencodeDir = resolveOpenCodeSshPluginsDir(ctx); + if (opencodeDir) { + removeSshFile(ctx, `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_FILE_NAME}`); + removeSshFile(ctx, `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_MANIFEST_NAME}`); + cleanupSshLegacyDrops(ctx, opencodeDir.linuxDir); + } + const cfgDir = resolveOpenCodeSshConfigDir(ctx); + if (cfgDir) { + updateOpenCodeSshConfigFile( + ctx, + `${cfgDir.linuxDir}/${OPENCODE_CONFIG_FILE_NAME}`, + undefined, + ); + } + removeStagedPluginDir("opencode", ctx); + return; + } if (isWslPluginContext(ctx)) { const opencodeDir = resolveOpenCodeWslPluginsDir(ctx.wslDistro); if (opencodeDir) { @@ -525,6 +663,60 @@ export function uninstallOpenCodePlugin(ctx?: AgentEnvContext): void { removeStagedPluginDir("opencode", ctx); } +function cleanupSshLegacyDrops( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + pluginsDir: string, +): void { + for (const name of OPENCODE_LEGACY_DROP_FILES) { + removeSshFile(ctx, `${pluginsDir}/${name}`); + } +} + +function updateOpenCodeSshConfigFile( + ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, + configPath: string, + servers: BrowserMcpServers, +): void { + const raw = readSshTextFile(ctx, configPath); + let original: Record = {}; + if (raw && raw.trim().length > 0) { + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + original = parsed as Record; + } + } catch { + return; + } + } + const config: Record = { ...original }; + const existingPlugin = config.plugin; + if (Array.isArray(existingPlugin)) { + const filtered = existingPlugin.filter((entry) => { + if (typeof entry !== "string") return true; + return !entry.includes(LIGHTCODE_PLUGIN_SPEC_MARKER); + }); + if (filtered.length === 0) delete config.plugin; + else if (filtered.length !== existingPlugin.length) config.plugin = filtered; + } + const mcpRaw = config.mcp; + const mcp: Record = + mcpRaw && typeof mcpRaw === "object" && !Array.isArray(mcpRaw) + ? { ...(mcpRaw as Record) } + : {}; + delete mcp[BROWSER_MCP_SERVER_NAME]; + if (servers) { + for (const [name, entry] of Object.entries(servers)) { + mcp[name] = entry; + } + } + if (Object.keys(mcp).length === 0) delete config.mcp; + else config.mcp = mcp; + const next = `${JSON.stringify(config, null, 2)}\n`; + if (raw === next) return; + void writeSshTextFile(ctx, configPath, next); +} + function removeIfPresent(path: string): void { try { const stat = statSync(path); diff --git a/src/supervisor/agents/opencode/sdkClient.ts b/src/supervisor/agents/opencode/sdkClient.ts index 29016eeb..2baf8397 100644 --- a/src/supervisor/agents/opencode/sdkClient.ts +++ b/src/supervisor/agents/opencode/sdkClient.ts @@ -19,6 +19,7 @@ export function resolveOpenCodeSessionDirectory(location: ProjectLocation): stri return location.path; case "wsl": return location.linuxPath; + case "ssh": case "posix": return location.path; } @@ -30,6 +31,8 @@ function poolKey(location: ProjectLocation): string { return `windows:${location.path}`; case "wsl": return `wsl:${location.distro}:${location.linuxPath}`; + case "ssh": + return `ssh:${location.host}:${location.path}`; case "posix": return `posix:${location.path}`; } diff --git a/src/supervisor/agents/opencode/sdkServer.ts b/src/supervisor/agents/opencode/sdkServer.ts index 2f656f67..19f2823e 100644 --- a/src/supervisor/agents/opencode/sdkServer.ts +++ b/src/supervisor/agents/opencode/sdkServer.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { terminateChildProcessTree } from "@/shared/processTree"; import type { CommandSpec } from "../base"; +import { OPENCODE_LOCAL_BASE_URL_ENV } from "./argv"; import { classifyOpenCodeError } from "./opencodeErrors"; const URL_LINE_PREFIX = "opencode server listening"; @@ -64,11 +65,12 @@ function registerProcessExitCleanup(): void { export function spawnOpenCodeServer(commandSpec: CommandSpec): OpenCodeServerHandle { registerProcessExitCleanup(); const isWin = process.platform === "win32"; + const { [OPENCODE_LOCAL_BASE_URL_ENV]: localBaseUrl, ...commandEnv } = commandSpec.env ?? {}; const child = spawn(commandSpec.command, commandSpec.args, { cwd: commandSpec.cwd, env: { ...process.env, - ...commandSpec.env, + ...commandEnv, }, stdio: ["pipe", "pipe", "pipe"], shell: false, @@ -141,7 +143,7 @@ export function spawnOpenCodeServer(commandSpec: CommandSpec): OpenCodeServerHan if (!line.startsWith(URL_LINE_PREFIX)) continue; const m = line.match(URL_REGEX); if (m && m[1]) { - baseUrl = m[1]; + baseUrl = localBaseUrl ?? m[1]; clearTimeout(readyTimeout); pending?.resolve(baseUrl); return; diff --git a/src/supervisor/agents/plugin/installerBase.test.ts b/src/supervisor/agents/plugin/installerBase.test.ts index de1b213c..993deb89 100644 --- a/src/supervisor/agents/plugin/installerBase.test.ts +++ b/src/supervisor/agents/plugin/installerBase.test.ts @@ -514,10 +514,13 @@ describe("memoByCtx", () => { }); describe("ctxCacheKey", () => { - it("produces a stable string per (envKind, wslDistro, baseDir) tuple", () => { - expect(ctxCacheKey({ envKind: "windows" })).toBe("windows||"); - expect(ctxCacheKey({ envKind: "windows", baseDir: "/tmp/a" })).toBe("windows||/tmp/a"); - expect(ctxCacheKey({ envKind: "wsl", wslDistro: "Ubuntu" })).toBe("wsl|Ubuntu|"); + it("produces a stable string per (envKind, wslDistro, sshHost, baseDir) tuple", () => { + expect(ctxCacheKey({ envKind: "windows" })).toBe("windows|||"); + expect(ctxCacheKey({ envKind: "windows", baseDir: "/tmp/a" })).toBe("windows|||/tmp/a"); + expect(ctxCacheKey({ envKind: "wsl", wslDistro: "Ubuntu" })).toBe("wsl|Ubuntu||"); + expect(ctxCacheKey({ envKind: "ssh", sshHost: "dev.example.com" })).toBe( + "ssh||dev.example.com|", + ); expect(ctxCacheKey(undefined)).toBe("no-ctx"); }); }); diff --git a/src/supervisor/agents/plugin/installerBase.ts b/src/supervisor/agents/plugin/installerBase.ts index b176130f..876bfe56 100644 --- a/src/supervisor/agents/plugin/installerBase.ts +++ b/src/supervisor/agents/plugin/installerBase.ts @@ -14,10 +14,16 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join, posix as pathPosix, resolve } from "node:path"; import { resolveLightcodePaths } from "@/shared/lightcodePaths"; import { toWslUncPath } from "@/shared/wsl"; import { resolveExecutablePath, resolveWslHomeDirectory, type AgentEnvContext } from "../base"; +import { + readSshCommandOutput, + resolveSshHomeDirectory, + runSshScriptSync, + type SshLocation, +} from "../../ssh"; import { deployFilesToWslHome, type WslHomeDeployResult } from "../../wsl/wslDeploy"; /** @@ -35,11 +41,16 @@ export interface PluginManifest { } export type WslAgentEnvContext = AgentEnvContext & { envKind: "wsl"; wslDistro: string }; +export type SshAgentEnvContext = AgentEnvContext & { envKind: "ssh"; sshHost: string }; export function isWslPluginContext(ctx: AgentEnvContext | undefined): ctx is WslAgentEnvContext { return Boolean(ctx && ctx.envKind === "wsl" && ctx.wslDistro); } +export function isSshPluginContext(ctx: AgentEnvContext | undefined): ctx is SshAgentEnvContext { + return Boolean(ctx && ctx.envKind === "ssh" && ctx.sshHost); +} + export interface PluginSourceResolverOptions { kind: string; /** Env var override for the source dir, e.g. `LIGHTCODE_CLAUDE_PLUGIN_SOURCE`. */ @@ -238,6 +249,11 @@ export interface WslPluginBaseDirs { uncBase: string; } +export interface SshPluginBaseDirs { + home: string; + linuxBase: string; +} + export function getWslPluginBaseDirs(distro: string, kind: string): WslPluginBaseDirs | undefined { const home = resolveWslHomeDirectory(distro); if (!home) return undefined; @@ -245,12 +261,31 @@ export function getWslPluginBaseDirs(distro: string, kind: string): WslPluginBas return { home, linuxBase, uncBase: toWslUncPath(distro, linuxBase) }; } +export function getSshLocation(ctx: SshAgentEnvContext): SshLocation { + return { kind: "ssh", host: ctx.sshHost, path: "/" }; +} + +export function getSshPluginBaseDirs( + ctx: SshAgentEnvContext, + kind: string, +): SshPluginBaseDirs | undefined { + const home = resolveSshHomeDirectory(getSshLocation(ctx)); + if (!home) return undefined; + return { home, linuxBase: `${home}/.lightcode/agent-plugins/${kind}` }; +} + export function getNativePluginBaseDir(kind: string, baseDir?: string): string { const paths = resolveLightcodePaths(baseDir); return join(paths.agentPluginsDir, kind); } export function removeStagedPluginDir(kind: string, ctx?: AgentEnvContext): void { + if (isSshPluginContext(ctx)) { + const ssh = getSshPluginBaseDirs(ctx, kind); + if (!ssh) return; + runSshScriptSync(getSshLocation(ctx), `rm -rf ${quoteHookCommandArg(ssh.linuxBase, "wsl")}`); + return; + } const dir = isWslPluginContext(ctx) ? getWslPluginBaseDirs(ctx.wslDistro, kind)?.uncBase : getNativePluginBaseDir(kind, ctx?.baseDir); @@ -334,7 +369,7 @@ export function memoByCtx( /** Stable string key for an `AgentEnvContext`. Process-local; not for persistence. */ export function ctxCacheKey(ctx: AgentEnvContext | undefined): string { if (!ctx) return "no-ctx"; - return `${ctx.envKind}|${ctx.wslDistro ?? ""}|${ctx.baseDir ?? ""}`; + return `${ctx.envKind}|${ctx.wslDistro ?? ""}|${ctx.sshHost ?? ""}|${ctx.baseDir ?? ""}`; } /** @@ -565,6 +600,18 @@ export async function resolveInstallNodePath( }; } } + if (isSshPluginContext(ctx)) { + const location = getSshLocation(ctx); + const result = await readSshCommandOutput(location, "sh", ["-lc", "command -v node"], { + timeout: 5_000, + }).catch((error: unknown) => ({ + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })); + const nodePath = result.stdout.trim().split("\n")[0]?.trim(); + if (nodePath) return { ok: true, nodePath }; + return { ok: false, reason: `failed to resolve node on ssh host ${ctx.sshHost}` }; + } try { const { resolveNativeNode } = await import("../../native/runtime"); @@ -648,6 +695,55 @@ export function writeHooksJsonFile(path: string, doc: unknown): void { writeFileSync(path, `${JSON.stringify(doc, null, 2)}\n`, "utf8"); } +export function sshPathExists(ctx: SshAgentEnvContext, path: string): boolean { + const result = runSshScriptSync( + getSshLocation(ctx), + `test -e ${quoteHookCommandArg(path, "wsl")}`, + { timeout: 5_000 }, + ); + return result.ok; +} + +export function readSshTextFile(ctx: SshAgentEnvContext, path: string): string | undefined { + const result = runSshScriptSync(getSshLocation(ctx), `cat ${quoteHookCommandArg(path, "wsl")}`, { + timeout: 5_000, + maxBuffer: 10 * 1024 * 1024, + }); + return result.ok ? result.stdout : undefined; +} + +export function writeSshTextFile( + ctx: SshAgentEnvContext, + path: string, + contents: string, +): { ok: true } | { ok: false; reason: string } { + return writeSshFiles(ctx, [{ remotePath: path, contents: Buffer.from(contents, "utf8") }]); +} + +export function writeSshJsonFile( + ctx: SshAgentEnvContext, + path: string, + doc: unknown, +): { ok: true } | { ok: false; reason: string } { + return writeSshTextFile(ctx, path, `${JSON.stringify(doc, null, 2)}\n`); +} + +export function removeSshFile(ctx: SshAgentEnvContext, path: string): void { + runSshScriptSync(getSshLocation(ctx), `rm -f ${quoteHookCommandArg(path, "wsl")}`, { + timeout: 5_000, + }); +} + +export function parseExistingSshJson(ctx: SshAgentEnvContext, path: string): unknown | null { + const raw = readSshTextFile(ctx, path); + if (raw === undefined) return null; + try { + return raw.trim() ? (JSON.parse(raw) as unknown) : {}; + } catch { + return null; + } +} + // ── Shared private-home state mirroring ────────────────────────────────── /** @@ -837,6 +933,34 @@ export function verifyStagedPluginAt( } } +export function verifySshStagedPlugin( + ctx: SshAgentEnvContext, + kind: string, + options?: Omit & { + extraCheck?: () => boolean; + }, +): { installed: boolean; version?: string } { + const dirs = getSshPluginBaseDirs(ctx, kind); + if (!dirs) return { installed: false }; + const assets = options?.assets ?? PLUGIN_ASSET_FILES; + const tests = assets + .map((asset) => `test -f ${quoteHookCommandArg(`${dirs.linuxBase}/${asset}`, "wsl")}`) + .join("\n"); + const result = runSshScriptSync( + getSshLocation(ctx), + `${tests}\ncat ${quoteHookCommandArg(`${dirs.linuxBase}/plugin.json`, "wsl")}`, + { timeout: 5_000 }, + ); + if (!result.ok) return { installed: false }; + if (options?.extraCheck && !options.extraCheck()) return { installed: false }; + try { + const version = (JSON.parse(result.stdout) as PluginManifest).version; + return typeof version === "string" ? { installed: true, version } : { installed: false }; + } catch { + return { installed: false }; + } +} + // ── Shared WSL plugin staging ──────────────────────────────────────────── export interface StagePluginAssetsToWslOptions { @@ -899,3 +1023,70 @@ export function stagePluginAssetsToWsl( linuxPluginDir: `${deploy.linuxBaseDir}/agent-plugins/${kind}`, }; } + +// ── Shared SSH plugin staging ──────────────────────────────────────────── + +export function stagePluginAssetsToSsh( + ctx: SshAgentEnvContext, + sourceDir: string, + kind: string, + options?: StagePluginAssetsToWslOptions | readonly string[], +): { ok: true; deploy: SshPluginBaseDirs; linuxPluginDir: string } | { ok: false; reason: string } { + let opts: StagePluginAssetsToWslOptions; + if (Array.isArray(options)) opts = { assets: options as readonly string[] }; + else opts = (options ?? {}) as StagePluginAssetsToWslOptions; + + const deploy = getSshPluginBaseDirs(ctx, kind); + if (!deploy) return { ok: false, reason: `failed to resolve home on ssh host ${ctx.sshHost}` }; + + const assets = opts.assets ?? PLUGIN_ASSET_FILES; + const files: Array<{ remotePath: string; contents: Buffer }> = assets.map((file) => ({ + remotePath: `${deploy.linuxBase}/${file}`, + contents: readFileSync(join(sourceDir, file)), + })); + if (opts.includeForwardRuntime) { + files.push({ + remotePath: `${deploy.linuxBase}/${FORWARD_RUNTIME_FILE}`, + contents: readFileSync(resolveForwardRuntimeSourcePath()), + }); + } + + const written = writeSshFiles(ctx, files); + if (!written.ok) return written; + return { ok: true, deploy, linuxPluginDir: deploy.linuxBase }; +} + +export function copySshFile( + ctx: SshAgentEnvContext, + sourcePath: string, + remotePath: string, +): { ok: true } | { ok: false; reason: string } { + return writeSshFiles(ctx, [{ remotePath, contents: readFileSync(sourcePath) }]); +} + +function writeSshFiles( + ctx: SshAgentEnvContext, + files: Array<{ remotePath: string; contents: Buffer }>, +): { ok: true } | { ok: false; reason: string } { + const script = files + .map((file, index) => { + const marker = `LIGHTCODE_FILE_${index}`; + return [ + `mkdir -p ${quoteHookCommandArg(pathPosix.dirname(file.remotePath), "wsl")}`, + `base64 -d > ${quoteHookCommandArg(file.remotePath, "wsl")} <<'${marker}'`, + file.contents.toString("base64"), + marker, + ].join("\n"); + }) + .join("\n"); + const result = runSshScriptSync(getSshLocation(ctx), script, { + timeout: 15_000, + maxBuffer: 10 * 1024 * 1024, + }); + return result.ok + ? { ok: true } + : { + ok: false, + reason: result.stderr.trim() || `failed to write files on ssh host ${ctx.sshHost}`, + }; +} 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 0ac715dc..ad025f35 100644 --- a/src/supervisor/git/checkpointService.ts +++ b/src/supervisor/git/checkpointService.ts @@ -8,6 +8,7 @@ import type { ProjectLocation, } from "@/shared/contracts"; import { readWslCommandOutputAsync } from "../agents/base"; +import { readSshCommandOutput } from "../ssh"; import type { WslBridgeClient } from "../wsl/bridge/client"; import { execGit, removeWslPathViaBridge } from "./exec"; @@ -240,6 +241,10 @@ async function removeTempIndex(projectLocation: ProjectLocation, tempIndex: stri await readWslCommandOutputAsync(projectLocation.distro, "rm", ["-f", tempIndex]); 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 47cc694b..d6d11f11 100644 --- a/src/supervisor/git/exec.ts +++ b/src/supervisor/git/exec.ts @@ -9,6 +9,7 @@ import { attachErrorDetails, errorDetail, msg } from "@/shared/messages"; import { getProjectName } from "@/shared/wsl"; import { sanitizeWorktreeBranchName, sanitizeWorktreePathSegment } from "@/shared/worktree"; import { buildAgentCommand, readWslCommandOutputAsync } from "../agents/base"; +import { readSshCommandOutput, resolveSshHomeDirectoryAsync } from "../ssh"; import type { WslBridgeClient, WslGitExecResult } from "../wsl/bridge/client"; import { mkdir } from "node:fs/promises"; @@ -102,6 +103,18 @@ export async function execGit( return stdout; } + 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, @@ -184,7 +197,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(); @@ -197,6 +210,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}`; } @@ -238,6 +254,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, @@ -268,6 +294,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 184642cd..6bff14ca 100644 --- a/src/supervisor/git/statusService.ts +++ b/src/supervisor/git/statusService.ts @@ -11,6 +11,7 @@ import { } from "@/shared/contracts"; import { getProjectFsPath, toWslUncPath } from "@/shared/wsl"; import { parallelWslCommandsAsync } from "../agents/base"; +import { readSshCommandOutput } from "../ssh"; import { execGit, execGitBatchWslBridge, @@ -649,10 +650,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 }; } @@ -710,6 +719,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 aa1ea1e1..3deba88b 100644 --- a/src/supervisor/github.ts +++ b/src/supervisor/github.ts @@ -25,6 +25,7 @@ import { } from "@/shared/contracts"; import { toWslUncPath } from "@/shared/wsl"; import { buildAgentCommand, parallelWslCommandsAsync, quotePosixShellArg } from "./agents/base"; +import { readSshCommandOutput } from "./ssh"; const execFileAsync = promisify(execFile); const GH_TIMEOUT = 30_000; @@ -91,7 +92,9 @@ function classifyError(error: unknown, operation: string): Error { async function runGh(location: ProjectLocation, args: string[]): Promise { const spec = buildAgentCommand(location, "gh", args); - const cwd = spec.cwd ?? (location.kind === "wsl" ? undefined : 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, @@ -106,6 +109,30 @@ async function createGhBodyFile( prefix: string, body: string, ): 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]); + }, + }; + } + const dirPrefix = location.kind === "wsl" ? toWslUncPath(location.distro, `/tmp/${prefix}-`) @@ -336,6 +363,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/projectTree.ts b/src/supervisor/projectTree.ts index ba82a943..6007031f 100644 --- a/src/supervisor/projectTree.ts +++ b/src/supervisor/projectTree.ts @@ -28,6 +28,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]); @@ -44,6 +45,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, ""); @@ -138,6 +140,9 @@ export class ProjectTreeService { if (payload.projectLocation.kind === "wsl" && this.wslClient) { return this.listProjectTreeWsl(payload.projectLocation, directoryPath, this.wslClient); } + 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 }); @@ -201,6 +206,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: [] }; @@ -218,7 +264,9 @@ export class ProjectTreeService { const raw = payload.projectLocation.kind === "wsl" && this.wslClient ? await this.readProjectFileBufferWsl(payload.projectLocation, path, this.wslClient) - : 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 }; @@ -275,6 +323,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), @@ -325,7 +378,9 @@ export class ProjectTreeService { payload.absolutePath, this.wslClient, ) - : 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 }; @@ -373,6 +428,9 @@ export class ProjectTreeService { if (payload.projectLocation.kind === "wsl" && this.wslClient) { return this.writeExternalFileWsl(payload.projectLocation, payload, this.wslClient); } + if (payload.projectLocation.kind === "ssh") { + return this.writeExternalFileSsh(payload.projectLocation, payload); + } const fileStat = await stat(payload.absolutePath); if (!fileStat.isFile()) { @@ -422,6 +480,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 @@ -465,6 +541,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); @@ -494,6 +575,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, @@ -526,12 +659,22 @@ 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); if (payload.projectLocation.kind === "wsl" && this.wslClient) { return this.writeProjectFileWsl(payload.projectLocation, path, payload, this.wslClient); } + if (payload.projectLocation.kind === "ssh") { + return this.writeProjectFileSsh(payload.projectLocation, path, payload); + } const { fullPath, fileStat } = await this.statFollowingWslSymlinks( payload.projectLocation, @@ -587,6 +730,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) { @@ -607,6 +820,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 }); @@ -633,6 +858,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), @@ -673,6 +907,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, @@ -701,6 +953,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, @@ -819,6 +1080,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 = [""]; @@ -844,6 +1108,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 3dc61344..b8bb3d04 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, @@ -193,6 +195,7 @@ import { LanguageServerManager } from "./lsp"; import { ProjectTreeService } from "./projectTree"; import { generatePrSummary } from "./prSummaryGenerator"; import { detectWindowsShell, type WindowsShellPreference } from "./shellPreference"; +import { readSshCommandOutput, SshBrowserMcpTunnelManager } from "./ssh"; import { generateTitle } from "./titleGenerator"; import { AgentStatusService, detectWslAgentStatuses } from "./runtime/agentStatusService"; import { UsageService } from "./runtime/usageService"; @@ -228,6 +231,7 @@ export class SupervisorRuntime { private readonly threadSessionManager: ThreadSessionManager; private readonly lspManager: LanguageServerManager; private readonly cliHookPluginCoordinator: CliHookPluginCoordinator; + private readonly sshBrowserMcpTunnels = new SshBrowserMcpTunnelManager(); private wslHookBridge: WslBridgeServer | undefined; private extractionAbortControllers = new Map(); @@ -400,7 +404,11 @@ 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), + ensureSshBridge: (location, env) => this.sshBrowserMcpTunnels.ensureTunnel(location, env), + }, resolvePluginEnvForSpawn: (input) => this.cliHookPluginCoordinator.resolvePluginEnvForSpawn(input), }); @@ -1327,6 +1335,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))) { @@ -1336,6 +1357,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); } @@ -1358,6 +1404,7 @@ export class SupervisorRuntime { await this.threadSessionManager.dispose(); this.sharedSettingsCache.dispose(); await this.cliHookPluginCoordinator.dispose().catch(() => undefined); + this.sshBrowserMcpTunnels.dispose(); const { shutdownSpawnedOpenCodeServers } = await import("./agents/opencode/sdkClient"); shutdownSpawnedOpenCodeServers(); } 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 6a706aea..3a7f225f 100644 --- a/src/supervisor/runtime/agentStatusService.test.ts +++ b/src/supervisor/runtime/agentStatusService.test.ts @@ -255,4 +255,61 @@ describe("AgentStatusService", () => { 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" }), + ); + }); + + 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 cb8eb855..2a65459c 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"; @@ -36,6 +37,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; function migrateSettingDef(definition: Record): Record { if (definition.type === "toggle" || definition.type === "select") { @@ -110,30 +112,56 @@ 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 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 +234,80 @@ 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 = { + kind: adapter.kind, + label: adapter.label, + installed: true, + authState: "unknown" as const, + capabilities: adapter.capabilities, + ...(adapter.update ? { update: adapter.update } : {}), + envKind: "ssh" as const, + envHost: 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 = { ...detected, envKind: "ssh" as const, envHost: location.host }; + } catch (error) { + console.error( + `[supervisor] detectInstall(${adapter.kind}, ssh:${location.host}) failed`, + error, + ); + status = { + kind: adapter.kind, + label: adapter.label, + installed: false, + authState: "unknown" as const, + capabilities: adapter.capabilities, + ...(adapter.update ? { update: adapter.update } : {}), + envKind: "ssh" as const, + envHost: location.host, + }; + } + } + onStatus?.(status); + return status; + }), + ); + }), + ); + + return statuses.flat(); +} + export interface AgentStatusServiceOptions { adapters: Map; settingsPath: string; @@ -216,12 +318,14 @@ export interface AgentStatusServiceOptions { interface DetectionResults { windows: AgentStatus[]; wsl: AgentStatus[]; + ssh: AgentStatus[]; } export class AgentStatusService { private pendingDetection: Promise | undefined; private startupDetectionLaunched = false; private startupDetectionWslDistros = new Set(); + private startupDetectionSshHosts = new Set(); constructor(private readonly options: AgentStatusServiceOptions) {} @@ -243,13 +347,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. @@ -258,18 +364,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 }; } @@ -287,15 +395,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 }; } @@ -305,7 +414,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( @@ -318,14 +427,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; @@ -334,6 +482,11 @@ export class AgentStatusService { return [ nativeEnv, ...wslDistros.map((distro) => ({ kind: "wsl", distro })), + ...sshProjects.map((project) => ({ + kind: "ssh", + host: project.host, + path: project.path, + })), ]; } @@ -343,9 +496,15 @@ 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 { @@ -357,20 +516,24 @@ export class AgentStatusService { ...(adapter.update ? { update: adapter.update } : {}), envKind, ...(envDistro ? { envDistro } : {}), + ...(envHost ? { envHost } : {}), }; } - 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 { ...detected, envKind, ...(envDistro ? { envDistro } : {}), + ...(envHost ? { envHost } : {}), }; } 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 { kind: adapter.kind, @@ -381,6 +544,7 @@ export class AgentStatusService { ...(adapter.update ? { update: adapter.update } : {}), envKind, ...(envDistro ? { envDistro } : {}), + ...(envHost ? { envHost } : {}), }; } } @@ -395,13 +559,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` @@ -410,7 +578,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) @@ -419,10 +587,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 }; } } @@ -448,7 +619,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, @@ -456,6 +627,7 @@ export class AgentStatusService { version: STATUS_CACHE_VERSION, windows, wsl, + ssh, savedAt: new Date().toISOString(), }), "utf8", @@ -485,28 +657,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(); @@ -581,9 +770,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. @@ -595,8 +802,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..822786cf 100644 --- a/src/supervisor/runtime/cliHookPluginCoordinator.test.ts +++ b/src/supervisor/runtime/cliHookPluginCoordinator.test.ts @@ -659,6 +659,67 @@ describe("CliHookPluginCoordinator install cache", () => { expect(resolved).toBeUndefined(); }); + it("routes SSH spawns through a project-scoped reverse tunnel", async () => { + const stub = makeStubAdapter("claude"); + stub.isPluginInstalled.mockResolvedValue({ installed: true, version: "1.0.0" }); + + const ensureTunnel = vi.fn< + () => Promise<{ url: string; secret: string; protocolVersion: number }> + >(async () => ({ + url: "http://127.0.0.1:55502/v1/agent-event", + secret: "ssh-secret", + protocolVersion: 1, + })); + + coordinator = new CliHookPluginCoordinator( + { + adapters: new Map([["claude", stub.adapter]]), + settingsPath, + envContext: (_kind, location) => + location?.kind === "ssh" + ? { envKind: "ssh", sshHost: location.host, sshPath: location.path } + : { envKind: "posix" }, + sshHookTunnel: { + ensureTunnel, + dispose: vi.fn<() => void>(), + }, + }, + () => undefined, + ); + coordinator.startIngress(); + + const resolved = await coordinator.resolvePluginEnvForSpawn({ + threadId: "t-ssh", + agentKind: "claude", + projectLocation: { + kind: "ssh", + host: "dev.example.com", + path: "/home/u/project", + }, + }); + + expect(ensureTunnel).toHaveBeenCalledWith( + { kind: "ssh", host: "dev.example.com", path: "/home/u/project" }, + expect.objectContaining({ protocolVersion: 1 }), + ); + expect(stub.isPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ + envKind: "ssh", + sshHost: "dev.example.com", + sshPath: "/home/u/project", + }), + ); + expect(resolved).toBeDefined(); + expect(resolved!.env).toMatchObject({ + LIGHTCODE_THREAD_ID: "t-ssh", + LIGHTCODE_AGENT_KIND: "claude", + LIGHTCODE_HOOK_URL: "http://127.0.0.1:55502/v1/agent-event", + LIGHTCODE_HOOK_SECRET: "ssh-secret", + LIGHTCODE_HOOK_PROTOCOL_VERSION: "1", + }); + expect(resolved!.extraArgs).toEqual(["--claude-marker"]); + }); + it("skips CLI hook plugin entirely for server-controlled (ACP/SDK) adapters", async () => { // ACP/SDK/server agents carry their own status channel. The coordinator // must not install the plugin nor return env/args for them — otherwise diff --git a/src/supervisor/runtime/cliHookPluginCoordinator.ts b/src/supervisor/runtime/cliHookPluginCoordinator.ts index 787fc829..c4017602 100644 --- a/src/supervisor/runtime/cliHookPluginCoordinator.ts +++ b/src/supervisor/runtime/cliHookPluginCoordinator.ts @@ -19,6 +19,7 @@ import { type AgentCliHookPluginSupport, } from "../agents/base"; import type { BrowserMcpHttpConfig } from "../agents/browserMcp"; +import { SshHookTunnelManager, type SshLocation } from "../ssh"; import type { WslBridgeServer } from "../wsl/bridge"; import { isLightcodeHookDebug } from "./hookDebug"; import { HookIngress, type HookIngressBootInfo } from "./hookIngress"; @@ -47,6 +48,13 @@ export interface CliHookPluginCoordinatorOptions { * reach. */ wslHookBridge?: WslBridgeServer; + sshHookTunnel?: { + ensureTunnel( + location: SshLocation, + upstream: { url: string; secret: string; protocolVersion: number }, + ): Promise<{ url: string; secret: string; protocolVersion: number } | undefined>; + dispose?(): void | Promise; + }; } const DEFAULT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; @@ -95,6 +103,7 @@ export class CliHookPluginCoordinator { ) => AgentEnvContext; private readonly installPromises = new Map>(); private wslHookBridge: WslBridgeServer | undefined; + private readonly sshHookTunnel: NonNullable; constructor( private readonly options: CliHookPluginCoordinatorOptions, @@ -121,6 +130,7 @@ export class CliHookPluginCoordinator { ingressOptions.preferredPort = options.preferredPort; } this.ingress = new HookIngress(ingressOptions); + this.sshHookTunnel = options.sshHookTunnel ?? new SshHookTunnelManager(); } /** @@ -173,6 +183,7 @@ export class CliHookPluginCoordinator { if (this.wslHookBridge) { await this.wslHookBridge.dispose(); } + await this.sshHookTunnel.dispose?.(); } /** @@ -207,7 +218,6 @@ export class CliHookPluginCoordinator { if (!isTerminalLiveInput(adapter)) { return undefined; } - const ctx = this.envContext(input.agentKind, input.projectLocation); if (input.browserMcpEnabled !== undefined) ctx.browserMcpEnabled = input.browserMcpEnabled; if (input.browserMcp) ctx.browserMcp = input.browserMcp; @@ -320,6 +330,14 @@ export class CliHookPluginCoordinator { const info = await this.ingress.ready; return { url: handle.hookUrl, secret: info.secret, protocolVersion: info.protocolVersion }; } + if (ctx.envKind === "ssh") { + if (!ctx.sshHost) return undefined; + const info = await this.ingress.ready; + return this.sshHookTunnel.ensureTunnel( + { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, + { url: info.url, secret: info.secret, protocolVersion: info.protocolVersion }, + ); + } const info = await this.ingress.ready; return { url: info.url, secret: info.secret, protocolVersion: info.protocolVersion }; } @@ -573,6 +591,9 @@ function composeCacheKey(kind: AgentKind, ctx: AgentEnvContext): string { if (ctx.envKind === "wsl" && ctx.wslDistro) { return `${kind}::wsl::${ctx.wslDistro}`; } + if (ctx.envKind === "ssh" && ctx.sshHost) { + return `${kind}::ssh::${ctx.sshHost}`; + } return kind; } @@ -589,6 +610,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 5f8ea3d3..3da797d0 100644 --- a/src/supervisor/runtime/threadAttachments.ts +++ b/src/supervisor/runtime/threadAttachments.ts @@ -1,10 +1,13 @@ -import { copyFileSync, mkdirSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { basename, join } from "node:path"; import type { PromptSegment, ProjectLocation } from "@/shared/contracts"; +import { isSafeSshHost } from "@/shared/ssh"; import { getWslCommand, resolveWslHomeDirectory } from "../agents/base"; +import { quotePosixShellArg } from "../agents/base/shellBasics"; import { spawnSync } from "node:child_process"; const wslAttachmentDirCache = new Map(); +const sshAttachmentDirCache = new Map(); function resolveWslAttachmentDirs(distro: string): { uncDir: string; linuxDir: string } { const cached = wslAttachmentDirCache.get(distro); @@ -25,6 +28,61 @@ function resolveWslAttachmentDirs(distro: string): { uncDir: string; linuxDir: s 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; @@ -40,11 +98,12 @@ export function rewriteSegmentsForWsl( location: ProjectLocation, options?: { preserveImageAttachments?: boolean }, ): PromptSegment[] { - if (location.kind !== "wsl") { + if (location.kind !== "wsl" && location.kind !== "ssh") { return segments; } let dirs: { uncDir: string; linuxDir: string } | undefined; + let sshDir: string | undefined; return segments.map((segment) => { if ((segment.kind !== "attachment" && segment.kind !== "file") || !segment.path) { return segment; @@ -52,6 +111,30 @@ export function rewriteSegmentsForWsl( if (options?.preserveImageAttachments && isImageAttachmentSegment(segment)) { return segment; } + + 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)) { + return segment; + } + sshDir ??= resolveSshAttachmentDir(location); + const fileName = basename(segment.path); + const destination = `${sshDir}/${fileName}`; + try { + return copyFileToSsh(location, segment.path, destination) + ? { ...segment, path: destination } + : segment; + } catch (error) { + console.warn(`[ssh-attach] failed to copy ${segment.path} -> ${destination}:`, error); + return segment; + } + } + + // 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)) { return segment; } diff --git a/src/supervisor/runtime/threadSession/helpers.ts b/src/supervisor/runtime/threadSession/helpers.ts index 4644662c..3cccb8ef 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 17118c48..04d0e7c4 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( @@ -1064,15 +1064,18 @@ export class ThreadSessionManager { hookEnvInjected: hasHookEnv, }); } else if (hasHookEnv) { - const viaWslBridge = projectLocation.kind === "wsl"; + const label = + projectLocation.kind === "wsl" + ? "CLI hook plugin → in-distro HTTP bridge (WSL) → supervisor" + : projectLocation.kind === "ssh" + ? "CLI hook plugin → SSH reverse tunnel → supervisor" + : "CLI hook plugin → host HookIngress → supervisor"; hookDebugSpawn({ threadId, agentKind, project: hookDebugProjectLabel(projectLocation), mode: "L1", - label: viaWslBridge - ? "CLI hook plugin → in-distro HTTP bridge (WSL) → supervisor" - : "CLI hook plugin → host HookIngress → supervisor", + label, liveInputMode, hookUrl, extraCliArgs: merged.extraArgs.length, @@ -2242,6 +2245,10 @@ export class ThreadSessionManager { }; } + if (location.kind === "ssh") { + return buildSshShellCommand(location, { startInHome }); + } + if (process.platform === "win32") { return { command: this.options.windowsShell.shell, @@ -2316,7 +2323,7 @@ export class ThreadSessionManager { const cfg = await resolveBrowserMcpHttpConfigForLaunch( location, enabled, - this.options.wslBridge, + this.options.browserMcpBridge, ); return cfg; } diff --git a/src/supervisor/workflows/transcriptReader.ts b/src/supervisor/workflows/transcriptReader.ts index 452127b6..8ff9fe52 100644 --- a/src/supervisor/workflows/transcriptReader.ts +++ b/src/supervisor/workflows/transcriptReader.ts @@ -7,6 +7,7 @@ import type { WorkflowRun, WorkflowRunStatus, } from "@/shared/contracts"; +import { listSessionDir, readSessionFileText } from "../agents/base"; /** * The on-disk manifest at `/workflows/.json` carries @@ -77,7 +78,13 @@ async function readJournalAgents(input: ReadWorkflowRunInput): Promise entry.name) ?? []; + } else { + entries = await readdir(dirPath); + } } catch (err) { if (isNotFoundError(err)) return []; throw err; @@ -185,6 +197,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"); } From b33302d54dfe1a9490261725e0bb91382d04bed8 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Wed, 3 Jun 2026 02:41:21 -0700 Subject: [PATCH 2/3] feat(ssh): add SSH project runtime and agents settings UI - Add shared SSH location parsing, formatting, and host validation - Add supervisor SSH commands, scripts, home resolution, and MCP/hook tunnels - Add AgentsSection for remote agent status, refresh, and sign-in --- .../parts/AgentsSection.tsx | 241 +++++++++ src/shared/ssh.ts | 47 ++ src/supervisor/ssh.ts | 510 ++++++++++++++++++ 3 files changed, 798 insertions(+) create mode 100644 src/renderer/views/ProjectSettingsOverlay/parts/AgentsSection.tsx create mode 100644 src/shared/ssh.ts create mode 100644 src/supervisor/ssh.ts 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/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/supervisor/ssh.ts b/src/supervisor/ssh.ts new file mode 100644 index 00000000..eb592396 --- /dev/null +++ b/src/supervisor/ssh.ts @@ -0,0 +1,510 @@ +import { randomInt } from "node:crypto"; +import { spawn, spawnSync, type ChildProcess } 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 normalizeLoopbackHost(hostname: string): string { + return hostname === "localhost" ? "127.0.0.1" : hostname; +} + +function parseTcpTarget(url: string): { host: string; port: number; path: string } | undefined { + try { + const parsed = new URL(url); + const port = parsed.port + ? Number(parsed.port) + : parsed.protocol === "https:" + ? 443 + : parsed.protocol === "http:" + ? 80 + : undefined; + if (!port) return undefined; + return { host: normalizeLoopbackHost(parsed.hostname), port, path: parsed.pathname }; + } catch { + return undefined; + } +} + +interface SshBrowserMcpTunnelState { + child: ChildProcess; + baseUrl: string; + secret: string; +} + +interface SshHookTunnelState { + child: ChildProcess; + url: string; + secret: string; + protocolVersion: number; +} + +export class SshBrowserMcpTunnelManager { + private readonly tunnels = new Map(); + private readonly inFlight = new Map< + string, + Promise<{ baseUrl: string; secret: string } | undefined> + >(); + + async ensureTunnel( + location: SshLocation, + upstream: { url: string; token: string }, + ): Promise<{ baseUrl: string; secret: string } | undefined> { + assertSshLocation(location); + const target = parseTcpTarget(upstream.url); + if (!target) return undefined; + const key = `${location.host}|${target.host}:${target.port}|${upstream.token}`; + const existing = this.tunnels.get(key); + if (existing && existing.child.exitCode === null && existing.child.signalCode === null) { + return { baseUrl: existing.baseUrl, secret: existing.secret }; + } + const pending = this.inFlight.get(key); + if (pending) return pending; + const task = this.startTunnel(location, upstream.token, target, key).finally(() => { + this.inFlight.delete(key); + }); + this.inFlight.set(key, task); + return task; + } + + dispose(): void { + for (const tunnel of this.tunnels.values()) { + tunnel.child.kill(); + } + this.tunnels.clear(); + } + + private async startTunnel( + location: SshLocation, + token: string, + target: { host: string; port: number; path: string }, + key: string, + ): Promise<{ baseUrl: string; secret: string } | undefined> { + for (let attempt = 0; attempt < 5; attempt += 1) { + const remotePort = randomInt(40_000, 60_000); + const child = spawn( + "ssh", + [ + ...baseSshOptions({ batchMode: "yes", tty: false }), + "-N", + "-o", + "ExitOnForwardFailure=yes", + "-R", + `127.0.0.1:${remotePort}:${target.host}:${target.port}`, + location.host, + ], + { windowsHide: true, stdio: "ignore" }, + ); + + const ready = await waitForTunnelReady(child); + if (!ready) continue; + + const baseUrl = `http://127.0.0.1:${remotePort}`; + const state: SshBrowserMcpTunnelState = { child, baseUrl, secret: token }; + this.tunnels.set(key, state); + child.once("exit", () => { + if (this.tunnels.get(key) === state) this.tunnels.delete(key); + }); + return { baseUrl, secret: token }; + } + return undefined; + } +} + +export class SshHookTunnelManager { + private readonly tunnels = new Map(); + private readonly inFlight = new Map< + string, + Promise<{ url: string; secret: string; protocolVersion: number } | undefined> + >(); + + async ensureTunnel( + location: SshLocation, + upstream: { url: string; secret: string; protocolVersion: number }, + ): Promise<{ url: string; secret: string; protocolVersion: number } | undefined> { + assertSshLocation(location); + const target = parseTcpTarget(upstream.url); + if (!target) return undefined; + const key = [ + location.host, + target.host, + target.port, + upstream.secret, + upstream.protocolVersion, + ].join("|"); + const existing = this.tunnels.get(key); + if (existing && existing.child.exitCode === null && existing.child.signalCode === null) { + return { + url: existing.url, + secret: existing.secret, + protocolVersion: existing.protocolVersion, + }; + } + const pending = this.inFlight.get(key); + if (pending) return pending; + const task = this.startTunnel(location, upstream, target, key).finally(() => { + this.inFlight.delete(key); + }); + this.inFlight.set(key, task); + return task; + } + + dispose(): void { + for (const tunnel of this.tunnels.values()) { + tunnel.child.kill(); + } + this.tunnels.clear(); + } + + private async startTunnel( + location: SshLocation, + upstream: { secret: string; protocolVersion: number }, + target: { host: string; port: number; path: string }, + key: string, + ): Promise<{ url: string; secret: string; protocolVersion: number } | undefined> { + for (let attempt = 0; attempt < 5; attempt += 1) { + const remotePort = randomInt(40_000, 60_000); + const child = spawn( + "ssh", + [ + ...baseSshOptions({ batchMode: "yes", tty: false }), + "-N", + "-o", + "ExitOnForwardFailure=yes", + "-R", + `127.0.0.1:${remotePort}:${target.host}:${target.port}`, + location.host, + ], + { windowsHide: true, stdio: "ignore" }, + ); + + const ready = await waitForTunnelReady(child); + if (!ready) continue; + + const state: SshHookTunnelState = { + child, + url: `http://127.0.0.1:${remotePort}${target.path}`, + secret: upstream.secret, + protocolVersion: upstream.protocolVersion, + }; + this.tunnels.set(key, state); + child.once("exit", () => { + if (this.tunnels.get(key) === state) this.tunnels.delete(key); + }); + return { + url: state.url, + secret: state.secret, + protocolVersion: state.protocolVersion, + }; + } + return undefined; + } +} + +function waitForTunnelReady(child: ChildProcess): Promise { + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + resolve(child.exitCode === null && child.signalCode === null); + }, 600); + if (typeof timer.unref === "function") timer.unref(); + + const onExit = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + cleanup(); + resolve(false); + }; + const onError = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + cleanup(); + resolve(false); + }; + const cleanup = () => { + child.off("exit", onExit); + child.off("error", onError); + }; + child.once("exit", onExit); + child.once("error", onError); + }); +} + +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 buildSshForwardedCommand( + location: SshLocation, + command: string, + args: string[], + env: Record | undefined, + forwards: Array<{ + localHost: string; + localPort: number; + remoteHost: string; + remotePort: number; + }>, + options?: { batchMode?: "yes" | "no"; tty?: boolean }, +): CommandSpec { + assertSshLocation(location); + const script = remoteScriptForCommand(location, command, args, env); + const forwardArgs = forwards.flatMap((forward) => [ + "-L", + `${forward.localHost}:${forward.localPort}:${forward.remoteHost}:${forward.remotePort}`, + ]); + return { + command: "ssh", + args: [ + ...baseSshOptions({ + batchMode: options?.batchMode ?? "yes", + tty: options?.tty ?? false, + }), + "-o", + "ExitOnForwardFailure=yes", + ...forwardArgs, + location.host, + `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 readSshCommandOutputSync( + location: SshLocation, + command: string, + args: string[], + options?: { timeout?: number; maxBuffer?: number; env?: Record }, +): { ok: boolean; stdout: string; stderr: string } { + const script = `${buildPosixExportPrefix(options?.env)}exec ${[command, ...args].map(quotePosixShellArg).join(" ")}`; + return runSshScriptSync(location, script, 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 function runSshScriptSync( + location: SshLocation, + script: string, + options?: { timeout?: number; maxBuffer?: number }, +): { ok: boolean; stdout: string; stderr: string } { + assertSshLocation(location); + const wrapped = `set -e\ncd ${quotePosixShellArg(location.path)}\n${script}\n`; + const result = spawnSync( + "ssh", + [...baseSshArgs(location, { batchMode: "yes", tty: false }), "sh", "-s"], + { + input: wrapped, + encoding: "utf8", + windowsHide: true, + timeout: options?.timeout ?? SSH_DEFAULT_TIMEOUT, + maxBuffer: options?.maxBuffer ?? 10 * 1024 * 1024, + }, + ); + return { + ok: !result.error && result.status === 0, + stdout: `${result.stdout ?? ""}`, + stderr: `${result.stderr ?? ""}`, + }; +} + +export function resolveSshHomeDirectory(location: SshLocation): string | undefined { + assertSshLocation(location); + const cached = sshHomeCache.get(location.host); + if (cached) return cached; + const result = readSshCommandOutputSync(location, "sh", ["-lc", 'printf %s "$HOME"'], { + timeout: 5_000, + }); + const home = result.ok ? result.stdout.trim() : ""; + if (!home) return undefined; + sshHomeCache.set(location.host, home); + return home; +} + +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; +} From 69a08addcf6e55d5a2ca0053c000038b4b72245d Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 6 Jun 2026 08:31:55 -0700 Subject: [PATCH 3/3] refactor(supervisor): limit SSH projects to terminal-only launches - Add NonSshProjectLocation and AgentCliHookEnvContext types - Disable session tracking, ref discovery, and structured sessions for SSH - Remove SSH hook plugin install paths from all provider installers - Delete SSH reverse tunnels for CLI hooks and Browser MCP - Strip remote session file I/O and SSH branches from agent adapters - Report SSH agent status as terminal-only with supportsResume: false - Fall back to command spawn for one-shot prompts and title generation - Remove unused SSH sync helpers and forwarded-command builders BREAKING CHANGE: SSH projects no longer support CLI hook plugins, Browser MCP bridges, structured runtimes, or session tracking/resume. --- src/supervisor/agents/acp-generic/index.ts | 3 - src/supervisor/agents/acp/session.ts | 58 +--- src/supervisor/agents/acp/sessionPaths.ts | 34 +- src/supervisor/agents/antigravity/session.ts | 59 +--- src/supervisor/agents/base/index.ts | 37 +- src/supervisor/agents/base/sessionFs.ts | 176 +--------- src/supervisor/agents/base/types.ts | 34 +- src/supervisor/agents/browserMcp/index.ts | 18 +- .../agents/browserMcp/providers.test.ts | 42 +-- .../agents/claude/plugin/install.ts | 65 ---- src/supervisor/agents/claude/sdkSession.ts | 41 +-- src/supervisor/agents/codex/index.ts | 46 +-- src/supervisor/agents/codex/plugin/install.ts | 146 -------- src/supervisor/agents/codex/session.ts | 126 +------ .../agents/copilot/plugin/install.ts | 99 ------ .../agents/cursor/plugin/install.ts | 117 ------- src/supervisor/agents/gemini/detection.ts | 46 +-- .../agents/gemini/plugin/install.ts | 106 ------ src/supervisor/agents/grok/detection.ts | 8 - src/supervisor/agents/grok/plugin/install.ts | 126 ------- src/supervisor/agents/grok/sessionFiles.ts | 37 +- src/supervisor/agents/opencode/argv.ts | 25 -- .../agents/opencode/plugin/install.ts | 192 ----------- src/supervisor/agents/opencode/sdkClient.ts | 13 +- src/supervisor/agents/opencode/sdkOneShot.ts | 3 +- src/supervisor/agents/opencode/sdkProbe.ts | 4 +- src/supervisor/agents/opencode/sdkServer.ts | 6 +- .../agents/plugin/installerBase.test.ts | 11 +- src/supervisor/agents/plugin/installerBase.ts | 195 +---------- src/supervisor/oneShotPromptRunner.test.ts | 48 +++ src/supervisor/oneShotPromptRunner.ts | 7 +- src/supervisor/runtime.ts | 5 +- .../runtime/agentStatusService.test.ts | 7 + src/supervisor/runtime/agentStatusService.ts | 68 ++-- .../runtime/cliHookPluginCoordinator.test.ts | 85 ++--- .../runtime/cliHookPluginCoordinator.ts | 61 ++-- .../runtime/threadSessionManager.ts | 55 ++- src/supervisor/ssh.ts | 316 +----------------- src/supervisor/titleGenerator.ts | 20 +- 39 files changed, 350 insertions(+), 2195 deletions(-) diff --git a/src/supervisor/agents/acp-generic/index.ts b/src/supervisor/agents/acp-generic/index.ts index 5c916470..cb1b0518 100644 --- a/src/supervisor/agents/acp-generic/index.ts +++ b/src/supervisor/agents/acp-generic/index.ts @@ -208,9 +208,6 @@ function detectProbeLocation(ctx: AgentEnvContext | undefined): ProjectLocation uncPath: "\\\\wsl$", }; } - if (ctx?.envKind === "ssh" && ctx.sshHost) { - return { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }; - } if (process.platform === "win32") { return { kind: "windows", path: homedir() }; } diff --git a/src/supervisor/agents/acp/session.ts b/src/supervisor/agents/acp/session.ts index b89ca593..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, @@ -74,7 +73,6 @@ import { import { terminateChildProcessTree } from "@/shared/processTree"; import { ensureNodePtySpawnHelperExecutable } from "@/supervisor/nodePty"; import { - buildAgentCommand, buildPosixExportPrefix, createKnownSessionRef, detectShell, @@ -85,12 +83,11 @@ import { getWslCommand, quotePosixShellArg, quotePowerShellLiteral, - readSessionFileText, resolveWslShellPath, - runSshScript, type AgentLaunchOptions, type CommandSpec, type CreateStructuredSessionInput, + type NonSshProjectLocation, type StartTurnOptions, type StructuredSessionHandle, type StructuredSessionListener, @@ -127,7 +124,7 @@ export { resolveAcpReadableHostFsPath, resolveAcpResourcePath, toAcpResourceUri */ async function segmentsToContentBlocks( prompt: string, - location: ProjectLocation, + location: NonSshProjectLocation, segments?: PromptSegment[], promptCapabilities?: PromptCapabilities, ): Promise { @@ -263,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 { @@ -309,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[], @@ -367,22 +364,6 @@ function buildAcpTerminalLaunch( }; } - if (location.kind === "ssh") { - const spec = buildAgentCommand( - { ...location, path: cwd }, - command, - args, - undefined, - { TERM: "xterm-256color", ...requestEnv }, - { sshBatchMode: "yes", sshTty: false }, - ); - return { - command: spec.command, - args: spec.args, - env: processEnvRecord(), - }; - } - if (args.length === 0) { return { command: process.env.SHELL || "/bin/bash", @@ -477,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; @@ -546,7 +527,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { private constructor( child: ChildProcess, connection: ClientSideConnection, - projectLocation: ProjectLocation, + projectLocation: NonSshProjectLocation, cwd: string, threadId: string, options?: AcpStructuredSessionOptions, @@ -761,7 +742,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { */ static create( command: CommandSpec, - projectLocation: ProjectLocation, + projectLocation: NonSshProjectLocation, threadId: string, options?: AcpStructuredSessionOptions, ): AcpStructuredSession { @@ -1229,13 +1210,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { private async handleReadTextFile(params: ReadTextFileRequest): Promise { this.assertRequestSession(params.sessionId); const path = resolveAcpReadableHostFsPath(this.projectLocation, params.path); - const fullContent = - this.projectLocation.kind === "ssh" - ? await readSessionFileText(this.projectLocation, path) - : await readFile(path, "utf8"); - if (fullContent === undefined) { - throw RequestError.invalidParams({ message: `File not found: ${params.path}` }); - } + const fullContent = await readFile(path, "utf8"); const content = sliceTextFileContent(fullContent, params.line, params.limit); return { content }; } @@ -1243,20 +1218,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { private async handleWriteTextFile(params: WriteTextFileRequest): Promise { this.assertRequestSession(params.sessionId); const path = resolveAcpHostFsPath(this.projectLocation, params.path); - if (this.projectLocation.kind === "ssh") { - const encoded = Buffer.from(params.content, "utf8").toString("base64"); - await runSshScript( - this.projectLocation, - [ - `path=${quotePosixShellArg(path)}`, - `dir=\${path%/*}`, - `mkdir -p "$dir"`, - `printf %s ${quotePosixShellArg(encoded)} | base64 -d > "$path"`, - ].join("\n"), - ); - } else { - await writeFile(path, params.content, "utf8"); - } + await writeFile(path, params.content, "utf8"); return {}; } diff --git a/src/supervisor/agents/acp/sessionPaths.ts b/src/supervisor/agents/acp/sessionPaths.ts index fa54fce1..8ea345ed 100644 --- a/src/supervisor/agents/acp/sessionPaths.ts +++ b/src/supervisor/agents/acp/sessionPaths.ts @@ -1,37 +1,34 @@ 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; case "wsl": return location.linuxPath; - case "ssh": case "posix": return location.path; } } /** 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; - if (location.kind === "ssh") 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); case "wsl": - case "ssh": case "posix": return posix.basename(filePath); } @@ -41,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; } @@ -50,13 +47,12 @@ export function resolveAcpResourcePath(location: ProjectLocation, rawPath: strin return win32.join(location.path, rawPath); case "wsl": return rawPath.startsWith("/") ? rawPath : posix.join(location.linuxPath, rawPath); - case "ssh": case "posix": return rawPath.startsWith("/") ? rawPath : posix.join(location.path, rawPath); } } -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); @@ -66,7 +62,6 @@ function isProjectRelativePath(location: ProjectLocation, absolutePath: string): const relative = posix.relative(location.linuxPath, absolutePath); return relative === "" || (!relative.startsWith("..") && !posix.isAbsolute(relative)); } - case "ssh": case "posix": { const relative = posix.relative(location.path, absolutePath); return relative === "" || (!relative.startsWith("..") && !posix.isAbsolute(relative)); @@ -74,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}` }); @@ -82,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; @@ -93,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); @@ -108,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 @@ -123,7 +121,6 @@ export function toAcpResourceUri(location: ProjectLocation, rawPath: string): st case "windows": return pathToFileURL(absolutePath).href; case "wsl": - case "ssh": case "posix": return new URL(`file://${absolutePath.replace(/\\/g, "/")}`).href; } @@ -165,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 = @@ -178,7 +175,6 @@ function isAgentSkillReadPath(location: ProjectLocation, absolutePath: string): return relative !== "" && !relative.startsWith("..") && !win32.isAbsolute(relative); } case "wsl": - case "ssh": case "posix": { const match = /^(\/(?:home\/[^/]+|Users\/[^/]+|root)\/\.agents\/skills)(?:\/.*)?$/.exec( absolutePath, @@ -191,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/antigravity/session.ts b/src/supervisor/agents/antigravity/session.ts index f44b797b..35989e9c 100644 --- a/src/supervisor/agents/antigravity/session.ts +++ b/src/supervisor/agents/antigravity/session.ts @@ -6,11 +6,8 @@ import { getCachedWslHomeDirectory, listSessionDir, readSessionFileText, - readSshCommandOutputSync, resolveAgentHomeSubpath, - resolveSshHomeDirectory, resolveWslHomeDirectoryAsync, - runSshScriptSync, statSessionPaths, } from "../base"; @@ -35,12 +32,7 @@ export function resolveAntigravityConfigDir(location: ProjectLocation): string | export function antigravityConfigDirExists(location: ProjectLocation): boolean { const dir = resolveAntigravityConfigDir(location); - if (!dir) return false; - if (location.kind === "ssh") { - const result = runSshScriptSync(location, `[ -d ${shellQuote(dir)} ]`, { timeout: 5_000 }); - return result.ok; - } - return existsSync(dir); + return Boolean(dir && existsSync(dir)); } interface AntigravityConversationFile { @@ -54,46 +46,11 @@ interface AntigravityConversationFile { // open database must be excluded — only the base file names a conversation. const CONVERSATION_FILE_RE = /\.(pb|db)$/; -function shellQuote(s: string): string { - return `'${s.replace(/'/g, "'\\''")}'`; -} - function readAntigravityConversationFiles( location: ProjectLocation, ): AntigravityConversationFile[] { const dir = resolveAntigravityConversationsDir(location); - if (!dir) return []; - if (location.kind === "ssh") { - const result = runSshScriptSync( - location, - [ - `dir=${shellQuote(dir)}`, - `[ -d "$dir" ] || exit 0`, - `find "$dir" -maxdepth 1 -type f -name '*.pb' 2>/dev/null | while IFS= read -r path; do`, - ` mtime=$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null || printf 0)`, - ` printf '%s\\t%s\\n' "$mtime" "\${path##*/}"`, - `done`, - ].join("\n"), - { timeout: 10_000 }, - ); - if (!result.ok) return []; - return result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .flatMap((line) => { - const [mtimeRaw, name] = line.split("\t"); - if (!name?.endsWith(".pb")) return []; - return [ - { - id: name.replace(/\.pb$/, ""), - path: `${dir}/${name}`, - mtimeMs: Number(mtimeRaw) * 1000, - }, - ]; - }); - } - if (!existsSync(dir)) return []; + if (!dir || !existsSync(dir)) return []; try { return readdirSync(dir) .filter((file) => CONVERSATION_FILE_RE.test(file)) @@ -269,11 +226,7 @@ export function readAntigravityLastConversationForCwd( const path = resolveAgentHomeSubpath(location, LAST_CONVERSATIONS_SUBPATH); if (!path) return undefined; try { - const raw = - location.kind === "ssh" - ? readSshCommandOutputSync(location, "cat", [path], { timeout: 10_000 }).stdout - : readFileSync(path, "utf8"); - const map = JSON.parse(raw) as Record; + const map = JSON.parse(readFileSync(path, "utf8")) as Record; const value = map[cwd]; return typeof value === "string" && value.length > 0 ? value : undefined; } catch { @@ -317,11 +270,6 @@ export function resolveAntigravityWatchPaths(location: ProjectLocation): string[ if (!home) return []; return [`${home}/${ANTIGRAVITY_CONFIG_SUBPATH}`, `${home}/${ANTIGRAVITY_PARENT_SUBPATH}`]; } - if (location.kind === "ssh") { - const home = resolveSshHomeDirectory(location); - if (!home) return []; - return [`${home}/${ANTIGRAVITY_CONFIG_SUBPATH}`, `${home}/${ANTIGRAVITY_PARENT_SUBPATH}`]; - } const home = homedir(); const paths = [ join(home, ...ANTIGRAVITY_CONFIG_SUBPATH.split("/")), @@ -331,6 +279,5 @@ export function resolveAntigravityWatchPaths(location: ProjectLocation): string[ } export function describeAntigravityLocation(location: ProjectLocation): string { - if (location.kind === "ssh") return `ssh:${location.host}`; return location.kind === "wsl" ? `wsl:${location.distro}` : location.kind; } diff --git a/src/supervisor/agents/base/index.ts b/src/supervisor/agents/base/index.ts index a1b902c7..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,19 +63,14 @@ import type { ThreadHistory, ThreadHistoryEntry, } from "./types"; -import { - buildSshCommand, - buildSshPtyCommand, - readSshCommandOutput, - resolveSshHomeDirectory, - runSshScript, -} from "../../ssh"; +import { buildSshCommand, buildSshPtyCommand, readSshCommandOutput, runSshScript } from "../../ssh"; export type { AcpSessionUpdateTransform, AgentAcpAuth, AgentAdapter, AgentArgvSpec, + AgentCliHookEnvContext, AgentCliHookPluginSupport, AgentDetector, AgentEnvContext, @@ -87,6 +84,7 @@ export type { AgentUpdater, AgentUpdaterCommand, AuthProbe, + CapabilitiesProbeCtx, CapabilitiesProbeResult, CommandSpec, CreateStructuredSessionInput, @@ -97,6 +95,7 @@ export type { ResolveExecutablePath, RunOneShotInput, StartTurnOptions, + NonSshProjectLocation, StatusProbe, StatusProbeResult, StructuredSessionHandle, @@ -359,6 +358,13 @@ export function resolveLaunchSpec(location: ProjectLocation, argv: AgentArgvSpec return spec; } +export function isSessionTrackingLocation( + location: ProjectLocation, + launchOptions?: AgentLaunchOptions, +): location is NonSshProjectLocation { + return launchOptions?.sessionTrackingEnabled !== false && location.kind !== "ssh"; +} + // ── Install-detection engine ─────────────────────────────────────── /** @@ -588,20 +594,9 @@ export function resolveAgentHomeSubpath( if (!home) return undefined; return toWslUncPath(location.distro, `${home}/${trimmed}`); } - if (location.kind === "ssh") { - const home = resolveSshHomeDirectory(location); - return home ? `${home}/${trimmed}` : undefined; - } return join(homedir(), ...subpath.split(/[\\/]/).filter((s) => s.length > 0)); } -export { - readSshCommandOutputSync, - resolveSshHomeDirectory, - runSshScript, - runSshScriptSync, -} from "../../ssh"; - /** * Recursive `fs.watch` wrapper with uniform error-swallow / cleanup semantics. * Returns an undo handle or `undefined` when the watcher could not be @@ -673,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/sessionFs.ts b/src/supervisor/agents/base/sessionFs.ts index b0b51c50..ae599488 100644 --- a/src/supervisor/agents/base/sessionFs.ts +++ b/src/supervisor/agents/base/sessionFs.ts @@ -1,10 +1,8 @@ import { existsSync, readFileSync, readdirSync, statSync, watch as fsWatch } from "node:fs"; -import { basename, join } from "node:path"; +import { join } from "node:path"; import type { ProjectLocation } from "@/shared/contracts"; import { toWslUncPath } from "@/shared/wsl"; import type { WslBridgeClient, WslLocation } from "../../wsl/bridge/client"; -import { runSshScript } from "../../ssh"; -import { quotePosixShellArg } from "./shellBasics"; /** * Shared filesystem helpers for agent session discovery. Routes WSL reads @@ -57,50 +55,10 @@ export interface SessionDirEntry { type: "file" | "directory" | "symlink" | "other"; } -function parseSshDirEntry(line: string): SessionDirEntry | undefined { - const tab = line.indexOf("\t"); - if (tab <= 0) return undefined; - const name = line.slice(0, tab); - const rawType = line.slice(tab + 1); - const type = - rawType === "file" || rawType === "directory" || rawType === "symlink" || rawType === "other" - ? rawType - : "other"; - return { name, type }; -} - -async function listSshSessionDir( - location: Extract, - absolutePath: string, -): Promise { - const dir = quotePosixShellArg(absolutePath); - const result = await runSshScript( - location, - [ - `dir=${dir}`, - `[ -d "$dir" ] || exit 0`, - `for p in "$dir"/* "$dir"/.[!.]* "$dir"/..?*; do`, - ` [ -e "$p" ] || [ -L "$p" ] || continue`, - ` name=\${p##*/}`, - ` if [ -f "$p" ]; then type=file; elif [ -d "$p" ]; then type=directory; elif [ -L "$p" ]; then type=symlink; else type=other; fi`, - ` printf '%s\\t%s\\n' "$name" "$type"`, - `done`, - ].join("\n"), - ).catch(() => undefined); - if (!result) return undefined; - return result.stdout - .split(/\r?\n/) - .map((line) => parseSshDirEntry(line)) - .filter((entry): entry is SessionDirEntry => entry !== undefined); -} - export async function listSessionDir( location: ProjectLocation, absolutePath: string, ): Promise { - if (location.kind === "ssh") { - return listSshSessionDir(location, absolutePath); - } if (location.kind !== "wsl") { try { return readdirSync(absolutePath, { withFileTypes: true }).map((d) => ({ @@ -136,59 +94,11 @@ export interface SessionStat { isFile?: boolean; } -async function statSshSessionPaths( - location: Extract, - paths: string[], -): Promise> { - const lines = [ - `mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || printf 0; }`, - ...paths.map((path, index) => { - const p = quotePosixShellArg(path); - return [ - `p=${p}`, - `if [ -e "$p" ] || [ -L "$p" ]; then`, - ` is_file=0; is_dir=0`, - ` [ -f "$p" ] && is_file=1`, - ` [ -d "$p" ] && is_dir=1`, - ` printf '${index}\\t1\\t%s\\t%s\\t%s\\n' "$(mtime "$p")" "$is_file" "$is_dir"`, - `else`, - ` printf '${index}\\t0\\t0\\t0\\t0\\n'`, - `fi`, - ].join("\n"); - }), - ]; - const result = await runSshScript(location, lines.join("\n")).catch(() => undefined); - const map = new Map(paths.map((p) => [p, { exists: false }])); - if (!result) return map; - for (const line of result.stdout.split(/\r?\n/)) { - if (!line.trim()) continue; - const [indexRaw, existsRaw, mtimeRaw, fileRaw, dirRaw] = line.split("\t"); - const index = Number(indexRaw); - const path = paths[index]; - if (!Number.isInteger(index) || !path) continue; - if (existsRaw !== "1") { - map.set(path, { exists: false }); - continue; - } - const mtimeSeconds = Number(mtimeRaw); - map.set(path, { - exists: true, - ...(Number.isFinite(mtimeSeconds) ? { mtimeMs: mtimeSeconds * 1000 } : {}), - isFile: fileRaw === "1", - isDirectory: dirRaw === "1", - }); - } - return map; -} - export async function statSessionPaths( location: ProjectLocation, paths: string[], ): Promise> { if (paths.length === 0) return new Map(); - if (location.kind === "ssh") { - return statSshSessionPaths(location, paths); - } if (location.kind !== "wsl") { const map = new Map(); for (const p of paths) { @@ -240,21 +150,6 @@ export async function readSessionFileText( absolutePath: string, maxBytes = 0, ): Promise { - if (location.kind === "ssh") { - const path = quotePosixShellArg(absolutePath); - const sizeGuard = - maxBytes > 0 - ? `size=$(wc -c < ${path} 2>/dev/null || printf 0); [ "$size" -le ${maxBytes} ] || exit 3` - : ""; - const result = await runSshScript( - location, - [`[ -f ${path} ] || exit 3`, sizeGuard, `cat -- ${path}`].filter(Boolean).join("\n"), - { - ...(maxBytes > 0 ? { maxBuffer: maxBytes + 1024 } : {}), - }, - ).catch(() => undefined); - return result?.stdout; - } if (location.kind !== "wsl") { try { const raw = readFileSync(absolutePath); @@ -297,41 +192,6 @@ export interface FoundSessionFile { mtimeMs?: number; } -async function findSshSessionFiles( - location: Extract, - opts: FindSessionFilesOptions, -): Promise { - const ignoreExpr = - opts.ignore && opts.ignore.length > 0 - ? `\\( ${opts.ignore.map((name) => `-name ${quotePosixShellArg(name)}`).join(" -o ")} \\) -prune -o ` - : ""; - const result = await runSshScript( - location, - [ - `root=${quotePosixShellArg(opts.root)}`, - `[ -d "$root" ] || exit 0`, - `find "$root" ${ignoreExpr}-type f -print 2>/dev/null | sed -n '1,${opts.maxEntries ?? 10_000}p'`, - ].join("\n"), - ).catch(() => undefined); - if (!result) return []; - const acceptFile = opts.acceptFile ?? (() => true); - const matches = result.stdout - .split(/\r?\n/) - .map((path) => path.trim()) - .filter((path) => path.length > 0) - .map((path) => ({ path, name: basename(path) })) - .filter((entry) => acceptFile(entry.name)); - if (!opts.includeMtime || matches.length === 0) return matches; - const stats = await statSshSessionPaths( - location, - matches.map((match) => match.path), - ); - return matches.map((match) => { - const mtimeMs = stats.get(match.path)?.mtimeMs; - return typeof mtimeMs === "number" ? { ...match, mtimeMs } : match; - }); -} - export async function findSessionFiles( location: ProjectLocation, opts: FindSessionFilesOptions, @@ -340,10 +200,6 @@ export async function findSessionFiles( const maxEntries = opts.maxEntries ?? 10_000; const ignore = opts.ignore ?? []; - if (location.kind === "ssh") { - return findSshSessionFiles(location, opts); - } - if (location.kind !== "wsl") { const out: FoundSessionFile[] = []; const walk = (dir: string): void => { @@ -422,36 +278,6 @@ export function watchSessionPaths( label: string, ): () => void { if (paths.length === 0) return () => undefined; - if (location.kind === "ssh") { - let disposed = false; - let timer: NodeJS.Timeout | undefined; - let previousKey: string | undefined; - const poll = async () => { - const stats = await statSshSessionPaths(location, paths); - const nextKey = paths - .map((p) => { - const stat = stats.get(p); - return `${p}:${stat?.exists ? "1" : "0"}:${stat?.mtimeMs ?? 0}`; - }) - .join("|"); - if (previousKey !== undefined && nextKey !== previousKey && !disposed) onChanged(); - previousKey = nextKey; - if (disposed) return; - timer = setTimeout(() => void poll(), 2_000); - if (typeof timer.unref === "function") timer.unref(); - }; - void poll().catch((err) => { - console.log( - "[%s] ssh session watcher failed: %s", - label, - err instanceof Error ? err.message : String(err), - ); - }); - return () => { - disposed = true; - if (timer) clearTimeout(timer); - }; - } if (location.kind !== "wsl") { const watchers: Array<() => void> = []; for (const p of paths) { diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index fe2ccb5c..b8460855 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -49,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; @@ -112,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; @@ -146,6 +153,10 @@ export interface DetectProbeCtx { version?: string | undefined; } +export type CapabilitiesProbeCtx = Omit & { + location: NonSshProjectLocation; +}; + export type AuthProbe = (ctx: DetectProbeCtx) => Promise; export interface StatusProbeResult { @@ -184,7 +195,7 @@ export interface DetectionSpec { versionArgs?: string[]; statusProbe?: StatusProbe; authProbes?: AuthProbe[]; - capabilitiesProbe?: (ctx: DetectProbeCtx) => Promise; + capabilitiesProbe?: (ctx: CapabilitiesProbeCtx) => Promise; } export interface AgentMetadata { @@ -264,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; @@ -339,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/browserMcp/index.ts b/src/supervisor/agents/browserMcp/index.ts index 43e18302..faef66fe 100644 --- a/src/supervisor/agents/browserMcp/index.ts +++ b/src/supervisor/agents/browserMcp/index.ts @@ -46,10 +46,6 @@ export interface BrowserMcpHttpConfig { export interface BrowserMcpBridge { ensureBridge(distro: string): Promise<{ baseUrl: string; secret: string } | undefined>; - ensureSshBridge?( - location: Extract, - upstream: BrowserMcpEnv, - ): Promise<{ baseUrl: string; secret: string } | undefined>; } /** @@ -85,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(); @@ -98,18 +95,5 @@ export async function resolveBrowserMcpHttpConfigForLaunch( headers: { Authorization: `Bearer ${handle.secret}` }, }; } - if (location.kind === "ssh") { - if (!bridge?.ensureSshBridge) return undefined; - const env = readBrowserMcpEnv(); - if (!env) return undefined; - const handle = await bridge.ensureSshBridge(location, env); - if (!handle) return undefined; - const url = `${handle.baseUrl.replace(/\/$/, "")}/mcp`; - return { - url, - token: handle.secret, - headers: { Authorization: `Bearer ${handle.secret}` }, - }; - } return resolveBrowserMcpHttpConfig(location) ?? undefined; } diff --git a/src/supervisor/agents/browserMcp/providers.test.ts b/src/supervisor/agents/browserMcp/providers.test.ts index 44389369..8c00e333 100644 --- a/src/supervisor/agents/browserMcp/providers.test.ts +++ b/src/supervisor/agents/browserMcp/providers.test.ts @@ -5,9 +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 = { kind: "ssh", host: "devbox", path: "/repo" } 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", @@ -84,44 +85,17 @@ describe("WSL Browser MCP provider configs", () => { expect(buildOpenCodeBrowserMcp(wslLocation)).toBeUndefined(); }); - it("resolves SSH Browser MCP through a reverse SSH bridge", async () => { + 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 ensureSshBridge = vi.fn< - ( - location: typeof sshLocation, - upstream: { url: string; token: string }, - ) => Promise<{ baseUrl: string; secret: string }> - >(async () => ({ + const ensureBridge = vi.fn<() => Promise<{ baseUrl: string; secret: string }>>(async () => ({ baseUrl: "http://127.0.0.1:45678", - secret: "host-token", + secret: "bridge-secret", })); - const ensureBridge = vi.fn<() => Promise<{ baseUrl: string; secret: string } | undefined>>(); await expect( - resolveBrowserMcpHttpConfigForLaunch(sshLocation, true, { - ensureBridge, - ensureSshBridge, - }), - ).resolves.toEqual({ - url: "http://127.0.0.1:45678/mcp", - token: "host-token", - headers: { Authorization: "Bearer host-token" }, - }); - expect(ensureSshBridge).toHaveBeenCalledWith(sshLocation, { - url: "http://127.0.0.1:65093", - token: "host-token", - }); - }); - - it("does not fall back to local loopback MCP for SSH when no SSH bridge exists", () => { - process.env.LIGHTCODE_BROWSER_MCP_URL = "http://127.0.0.1:65093"; - process.env.LIGHTCODE_BROWSER_MCP_TOKEN = "host-token"; - - expect(buildAcpBrowserMcpServers(sshLocation, true)).toEqual([]); - expect(buildClaudeBrowserMcpServers(sshLocation, true)).toBeUndefined(); - expect(buildCodexBrowserMcpArgs(sshLocation, true)).toEqual([]); - expect(buildGeminiBrowserMcpServers(sshLocation)).toBeUndefined(); - expect(buildOpenCodeBrowserMcp(sshLocation)).toBeUndefined(); + resolveBrowserMcpHttpConfigForLaunch(sshLocation, true, { ensureBridge }), + ).resolves.toBeUndefined(); + expect(ensureBridge).not.toHaveBeenCalled(); }); }); diff --git a/src/supervisor/agents/claude/plugin/install.ts b/src/supervisor/agents/claude/plugin/install.ts index 04dedaaf..9b7012c8 100644 --- a/src/supervisor/agents/claude/plugin/install.ts +++ b/src/supervisor/agents/claude/plugin/install.ts @@ -13,19 +13,14 @@ import { ctxCacheKey, getNativeHookWrapperFilename, getNativePluginBaseDir, - getSshPluginBaseDirs, getWslPluginBaseDirs, hasNativeHookWrapper, - isSshPluginContext, isWslPluginContext, memoByCtx, readBundledPluginVersion, readPluginManifest, removeStagedPluginDir, - stagePluginAssetsToSsh, stagePluginAssetsToWsl, - verifySshStagedPlugin, - writeSshJsonFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; @@ -88,15 +83,6 @@ export function readBundledClaudePluginVersion(): string { } function computeClaudePluginPaths(ctx?: AgentEnvContext): ClaudePluginPaths { - if (isSshPluginContext(ctx)) { - const ssh = getSshPluginBaseDirs(ctx, "claude"); - if (!ssh) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; - return { - pluginDir: ssh.linuxBase, - settingsPath: `${ssh.linuxBase}/settings.json`, - version: "0.0.0", - }; - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "claude"); if (!wsl) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; @@ -186,15 +172,6 @@ export function installClaudePlugin( } return installClaudePluginWsl(ctx.wslDistro, sourceDir, manifest, options.resolvedNodePath); } - if (isSshPluginContext(ctx)) { - if (!options?.resolvedNodePath) { - return { - ok: false, - reason: "SSH Claude plugin install requires a resolved node path on the remote host.", - }; - } - return installClaudePluginSsh(ctx, sourceDir, manifest, options.resolvedNodePath); - } const pluginDir = getNativePluginBaseDir("claude", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -223,37 +200,6 @@ export function installClaudePlugin( }; } -function installClaudePluginSsh( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - sourceDir: string, - manifest: PluginManifest, - resolvedNodePath: string, -): { ok: true; paths: ClaudePluginPaths; version: string } | { ok: false; reason: string } { - const staged = stagePluginAssetsToSsh(ctx, sourceDir, "claude", { - includeForwardRuntime: true, - }); - if (!staged.ok) return staged; - - const linuxPluginDir = staged.linuxPluginDir; - const settingsPath = `${linuxPluginDir}/settings.json`; - const headExpression = buildWslHookCommandHead(resolvedNodePath, `${linuxPluginDir}/forward.mjs`); - const settings = renderClaudeSettings(headExpression); - const settingsResult = writeSshJsonFile(ctx, settingsPath, settings); - if (!settingsResult.ok) return settingsResult; - const hooksResult = writeSshJsonFile(ctx, `${linuxPluginDir}/hooks/hooks.json`, settings); - if (!hooksResult.ok) return hooksResult; - - console.log( - `[supervisor] Claude hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost} at ${linuxPluginDir} (forward.mjs, settings.json, hooks/hooks.json) using node=${resolvedNodePath}`, - ); - - return { - ok: true, - version: manifest.version, - paths: { pluginDir: linuxPluginDir, settingsPath, version: manifest.version }, - }; -} - function installClaudePluginWsl( distro: string, sourceDir: string, @@ -310,17 +256,6 @@ export function isClaudePluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { - if (isSshPluginContext(ctx)) { - return verifySshStagedPlugin(ctx, "claude", { - assets: [ - "plugin.json", - "forward.mjs", - FORWARD_RUNTIME_FILE, - "settings.json", - "hooks/hooks.json", - ], - }); - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "claude"); if (!wsl) return { installed: false }; diff --git a/src/supervisor/agents/claude/sdkSession.ts b/src/supervisor/agents/claude/sdkSession.ts index ce403ae0..2ff32e98 100644 --- a/src/supervisor/agents/claude/sdkSession.ts +++ b/src/supervisor/agents/claude/sdkSession.ts @@ -28,7 +28,6 @@ import type { import { areAgentSlashCommandsEqual } from "@/shared/contracts"; import { buildClaudeBrowserMcpServers } from "./mcpBrowser"; import { - buildAgentCommand, createKnownSessionRef, getWslCommand, getPrimedPosixEnv, @@ -39,6 +38,7 @@ import { resolveExecutablePathAsync, type AgentLaunchOptions, type CreateStructuredSessionInput, + type NonSshProjectLocation, type StartTurnOptions, type StructuredSessionHandle, type StructuredSessionListener, @@ -98,11 +98,10 @@ type CompletedClaudeTurn = { resumeSessionAt: string | undefined; }; -function projectCwd(location: ProjectLocation): string { +function projectCwd(location: NonSshProjectLocation): string { switch (location.kind) { case "wsl": return location.linuxPath; - case "ssh": case "windows": case "posix": return location.path; @@ -214,26 +213,6 @@ function spawnClaudeNative(location: ProjectLocation, options: SpawnOptions): Sp }) as unknown as SpawnedProcess; } -function spawnClaudeInSsh(location: ProjectLocation, options: SpawnOptions): SpawnedProcess { - if (location.kind !== "ssh") { - throw new Error("spawnClaudeInSsh called for a non-SSH project."); - } - const command = options.command || "claude"; - const spec = buildAgentCommand( - location, - command, - options.args, - undefined, - filteredEnv(options.env), - ); - return spawn(spec.command, spec.args, { - env: process.env, - signal: options.signal, - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }) as unknown as SpawnedProcess; -} - function isImageAttachment(segment: PromptSegment): boolean { return ( segment.kind === "attachment" && @@ -846,7 +825,7 @@ export class ClaudeSdkSession implements StructuredSessionHandle { (process.env as Record)) : undefined; const env = - this.input.projectLocation.kind === "wsl" || this.input.projectLocation.kind === "ssh" + this.input.projectLocation.kind === "wsl" ? { CLAUDE_AGENT_SDK_CLIENT_APP: "lightcode", BROWSER: "/bin/true" } : { ...(posixEnv ?? process.env), CLAUDE_AGENT_SDK_CLIENT_APP: "lightcode" }; // Posix builds ship without the SDK's bundled `claude` SEA binary @@ -884,9 +863,6 @@ export class ClaudeSdkSession implements StructuredSessionHandle { claudeExecutablePath = resolveAgentBinaryPath(this.input.projectLocation, "claude") ?? "claude"; break; - case "ssh": - claudeExecutablePath = "claude"; - break; default: { const _exhaustive: never = this.input.projectLocation; void _exhaustive; @@ -933,17 +909,12 @@ export class ClaudeSdkSession implements StructuredSessionHandle { spawnClaudeCodeProcess: (spawnOptions) => spawnClaudeInWsl(this.input.projectLocation, spawnOptions), } - : this.input.projectLocation.kind === "ssh" + : this.input.projectLocation.kind === "windows" ? { spawnClaudeCodeProcess: (spawnOptions) => - spawnClaudeInSsh(this.input.projectLocation, spawnOptions), + spawnClaudeNative(this.input.projectLocation, spawnOptions), } - : this.input.projectLocation.kind === "windows" - ? { - spawnClaudeCodeProcess: (spawnOptions) => - spawnClaudeNative(this.input.projectLocation, spawnOptions), - } - : {}), + : {}), }; this.queryRuntime = query({ prompt: this.promptQueue, options }); diff --git a/src/supervisor/agents/codex/index.ts b/src/supervisor/agents/codex/index.ts index bf929d99..50c8fb63 100644 --- a/src/supervisor/agents/codex/index.ts +++ b/src/supervisor/agents/codex/index.ts @@ -8,13 +8,13 @@ import { detectAgentInstall, detectProbeLocation, getOscNotificationText, + isSessionTrackingLocation, watchSessionPaths, type AgentAdapter, type CreateStructuredSessionInput, type TerminalStatusHint, } from "../base"; import { resolveAgentBinaryPath } from "../binaryResolver"; -import { readSshCommandOutput } from "../../ssh"; import { CodexStructuredSession } from "./acp"; import { buildCodexArgvFor, primeCodexGoalsSupport } from "./argv"; import { codexDefaultCapabilities, codexDetectionSpec } from "./detection"; @@ -87,10 +87,8 @@ function codexOscHint(notification: OscNotification): TerminalStatusHint | null } async function resolveCodexHooksFeatureFlag(ctx: { - envKind: "windows" | "wsl" | "posix" | "ssh"; + envKind: "windows" | "wsl" | "posix"; wslDistro?: string; - sshHost?: string; - sshPath?: string; }): Promise { if (ctx.envKind === "wsl" && ctx.wslDistro) { const [verOut] = await batchWslCommandsAsync(ctx.wslDistro, ["codex --version"]); @@ -101,20 +99,6 @@ async function resolveCodexHooksFeatureFlag(ctx: { .find((line) => line.length > 0) ?? ""; return codexHooksFeatureFlagForSemver(parseCodexVersionLine(versionLine)); } - if (ctx.envKind === "ssh" && ctx.sshHost) { - const out = await readSshCommandOutput( - { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, - "codex", - ["--version"], - { timeout: 8_000 }, - ).catch(() => undefined); - const versionLine = - out?.stdout - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? ""; - return codexHooksFeatureFlagForSemver(parseCodexVersionLine(versionLine)); - } return codexHooksFeatureFlagForSemver(probeCodexCliSemver()); } @@ -158,30 +142,6 @@ export function createCodexAdapter(): AgentAdapter { } return true; } - if (ctx.envKind === "ssh" && ctx.sshHost) { - const out = await readSshCommandOutput( - { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, - "codex", - ["--version"], - { timeout: 8_000 }, - ).catch(() => undefined); - const versionLine = - out?.stdout - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) ?? ""; - const v = parseCodexVersionLine(versionLine); - if (!isCodexSemverSupportedForHooks(v)) { - console.warn( - `[codex] SSH hook plugin unsupported on host ${ctx.sshHost}: ` + - `need codex-cli >= ${CODEX_MIN_HOOKS_VERSION_LABEL}, got ${ - versionLine || "(unparseable `codex --version` output)" - }`, - ); - return false; - } - return true; - } return isCodexVersionSupportedForHooks(); }, isPluginInstalled(ctx) { @@ -216,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/plugin/install.ts b/src/supervisor/agents/codex/plugin/install.ts index a0afe9e0..8e9ac55e 100644 --- a/src/supervisor/agents/codex/plugin/install.ts +++ b/src/supervisor/agents/codex/plugin/install.ts @@ -12,7 +12,6 @@ import { fileURLToPath } from "node:url"; import { toWslUncPath } from "@/shared/wsl"; import type { AgentEnvContext } from "../../base"; import { execInWsl, quotePosixShellArg } from "../../base"; -import { runSshScriptSync } from "../../../ssh"; import { FORWARD_RUNTIME_FILE, buildNativeHookCommandHeads, @@ -22,23 +21,15 @@ import { ctxCacheKey, ensureNativeStateLink, getNativePluginBaseDir, - getSshPluginBaseDirs, getWslPluginBaseDirs, hasNativeHookWrapper, - isSshPluginContext, isWslPluginContext, memoByCtx, parseExistingHooksJson, - parseExistingSshJson, readBundledPluginVersion, readPluginManifest, - readSshTextFile, removeStagedPluginDir, - stagePluginAssetsToSsh, stagePluginAssetsToWsl, - sshPathExists, - verifySshStagedPlugin, - writeSshJsonFile, writeHooksJsonFile, writeNativeHookWrapper, type PluginManifest, @@ -87,18 +78,6 @@ export function readBundledCodexPluginVersion(): string { } function computeCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths { - if (isSshPluginContext(ctx)) { - const ssh = getSshPluginBaseDirs(ctx, "codex"); - if (!ssh) { - return { pluginDir: "", codexHomeDir: "", codexHooksPath: "", version: "0.0.0" }; - } - return { - pluginDir: ssh.linuxBase, - codexHomeDir: `${ssh.linuxBase}/home`, - codexHooksPath: `${ssh.linuxBase}/home/hooks.json`, - version: "0.0.0", - }; - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "codex"); if (!wsl) { @@ -270,36 +249,6 @@ async function seedWslCodexHome( await execInWsl(distro, "/", "sh", ["-lc", script], { timeout: 15_000 }).catch(() => undefined); } -function seedSshCodexHome( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - home: string, - linuxCodexHome: string, -): void { - const globalCodexHome = `${home}/.codex`; - const linkExists = (path: string) => - `[ -e ${quotePosixShellArg(path)} ] || [ -L ${quotePosixShellArg(path)} ]`; - const linkLine = (name: string, kind: "dir" | "file") => { - const target = quotePosixShellArg(`${linuxCodexHome}/${name}`); - const source = quotePosixShellArg(`${globalCodexHome}/${name}`); - const attempts = [ - linkExists(`${linuxCodexHome}/${name}`), - `ln -s ${source} ${target}`, - ...(kind === "file" ? [`ln ${source} ${target}`, `cp ${source} ${target}`] : []), - ]; - return attempts.join(" || "); - }; - const script = [ - [ - "mkdir -p", - quotePosixShellArg(linuxCodexHome), - quotePosixShellArg(`${globalCodexHome}/sessions`), - ].join(" "), - `touch ${quotePosixShellArg(`${globalCodexHome}/session_index.jsonl`)}`, - ...CODEX_LINK_TARGETS.map(({ name, kind }) => linkLine(name, kind)), - ].join("\n"); - runSshScriptSync({ kind: "ssh", host: ctx.sshHost, path: "/" }, script); -} - const MIN_CODEX_SEMVER = [0, 122, 0] as const; const CODEX_HOOKS_FEATURE_RENAME_SEMVER = [0, 130, 0] as const; const CODEX_GOALS_FEATURE_SEMVER = [0, 130, 0] as const; @@ -397,15 +346,6 @@ export async function installCodexPlugin( } return installCodexPluginWsl(ctx.wslDistro, sourceDir, manifest, options.resolvedNodePath); } - if (isSshPluginContext(ctx)) { - if (!options?.resolvedNodePath) { - return { - ok: false, - reason: "SSH Codex plugin install requires a resolved node path on the remote host.", - }; - } - return installCodexPluginSsh(ctx, sourceDir, manifest, options.resolvedNodePath); - } const pluginDir = getNativePluginBaseDir("codex", ctx?.baseDir); const codexHomeDir = join(pluginDir, "home"); @@ -460,58 +400,6 @@ export async function installCodexPlugin( }; } -function installCodexPluginSsh( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - sourceDir: string, - manifest: PluginManifest, - resolvedNodePath: string, -): { ok: true; paths: CodexPluginPaths; version: string } | { ok: false; reason: string } { - const staged = stagePluginAssetsToSsh(ctx, sourceDir, "codex", { - includeForwardRuntime: true, - }); - if (!staged.ok) return staged; - - const linuxCodexHome = `${staged.linuxPluginDir}/home`; - seedSshCodexHome(ctx, staged.deploy.home, linuxCodexHome); - const hooksPath = `${linuxCodexHome}/hooks.json`; - const existing = parseExistingSshJson(ctx, hooksPath); - if (existing === null && sshPathExists(ctx, hooksPath)) { - return { - ok: false, - reason: `malformed private Codex hooks.json on ssh host ${ctx.sshHost}`, - }; - } - - const commandHead = `${JSON.stringify(resolvedNodePath)} ${JSON.stringify(`${staged.linuxPluginDir}/forward.mjs`)}`; - const merged = mergeCodexHooksDocument(existing, commandHead); - const writeResult = writeSshJsonFile(ctx, hooksPath, merged); - if (!writeResult.ok) { - return { - ok: false, - reason: `failed to write hooks.json on ssh host ${ctx.sshHost}: ${writeResult.reason}`, - }; - } - - console.log( - [ - `[supervisor] Codex hook plugin staged v${manifest.version} (ssh:${ctx.sshHost})`, - ` pluginDir: ${staged.linuxPluginDir}`, - ` CODEX_HOME: ${linuxCodexHome}`, - ].join("\n"), - ); - - return { - ok: true, - version: manifest.version, - paths: { - pluginDir: staged.linuxPluginDir, - codexHomeDir: linuxCodexHome, - codexHooksPath: hooksPath, - version: manifest.version, - }, - }; -} - async function installCodexPluginWsl( distro: string, sourceDir: string, @@ -577,15 +465,6 @@ async function installCodexPluginWsl( export function isCodexPluginInstalled( ctx?: AgentEnvContext, ): Promise<{ installed: boolean; version?: string }> { - if (isSshPluginContext(ctx)) { - const paths = getCodexPluginPaths(ctx); - return Promise.resolve( - verifySshStagedPlugin(ctx, "codex", { - assets: ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE, "home/hooks.json"], - extraCheck: () => codexHooksHaveLightcodeEntry(readSshTextFile(ctx, paths.codexHooksPath)), - }), - ); - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "codex"); if (!wsl) return Promise.resolve({ installed: false }); @@ -639,28 +518,3 @@ function verifyCodexInstallAt( return { installed: false }; } } - -function codexHooksHaveLightcodeEntry(raw: string | undefined): boolean { - if (!raw) return false; - try { - const doc = JSON.parse(raw) as { hooks?: Record }; - if (!doc.hooks) return false; - for (const event of CODEX_HOOK_EVENTS) { - const groups = doc.hooks[event]; - if (!Array.isArray(groups)) continue; - for (const g of groups) { - if (!g || typeof g !== "object") continue; - const hooks = (g as { hooks?: unknown }).hooks; - if (!Array.isArray(hooks)) continue; - for (const h of hooks) { - if (!h || typeof h !== "object") continue; - const cmd = (h as { command?: string }).command; - if (typeof cmd === "string" && LIGHTCODE_FORWARD_RE.test(cmd)) return true; - } - } - } - return false; - } catch { - return false; - } -} diff --git a/src/supervisor/agents/codex/session.ts b/src/supervisor/agents/codex/session.ts index 619545b2..e1fc15d0 100644 --- a/src/supervisor/agents/codex/session.ts +++ b/src/supervisor/agents/codex/session.ts @@ -1,17 +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, - quotePosixShellArg, readSessionFileText, - readSshCommandOutputSync, - resolveSshHomeDirectory, resolveWslHomeDirectoryAsync, - runSshScriptSync, + type NonSshProjectLocation, } from "../base"; import { parseCodexRolloutIdFromPath, @@ -38,12 +34,6 @@ function codexPrivateHomeFrom(home: string | undefined): string | undefined { return home ? `${home}/.lightcode/agent-plugins/codex/home` : undefined; } -function sshCodexHomeCandidates(location: Extract): string[] { - const home = resolveSshHomeDirectory(location); - if (!home) return []; - return [`${home}/.codex`, `${home}/.lightcode/agent-plugins/codex/home`]; -} - function wslPrivateCodexHome(distro: string): string | undefined { return codexPrivateHomeFrom(getCachedWslHomeDirectory(distro)); } @@ -69,34 +59,22 @@ 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}`; case "wsl": return `wsl:${location.distro}:${location.linuxPath}`; - case "ssh": - return `ssh:${location.host}:${location.path}`; case "posix": return `posix:${location.path}`; } } -export function readCodexSessionIndexForLocation(location: ProjectLocation) { +export function readCodexSessionIndexForLocation(location: NonSshProjectLocation) { if (location.kind === "wsl") { return []; } - if (location.kind === "ssh") { - const commands = sshCodexHomeCandidates(location).map( - (home) => `cat ${quotePosixShellArg(`${home}/session_index.jsonl`)} 2>/dev/null || true`, - ); - if (commands.length === 0) return []; - const result = runSshScriptSync(location, commands.join("\n"), { timeout: 10_000 }); - if (!result.ok || result.stdout.length === 0) return []; - return dedupeSessionIndex(parseCodexSessionIndex(result.stdout)); - } - const sessions = readCodexSessionIndex(); const privateIndexPath = join(nativePrivateCodexHome(), "session_index.jsonl"); let privateRaw: string; @@ -115,15 +93,8 @@ 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 === "ssh") { - const paths = sshCodexHomeCandidates(location).map((home) => `${home}/session_index.jsonl`); - const reads = await Promise.all(paths.map((p) => readSessionFileText(location, p))); - const parts = reads.filter((r): r is string => typeof r === "string" && r.length > 0); - if (parts.length === 0) return []; - return dedupeSessionIndex(parts.flatMap((raw) => parseCodexSessionIndex(raw))); - } if (location.kind !== "wsl") { return readCodexSessionIndexForLocation(location); } @@ -142,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; @@ -157,57 +128,16 @@ export function isInteractiveCodexRollout( return rollout.cwd === location.path; case "posix": return rollout.cwd === location.path; - case "ssh": - return rollout.cwd === location.path; case "wsl": return rollout.cwd === location.linuxPath || rollout.cwd === location.uncPath; } } -export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRolloutMeta[] { +export function readCodexRolloutsForLocation(location: NonSshProjectLocation): CodexRolloutMeta[] { if (location.kind === "wsl") { return []; } - if (location.kind === "ssh") { - const roots = sshCodexHomeCandidates(location) - .map((home) => quotePosixShellArg(`${home}/sessions`)) - .join(" "); - if (!roots) return []; - const result = runSshScriptSync( - location, - [ - `find ${roots} -type f -name 'rollout-*.jsonl' 2>/dev/null | while IFS= read -r path; do`, - ` mtime=$(stat -c %Y "$path" 2>/dev/null || stat -f %m "$path" 2>/dev/null || printf 0)`, - ` printf '%s\\t%s\\n' "$mtime" "$path"`, - `done`, - ].join("\n"), - { timeout: 15_000 }, - ); - if (!result.ok || result.stdout.length === 0) return []; - return dedupeRollouts( - result.stdout - .split(/\r?\n/g) - .map((line) => line.trim()) - .filter(Boolean) - .flatMap((line) => { - const [mtimeRaw, path] = line.split("\t"); - if (!path) return []; - const updatedAt = Number.isFinite(Number(mtimeRaw)) - ? Math.round(Number(mtimeRaw) * 1000) - : undefined; - const id = parseCodexRolloutIdFromPath(path); - if (!id) return []; - const meta: CodexRolloutMeta = { - id, - path, - ...(updatedAt !== undefined ? { updatedAt } : {}), - }; - return [meta]; - }), - ); - } - const rollouts: CodexRolloutMeta[] = []; const walk = (dir: string) => { let entries: import("node:fs").Dirent[]; @@ -254,21 +184,13 @@ export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRo } export function readCodexRolloutMetaForLocation( - location: ProjectLocation, + location: NonSshProjectLocation, rollout: CodexRolloutMeta, ): CodexRolloutMeta | undefined { if (location.kind === "wsl") { return rollout; } - if (location.kind === "ssh") { - const result = readSshCommandOutputSync(location, "head", ["-n", "1", "--", rollout.path], { - timeout: 10_000, - }); - if (!result.ok || result.stdout.length === 0) return rollout; - return parseCodexRolloutMeta(rollout.path, result.stdout, rollout.updatedAt) ?? rollout; - } - try { const firstLine = readFileSync(rollout.path, "utf8").split(/\r?\n/g)[0] ?? ""; return parseCodexRolloutMeta(rollout.path, firstLine, rollout.updatedAt) ?? rollout; @@ -278,15 +200,9 @@ export function readCodexRolloutMetaForLocation( } export async function readCodexRolloutMetaForLocationAsync( - location: ProjectLocation, + location: NonSshProjectLocation, rollout: CodexRolloutMeta, ): Promise { - if (location.kind === "ssh") { - const text = await readSessionFileText(location, rollout.path); - if (!text) return rollout; - const firstLine = text.split(/\r?\n/g)[0] ?? ""; - return parseCodexRolloutMeta(rollout.path, firstLine, rollout.updatedAt) ?? rollout; - } if (location.kind !== "wsl") { return readCodexRolloutMetaForLocation(location, rollout); } @@ -297,22 +213,17 @@ export async function readCodexRolloutMetaForLocationAsync( } export async function readCodexRolloutsForLocationAsync( - location: ProjectLocation, + location: NonSshProjectLocation, ): Promise { - if (location.kind !== "wsl" && location.kind !== "ssh") { + if (location.kind !== "wsl") { return readCodexRolloutsForLocation(location); } - let roots: string[]; - if (location.kind === "ssh") { - roots = sshCodexHomeCandidates(location).map((home) => `${home}/sessions`); - } else { - const home = await resolveWslHomeDirectoryAsync(location.distro); - const privateHome = codexPrivateHomeFrom(home); - roots = [ - home ? `${home}/.codex/sessions` : undefined, - privateHome ? `${privateHome}/sessions` : undefined, - ].filter((r): r is string => Boolean(r)); - } + const home = await resolveWslHomeDirectoryAsync(location.distro); + const privateHome = codexPrivateHomeFrom(home); + const roots = [ + home ? `${home}/.codex/sessions` : undefined, + privateHome ? `${privateHome}/sessions` : undefined, + ].filter((r): r is string => Boolean(r)); const accept = (name: string): boolean => name.startsWith("rollout-") && name.endsWith(".jsonl"); const found = ( @@ -342,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); @@ -351,9 +262,6 @@ export function resolveCodexSessionWatchPaths(location: ProjectLocation): string privateHome ? `${privateHome}/sessions` : undefined, ].filter((p): p is string => Boolean(p)); } - if (location.kind === "ssh") { - return sshCodexHomeCandidates(location).map((home) => `${home}/sessions`); - } const paths: string[] = []; const publicSessions = join(homedir(), ".codex", "sessions"); if (existsSync(publicSessions)) paths.push(publicSessions); diff --git a/src/supervisor/agents/copilot/plugin/install.ts b/src/supervisor/agents/copilot/plugin/install.ts index ea70a622..910382c7 100644 --- a/src/supervisor/agents/copilot/plugin/install.ts +++ b/src/supervisor/agents/copilot/plugin/install.ts @@ -13,21 +13,14 @@ import { createPluginSourceResolver, ctxCacheKey, getNativePluginBaseDir, - getSshPluginBaseDirs, getWslPluginBaseDirs, - isSshPluginContext, isWslPluginContext, memoByCtx, readBundledPluginVersion, readPluginManifest, - readSshTextFile, - removeSshFile, removeStagedPluginDir, - stagePluginAssetsToSsh, stagePluginAssetsToWsl, verifyStagedPluginAt, - verifySshStagedPlugin, - writeSshTextFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; @@ -113,15 +106,6 @@ function wslGlobalCopilotDir(distro: string): string { } function computeCopilotPluginPaths(ctx?: AgentEnvContext): CopilotPluginPaths { - if (isSshPluginContext(ctx)) { - const ssh = getSshPluginBaseDirs(ctx, "copilot"); - if (!ssh) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; - return { - pluginDir: ssh.linuxBase, - globalHookFilePath: `${ssh.home}/.copilot/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`, - version: "0.0.0", - }; - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "copilot"); if (!wsl) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; @@ -213,21 +197,6 @@ export function installCopilotPlugin( options.globalCopilotDirOverride, ); } - if (isSshPluginContext(ctx)) { - if (!options?.resolvedNodePath) { - return { - ok: false, - reason: "SSH Copilot plugin install requires a resolved node path on the remote host.", - }; - } - return installCopilotPluginSsh( - ctx, - sourceDir, - manifest, - options.resolvedNodePath, - options.globalCopilotDirOverride, - ); - } const pluginDir = getNativePluginBaseDir("copilot", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -265,49 +234,6 @@ export function installCopilotPlugin( }; } -function installCopilotPluginSsh( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - sourceDir: string, - manifest: PluginManifest, - resolvedNodePath: string, - globalCopilotDirOverride: string | undefined, -): { ok: true; paths: CopilotPluginPaths; version: string } | { ok: false; reason: string } { - const staged = stagePluginAssetsToSsh(ctx, sourceDir, "copilot", { - includeForwardRuntime: true, - }); - if (!staged.ok) return staged; - - const linuxForward = `${staged.linuxPluginDir}/forward.mjs`; - const linuxCopilotDir = globalCopilotDirOverride ?? `${staged.deploy.home}/.copilot`; - const hookFilePath = `${linuxCopilotDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`; - const bashCommand = buildWslHookCommandHead(resolvedNodePath, linuxForward); - const writeResult = writeSshCopilotHookFileIfChanged(ctx, hookFilePath, { bashCommand }); - if (!writeResult.ok) { - return { - ok: false, - reason: `failed to write Copilot hook file at ${hookFilePath} on ssh host ${ctx.sshHost}: ${writeResult.reason}`, - }; - } - - console.log( - [ - `[supervisor] Copilot hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost}`, - ` pluginDir: ${staged.linuxPluginDir}`, - ` hookFile: ${hookFilePath}`, - ].join("\n"), - ); - - return { - ok: true, - version: manifest.version, - paths: { - pluginDir: staged.linuxPluginDir, - globalHookFilePath: hookFilePath, - version: manifest.version, - }, - }; -} - function installCopilotPluginWsl( distro: string, sourceDir: string, @@ -360,13 +286,6 @@ export function isCopilotPluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { - if (isSshPluginContext(ctx)) { - const paths = getCopilotPluginPaths(ctx); - return verifySshStagedPlugin(ctx, "copilot", { - assets: COPILOT_VERIFY_ASSETS, - extraCheck: () => readSshTextFile(ctx, paths.globalHookFilePath) !== undefined, - }); - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "copilot"); if (!wsl) return { installed: false }; @@ -387,14 +306,6 @@ export function isCopilotPluginInstalled(ctx?: AgentEnvContext): { } export function uninstallCopilotPlugin(ctx?: AgentEnvContext): void { - if (isSshPluginContext(ctx)) { - const paths = getCopilotPluginPaths(ctx); - if (readSshTextFile(ctx, paths.globalHookFilePath) !== undefined) { - removeSshFile(ctx, paths.globalHookFilePath); - } - removeStagedPluginDir("copilot", ctx); - return; - } const hookFile = isWslPluginContext(ctx) ? toWslUncPath( ctx.wslDistro, @@ -471,15 +382,5 @@ function writeCopilotHookFileIfChanged( } } -function writeSshCopilotHookFileIfChanged( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - hookFilePath: string, - input: { bashCommand: string; powershellCommand?: string }, -): { ok: true } | { ok: false; reason: string } { - const serialized = `${JSON.stringify(renderCopilotHookConfig(input), null, 2)}\n`; - if (readSshTextFile(ctx, hookFilePath) === serialized) return { ok: true }; - return writeSshTextFile(ctx, hookFilePath, serialized); -} - /** Exposed for tests. */ export { COPILOT_HOOK_EVENTS, GLOBAL_HOOK_FILENAME, GLOBAL_HOOK_DIR_NAME }; diff --git a/src/supervisor/agents/cursor/plugin/install.ts b/src/supervisor/agents/cursor/plugin/install.ts index 654c0dda..bc06320d 100644 --- a/src/supervisor/agents/cursor/plugin/install.ts +++ b/src/supervisor/agents/cursor/plugin/install.ts @@ -14,22 +14,14 @@ import { createPluginSourceResolver, ctxCacheKey, getNativePluginBaseDir, - getSshPluginBaseDirs, getWslPluginBaseDirs, - isSshPluginContext, isWslPluginContext, memoByCtx, parseExistingHooksJson, - parseExistingSshJson, readBundledPluginVersion, readPluginManifest, - readSshTextFile, removeStagedPluginDir, - stagePluginAssetsToSsh, stagePluginAssetsToWsl, - sshPathExists, - verifySshStagedPlugin, - writeSshJsonFile, verifyStagedPluginAt, writeHooksJsonFile, writeNativeHookWrapper, @@ -98,14 +90,6 @@ function wslGlobalCursorHooksPath(distro: string): string { } function computeCursorPluginPaths(ctx?: AgentEnvContext): CursorPluginPaths { - if (isSshPluginContext(ctx)) { - const ssh = getSshPluginBaseDirs(ctx, "cursor"); - if (!ssh) return { pluginDir: "", globalHooksPath: "" }; - return { - pluginDir: ssh.linuxBase, - globalHooksPath: `${ssh.home}/.cursor/hooks.json`, - }; - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "cursor"); if (!wsl) return { pluginDir: "", globalHooksPath: "" }; @@ -253,21 +237,6 @@ export function installCursorPlugin( options.globalCursorDirOverride, ); } - if (isSshPluginContext(ctx)) { - if (!options?.resolvedNodePath) { - return { - ok: false, - reason: "SSH Cursor plugin install requires a resolved node path on the remote host.", - }; - } - return installCursorPluginSsh( - ctx, - sourceDir, - manifest, - options.resolvedNodePath, - options.globalCursorDirOverride, - ); - } const pluginDir = getNativePluginBaseDir("cursor", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -309,53 +278,6 @@ export function installCursorPlugin( }; } -function installCursorPluginSsh( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - sourceDir: string, - manifest: PluginManifest, - resolvedNodePath: string, - globalCursorDirOverride: string | undefined, -): { ok: true; paths: CursorPluginPaths; version: string } | { ok: false; reason: string } { - const staged = stagePluginAssetsToSsh(ctx, sourceDir, "cursor", { - includeForwardRuntime: true, - }); - if (!staged.ok) return staged; - - const hooksPath = globalCursorDirOverride - ? `${globalCursorDirOverride}/hooks.json` - : `${staged.deploy.home}/.cursor/hooks.json`; - const existing = parseExistingSshJson(ctx, hooksPath); - if (existing === null && sshPathExists(ctx, hooksPath)) { - return { - ok: false, - reason: `malformed Cursor hooks.json at ${hooksPath} on ssh host ${ctx.sshHost}`, - }; - } - - const commandHead = buildWslHookCommandHead( - resolvedNodePath, - `${staged.linuxPluginDir}/forward.mjs`, - ); - const merged = mergeCursorHooksDocument(existing, commandHead); - const writeResult = writeSshJsonFile(ctx, hooksPath, merged); - if (!writeResult.ok) { - return { - ok: false, - reason: `failed to write hooks.json at ${hooksPath} on ssh host ${ctx.sshHost}: ${writeResult.reason}`, - }; - } - - console.log( - `[supervisor] Cursor hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost} at ${staged.linuxPluginDir}; merged hooks into ${hooksPath}`, - ); - - return { - ok: true, - version: manifest.version, - paths: { pluginDir: staged.linuxPluginDir, globalHooksPath: hooksPath }, - }; -} - function installCursorPluginWsl( distro: string, sourceDir: string, @@ -410,16 +332,6 @@ function installCursorPluginWsl( export function isCursorPluginInstalled( ctx?: AgentEnvContext, ): Promise<{ installed: boolean; version?: string }> { - if (isSshPluginContext(ctx)) { - const paths = getCursorPluginPaths(ctx); - return Promise.resolve( - verifySshStagedPlugin(ctx, "cursor", { - assets: CURSOR_VERIFY_ASSETS, - extraCheck: () => - hooksJsonTextHasLightcodeEntry(readSshTextFile(ctx, paths.globalHooksPath)), - }), - ); - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "cursor"); if (!wsl) return Promise.resolve({ installed: false }); @@ -433,15 +345,6 @@ export function isCursorPluginInstalled( } export function uninstallCursorPlugin(ctx?: AgentEnvContext): void { - if (isSshPluginContext(ctx)) { - const paths = getCursorPluginPaths(ctx); - const existing = parseExistingSshJson(ctx, paths.globalHooksPath); - if (existing !== null || sshPathExists(ctx, paths.globalHooksPath)) { - void writeSshJsonFile(ctx, paths.globalHooksPath, removeCursorHooksDocument(existing)); - } - removeStagedPluginDir("cursor", ctx); - return; - } const hooksPath = isWslPluginContext(ctx) ? toWslUncPath(ctx.wslDistro, wslGlobalCursorHooksPath(ctx.wslDistro)) : join(nativeGlobalCursorDir(), "hooks.json"); @@ -472,26 +375,6 @@ function hooksJsonHasLightcodeEntry(hooksPath: string): boolean { } } -function hooksJsonTextHasLightcodeEntry(raw: string | undefined): boolean { - if (!raw) return false; - try { - const doc = JSON.parse(raw) as { hooks?: Record }; - if (!doc.hooks) return false; - for (const spec of CURSOR_HOOK_SPECS) { - const entries = doc.hooks[spec.event]; - if (!Array.isArray(entries)) continue; - for (const entry of entries) { - if (!entry || typeof entry !== "object") continue; - const cmd = (entry as { command?: string }).command; - if (typeof cmd === "string" && LIGHTCODE_FORWARD_RE.test(cmd)) return true; - } - } - return false; - } catch { - return false; - } -} - const CURSOR_VERIFY_ASSETS = ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE] as const; function verifyCursorInstallAt( diff --git a/src/supervisor/agents/gemini/detection.ts b/src/supervisor/agents/gemini/detection.ts index b89b00c3..aa39be31 100644 --- a/src/supervisor/agents/gemini/detection.ts +++ b/src/supervisor/agents/gemini/detection.ts @@ -13,7 +13,6 @@ import { } from "../base"; import { buildContextSizeCapabilities } from "../contextWindowLabel"; import { getAgentProbeCwd } from "../probeCwd"; -import { runSshScript } from "../../ssh"; // Gemini's ACP probe reports the selectable model ids/names, but not token // limits. Keep this as an exact documented allowlist so new ids do not inherit @@ -56,12 +55,6 @@ export const defaultGeminiCapabilities: AgentCapability = { // Gemini stores a config dir at ~/.gemini after first login; treat its // presence as authenticated even without GEMINI_API_KEY set. const configDirAuthProbe: AuthProbe = async (ctx) => { - if (ctx.location.kind === "ssh") { - const result = await runSshScript(ctx.location, "test -d ~/.gemini && echo yes").catch( - () => undefined, - ); - return result?.stdout.trim() === "yes" ? "authenticated" : "unknown"; - } if (ctx.location.kind !== "wsl") { return existsSync(join(homedir(), ".gemini")) ? "authenticated" : "unknown"; } @@ -89,33 +82,20 @@ export function parseGeminiGoogleAccountsJson(raw: string): string | undefined { } async function probeGeminiMetadata(ctx: Parameters>[0]) { - if (ctx.location.kind === "wsl" || ctx.location.kind === "ssh") { - const commands = [ - 'printf %s "$GEMINI_API_KEY"', - "test -d ~/.gemini && echo yes", - 'cat ~/.gemini/google_accounts.json 2>/dev/null || printf ""', - ]; - let outputs: string[]; - if (ctx.location.kind === "wsl") { - outputs = (await batchWslCommandsAsync(ctx.location.distro, commands)).map((result) => - result?.ok ? result.stdout : "", - ); - } else { - const sshLocation = ctx.location; - outputs = await Promise.all( - commands.map((command) => - runSshScript(sshLocation, command) - .then((result) => result.stdout) - .catch(() => ""), - ), - ); - } - const [apiKeyStdout, configDirStdout, accountsStdout] = outputs; - const apiKeySet = !!apiKeyStdout?.trim(); - const configDirPresent = configDirStdout?.trim() === "yes"; + if (ctx.location.kind === "wsl") { + const [apiKeyResult, configDirResult, accountsResult] = await batchWslCommandsAsync( + ctx.location.distro, + [ + 'printf %s "$GEMINI_API_KEY"', + "test -d ~/.gemini && echo yes", + 'cat ~/.gemini/google_accounts.json 2>/dev/null || printf ""', + ], + ); + const apiKeySet = !!(apiKeyResult?.ok && apiKeyResult.stdout.trim().length > 0); + const configDirPresent = !!(configDirResult?.ok && configDirResult.stdout.trim() === "yes"); const activeAccount = - !apiKeySet && accountsStdout && accountsStdout.length > 0 - ? parseGeminiGoogleAccountsJson(accountsStdout) + !apiKeySet && accountsResult?.ok && accountsResult.stdout.length > 0 + ? parseGeminiGoogleAccountsJson(accountsResult.stdout) : undefined; const providerMetadata = compactAgentProviderMetadata({ ...(activeAccount ? { authenticatedAs: activeAccount } : {}), diff --git a/src/supervisor/agents/gemini/plugin/install.ts b/src/supervisor/agents/gemini/plugin/install.ts index 952efc92..fd7de4af 100644 --- a/src/supervisor/agents/gemini/plugin/install.ts +++ b/src/supervisor/agents/gemini/plugin/install.ts @@ -15,20 +15,14 @@ import { ctxCacheKey, getNativeHookWrapperFilename, getNativePluginBaseDir, - getSshPluginBaseDirs, getWslPluginBaseDirs, hasNativeHookWrapper, - isSshPluginContext, isWslPluginContext, memoByCtx, readBundledPluginVersion, readPluginManifest, - readSshTextFile, removeStagedPluginDir, - stagePluginAssetsToSsh, stagePluginAssetsToWsl, - verifySshStagedPlugin, - writeSshJsonFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; @@ -105,15 +99,6 @@ export function readBundledGeminiPluginVersion(): string { } function computeGeminiPluginPaths(ctx?: AgentEnvContext): GeminiPluginPaths { - if (isSshPluginContext(ctx)) { - const ssh = getSshPluginBaseDirs(ctx, "gemini"); - if (!ssh) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; - return { - pluginDir: ssh.linuxBase, - settingsPath: `${ssh.linuxBase}/settings.json`, - version: "0.0.0", - }; - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "gemini"); if (!wsl) return { pluginDir: "", settingsPath: "", version: "0.0.0" }; @@ -160,26 +145,6 @@ export function syncGeminiBrowserMcpSettings( if (ctx?.browserMcpEnabled === undefined) return; const paths = getGeminiPluginPaths(ctx); if (!paths.settingsPath) return; - if (isSshPluginContext(ctx)) { - try { - const raw = readSshTextFile(ctx, paths.settingsPath); - if (!raw) return; - const settings = JSON.parse(raw) as GeminiSettings; - if (ctx.browserMcpEnabled && browserMcp) { - const servers = buildGeminiBrowserMcpServers( - { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, - browserMcp, - ); - if (servers) settings.mcpServers = servers; - } else { - delete settings.mcpServers; - } - void writeSshJsonFile(ctx, paths.settingsPath, settings); - } catch { - // Best-effort; stale settings should not block thread launch. - } - return; - } const settingsPath = resolveSettingsWritePath(ctx, paths.settingsPath); try { const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as GeminiSettings; @@ -246,21 +211,6 @@ export function installGeminiPlugin( ctx.browserMcp, ); } - if (isSshPluginContext(ctx)) { - if (!options?.resolvedNodePath) { - return { - ok: false, - reason: "SSH Gemini plugin install requires a resolved node path on the remote host.", - }; - } - return installGeminiPluginSsh( - ctx, - sourceDir, - manifest, - options.resolvedNodePath, - ctx.browserMcp, - ); - } const pluginDir = getNativePluginBaseDir("gemini", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -290,45 +240,6 @@ export function installGeminiPlugin( }; } -function installGeminiPluginSsh( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - sourceDir: string, - manifest: PluginManifest, - resolvedNodePath: string, - browserMcp?: BrowserMcpHttpConfig, -): { ok: true; paths: GeminiPluginPaths; version: string } | { ok: false; reason: string } { - const staged = stagePluginAssetsToSsh(ctx, sourceDir, "gemini", { - includeForwardRuntime: true, - }); - if (!staged.ok) return staged; - - const settingsPath = `${staged.linuxPluginDir}/settings.json`; - const headExpression = buildWslHookCommandHead( - resolvedNodePath, - `${staged.linuxPluginDir}/forward.mjs`, - ); - const browserMcpServers = buildGeminiBrowserMcpServers( - { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, - browserMcp, - ); - const settings = renderGeminiSettings({ - headExpression, - ...(browserMcpServers ? { mcpServers: browserMcpServers } : {}), - }); - const writeResult = writeSshJsonFile(ctx, settingsPath, settings); - if (!writeResult.ok) return writeResult; - - console.log( - `[supervisor] Gemini hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost} at ${staged.linuxPluginDir} (forward.mjs, settings.json)`, - ); - - return { - ok: true, - version: manifest.version, - paths: { pluginDir: staged.linuxPluginDir, settingsPath, version: manifest.version }, - }; -} - function installGeminiPluginWsl( distro: string, sourceDir: string, @@ -383,13 +294,6 @@ export function isGeminiPluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { - if (isSshPluginContext(ctx)) { - const paths = getGeminiPluginPaths(ctx); - return verifySshStagedPlugin(ctx, "gemini", { - assets: ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE, "settings.json"], - extraCheck: () => hasGeminiHooksFromRaw(readSshTextFile(ctx, paths.settingsPath)), - }); - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "gemini"); if (!wsl) return { installed: false }; @@ -450,16 +354,6 @@ function hasGeminiHooks(hooks: Record | undefined): boolean { return true; } -function hasGeminiHooksFromRaw(raw: string | undefined): boolean { - if (!raw) return false; - try { - const settings = JSON.parse(raw) as { hooks?: Record }; - return hasGeminiHooks(settings.hooks); - } catch { - return false; - } -} - export interface RenderGeminiSettingsOptions { headExpression: string; mcpServers?: GeminiSettings["mcpServers"]; diff --git a/src/supervisor/agents/grok/detection.ts b/src/supervisor/agents/grok/detection.ts index 6f5c39dc..3675c96d 100644 --- a/src/supervisor/agents/grok/detection.ts +++ b/src/supervisor/agents/grok/detection.ts @@ -17,7 +17,6 @@ import { } from "../base"; import { buildContextSizeCapabilities } from "../contextWindowLabel"; import { getAgentProbeCwd, resolveProbeSpawnCwd } from "../probeCwd"; -import { runSshScript } from "../../ssh"; // Approval policies surfaced to Lightcode. Grok only honors `--always-approve` // (bypass) at launch — `--permission-mode ` is headless-only and is @@ -147,13 +146,6 @@ async function grokAuthFileProbe( if (existsSync(join(home, ".grok", "auth.json"))) return "authenticated"; return "unknown"; }; - if (ctx.location.kind === "ssh") { - const result = await runSshScript( - ctx.location, - "test -f ~/.grok/auth.json && echo yes || echo no", - ).catch(() => undefined); - return result?.stdout.trim() === "yes" ? "authenticated" : "unknown"; - } if (ctx.location.kind !== "wsl") { return check(homedir()); } diff --git a/src/supervisor/agents/grok/plugin/install.ts b/src/supervisor/agents/grok/plugin/install.ts index 645bd36f..edc7b7ed 100644 --- a/src/supervisor/agents/grok/plugin/install.ts +++ b/src/supervisor/agents/grok/plugin/install.ts @@ -13,21 +13,14 @@ import { createPluginSourceResolver, ctxCacheKey, getNativePluginBaseDir, - getSshPluginBaseDirs, getWslPluginBaseDirs, - isSshPluginContext, isWslPluginContext, memoByCtx, readBundledPluginVersion, readPluginManifest, - readSshTextFile, - removeSshFile, removeStagedPluginDir, - stagePluginAssetsToSsh, stagePluginAssetsToWsl, verifyStagedPluginAt, - verifySshStagedPlugin, - writeSshTextFile, writeNativeHookWrapper, type PluginManifest, } from "../../plugin/installerBase"; @@ -92,15 +85,6 @@ function wslGlobalGrokDir(distro: string): string { } function computeGrokPluginPaths(ctx?: AgentEnvContext): GrokPluginPaths { - if (isSshPluginContext(ctx)) { - const ssh = getSshPluginBaseDirs(ctx, "grok"); - if (!ssh) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; - return { - pluginDir: ssh.linuxBase, - globalHookFilePath: `${ssh.home}/${GLOBAL_GROK_DIR_NAME}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`, - version: "0.0.0", - }; - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "grok"); if (!wsl) return { pluginDir: "", globalHookFilePath: "", version: "0.0.0" }; @@ -191,21 +175,6 @@ export function installGrokPlugin( options.globalGrokDirOverride, ); } - if (isSshPluginContext(ctx)) { - if (!options?.resolvedNodePath) { - return { - ok: false, - reason: "SSH Grok plugin install requires a resolved node path on the remote host.", - }; - } - return installGrokPluginSsh( - ctx, - sourceDir, - manifest, - options.resolvedNodePath, - options.globalGrokDirOverride, - ); - } const pluginDir = getNativePluginBaseDir("grok", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -242,49 +211,6 @@ export function installGrokPlugin( }; } -function installGrokPluginSsh( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - sourceDir: string, - manifest: PluginManifest, - resolvedNodePath: string, - globalGrokDirOverride: string | undefined, -): { ok: true; paths: GrokPluginPaths; version: string } | { ok: false; reason: string } { - const staged = stagePluginAssetsToSsh(ctx, sourceDir, "grok", { - includeForwardRuntime: true, - }); - if (!staged.ok) return staged; - - const linuxForward = `${staged.linuxPluginDir}/forward.mjs`; - const linuxGrokDir = globalGrokDirOverride ?? `${staged.deploy.home}/${GLOBAL_GROK_DIR_NAME}`; - const hookFilePath = `${linuxGrokDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`; - const command = buildWslHookCommandHead(resolvedNodePath, linuxForward); - const writeResult = writeSshGrokHookFileIfChanged(ctx, hookFilePath, { command }); - if (!writeResult.ok) { - return { - ok: false, - reason: `failed to write Grok hook file at ${hookFilePath} on ssh host ${ctx.sshHost}: ${writeResult.reason}`, - }; - } - - console.log( - [ - `[supervisor] Grok hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost}`, - ` pluginDir: ${staged.linuxPluginDir}`, - ` hookFile: ${hookFilePath}`, - ].join("\n"), - ); - - return { - ok: true, - version: manifest.version, - paths: { - pluginDir: staged.linuxPluginDir, - globalHookFilePath: hookFilePath, - version: manifest.version, - }, - }; -} - function installGrokPluginWsl( distro: string, sourceDir: string, @@ -337,14 +263,6 @@ export function isGrokPluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { - if (isSshPluginContext(ctx)) { - const paths = getGrokPluginPaths(ctx); - return verifySshStagedPlugin(ctx, "grok", { - assets: GROK_VERIFY_ASSETS, - extraCheck: () => - hookFileTextMatchesLightcode(readSshTextFile(ctx, paths.globalHookFilePath)), - }); - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "grok"); if (!wsl) return { installed: false }; @@ -365,14 +283,6 @@ export function isGrokPluginInstalled(ctx?: AgentEnvContext): { } export function uninstallGrokPlugin(ctx?: AgentEnvContext): void { - if (isSshPluginContext(ctx)) { - const paths = getGrokPluginPaths(ctx); - if (hookFileTextMatchesLightcode(readSshTextFile(ctx, paths.globalHookFilePath))) { - removeSshFile(ctx, paths.globalHookFilePath); - } - removeStagedPluginDir("grok", ctx); - return; - } const hookFile = isWslPluginContext(ctx) ? toWslUncPath( ctx.wslDistro, @@ -423,32 +333,6 @@ function hookFileMatchesLightcode(path: string): boolean { } } -function hookFileTextMatchesLightcode(raw: string | undefined): boolean { - if (!raw) return false; - try { - const parsed = JSON.parse(raw) as { hooks?: Record }; - if (!parsed.hooks || typeof parsed.hooks !== "object") return false; - for (const event of GROK_HOOK_EVENTS) { - const groups = parsed.hooks[event]; - if (!Array.isArray(groups) || groups.length === 0) return false; - const found = groups.some((group) => { - if (!group || typeof group !== "object") return false; - const hookEntries = (group as { hooks?: unknown }).hooks; - if (!Array.isArray(hookEntries)) return false; - return hookEntries.some((hook) => { - if (!hook || typeof hook !== "object") return false; - const command = (hook as { command?: unknown }).command; - return typeof command === "string" && LIGHTCODE_GROK_HOOK_RE.test(command); - }); - }); - if (!found) return false; - } - return true; - } catch { - return false; - } -} - // ── Hook config rendering / write ───────────────────────────────────────── interface GrokHookCommand { @@ -512,14 +396,4 @@ function writeGrokHookFileIfChanged( } } -function writeSshGrokHookFileIfChanged( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - hookFilePath: string, - input: { command: string }, -): { ok: true } | { ok: false; reason: string } { - const serialized = `${JSON.stringify(renderGrokHookConfig(input), null, 2)}\n`; - if (readSshTextFile(ctx, hookFilePath) === serialized) return { ok: true }; - return writeSshTextFile(ctx, hookFilePath, serialized); -} - export { GROK_HOOK_EVENTS, GLOBAL_HOOK_FILENAME, GLOBAL_HOOK_DIR_NAME }; diff --git a/src/supervisor/agents/grok/sessionFiles.ts b/src/supervisor/agents/grok/sessionFiles.ts index 98d9fb5b..b6ba1182 100644 --- a/src/supervisor/agents/grok/sessionFiles.ts +++ b/src/supervisor/agents/grok/sessionFiles.ts @@ -7,8 +7,6 @@ import { createKnownSessionRef, getCachedWslHomeDirectory, listSessionDir, - readSshCommandOutputSync, - resolveSshHomeDirectory, resolveWslHomeDirectoryAsync, statSessionPaths, watchSessionPaths, @@ -34,10 +32,6 @@ function getGrokCwdSessionsDir(location: ProjectLocation, cwd: string): string | if (!home) return null; return `${home}/.grok/sessions/${encodeCwdKey(cwd)}`; } - if (location.kind === "ssh") { - const home = resolveSshHomeDirectory(location); - return home ? `${home}/.grok/sessions/${encodeCwdKey(cwd)}` : null; - } return join(GROK_SESSIONS_ROOT, encodeCwdKey(cwd)); } @@ -55,10 +49,6 @@ function getGrokSessionsRoot(location: ProjectLocation): string | null { const home = getCachedWslHomeDirectory(location.distro); return home ? `${home}/.grok/sessions` : null; } - if (location.kind === "ssh") { - const home = resolveSshHomeDirectory(location); - return home ? `${home}/.grok/sessions` : null; - } return GROK_SESSIONS_ROOT; } @@ -78,20 +68,6 @@ export function snapshotGrokPreSpawnSessions(location: ProjectLocation, cwd: str const dir = getGrokCwdSessionsDir(location, cwd); if (!dir) return; - if (location.kind === "ssh") { - const result = readSshCommandOutputSync(location, "sh", [ - "-lc", - `[ -d ${shellQuote(dir)} ] && ls -1 -- ${shellQuote(dir)} 2>/dev/null || true`, - ]); - if (!result.ok) return; - preSpawnCwdKey = encodeCwdKey(cwd); - for (const name of result.stdout.split(/\r?\n/)) { - const trimmed = name.trim(); - if (isUuid(trimmed)) preSpawnSessionIds.add(trimmed); - } - return; - } - if (!existsSync(dir)) return; preSpawnCwdKey = encodeCwdKey(cwd); @@ -107,10 +83,6 @@ export function snapshotGrokPreSpawnSessions(location: ProjectLocation, cwd: str } } -function shellQuote(s: string): string { - return `'${s.replace(/'/g, "'\\''")}'`; -} - function isUuid(s: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); } @@ -169,7 +141,7 @@ export function makeGrokDiscoverSessionRef() { */ export function resolveGrokSessionsWatchPaths(location: ProjectLocation, cwd: string): string[] { const dir = getGrokCwdSessionsDir(location, cwd); - if (location.kind === "wsl" || location.kind === "ssh") { + if (location.kind === "wsl") { const root = getGrokSessionsRoot(location); return [dir ?? undefined, root ?? undefined].filter((p): p is string => Boolean(p)); } @@ -266,16 +238,15 @@ for (const r of reqs) p.stdin.write(JSON.stringify(r) + "\\n"); * Returns `undefined` on any failure (timeout, auth error, JSON parse, etc.). * Callers must treat undefined as "no minted ID" and proceed without `-r`. * - * Remote environments skip this local pre-mint path. The remote PTY launch - * still snapshots sessions before spawn and discovers the created session - * afterward, matching the fallback path used when local minting fails. + * WSL is skipped — minting would require routing the ACP child through + * `wsl.exe`, which is out of scope here. */ export function mintGrokSessionIdViaAcpSync( location: ProjectLocation, timeoutMs = 4500, extraLaunchArgs: string[] = [], ): string | undefined { - if (location.kind === "wsl" || location.kind === "ssh") return undefined; + if (location.kind === "wsl") return undefined; // Use the absolute path resolved during detection. Packaged Electron apps // start with a minimal PATH that may exclude Homebrew/asdf/etc., so a bare diff --git a/src/supervisor/agents/opencode/argv.ts b/src/supervisor/agents/opencode/argv.ts index e76e5d55..4df7f31e 100644 --- a/src/supervisor/agents/opencode/argv.ts +++ b/src/supervisor/agents/opencode/argv.ts @@ -1,10 +1,6 @@ -import { randomInt } from "node:crypto"; import { dirname as posixDirname } from "node:path/posix"; import type { ProjectLocation, ThreadConfig } from "@/shared/contracts"; import { buildAgentCommand, DEFAULT_WSL_EXEC_PATH, getWslCommand, type CommandSpec } from "../base"; -import { buildSshForwardedCommand } from "../../ssh"; - -export const OPENCODE_LOCAL_BASE_URL_ENV = "LIGHTCODE_OPENCODE_LOCAL_BASE_URL"; // `opencode` (default TUI) only accepts `[project]` as a positional, so the // initial prompt must go through `--prompt` rather than a trailing arg. @@ -70,26 +66,5 @@ export function buildOpenCodeServerCommand( ], }; } - if (location.kind === "ssh") { - const localPort = randomInt(40_000, 60_000); - const remotePort = randomInt(40_000, 60_000); - const spec = buildSshForwardedCommand( - location, - resolvedExecPath ?? "opencode", - ["serve", "--hostname=127.0.0.1", `--port=${remotePort}`, "--print-logs"], - undefined, - [ - { - localHost: "127.0.0.1", - localPort, - remoteHost: "127.0.0.1", - remotePort, - }, - ], - { batchMode: "yes", tty: false }, - ); - spec.env = { [OPENCODE_LOCAL_BASE_URL_ENV]: `http://127.0.0.1:${localPort}` }; - return spec; - } return buildAgentCommand(location, "opencode", args, resolvedExecPath); } diff --git a/src/supervisor/agents/opencode/plugin/install.ts b/src/supervisor/agents/opencode/plugin/install.ts index c0a6f8ac..ea070e55 100644 --- a/src/supervisor/agents/opencode/plugin/install.ts +++ b/src/supervisor/agents/opencode/plugin/install.ts @@ -17,22 +17,14 @@ import { getCachedWslHomeDirectory, type AgentEnvContext } from "../../base"; import { BROWSER_MCP_SERVER_NAME } from "../../browserMcp"; import { copyPluginAssetsIfStale, - copySshFile, createPluginSourceResolver, getNativePluginBaseDir, - getSshPluginBaseDirs, getWslPluginBaseDirs, - isSshPluginContext, isWslPluginContext, readBundledPluginVersion, readPluginManifest, - readSshTextFile, - removeSshFile, removeStagedPluginDir, - stagePluginAssetsToSsh, stagePluginAssetsToWsl, - verifySshStagedPlugin, - writeSshTextFile, type PluginManifest, } from "../../plugin/installerBase"; import { buildOpenCodeBrowserMcp } from "../mcpBrowser"; @@ -165,36 +157,7 @@ function resolveOpenCodeWslPluginsDir( }; } -function resolveOpenCodeSshConfigDir( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, -): { linuxDir: string } | undefined { - const ssh = getSshPluginBaseDirs(ctx, "opencode"); - if (!ssh) return undefined; - return { linuxDir: `${ssh.home}/.config/opencode` }; -} - -function resolveOpenCodeSshPluginsDir( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, -): { linuxDir: string } | undefined { - const cfg = resolveOpenCodeSshConfigDir(ctx); - return cfg ? { linuxDir: `${cfg.linuxDir}/plugins` } : undefined; -} - export function getOpenCodePluginPaths(ctx?: AgentEnvContext): OpenCodePluginPaths { - if (isSshPluginContext(ctx)) { - const ssh = getSshPluginBaseDirs(ctx, "opencode"); - if (!ssh) { - return { pluginDir: "", opencodePluginFile: "", version: "0.0.0" }; - } - const opencodeDir = resolveOpenCodeSshPluginsDir(ctx); - return { - pluginDir: ssh.linuxBase, - opencodePluginFile: opencodeDir - ? `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_FILE_NAME}` - : "", - version: "0.0.0", - }; - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "opencode"); if (!wsl) { @@ -249,9 +212,6 @@ export function installOpenCodePlugin( if (isWslPluginContext(ctx)) { return installOpenCodePluginWsl(ctx.wslDistro, sourceDir, manifest); } - if (isSshPluginContext(ctx)) { - return installOpenCodePluginSsh(ctx, sourceDir, manifest); - } const pluginDir = getNativePluginBaseDir("opencode", ctx?.baseDir); mkdirSync(pluginDir, { recursive: true }); @@ -292,49 +252,6 @@ export function installOpenCodePlugin( }; } -function installOpenCodePluginSsh( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - sourceDir: string, - manifest: PluginManifest, -): { ok: true; paths: OpenCodePluginPaths; version: string } | { ok: false; reason: string } { - const staged = stagePluginAssetsToSsh(ctx, sourceDir, "opencode", OPENCODE_PLUGIN_ASSET_FILES); - if (!staged.ok) return staged; - - const opencodeDir = resolveOpenCodeSshPluginsDir(ctx); - if (!opencodeDir) { - return { - ok: false, - reason: `failed to resolve OpenCode plugins dir on ssh host ${ctx.sshHost}`, - }; - } - const opencodePluginFile = `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_FILE_NAME}`; - const opencodeManifestFile = `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_MANIFEST_NAME}`; - const pluginCopy = copySshFile(ctx, join(sourceDir, "lightcode-status.mjs"), opencodePluginFile); - if (!pluginCopy.ok) return pluginCopy; - const manifestCopy = copySshFile(ctx, join(sourceDir, "plugin.json"), opencodeManifestFile); - if (!manifestCopy.ok) return manifestCopy; - cleanupSshLegacyDrops(ctx, opencodeDir.linuxDir); - - const cfgDir = resolveOpenCodeSshConfigDir(ctx); - if (cfgDir) { - updateOpenCodeSshConfigFile(ctx, `${cfgDir.linuxDir}/${OPENCODE_CONFIG_FILE_NAME}`, undefined); - } - - console.log( - `[supervisor] OpenCode hook plugin staged v${manifest.version} on SSH host ${ctx.sshHost} at ${staged.linuxPluginDir} → ${opencodePluginFile}`, - ); - - return { - ok: true, - version: manifest.version, - paths: { - pluginDir: staged.linuxPluginDir, - opencodePluginFile, - version: manifest.version, - }, - }; -} - function installOpenCodePluginWsl( distro: string, sourceDir: string, @@ -398,32 +315,6 @@ export function isOpenCodePluginInstalled(ctx?: AgentEnvContext): { installed: boolean; version?: string; } { - if (isSshPluginContext(ctx)) { - const paths = getOpenCodePluginPaths(ctx); - const opencodeDir = resolveOpenCodeSshPluginsDir(ctx); - if (!opencodeDir) return { installed: false }; - return verifySshStagedPlugin(ctx, "opencode", { - assets: OPENCODE_PLUGIN_ASSET_FILES, - extraCheck: () => { - const stagedPlugin = readSshTextFile(ctx, `${paths.pluginDir}/lightcode-status.mjs`); - const droppedPlugin = readSshTextFile( - ctx, - `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_FILE_NAME}`, - ); - const stagedManifest = readSshTextFile(ctx, `${paths.pluginDir}/plugin.json`); - const droppedManifest = readSshTextFile( - ctx, - `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_MANIFEST_NAME}`, - ); - return ( - stagedPlugin !== undefined && - stagedPlugin === droppedPlugin && - stagedManifest !== undefined && - stagedManifest === droppedManifest - ); - }, - }); - } if (isWslPluginContext(ctx)) { const wsl = getWslPluginBaseDirs(ctx.wslDistro, "opencode"); if (!wsl) return { installed: false }; @@ -585,17 +476,6 @@ export function syncOpenCodeBrowserMcpConfigFile( enabled: boolean, browserMcp?: BrowserMcpHttpConfig, ): void { - if (location.kind === "ssh") { - const ctx = { envKind: "ssh", sshHost: location.host, sshPath: location.path } as const; - const cfgDir = resolveOpenCodeSshConfigDir(ctx); - if (!cfgDir) return; - updateOpenCodeSshConfigFile( - ctx, - `${cfgDir.linuxDir}/${OPENCODE_CONFIG_FILE_NAME}`, - enabled ? buildOpenCodeBrowserMcp(location, browserMcp) : undefined, - ); - return; - } if (location.kind === "wsl") { const cfgDir = resolveOpenCodeWslConfigDir(location.distro); if (!cfgDir) return; @@ -619,24 +499,6 @@ export function syncOpenCodeBrowserMcpConfigFile( * Best-effort: missing files / unreachable distros are swallowed. */ export function uninstallOpenCodePlugin(ctx?: AgentEnvContext): void { - if (isSshPluginContext(ctx)) { - const opencodeDir = resolveOpenCodeSshPluginsDir(ctx); - if (opencodeDir) { - removeSshFile(ctx, `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_FILE_NAME}`); - removeSshFile(ctx, `${opencodeDir.linuxDir}/${OPENCODE_PLUGIN_DROP_MANIFEST_NAME}`); - cleanupSshLegacyDrops(ctx, opencodeDir.linuxDir); - } - const cfgDir = resolveOpenCodeSshConfigDir(ctx); - if (cfgDir) { - updateOpenCodeSshConfigFile( - ctx, - `${cfgDir.linuxDir}/${OPENCODE_CONFIG_FILE_NAME}`, - undefined, - ); - } - removeStagedPluginDir("opencode", ctx); - return; - } if (isWslPluginContext(ctx)) { const opencodeDir = resolveOpenCodeWslPluginsDir(ctx.wslDistro); if (opencodeDir) { @@ -662,60 +524,6 @@ export function uninstallOpenCodePlugin(ctx?: AgentEnvContext): void { removeStagedPluginDir("opencode", ctx); } -function cleanupSshLegacyDrops( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - pluginsDir: string, -): void { - for (const name of OPENCODE_LEGACY_DROP_FILES) { - removeSshFile(ctx, `${pluginsDir}/${name}`); - } -} - -function updateOpenCodeSshConfigFile( - ctx: AgentEnvContext & { envKind: "ssh"; sshHost: string }, - configPath: string, - servers: BrowserMcpServers, -): void { - const raw = readSshTextFile(ctx, configPath); - let original: Record = {}; - if (raw && raw.trim().length > 0) { - try { - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - original = parsed as Record; - } - } catch { - return; - } - } - const config: Record = { ...original }; - const existingPlugin = config.plugin; - if (Array.isArray(existingPlugin)) { - const filtered = existingPlugin.filter((entry) => { - if (typeof entry !== "string") return true; - return !entry.includes(LIGHTCODE_PLUGIN_SPEC_MARKER); - }); - if (filtered.length === 0) delete config.plugin; - else if (filtered.length !== existingPlugin.length) config.plugin = filtered; - } - const mcpRaw = config.mcp; - const mcp: Record = - mcpRaw && typeof mcpRaw === "object" && !Array.isArray(mcpRaw) - ? { ...(mcpRaw as Record) } - : {}; - delete mcp[BROWSER_MCP_SERVER_NAME]; - if (servers) { - for (const [name, entry] of Object.entries(servers)) { - mcp[name] = entry; - } - } - if (Object.keys(mcp).length === 0) delete config.mcp; - else config.mcp = mcp; - const next = `${JSON.stringify(config, null, 2)}\n`; - if (raw === next) return; - void writeSshTextFile(ctx, configPath, next); -} - function removeIfPresent(path: string): void { try { const stat = statSync(path); diff --git a/src/supervisor/agents/opencode/sdkClient.ts b/src/supervisor/agents/opencode/sdkClient.ts index 2baf8397..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,26 +13,23 @@ 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; case "wsl": return location.linuxPath; - case "ssh": case "posix": return location.path; } } -function poolKey(location: ProjectLocation): string { +function poolKey(location: NonSshProjectLocation): string { switch (location.kind) { case "windows": return `windows:${location.path}`; case "wsl": return `wsl:${location.distro}:${location.linuxPath}`; - case "ssh": - return `ssh:${location.host}:${location.path}`; case "posix": return `posix:${location.path}`; } @@ -118,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); @@ -156,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/opencode/sdkServer.ts b/src/supervisor/agents/opencode/sdkServer.ts index 19f2823e..2f656f67 100644 --- a/src/supervisor/agents/opencode/sdkServer.ts +++ b/src/supervisor/agents/opencode/sdkServer.ts @@ -1,7 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import { terminateChildProcessTree } from "@/shared/processTree"; import type { CommandSpec } from "../base"; -import { OPENCODE_LOCAL_BASE_URL_ENV } from "./argv"; import { classifyOpenCodeError } from "./opencodeErrors"; const URL_LINE_PREFIX = "opencode server listening"; @@ -65,12 +64,11 @@ function registerProcessExitCleanup(): void { export function spawnOpenCodeServer(commandSpec: CommandSpec): OpenCodeServerHandle { registerProcessExitCleanup(); const isWin = process.platform === "win32"; - const { [OPENCODE_LOCAL_BASE_URL_ENV]: localBaseUrl, ...commandEnv } = commandSpec.env ?? {}; const child = spawn(commandSpec.command, commandSpec.args, { cwd: commandSpec.cwd, env: { ...process.env, - ...commandEnv, + ...commandSpec.env, }, stdio: ["pipe", "pipe", "pipe"], shell: false, @@ -143,7 +141,7 @@ export function spawnOpenCodeServer(commandSpec: CommandSpec): OpenCodeServerHan if (!line.startsWith(URL_LINE_PREFIX)) continue; const m = line.match(URL_REGEX); if (m && m[1]) { - baseUrl = localBaseUrl ?? m[1]; + baseUrl = m[1]; clearTimeout(readyTimeout); pending?.resolve(baseUrl); return; diff --git a/src/supervisor/agents/plugin/installerBase.test.ts b/src/supervisor/agents/plugin/installerBase.test.ts index 993deb89..de1b213c 100644 --- a/src/supervisor/agents/plugin/installerBase.test.ts +++ b/src/supervisor/agents/plugin/installerBase.test.ts @@ -514,13 +514,10 @@ describe("memoByCtx", () => { }); describe("ctxCacheKey", () => { - it("produces a stable string per (envKind, wslDistro, sshHost, baseDir) tuple", () => { - expect(ctxCacheKey({ envKind: "windows" })).toBe("windows|||"); - expect(ctxCacheKey({ envKind: "windows", baseDir: "/tmp/a" })).toBe("windows|||/tmp/a"); - expect(ctxCacheKey({ envKind: "wsl", wslDistro: "Ubuntu" })).toBe("wsl|Ubuntu||"); - expect(ctxCacheKey({ envKind: "ssh", sshHost: "dev.example.com" })).toBe( - "ssh||dev.example.com|", - ); + it("produces a stable string per (envKind, wslDistro, baseDir) tuple", () => { + expect(ctxCacheKey({ envKind: "windows" })).toBe("windows||"); + expect(ctxCacheKey({ envKind: "windows", baseDir: "/tmp/a" })).toBe("windows||/tmp/a"); + expect(ctxCacheKey({ envKind: "wsl", wslDistro: "Ubuntu" })).toBe("wsl|Ubuntu|"); expect(ctxCacheKey(undefined)).toBe("no-ctx"); }); }); diff --git a/src/supervisor/agents/plugin/installerBase.ts b/src/supervisor/agents/plugin/installerBase.ts index a0ac4e4d..758b1cd9 100644 --- a/src/supervisor/agents/plugin/installerBase.ts +++ b/src/supervisor/agents/plugin/installerBase.ts @@ -14,7 +14,7 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { dirname, join, posix as pathPosix, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { resolveLightcodePaths } from "@/shared/lightcodePaths"; import { toWslUncPath } from "@/shared/wsl"; import { @@ -23,12 +23,6 @@ import { resolveWslHomeDirectoryAsync, type AgentEnvContext, } from "../base"; -import { - readSshCommandOutput, - resolveSshHomeDirectory, - runSshScriptSync, - type SshLocation, -} from "../../ssh"; import { deployFilesToWslHome, type WslHomeDeployResult } from "../../wsl/wslDeploy"; /** @@ -46,16 +40,11 @@ export interface PluginManifest { } export type WslAgentEnvContext = AgentEnvContext & { envKind: "wsl"; wslDistro: string }; -export type SshAgentEnvContext = AgentEnvContext & { envKind: "ssh"; sshHost: string }; export function isWslPluginContext(ctx: AgentEnvContext | undefined): ctx is WslAgentEnvContext { return Boolean(ctx && ctx.envKind === "wsl" && ctx.wslDistro); } -export function isSshPluginContext(ctx: AgentEnvContext | undefined): ctx is SshAgentEnvContext { - return Boolean(ctx && ctx.envKind === "ssh" && ctx.sshHost); -} - export interface PluginSourceResolverOptions { kind: string; /** Env var override for the source dir, e.g. `LIGHTCODE_CLAUDE_PLUGIN_SOURCE`. */ @@ -254,11 +243,6 @@ export interface WslPluginBaseDirs { uncBase: string; } -export interface SshPluginBaseDirs { - home: string; - linuxBase: string; -} - export function getWslPluginBaseDirs(distro: string, kind: string): WslPluginBaseDirs | undefined { const home = getCachedWslHomeDirectory(distro); if (!home) return undefined; @@ -266,31 +250,12 @@ export function getWslPluginBaseDirs(distro: string, kind: string): WslPluginBas return { home, linuxBase, uncBase: toWslUncPath(distro, linuxBase) }; } -export function getSshLocation(ctx: SshAgentEnvContext): SshLocation { - return { kind: "ssh", host: ctx.sshHost, path: "/" }; -} - -export function getSshPluginBaseDirs( - ctx: SshAgentEnvContext, - kind: string, -): SshPluginBaseDirs | undefined { - const home = resolveSshHomeDirectory(getSshLocation(ctx)); - if (!home) return undefined; - return { home, linuxBase: `${home}/.lightcode/agent-plugins/${kind}` }; -} - export function getNativePluginBaseDir(kind: string, baseDir?: string): string { const paths = resolveLightcodePaths(baseDir); return join(paths.agentPluginsDir, kind); } export function removeStagedPluginDir(kind: string, ctx?: AgentEnvContext): void { - if (isSshPluginContext(ctx)) { - const ssh = getSshPluginBaseDirs(ctx, kind); - if (!ssh) return; - runSshScriptSync(getSshLocation(ctx), `rm -rf ${quoteHookCommandArg(ssh.linuxBase, "wsl")}`); - return; - } const dir = isWslPluginContext(ctx) ? getWslPluginBaseDirs(ctx.wslDistro, kind)?.uncBase : getNativePluginBaseDir(kind, ctx?.baseDir); @@ -374,7 +339,7 @@ export function memoByCtx( /** Stable string key for an `AgentEnvContext`. Process-local; not for persistence. */ export function ctxCacheKey(ctx: AgentEnvContext | undefined): string { if (!ctx) return "no-ctx"; - return `${ctx.envKind}|${ctx.wslDistro ?? ""}|${ctx.sshHost ?? ""}|${ctx.baseDir ?? ""}`; + return `${ctx.envKind}|${ctx.wslDistro ?? ""}|${ctx.baseDir ?? ""}`; } /** @@ -606,18 +571,6 @@ export async function resolveInstallNodePath( }; } } - if (isSshPluginContext(ctx)) { - const location = getSshLocation(ctx); - const result = await readSshCommandOutput(location, "sh", ["-lc", "command -v node"], { - timeout: 5_000, - }).catch((error: unknown) => ({ - stdout: "", - stderr: error instanceof Error ? error.message : String(error), - })); - const nodePath = result.stdout.trim().split("\n")[0]?.trim(); - if (nodePath) return { ok: true, nodePath }; - return { ok: false, reason: `failed to resolve node on ssh host ${ctx.sshHost}` }; - } try { const { resolveNativeNode } = await import("../../native/runtime"); @@ -701,55 +654,6 @@ export function writeHooksJsonFile(path: string, doc: unknown): void { writeFileSync(path, `${JSON.stringify(doc, null, 2)}\n`, "utf8"); } -export function sshPathExists(ctx: SshAgentEnvContext, path: string): boolean { - const result = runSshScriptSync( - getSshLocation(ctx), - `test -e ${quoteHookCommandArg(path, "wsl")}`, - { timeout: 5_000 }, - ); - return result.ok; -} - -export function readSshTextFile(ctx: SshAgentEnvContext, path: string): string | undefined { - const result = runSshScriptSync(getSshLocation(ctx), `cat ${quoteHookCommandArg(path, "wsl")}`, { - timeout: 5_000, - maxBuffer: 10 * 1024 * 1024, - }); - return result.ok ? result.stdout : undefined; -} - -export function writeSshTextFile( - ctx: SshAgentEnvContext, - path: string, - contents: string, -): { ok: true } | { ok: false; reason: string } { - return writeSshFiles(ctx, [{ remotePath: path, contents: Buffer.from(contents, "utf8") }]); -} - -export function writeSshJsonFile( - ctx: SshAgentEnvContext, - path: string, - doc: unknown, -): { ok: true } | { ok: false; reason: string } { - return writeSshTextFile(ctx, path, `${JSON.stringify(doc, null, 2)}\n`); -} - -export function removeSshFile(ctx: SshAgentEnvContext, path: string): void { - runSshScriptSync(getSshLocation(ctx), `rm -f ${quoteHookCommandArg(path, "wsl")}`, { - timeout: 5_000, - }); -} - -export function parseExistingSshJson(ctx: SshAgentEnvContext, path: string): unknown | null { - const raw = readSshTextFile(ctx, path); - if (raw === undefined) return null; - try { - return raw.trim() ? (JSON.parse(raw) as unknown) : {}; - } catch { - return null; - } -} - // ── Shared private-home state mirroring ────────────────────────────────── /** @@ -939,34 +843,6 @@ export function verifyStagedPluginAt( } } -export function verifySshStagedPlugin( - ctx: SshAgentEnvContext, - kind: string, - options?: Omit & { - extraCheck?: () => boolean; - }, -): { installed: boolean; version?: string } { - const dirs = getSshPluginBaseDirs(ctx, kind); - if (!dirs) return { installed: false }; - const assets = options?.assets ?? PLUGIN_ASSET_FILES; - const tests = assets - .map((asset) => `test -f ${quoteHookCommandArg(`${dirs.linuxBase}/${asset}`, "wsl")}`) - .join("\n"); - const result = runSshScriptSync( - getSshLocation(ctx), - `${tests}\ncat ${quoteHookCommandArg(`${dirs.linuxBase}/plugin.json`, "wsl")}`, - { timeout: 5_000 }, - ); - if (!result.ok) return { installed: false }; - if (options?.extraCheck && !options.extraCheck()) return { installed: false }; - try { - const version = (JSON.parse(result.stdout) as PluginManifest).version; - return typeof version === "string" ? { installed: true, version } : { installed: false }; - } catch { - return { installed: false }; - } -} - // ── Shared WSL plugin staging ──────────────────────────────────────────── export interface StagePluginAssetsToWslOptions { @@ -1029,70 +905,3 @@ export function stagePluginAssetsToWsl( linuxPluginDir: `${deploy.linuxBaseDir}/agent-plugins/${kind}`, }; } - -// ── Shared SSH plugin staging ──────────────────────────────────────────── - -export function stagePluginAssetsToSsh( - ctx: SshAgentEnvContext, - sourceDir: string, - kind: string, - options?: StagePluginAssetsToWslOptions | readonly string[], -): { ok: true; deploy: SshPluginBaseDirs; linuxPluginDir: string } | { ok: false; reason: string } { - let opts: StagePluginAssetsToWslOptions; - if (Array.isArray(options)) opts = { assets: options as readonly string[] }; - else opts = (options ?? {}) as StagePluginAssetsToWslOptions; - - const deploy = getSshPluginBaseDirs(ctx, kind); - if (!deploy) return { ok: false, reason: `failed to resolve home on ssh host ${ctx.sshHost}` }; - - const assets = opts.assets ?? PLUGIN_ASSET_FILES; - const files: Array<{ remotePath: string; contents: Buffer }> = assets.map((file) => ({ - remotePath: `${deploy.linuxBase}/${file}`, - contents: readFileSync(join(sourceDir, file)), - })); - if (opts.includeForwardRuntime) { - files.push({ - remotePath: `${deploy.linuxBase}/${FORWARD_RUNTIME_FILE}`, - contents: readFileSync(resolveForwardRuntimeSourcePath()), - }); - } - - const written = writeSshFiles(ctx, files); - if (!written.ok) return written; - return { ok: true, deploy, linuxPluginDir: deploy.linuxBase }; -} - -export function copySshFile( - ctx: SshAgentEnvContext, - sourcePath: string, - remotePath: string, -): { ok: true } | { ok: false; reason: string } { - return writeSshFiles(ctx, [{ remotePath, contents: readFileSync(sourcePath) }]); -} - -function writeSshFiles( - ctx: SshAgentEnvContext, - files: Array<{ remotePath: string; contents: Buffer }>, -): { ok: true } | { ok: false; reason: string } { - const script = files - .map((file, index) => { - const marker = `LIGHTCODE_FILE_${index}`; - return [ - `mkdir -p ${quoteHookCommandArg(pathPosix.dirname(file.remotePath), "wsl")}`, - `base64 -d > ${quoteHookCommandArg(file.remotePath, "wsl")} <<'${marker}'`, - file.contents.toString("base64"), - marker, - ].join("\n"); - }) - .join("\n"); - const result = runSshScriptSync(getSshLocation(ctx), script, { - timeout: 15_000, - maxBuffer: 10 * 1024 * 1024, - }); - return result.ok - ? { ok: true } - : { - ok: false, - reason: result.stderr.trim() || `failed to write files on ssh host ${ctx.sshHost}`, - }; -} 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/runtime.ts b/src/supervisor/runtime.ts index 000b9406..2abada13 100644 --- a/src/supervisor/runtime.ts +++ b/src/supervisor/runtime.ts @@ -196,7 +196,7 @@ import { LanguageServerManager } from "./lsp"; import { ProjectTreeService } from "./projectTree"; import { generatePrSummary } from "./prSummaryGenerator"; import { detectWindowsShell, type WindowsShellPreference } from "./shellPreference"; -import { readSshCommandOutput, SshBrowserMcpTunnelManager } from "./ssh"; +import { readSshCommandOutput } from "./ssh"; import { generateTitle } from "./titleGenerator"; import { AgentStatusService, detectWslAgentStatuses } from "./runtime/agentStatusService"; import { createLocalUsageCollectors } from "./runtime/localUsageCollectors"; @@ -233,7 +233,6 @@ export class SupervisorRuntime { private readonly threadSessionManager: ThreadSessionManager; private readonly lspManager: LanguageServerManager; private readonly cliHookPluginCoordinator: CliHookPluginCoordinator; - private readonly sshBrowserMcpTunnels = new SshBrowserMcpTunnelManager(); private wslHookBridge: WslBridgeServer | undefined; private extractionAbortControllers = new Map(); @@ -404,7 +403,6 @@ export class SupervisorRuntime { browserMcpBridge: { ensureBridge: (distro) => this.wslHookBridge?.ensureBridge(distro) ?? Promise.resolve(undefined), - ensureSshBridge: (location, env) => this.sshBrowserMcpTunnels.ensureTunnel(location, env), }, resolvePluginEnvForSpawn: (input) => this.cliHookPluginCoordinator.resolvePluginEnvForSpawn(input), @@ -1417,7 +1415,6 @@ export class SupervisorRuntime { await this.threadSessionManager.dispose(); this.sharedSettingsCache.dispose(); await this.cliHookPluginCoordinator.dispose().catch(() => undefined); - this.sshBrowserMcpTunnels.dispose(); const { shutdownSpawnedOpenCodeServers } = await import("./agents/opencode/sdkClient"); shutdownSpawnedOpenCodeServers(); } diff --git a/src/supervisor/runtime/agentStatusService.test.ts b/src/supervisor/runtime/agentStatusService.test.ts index ce242f33..85db1b35 100644 --- a/src/supervisor/runtime/agentStatusService.test.ts +++ b/src/supervisor/runtime/agentStatusService.test.ts @@ -293,6 +293,13 @@ HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\{333} 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 () => { diff --git a/src/supervisor/runtime/agentStatusService.ts b/src/supervisor/runtime/agentStatusService.ts index e620ac80..d0ccf8d9 100644 --- a/src/supervisor/runtime/agentStatusService.ts +++ b/src/supervisor/runtime/agentStatusService.ts @@ -133,6 +133,21 @@ function filterSshStatusesForHosts( 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 ?? ""}|${status.envHost ?? ""}`; } @@ -252,16 +267,17 @@ export async function detectSshAgentStatuses( adapterList.map(async (adapter) => { let status: AgentStatus; if (disabled?.has(adapter.kind)) { - status = { - kind: adapter.kind, - label: adapter.label, - installed: true, - authState: "unknown" as const, - capabilities: adapter.capabilities, - ...(adapter.update ? { update: adapter.update } : {}), - envKind: "ssh" as const, - envHost: location.host, - }; + 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; @@ -280,22 +296,23 @@ export async function detectSshAgentStatuses( ]).finally(() => { if (timeout) clearTimeout(timeout); }); - status = { ...detected, envKind: "ssh" as const, envHost: location.host }; + status = asSshTerminalStatus(detected, location.host); } catch (error) { console.error( `[supervisor] detectInstall(${adapter.kind}, ssh:${location.host}) failed`, error, ); - status = { - kind: adapter.kind, - label: adapter.label, - installed: false, - authState: "unknown" as const, - capabilities: adapter.capabilities, - ...(adapter.update ? { update: adapter.update } : {}), - envKind: "ssh" as const, - envHost: location.host, - }; + 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); @@ -525,7 +542,7 @@ export class AgentStatusService { const envHost = isSsh ? env.host : undefined; if (disabled.has(adapter.kind)) { - return { + const status: AgentStatus = { kind: adapter.kind, label: adapter.label, installed: true, @@ -536,6 +553,7 @@ export class AgentStatusService { ...(envDistro ? { envDistro } : {}), ...(envHost ? { envHost } : {}), }; + return isSsh && envHost ? asSshTerminalStatus(status, envHost) : status; } const ctx: AgentEnvContext | undefined = isSsh ? { envKind: "ssh", sshHost: env.host, sshPath: env.path } @@ -544,16 +562,17 @@ export class AgentStatusService { : 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 = 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, @@ -564,6 +583,7 @@ export class AgentStatusService { ...(envDistro ? { envDistro } : {}), ...(envHost ? { envHost } : {}), }; + return isSsh && envHost ? asSshTerminalStatus(status, envHost) : status; } } diff --git a/src/supervisor/runtime/cliHookPluginCoordinator.test.ts b/src/supervisor/runtime/cliHookPluginCoordinator.test.ts index 822786cf..7a25c985 100644 --- a/src/supervisor/runtime/cliHookPluginCoordinator.test.ts +++ b/src/supervisor/runtime/cliHookPluginCoordinator.test.ts @@ -659,67 +659,6 @@ describe("CliHookPluginCoordinator install cache", () => { expect(resolved).toBeUndefined(); }); - it("routes SSH spawns through a project-scoped reverse tunnel", async () => { - const stub = makeStubAdapter("claude"); - stub.isPluginInstalled.mockResolvedValue({ installed: true, version: "1.0.0" }); - - const ensureTunnel = vi.fn< - () => Promise<{ url: string; secret: string; protocolVersion: number }> - >(async () => ({ - url: "http://127.0.0.1:55502/v1/agent-event", - secret: "ssh-secret", - protocolVersion: 1, - })); - - coordinator = new CliHookPluginCoordinator( - { - adapters: new Map([["claude", stub.adapter]]), - settingsPath, - envContext: (_kind, location) => - location?.kind === "ssh" - ? { envKind: "ssh", sshHost: location.host, sshPath: location.path } - : { envKind: "posix" }, - sshHookTunnel: { - ensureTunnel, - dispose: vi.fn<() => void>(), - }, - }, - () => undefined, - ); - coordinator.startIngress(); - - const resolved = await coordinator.resolvePluginEnvForSpawn({ - threadId: "t-ssh", - agentKind: "claude", - projectLocation: { - kind: "ssh", - host: "dev.example.com", - path: "/home/u/project", - }, - }); - - expect(ensureTunnel).toHaveBeenCalledWith( - { kind: "ssh", host: "dev.example.com", path: "/home/u/project" }, - expect.objectContaining({ protocolVersion: 1 }), - ); - expect(stub.isPluginInstalled).toHaveBeenCalledWith( - expect.objectContaining({ - envKind: "ssh", - sshHost: "dev.example.com", - sshPath: "/home/u/project", - }), - ); - expect(resolved).toBeDefined(); - expect(resolved!.env).toMatchObject({ - LIGHTCODE_THREAD_ID: "t-ssh", - LIGHTCODE_AGENT_KIND: "claude", - LIGHTCODE_HOOK_URL: "http://127.0.0.1:55502/v1/agent-event", - LIGHTCODE_HOOK_SECRET: "ssh-secret", - LIGHTCODE_HOOK_PROTOCOL_VERSION: "1", - }); - expect(resolved!.extraArgs).toEqual(["--claude-marker"]); - }); - it("skips CLI hook plugin entirely for server-controlled (ACP/SDK) adapters", async () => { // ACP/SDK/server agents carry their own status channel. The coordinator // must not install the plugin nor return env/args for them — otherwise @@ -776,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 675f490c..b8572f16 100644 --- a/src/supervisor/runtime/cliHookPluginCoordinator.ts +++ b/src/supervisor/runtime/cliHookPluginCoordinator.ts @@ -15,12 +15,12 @@ import type { } from "@/shared/contracts"; import { type AgentAdapter, + type AgentCliHookEnvContext, type AgentEnvContext, type AgentCliHookPluginSupport, resolveWslHomeDirectoryAsync, } from "../agents/base"; import type { BrowserMcpHttpConfig } from "../agents/browserMcp"; -import { SshHookTunnelManager, type SshLocation } from "../ssh"; import type { WslBridgeServer } from "../wsl/bridge"; import { isLightcodeHookDebug } from "./hookDebug"; import { HookIngress, type HookIngressBootInfo } from "./hookIngress"; @@ -49,13 +49,6 @@ export interface CliHookPluginCoordinatorOptions { * reach. */ wslHookBridge?: WslBridgeServer; - sshHookTunnel?: { - ensureTunnel( - location: SshLocation, - upstream: { url: string; secret: string; protocolVersion: number }, - ): Promise<{ url: string; secret: string; protocolVersion: number } | undefined>; - dispose?(): void | Promise; - }; } const DEFAULT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; @@ -104,7 +97,6 @@ export class CliHookPluginCoordinator { ) => AgentEnvContext; private readonly installPromises = new Map>(); private wslHookBridge: WslBridgeServer | undefined; - private readonly sshHookTunnel: NonNullable; constructor( private readonly options: CliHookPluginCoordinatorOptions, @@ -131,7 +123,6 @@ export class CliHookPluginCoordinator { ingressOptions.preferredPort = options.preferredPort; } this.ingress = new HookIngress(ingressOptions); - this.sshHookTunnel = options.sshHookTunnel ?? new SshHookTunnelManager(); } /** @@ -184,7 +175,6 @@ export class CliHookPluginCoordinator { if (this.wslHookBridge) { await this.wslHookBridge.dispose(); } - await this.sshHookTunnel.dispose?.(); } /** @@ -219,7 +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); @@ -259,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); @@ -321,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; @@ -331,14 +328,6 @@ export class CliHookPluginCoordinator { const info = await this.ingress.ready; return { url: handle.hookUrl, secret: info.secret, protocolVersion: info.protocolVersion }; } - if (ctx.envKind === "ssh") { - if (!ctx.sshHost) return undefined; - const info = await this.ingress.ready; - return this.sshHookTunnel.ensureTunnel( - { kind: "ssh", host: ctx.sshHost, path: ctx.sshPath ?? "/" }, - { url: info.url, secret: info.secret, protocolVersion: info.protocolVersion }, - ); - } const info = await this.ingress.ready; return { url: info.url, secret: info.secret, protocolVersion: info.protocolVersion }; } @@ -346,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); @@ -369,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; @@ -514,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; @@ -594,9 +583,6 @@ function composeCacheKey(kind: AgentKind, ctx: AgentEnvContext): string { if (ctx.envKind === "wsl" && ctx.wslDistro) { return `${kind}::wsl::${ctx.wslDistro}`; } - if (ctx.envKind === "ssh" && ctx.sshHost) { - return `${kind}::ssh::${ctx.sshHost}`; - } return kind; } @@ -605,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, diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index 4d2b9b45..dc3cd404 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -1064,18 +1064,15 @@ export class ThreadSessionManager { hookEnvInjected: hasHookEnv, }); } else if (hasHookEnv) { - const label = - projectLocation.kind === "wsl" - ? "CLI hook plugin → in-distro HTTP bridge (WSL) → supervisor" - : projectLocation.kind === "ssh" - ? "CLI hook plugin → SSH reverse tunnel → supervisor" - : "CLI hook plugin → host HookIngress → supervisor"; + const viaWslBridge = projectLocation.kind === "wsl"; hookDebugSpawn({ threadId, agentKind, project: hookDebugProjectLabel(projectLocation), mode: "L1", - label, + label: viaWslBridge + ? "CLI hook plugin → in-distro HTTP bridge (WSL) → supervisor" + : "CLI hook plugin → host HookIngress → supervisor", liveInputMode, hookUrl, extraCliArgs: merged.extraArgs.length, @@ -1359,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, @@ -1465,6 +1465,9 @@ export class ThreadSessionManager { if (!adapter.createStructuredSession) { return undefined; } + if (projectLocation.kind === "ssh") { + return undefined; + } try { return await adapter.createStructuredSession({ threadId, @@ -1632,6 +1635,7 @@ export class ThreadSessionManager { this.outputPipeline.emitState(session); if ( pty && + input.projectLocation.kind !== "ssh" && !session.sessionRef && !session.sessionRefDiscoveryStarted && input.adapter.discoverSessionRef @@ -1805,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(); @@ -1826,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; @@ -1847,7 +1855,7 @@ export class ThreadSessionManager { }; session.stopSessionRefWatcher = session.adapter.watchSessionRef?.( - session.projectLocation, + projectLocation, () => void poll(true), ); const initialDelay = session.adapter.initialSessionRefDiscoveryDelayMs ?? 0; @@ -1990,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) { @@ -2090,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) { @@ -2305,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, diff --git a/src/supervisor/ssh.ts b/src/supervisor/ssh.ts index eb592396..12fb9d4a 100644 --- a/src/supervisor/ssh.ts +++ b/src/supervisor/ssh.ts @@ -1,5 +1,4 @@ -import { randomInt } from "node:crypto"; -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { spawn } from "node:child_process"; import type { ProjectLocation } from "@/shared/contracts"; import { isSafeSshHost } from "@/shared/ssh"; import type { CommandSpec } from "./agents/base"; @@ -40,236 +39,6 @@ function baseSshOptions(options: { batchMode: "yes" | "no"; tty: boolean }): str ]; } -function normalizeLoopbackHost(hostname: string): string { - return hostname === "localhost" ? "127.0.0.1" : hostname; -} - -function parseTcpTarget(url: string): { host: string; port: number; path: string } | undefined { - try { - const parsed = new URL(url); - const port = parsed.port - ? Number(parsed.port) - : parsed.protocol === "https:" - ? 443 - : parsed.protocol === "http:" - ? 80 - : undefined; - if (!port) return undefined; - return { host: normalizeLoopbackHost(parsed.hostname), port, path: parsed.pathname }; - } catch { - return undefined; - } -} - -interface SshBrowserMcpTunnelState { - child: ChildProcess; - baseUrl: string; - secret: string; -} - -interface SshHookTunnelState { - child: ChildProcess; - url: string; - secret: string; - protocolVersion: number; -} - -export class SshBrowserMcpTunnelManager { - private readonly tunnels = new Map(); - private readonly inFlight = new Map< - string, - Promise<{ baseUrl: string; secret: string } | undefined> - >(); - - async ensureTunnel( - location: SshLocation, - upstream: { url: string; token: string }, - ): Promise<{ baseUrl: string; secret: string } | undefined> { - assertSshLocation(location); - const target = parseTcpTarget(upstream.url); - if (!target) return undefined; - const key = `${location.host}|${target.host}:${target.port}|${upstream.token}`; - const existing = this.tunnels.get(key); - if (existing && existing.child.exitCode === null && existing.child.signalCode === null) { - return { baseUrl: existing.baseUrl, secret: existing.secret }; - } - const pending = this.inFlight.get(key); - if (pending) return pending; - const task = this.startTunnel(location, upstream.token, target, key).finally(() => { - this.inFlight.delete(key); - }); - this.inFlight.set(key, task); - return task; - } - - dispose(): void { - for (const tunnel of this.tunnels.values()) { - tunnel.child.kill(); - } - this.tunnels.clear(); - } - - private async startTunnel( - location: SshLocation, - token: string, - target: { host: string; port: number; path: string }, - key: string, - ): Promise<{ baseUrl: string; secret: string } | undefined> { - for (let attempt = 0; attempt < 5; attempt += 1) { - const remotePort = randomInt(40_000, 60_000); - const child = spawn( - "ssh", - [ - ...baseSshOptions({ batchMode: "yes", tty: false }), - "-N", - "-o", - "ExitOnForwardFailure=yes", - "-R", - `127.0.0.1:${remotePort}:${target.host}:${target.port}`, - location.host, - ], - { windowsHide: true, stdio: "ignore" }, - ); - - const ready = await waitForTunnelReady(child); - if (!ready) continue; - - const baseUrl = `http://127.0.0.1:${remotePort}`; - const state: SshBrowserMcpTunnelState = { child, baseUrl, secret: token }; - this.tunnels.set(key, state); - child.once("exit", () => { - if (this.tunnels.get(key) === state) this.tunnels.delete(key); - }); - return { baseUrl, secret: token }; - } - return undefined; - } -} - -export class SshHookTunnelManager { - private readonly tunnels = new Map(); - private readonly inFlight = new Map< - string, - Promise<{ url: string; secret: string; protocolVersion: number } | undefined> - >(); - - async ensureTunnel( - location: SshLocation, - upstream: { url: string; secret: string; protocolVersion: number }, - ): Promise<{ url: string; secret: string; protocolVersion: number } | undefined> { - assertSshLocation(location); - const target = parseTcpTarget(upstream.url); - if (!target) return undefined; - const key = [ - location.host, - target.host, - target.port, - upstream.secret, - upstream.protocolVersion, - ].join("|"); - const existing = this.tunnels.get(key); - if (existing && existing.child.exitCode === null && existing.child.signalCode === null) { - return { - url: existing.url, - secret: existing.secret, - protocolVersion: existing.protocolVersion, - }; - } - const pending = this.inFlight.get(key); - if (pending) return pending; - const task = this.startTunnel(location, upstream, target, key).finally(() => { - this.inFlight.delete(key); - }); - this.inFlight.set(key, task); - return task; - } - - dispose(): void { - for (const tunnel of this.tunnels.values()) { - tunnel.child.kill(); - } - this.tunnels.clear(); - } - - private async startTunnel( - location: SshLocation, - upstream: { secret: string; protocolVersion: number }, - target: { host: string; port: number; path: string }, - key: string, - ): Promise<{ url: string; secret: string; protocolVersion: number } | undefined> { - for (let attempt = 0; attempt < 5; attempt += 1) { - const remotePort = randomInt(40_000, 60_000); - const child = spawn( - "ssh", - [ - ...baseSshOptions({ batchMode: "yes", tty: false }), - "-N", - "-o", - "ExitOnForwardFailure=yes", - "-R", - `127.0.0.1:${remotePort}:${target.host}:${target.port}`, - location.host, - ], - { windowsHide: true, stdio: "ignore" }, - ); - - const ready = await waitForTunnelReady(child); - if (!ready) continue; - - const state: SshHookTunnelState = { - child, - url: `http://127.0.0.1:${remotePort}${target.path}`, - secret: upstream.secret, - protocolVersion: upstream.protocolVersion, - }; - this.tunnels.set(key, state); - child.once("exit", () => { - if (this.tunnels.get(key) === state) this.tunnels.delete(key); - }); - return { - url: state.url, - secret: state.secret, - protocolVersion: state.protocolVersion, - }; - } - return undefined; - } -} - -function waitForTunnelReady(child: ChildProcess): Promise { - return new Promise((resolve) => { - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - cleanup(); - resolve(child.exitCode === null && child.signalCode === null); - }, 600); - if (typeof timer.unref === "function") timer.unref(); - - const onExit = () => { - if (settled) return; - settled = true; - clearTimeout(timer); - cleanup(); - resolve(false); - }; - const onError = () => { - if (settled) return; - settled = true; - clearTimeout(timer); - cleanup(); - resolve(false); - }; - const cleanup = () => { - child.off("exit", onExit); - child.off("error", onError); - }; - child.once("exit", onExit); - child.once("error", onError); - }); -} - function remoteScriptForCommand( location: SshLocation, command: string, @@ -311,41 +80,6 @@ export function buildSshCommand( }; } -export function buildSshForwardedCommand( - location: SshLocation, - command: string, - args: string[], - env: Record | undefined, - forwards: Array<{ - localHost: string; - localPort: number; - remoteHost: string; - remotePort: number; - }>, - options?: { batchMode?: "yes" | "no"; tty?: boolean }, -): CommandSpec { - assertSshLocation(location); - const script = remoteScriptForCommand(location, command, args, env); - const forwardArgs = forwards.flatMap((forward) => [ - "-L", - `${forward.localHost}:${forward.localPort}:${forward.remoteHost}:${forward.remotePort}`, - ]); - return { - command: "ssh", - args: [ - ...baseSshOptions({ - batchMode: options?.batchMode ?? "yes", - tty: options?.tty ?? false, - }), - "-o", - "ExitOnForwardFailure=yes", - ...forwardArgs, - location.host, - `sh -lc ${quotePosixShellArg(script)}`, - ], - }; -} - export function buildSshShellCommand( location: SshLocation, options?: { startInHome?: boolean }, @@ -374,16 +108,6 @@ export async function readSshCommandOutput( ); } -export function readSshCommandOutputSync( - location: SshLocation, - command: string, - args: string[], - options?: { timeout?: number; maxBuffer?: number; env?: Record }, -): { ok: boolean; stdout: string; stderr: string } { - const script = `${buildPosixExportPrefix(options?.env)}exec ${[command, ...args].map(quotePosixShellArg).join(" ")}`; - return runSshScriptSync(location, script, options); -} - export function runSshScript( location: SshLocation, script: string, @@ -456,44 +180,6 @@ export function runSshScript( }); } -export function runSshScriptSync( - location: SshLocation, - script: string, - options?: { timeout?: number; maxBuffer?: number }, -): { ok: boolean; stdout: string; stderr: string } { - assertSshLocation(location); - const wrapped = `set -e\ncd ${quotePosixShellArg(location.path)}\n${script}\n`; - const result = spawnSync( - "ssh", - [...baseSshArgs(location, { batchMode: "yes", tty: false }), "sh", "-s"], - { - input: wrapped, - encoding: "utf8", - windowsHide: true, - timeout: options?.timeout ?? SSH_DEFAULT_TIMEOUT, - maxBuffer: options?.maxBuffer ?? 10 * 1024 * 1024, - }, - ); - return { - ok: !result.error && result.status === 0, - stdout: `${result.stdout ?? ""}`, - stderr: `${result.stderr ?? ""}`, - }; -} - -export function resolveSshHomeDirectory(location: SshLocation): string | undefined { - assertSshLocation(location); - const cached = sshHomeCache.get(location.host); - if (cached) return cached; - const result = readSshCommandOutputSync(location, "sh", ["-lc", 'printf %s "$HOME"'], { - timeout: 5_000, - }); - const home = result.ok ? result.stdout.trim() : ""; - if (!home) return undefined; - sshHomeCache.set(location.host, home); - return home; -} - export async function resolveSshHomeDirectoryAsync( location: SshLocation, ): Promise { 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) {