From 6ea008a680aa4294d55474c00db659ac6e2ee583 Mon Sep 17 00:00:00 2001 From: frontierkodiak Date: Fri, 30 Jan 2026 19:26:49 +0000 Subject: [PATCH] feat: add remote host aggregation --- README.md | 17 ++ src/client/App.tsx | 22 +- src/client/__tests__/app.test.tsx | 2 + src/client/__tests__/sessionDrawer.test.tsx | 2 + .../__tests__/sessionListComponent.test.tsx | 2 + .../__tests__/sessionListFilters.test.tsx | 4 +- src/client/__tests__/sessionState.test.ts | 1 + src/client/__tests__/settingsModal.test.tsx | 2 + src/client/__tests__/settingsStore.test.ts | 2 + src/client/components/HostBadge.tsx | 20 ++ src/client/components/HostFilterDropdown.tsx | 133 +++++++++ src/client/components/SessionList.tsx | 87 +++++- src/client/components/Terminal.tsx | 40 +-- src/client/hooks/useTerminal.ts | 15 +- src/client/stores/sessionStore.ts | 6 +- src/client/stores/settingsStore.ts | 4 + src/client/utils/sessions.ts | 35 +++ src/server/SessionRegistry.ts | 4 +- src/server/__tests__/config.test.ts | 36 +++ .../__tests__/isolated/indexHandlers.test.ts | 10 +- src/server/agentSessions.ts | 2 + src/server/config.ts | 29 ++ src/server/index.ts | 106 ++++++-- src/server/remoteSessions.ts | 255 ++++++++++++++++++ src/shared/types.ts | 11 + 25 files changed, 789 insertions(+), 58 deletions(-) create mode 100644 src/client/components/HostBadge.tsx create mode 100644 src/client/components/HostFilterDropdown.tsx create mode 100644 src/server/remoteSessions.ts diff --git a/README.md b/README.md index 39e37e5..582b309 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,13 @@ VITE_ALLOWED_HOSTS=nuc,myserver AGENTBOARD_DB_PATH=~/.agentboard/agentboard.db AGENTBOARD_INACTIVE_MAX_AGE_HOURS=24 AGENTBOARD_EXCLUDE_PROJECTS=,/workspace +AGENTBOARD_HOST=blade +AGENTBOARD_REMOTE_HOSTS=mba,carbon,worm +AGENTBOARD_REMOTE_POLL_MS=15000 +AGENTBOARD_REMOTE_TIMEOUT_MS=4000 +AGENTBOARD_REMOTE_STALE_MS=45000 +AGENTBOARD_REMOTE_SSH_OPTS=-o BatchMode=yes -o ConnectTimeout=3 +AGENTBOARD_REMOTE_ALLOW_CONTROL=false ``` `HOSTNAME` controls which interfaces the server binds to (default `0.0.0.0` for network access; use `127.0.0.1` for local-only). @@ -94,6 +101,16 @@ All persistent data is stored in `~/.agentboard/`: session database (`agentboard `AGENTBOARD_SKIP_MATCHING_PATTERNS` controls which orphan sessions skip expensive window matching (comma-separated). Defaults: `` (headless Codex exec sessions), `/private/tmp/*`, `/private/var/folders/*`, `/var/folders/*`, `/tmp/*`. Patterns support trailing `*` for prefix matching. Set to empty string to disable skip matching entirely. +`AGENTBOARD_HOST` sets the host label for local sessions (default: `hostname`). + +`AGENTBOARD_REMOTE_HOSTS` enables remote tmux polling over SSH. Provide a comma-separated list of hosts (e.g., `mba,carbon,worm`). + +`AGENTBOARD_REMOTE_POLL_MS`, `AGENTBOARD_REMOTE_TIMEOUT_MS`, and `AGENTBOARD_REMOTE_STALE_MS` control remote poll cadence, SSH timeout, and stale host cutoff. + +`AGENTBOARD_REMOTE_SSH_OPTS` appends extra SSH options (space-separated). + +`AGENTBOARD_REMOTE_ALLOW_CONTROL` is reserved for future remote control support (read-only in MVP). + ## Logging ``` diff --git a/src/client/App.tsx b/src/client/App.tsx index 68fd35d..08ecb3d 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -38,6 +38,7 @@ export default function App() { ) const setSessions = useSessionStore((state) => state.setSessions) const setAgentSessions = useSessionStore((state) => state.setAgentSessions) + const setHostStatuses = useSessionStore((state) => state.setHostStatuses) const updateSession = useSessionStore((state) => state.updateSession) const setSelectedSessionId = useSessionStore( (state) => state.setSelectedSessionId @@ -65,6 +66,7 @@ export default function App() { const sidebarWidth = useSettingsStore((state) => state.sidebarWidth) const setSidebarWidth = useSettingsStore((state) => state.setSidebarWidth) const projectFilters = useSettingsStore((state) => state.projectFilters) + const hostFilters = useSettingsStore((state) => state.hostFilters) const soundOnPermission = useSettingsStore((state) => state.soundOnPermission) const soundOnIdle = useSettingsStore((state) => state.soundOnIdle) @@ -159,6 +161,9 @@ export default function App() { setSessions(message.sessions) } + if (message.type === 'host-status') { + setHostStatuses(message.hosts) + } if (message.type === 'session-update') { // Detect status transitions for sound notifications // Capture previous status BEFORE updating to ensure we have the old value @@ -277,6 +282,7 @@ export default function App() { setSelectedSessionId, setSessions, setAgentSessions, + setHostStatuses, subscribe, updateSession, ]) @@ -312,13 +318,17 @@ export default function App() { [sessions, sessionSortMode, sessionSortDirection, manualSessionOrder] ) - // Apply project filters to sorted sessions for keyboard navigation + // Apply filters to sorted sessions for keyboard navigation const filteredSortedSessions = useMemo(() => { - if (projectFilters.length === 0) return sortedSessions - return sortedSessions.filter((session) => - projectFilters.includes(session.projectPath) - ) - }, [sortedSessions, projectFilters]) + let next = sortedSessions + if (projectFilters.length > 0) { + next = next.filter((session) => projectFilters.includes(session.projectPath)) + } + if (hostFilters.length > 0) { + next = next.filter((session) => hostFilters.includes(session.host ?? '')) + } + return next + }, [sortedSessions, projectFilters, hostFilters]) // Auto-select first visible session when current selection is filtered out useEffect(() => { diff --git a/src/client/__tests__/app.test.tsx b/src/client/__tests__/app.test.tsx index 7506443..292ad25 100644 --- a/src/client/__tests__/app.test.tsx +++ b/src/client/__tests__/app.test.tsx @@ -168,6 +168,7 @@ beforeEach(() => { showProjectName: true, showLastUserMessage: true, showSessionIdPrefix: false, + hostFilters: [], }) useThemeStore.setState({ theme: 'dark' }) @@ -190,6 +191,7 @@ afterEach(() => { showProjectName: true, showLastUserMessage: true, showSessionIdPrefix: false, + hostFilters: [], }) }) diff --git a/src/client/__tests__/sessionDrawer.test.tsx b/src/client/__tests__/sessionDrawer.test.tsx index 0d5ee6b..2f596bf 100644 --- a/src/client/__tests__/sessionDrawer.test.tsx +++ b/src/client/__tests__/sessionDrawer.test.tsx @@ -112,6 +112,7 @@ beforeEach(() => { showProjectName: true, showLastUserMessage: true, showSessionIdPrefix: false, + hostFilters: [], }) }) @@ -127,6 +128,7 @@ afterEach(() => { showProjectName: true, showLastUserMessage: true, showSessionIdPrefix: false, + hostFilters: [], }) }) diff --git a/src/client/__tests__/sessionListComponent.test.tsx b/src/client/__tests__/sessionListComponent.test.tsx index 6e74fd3..b3f557a 100644 --- a/src/client/__tests__/sessionListComponent.test.tsx +++ b/src/client/__tests__/sessionListComponent.test.tsx @@ -59,6 +59,7 @@ beforeEach(() => { showLastUserMessage: true, showSessionIdPrefix: false, projectFilters: [], + hostFilters: [], }) useSessionStore.setState({ @@ -80,6 +81,7 @@ afterEach(() => { showLastUserMessage: true, showSessionIdPrefix: false, projectFilters: [], + hostFilters: [], }) useSessionStore.setState({ exitingSessions: new Map(), diff --git a/src/client/__tests__/sessionListFilters.test.tsx b/src/client/__tests__/sessionListFilters.test.tsx index d88d328..3ea0862 100644 --- a/src/client/__tests__/sessionListFilters.test.tsx +++ b/src/client/__tests__/sessionListFilters.test.tsx @@ -32,6 +32,7 @@ beforeEach(() => { showLastUserMessage: true, showSessionIdPrefix: false, projectFilters: [], + hostFilters: [], }) useSessionStore.setState({ @@ -50,6 +51,7 @@ afterEach(() => { showLastUserMessage: true, showSessionIdPrefix: false, projectFilters: [], + hostFilters: [], }) useSessionStore.setState({ exitingSessions: new Map(), @@ -69,7 +71,7 @@ const baseSession: Session = { describe('SessionList project filters', () => { test('marks hidden permission sessions when filters exclude them', () => { - useSettingsStore.setState({ projectFilters: ['/tmp/visible'] }) + useSettingsStore.setState({ projectFilters: ['/tmp/visible'], hostFilters: [] }) const sessions: Session[] = [ { ...baseSession, id: 'visible', projectPath: '/tmp/visible', status: 'working' }, diff --git a/src/client/__tests__/sessionState.test.ts b/src/client/__tests__/sessionState.test.ts index 9bf47e1..f2f2086 100644 --- a/src/client/__tests__/sessionState.test.ts +++ b/src/client/__tests__/sessionState.test.ts @@ -34,6 +34,7 @@ beforeEach(() => { showProjectName: true, showLastUserMessage: true, showSessionIdPrefix: false, + hostFilters: [], }) }) diff --git a/src/client/__tests__/settingsModal.test.tsx b/src/client/__tests__/settingsModal.test.tsx index effd940..0b265ec 100644 --- a/src/client/__tests__/settingsModal.test.tsx +++ b/src/client/__tests__/settingsModal.test.tsx @@ -47,6 +47,7 @@ beforeEach(() => { showProjectName: true, showLastUserMessage: true, showSessionIdPrefix: false, + hostFilters: [], }) useThemeStore.setState({ theme: 'dark' }) }) @@ -64,6 +65,7 @@ afterEach(() => { showProjectName: true, showLastUserMessage: true, showSessionIdPrefix: false, + hostFilters: [], }) useThemeStore.setState({ theme: 'dark' }) }) diff --git a/src/client/__tests__/settingsStore.test.ts b/src/client/__tests__/settingsStore.test.ts index 254edb2..2dd36ad 100644 --- a/src/client/__tests__/settingsStore.test.ts +++ b/src/client/__tests__/settingsStore.test.ts @@ -63,6 +63,7 @@ beforeEach(() => { showLastUserMessage: true, showSessionIdPrefix: false, projectFilters: [], + hostFilters: [], }) }) @@ -74,6 +75,7 @@ describe('useSettingsStore', () => { expect(state.lastProjectPath).toBeNull() expect(state.recentPaths).toEqual([]) expect(state.projectFilters).toEqual([]) + expect(state.hostFilters).toEqual([]) }) test('updates default project dir', () => { diff --git a/src/client/components/HostBadge.tsx b/src/client/components/HostBadge.tsx new file mode 100644 index 0000000..9f0870f --- /dev/null +++ b/src/client/components/HostBadge.tsx @@ -0,0 +1,20 @@ +import { getProjectColorStyle } from '../utils/projectColor' + +interface HostBadgeProps { + name: string + className?: string +} + +export default function HostBadge({ name, className = '' }: HostBadgeProps) { + const colorStyle = getProjectColorStyle(`host:${name}`) + + return ( + + {name} + + ) +} diff --git a/src/client/components/HostFilterDropdown.tsx b/src/client/components/HostFilterDropdown.tsx new file mode 100644 index 0000000..a4709e0 --- /dev/null +++ b/src/client/components/HostFilterDropdown.tsx @@ -0,0 +1,133 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import ChevronDownIcon from '@untitledui-icons/react/line/esm/ChevronDownIcon' +import type { HostStatus } from '@shared/types' + +interface HostFilterDropdownProps { + hosts: string[] + selectedHosts: string[] + onSelect: (hosts: string[]) => void + statuses?: HostStatus[] +} + +export default function HostFilterDropdown({ + hosts, + selectedHosts, + onSelect, + statuses = [], +}: HostFilterDropdownProps) { + const [open, setOpen] = useState(false) + const menuId = useId() + const containerRef = useRef(null) + const selectedSet = useMemo(() => new Set(selectedHosts), [selectedHosts]) + const statusMap = useMemo( + () => new Map(statuses.map((status) => [status.host, status])), + [statuses] + ) + + const selectedTitle = useMemo(() => { + if (selectedHosts.length === 0) return 'All Hosts' + return selectedHosts.join(', ') + }, [selectedHosts]) + + const selectedLabel = useMemo(() => { + if (selectedHosts.length === 0) return 'All Hosts' + if (selectedHosts.length === 1) return selectedHosts[0] + return `${selectedHosts.length} hosts` + }, [selectedHosts]) + + useEffect(() => { + if (!open || typeof document === 'undefined') return + if (!document.addEventListener || !document.removeEventListener) return + const handlePointer = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node | null + if (target && containerRef.current?.contains(target)) return + setOpen(false) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', handlePointer) + document.addEventListener('touchstart', handlePointer, { passive: true }) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handlePointer) + document.removeEventListener('touchstart', handlePointer) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open]) + + const toggleHost = (host: string) => { + const next = new Set(selectedSet) + if (next.has(host)) { + next.delete(host) + } else { + next.add(host) + } + const ordered = hosts.filter((value) => next.has(value)) + onSelect(ordered) + } + + return ( +
+ + {open && ( + + ) +} diff --git a/src/client/components/SessionList.tsx b/src/client/components/SessionList.tsx index c1c7d4b..72dafbf 100644 --- a/src/client/components/SessionList.tsx +++ b/src/client/components/SessionList.tsx @@ -24,7 +24,7 @@ import ChevronRightIcon from '@untitledui-icons/react/line/esm/ChevronRightIcon' import Edit05Icon from '@untitledui-icons/react/line/esm/Edit05Icon' import Pin02Icon from '@untitledui-icons/react/line/esm/Pin02Icon' import type { AgentSession, Session } from '@shared/types' -import { getSessionOrderKey, getUniqueProjects, sortSessions } from '../utils/sessions' +import { getSessionOrderKey, getUniqueHosts, getUniqueProjects, sortSessions } from '../utils/sessions' import { formatRelativeTime } from '../utils/time' import { getPathLeaf } from '../utils/sessionLabel' import { getSessionIdShort } from '../utils/sessionId' @@ -36,6 +36,8 @@ import { useExitCleanup } from '../hooks/useExitCleanup' import AgentIcon from './AgentIcon' import InactiveSessionItem from './InactiveSessionItem' import ProjectBadge from './ProjectBadge' +import HostBadge from './HostBadge' +import HostFilterDropdown from './HostFilterDropdown' import ProjectFilterDropdown from './ProjectFilterDropdown' import SessionPreviewModal from './SessionPreviewModal' @@ -166,10 +168,13 @@ export default function SessionList({ ) const projectFilters = useSettingsStore((state) => state.projectFilters) const setProjectFilters = useSettingsStore((state) => state.setProjectFilters) + const hostFilters = useSettingsStore((state) => state.hostFilters) + const setHostFilters = useSettingsStore((state) => state.setHostFilters) // Get exiting sessions from store (for kill-failed rollback only) const exitingSessions = useSessionStore((state) => state.exitingSessions) const clearExitingSession = useSessionStore((state) => state.clearExitingSession) + const hostStatuses = useSessionStore((state) => state.hostStatuses) // Clean up exiting session state after animations useExitCleanup(sessions, exitingSessions, clearExitingSession, EXIT_DURATION) @@ -211,14 +216,45 @@ export default function SessionList({ [sessions, inactiveSessions] ) + const uniqueHosts = useMemo(() => { + const sessionHosts = getUniqueHosts(sessions, inactiveSessions) + const statusHosts = hostStatuses.map((status) => status.host) + const seen = new Set() + const merged: string[] = [] + + for (const host of statusHosts) { + if (!host || seen.has(host)) continue + seen.add(host) + merged.push(host) + } + + for (const host of sessionHosts) { + if (!host || seen.has(host)) continue + seen.add(host) + merged.push(host) + } + + return merged + }, [sessions, inactiveSessions, hostStatuses]) + const filteredSessions = useMemo(() => { - if (projectFilters.length === 0) return sortedSessions - return sortedSessions.filter((session) => projectFilters.includes(session.projectPath)) - }, [sortedSessions, projectFilters]) + let next = sortedSessions + if (projectFilters.length > 0) { + next = next.filter((session) => projectFilters.includes(session.projectPath)) + } + if (hostFilters.length > 0) { + next = next.filter((session) => hostFilters.includes(session.host ?? '')) + } + return next + }, [sortedSessions, projectFilters, hostFilters]) const filterKey = useMemo( - () => (projectFilters.length === 0 ? 'all-projects' : projectFilters.join('|')), - [projectFilters] + () => { + const projectKey = projectFilters.length === 0 ? 'all-projects' : projectFilters.join('|') + const hostKey = hostFilters.length === 0 ? 'all-hosts' : hostFilters.join('|') + return `${projectKey}::${hostKey}` + }, + [projectFilters, hostFilters] ) // Track sessions that became visible due to filter changes (for entry animation) @@ -256,11 +292,15 @@ export default function SessionList({ }, [newlyFilteredInIds]) const filteredInactiveSessions = useMemo(() => { - if (projectFilters.length === 0) return inactiveSessions - return inactiveSessions.filter( - (session) => projectFilters.includes(session.projectPath) - ) - }, [inactiveSessions, projectFilters]) + let next = inactiveSessions + if (projectFilters.length > 0) { + next = next.filter((session) => projectFilters.includes(session.projectPath)) + } + if (hostFilters.length > 0) { + next = next.filter((session) => hostFilters.includes(session.host ?? '')) + } + return next + }, [inactiveSessions, projectFilters, hostFilters]) const hiddenPermissionCount = useMemo(() => { if (projectFilters.length === 0) return 0 @@ -281,6 +321,15 @@ export default function SessionList({ } }, [projectFilters, uniqueProjects, setProjectFilters]) + useEffect(() => { + if (hostFilters.length === 0 || uniqueHosts.length === 0) return + const validHosts = new Set(uniqueHosts) + const nextFilters = hostFilters.filter((host) => validHosts.has(host)) + if (nextFilters.length !== hostFilters.length) { + setHostFilters(nextFilters) + } + }, [hostFilters, uniqueHosts, setHostFilters]) + // Drag-and-drop setup const sensors = useSensors( useSensor(PointerSensor, { @@ -397,6 +446,12 @@ export default function SessionList({ Sessions
+ { const isTrulyNew = newlyActiveIds.has(session.id) const isFilteredIn = newlyFilteredInIds.has(session.id) + const isRemote = session.remote === true // Calculate drop indicator position const activeIndex = activeId ? filteredSessions.findIndex((s) => s.id === activeId) @@ -470,8 +526,8 @@ export default function SessionList({ onStartEdit={() => setEditingSessionId(session.id)} onCancelEdit={() => setEditingSessionId(null)} onRename={(newName) => handleRename(session.id, newName)} - onKill={onKill ? () => onKill(session.id) : undefined} - onDuplicate={onDuplicate ? () => onDuplicate(session.id) : undefined} + onKill={onKill && !isRemote ? () => onKill(session.id) : undefined} + onDuplicate={onDuplicate && !isRemote ? () => onDuplicate(session.id) : undefined} onSetPinned={onSetPinned && session.agentSessionId ? (isPinned) => onSetPinned(session.agentSessionId!.trim(), isPinned) : undefined} /> ) @@ -733,6 +789,7 @@ function SessionRow({ const longPressTimer = useRef | null>(null) const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) const directoryLeaf = getPathLeaf(session.projectPath) + const hostLabel = session.host?.trim() const needsInput = session.status === 'permission' const agentSessionId = session.agentSessionId?.trim() const sessionIdPrefix = @@ -740,6 +797,7 @@ function SessionRow({ ? getSessionIdShort(agentSessionId) : '' const showDirectory = showProjectName && Boolean(directoryLeaf) + const showHostBadge = Boolean(hostLabel) const showMessage = showLastUserMessage && Boolean(session.lastUserMessage) // Track previous status for transition animation @@ -917,8 +975,9 @@ function SessionRow({
{/* Line 2: Project badge + last user message (up to 2 lines total) */} - {(showDirectory || showMessage) && ( + {(showDirectory || showHostBadge || showMessage) && (
+ {showHostBadge && } {showDirectory && ( )} diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 5026af4..30fc0f9 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -112,10 +112,12 @@ export default function Terminal({ const moreMenuRef = useRef(null) const renameInputRef = useRef(null) const endSessionButtonRef = useRef(null) + const isRemoteSession = session?.remote === true const { containerRef, terminalRef, inTmuxCopyModeRef, setTmuxCopyMode } = useTerminal({ sessionId: session?.id ?? null, tmuxTarget: session?.tmuxWindow ?? null, + allowAttach: !isRemoteSession, sendMessage, subscribe, theme: terminalTheme, @@ -131,11 +133,11 @@ export default function Terminal({ const scrollToBottom = useCallback(() => { // Exit tmux copy-mode to return to live output (oracle recommendation) - if (!session) return + if (!session || isRemoteSession) return sendMessage({ type: 'tmux-cancel-copy-mode', sessionId: session.id }) setTmuxCopyMode(false) terminalRef.current?.scrollToBottom() - }, [session, sendMessage, setTmuxCopyMode, terminalRef]) + }, [session, isRemoteSession, sendMessage, setTmuxCopyMode, terminalRef]) // Edge swipe to open drawer const handleOpenDrawer = useCallback(() => { @@ -782,10 +784,10 @@ export default function Terminal({ const handleSendKey = useCallback( (key: string) => { - if (!session) return + if (!session || isRemoteSession) return sendMessage({ type: 'terminal-input', sessionId: session.id, data: key }) }, - [session, sendMessage] + [session, isRemoteSession, sendMessage] ) const handleRefocus = useCallback(() => { @@ -800,7 +802,8 @@ export default function Terminal({ // Enter text mode: exit copy-mode and focus input (for keyboard button) const handleEnterTextMode = useCallback(() => { - if (session && inTmuxCopyModeRef.current) { + if (!session || isRemoteSession) return + if (inTmuxCopyModeRef.current) { sendMessage({ type: 'tmux-cancel-copy-mode', sessionId: session.id }) setTmuxCopyMode(false) } @@ -884,7 +887,7 @@ export default function Terminal({ {/* Kill session button - desktop only, left of session name */} - {session && ( + {session && !isRemoteSession && ( {/* Kill session button - mobile only (desktop has it on left) */} - {session && ( + {session && !isRemoteSession && ( + {!isRemoteSession && ( + + )}
)} + {session && isRemoteSession && ( +
+ Remote session (read-only). Use SSH to attach on the host. +
+ )} {/* Scroll to bottom button */} {showScrollButton && session && !isSelectingText && ( @@ -1067,7 +1077,7 @@ export default function Terminal({ {session && ( ({ id: s.id, name: s.name, status: s.status }))} currentSessionId={session.id} onSelectSession={onSelectSession} diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index 3153bbd..779a8e3 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -106,6 +106,7 @@ export function forceTextPresentation(data: string): string { interface UseTerminalOptions { sessionId: string | null tmuxTarget: string | null + allowAttach?: boolean sendMessage: SendClientMessage subscribe: SubscribeServerMessage theme: ITheme @@ -120,6 +121,7 @@ interface UseTerminalOptions { export function useTerminal({ sessionId, tmuxTarget, + allowAttach = true, sendMessage, subscribe, theme, @@ -666,6 +668,17 @@ export function useTerminal({ const prevAttached = attachedSessionRef.current const prevTarget = attachedTargetRef.current + if (!allowAttach) { + if (prevAttached) { + sendMessage({ type: 'terminal-detach', sessionId: prevAttached }) + attachedSessionRef.current = null + attachedTargetRef.current = null + inTmuxCopyModeRef.current = false + } + terminal.reset() + return + } + // Detach from previous session first if (prevAttached && prevAttached !== sessionId) { sendMessage({ type: 'terminal-detach', sessionId: prevAttached }) @@ -718,7 +731,7 @@ export function useTerminal({ attachedSessionRef.current = null attachedTargetRef.current = null } - }, [sessionId, tmuxTarget, sendMessage, checkScrollPosition]) + }, [sessionId, tmuxTarget, allowAttach, sendMessage, checkScrollPosition]) // Subscribe to terminal output with idle-based buffering + synchronized output // This prevents flicker by: (1) batching output until stream goes idle, diff --git a/src/client/stores/sessionStore.ts b/src/client/stores/sessionStore.ts index 36ac179..d046441 100644 --- a/src/client/stores/sessionStore.ts +++ b/src/client/stores/sessionStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' -import type { AgentSession, Session } from '@shared/types' +import type { AgentSession, HostStatus, Session } from '@shared/types' import { sortSessions } from '../utils/sessions' import { useSettingsStore } from './settingsStore' import { safeStorage } from '../utils/storage' @@ -15,6 +15,7 @@ export type ConnectionStatus = interface SessionState { sessions: Session[] agentSessions: { active: AgentSession[]; inactive: AgentSession[] } + hostStatuses: HostStatus[] // Sessions being animated out - keyed by session ID, value is the session data exitingSessions: Map selectedSessionId: string | null @@ -23,6 +24,7 @@ interface SessionState { connectionError: string | null setSessions: (sessions: Session[]) => void setAgentSessions: (active: AgentSession[], inactive: AgentSession[]) => void + setHostStatuses: (hosts: HostStatus[]) => void updateSession: (session: Session) => void setSelectedSessionId: (sessionId: string | null) => void setConnectionStatus: (status: ConnectionStatus) => void @@ -38,6 +40,7 @@ export const useSessionStore = create()( (set, get) => ({ sessions: [], agentSessions: { active: [], inactive: [] }, + hostStatuses: [], exitingSessions: new Map(), selectedSessionId: null, hasLoaded: false, @@ -96,6 +99,7 @@ export const useSessionStore = create()( set({ agentSessions: { active, inactive }, }), + setHostStatuses: (hosts) => set({ hostStatuses: hosts }), updateSession: (session) => set((state) => ({ sessions: state.sessions.map((existing) => diff --git a/src/client/stores/settingsStore.ts b/src/client/stores/settingsStore.ts index d4dec7b..b6c7c2d 100644 --- a/src/client/stores/settingsStore.ts +++ b/src/client/stores/settingsStore.ts @@ -140,6 +140,8 @@ interface SettingsState { setSidebarWidth: (width: number) => void projectFilters: string[] setProjectFilters: (filters: string[]) => void + hostFilters: string[] + setHostFilters: (filters: string[]) => void // Sound notifications soundOnPermission: boolean setSoundOnPermission: (enabled: boolean) => void @@ -205,6 +207,8 @@ export const useSettingsStore = create()( }), projectFilters: [], setProjectFilters: (filters) => set({ projectFilters: filters }), + hostFilters: [], + setHostFilters: (filters) => set({ hostFilters: filters }), // Sound notifications soundOnPermission: false, setSoundOnPermission: (enabled) => set({ soundOnPermission: enabled }), diff --git a/src/client/utils/sessions.ts b/src/client/utils/sessions.ts index 0a4f457..b94f4fd 100644 --- a/src/client/utils/sessions.ts +++ b/src/client/utils/sessions.ts @@ -103,3 +103,38 @@ export function getUniqueProjects( return bTime - aTime }) } + +export function getUniqueHosts( + sessions: Session[], + inactiveSessions: AgentSession[] +): string[] { + const hostActivity = new Map() + + for (const session of sessions) { + const host = session.host?.trim() + if (host) { + const timestamp = Date.parse(session.lastActivity) || 0 + const existing = hostActivity.get(host) || 0 + if (timestamp > existing) { + hostActivity.set(host, timestamp) + } + } + } + + for (const session of inactiveSessions) { + const host = session.host?.trim() + if (host) { + const timestamp = Date.parse(session.lastActivityAt) || 0 + const existing = hostActivity.get(host) || 0 + if (timestamp > existing) { + hostActivity.set(host, timestamp) + } + } + } + + return Array.from(hostActivity.keys()).sort((a, b) => { + const aTime = hostActivity.get(a) || 0 + const bTime = hostActivity.get(b) || 0 + return bTime - aTime + }) +} diff --git a/src/server/SessionRegistry.ts b/src/server/SessionRegistry.ts index 003cf62..13b7d81 100644 --- a/src/server/SessionRegistry.ts +++ b/src/server/SessionRegistry.ts @@ -141,6 +141,8 @@ function sessionsEqual(a: Session, b: Session): boolean { a.agentSessionName === b.agentSessionName && a.logFilePath === b.logFilePath && a.lastUserMessage === b.lastUserMessage && - a.isPinned === b.isPinned + a.isPinned === b.isPinned && + a.host === b.host && + a.remote === b.remote ) } diff --git a/src/server/__tests__/config.test.ts b/src/server/__tests__/config.test.ts index b9dfc88..f8dd160 100644 --- a/src/server/__tests__/config.test.ts +++ b/src/server/__tests__/config.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' +import os from 'node:os' const ORIGINAL_ENV = { PORT: process.env.PORT, @@ -20,6 +21,13 @@ const ORIGINAL_ENV = { CODEX_HOME: process.env.CODEX_HOME, CLAUDE_RESUME_CMD: process.env.CLAUDE_RESUME_CMD, CODEX_RESUME_CMD: process.env.CODEX_RESUME_CMD, + AGENTBOARD_HOST: process.env.AGENTBOARD_HOST, + AGENTBOARD_REMOTE_HOSTS: process.env.AGENTBOARD_REMOTE_HOSTS, + AGENTBOARD_REMOTE_POLL_MS: process.env.AGENTBOARD_REMOTE_POLL_MS, + AGENTBOARD_REMOTE_TIMEOUT_MS: process.env.AGENTBOARD_REMOTE_TIMEOUT_MS, + AGENTBOARD_REMOTE_STALE_MS: process.env.AGENTBOARD_REMOTE_STALE_MS, + AGENTBOARD_REMOTE_SSH_OPTS: process.env.AGENTBOARD_REMOTE_SSH_OPTS, + AGENTBOARD_REMOTE_ALLOW_CONTROL: process.env.AGENTBOARD_REMOTE_ALLOW_CONTROL, } const ENV_KEYS = Object.keys(ORIGINAL_ENV) as Array @@ -58,6 +66,13 @@ async function loadConfig(tag: string) { codexHomeDir: string claudeResumeCmd: string codexResumeCmd: string + hostLabel: string + remoteHosts: string[] + remotePollMs: number + remoteTimeoutMs: number + remoteStaleMs: number + remoteSshOpts: string + remoteAllowControl: boolean } } @@ -89,6 +104,13 @@ describe('config', () => { expect(config.logMatchProfile).toBe(false) expect(config.claudeResumeCmd).toBe('claude --resume {sessionId}') expect(config.codexResumeCmd).toBe('codex resume {sessionId}') + expect(config.hostLabel).toBe(os.hostname()) + expect(config.remoteHosts).toEqual([]) + expect(config.remotePollMs).toBe(15000) + expect(config.remoteTimeoutMs).toBe(4000) + expect(config.remoteStaleMs).toBe(45000) + expect(config.remoteSshOpts).toBe('') + expect(config.remoteAllowControl).toBe(false) }) test('parses env overrides and trims discover prefixes', async () => { @@ -111,6 +133,13 @@ describe('config', () => { process.env.CODEX_HOME = '/tmp/codex' process.env.CLAUDE_RESUME_CMD = 'claude --resume={sessionId}' process.env.CODEX_RESUME_CMD = 'codex --resume={sessionId}' + process.env.AGENTBOARD_HOST = 'blade' + process.env.AGENTBOARD_REMOTE_HOSTS = 'mba,carbon,worm' + process.env.AGENTBOARD_REMOTE_POLL_MS = '12000' + process.env.AGENTBOARD_REMOTE_TIMEOUT_MS = '9000' + process.env.AGENTBOARD_REMOTE_STALE_MS = '50000' + process.env.AGENTBOARD_REMOTE_SSH_OPTS = '-o StrictHostKeyChecking=accept-new' + process.env.AGENTBOARD_REMOTE_ALLOW_CONTROL = 'true' const config = await loadConfig('overrides') expect(config.port).toBe(9090) @@ -132,5 +161,12 @@ describe('config', () => { expect(config.codexHomeDir).toBe('/tmp/codex') expect(config.claudeResumeCmd).toBe('claude --resume={sessionId}') expect(config.codexResumeCmd).toBe('codex --resume={sessionId}') + expect(config.hostLabel).toBe('blade') + expect(config.remoteHosts).toEqual(['mba', 'carbon', 'worm']) + expect(config.remotePollMs).toBe(12000) + expect(config.remoteTimeoutMs).toBe(9000) + expect(config.remoteStaleMs).toBe(50000) + expect(config.remoteSshOpts).toBe('-o StrictHostKeyChecking=accept-new') + expect(config.remoteAllowControl).toBe(true) }) }) diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index 95c8da6..863abe9 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -462,8 +462,14 @@ describe('server message handlers', () => { } websocket.open?.(ws as never) - expect(sent[0]).toEqual({ type: 'sessions', sessions: [baseSession] }) - expect(sent[1]).toMatchObject({ type: 'agent-sessions' }) + expect(sent.find((message) => message.type === 'sessions')).toEqual({ + type: 'sessions', + sessions: [baseSession], + }) + expect(sent.find((message) => message.type === 'host-status')).toBeTruthy() + expect(sent.find((message) => message.type === 'agent-sessions')).toMatchObject({ + type: 'agent-sessions', + }) const nextSession = { ...baseSession, id: 'session-2', name: 'beta' } registryInstance.emit('session-update', nextSession) diff --git a/src/server/agentSessions.ts b/src/server/agentSessions.ts index dd0b890..3da20fb 100644 --- a/src/server/agentSessions.ts +++ b/src/server/agentSessions.ts @@ -1,5 +1,6 @@ import path from 'node:path' import type { AgentSession } from '../shared/types' +import { config } from './config' import type { AgentSessionRecord } from './db' export function toAgentSession(record: AgentSessionRecord): AgentSession { @@ -12,6 +13,7 @@ export function toAgentSession(record: AgentSessionRecord): AgentSession { createdAt: record.createdAt, lastActivityAt: record.lastActivityAt, isActive: record.currentWindow !== null, + host: config.hostLabel, lastUserMessage: record.lastUserMessage ?? undefined, isPinned: record.isPinned, lastResumeError: record.lastResumeError ?? undefined, diff --git a/src/server/config.ts b/src/server/config.ts index 9652797..cd09c83 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,3 +1,4 @@ +import os from 'node:os' import path from 'node:path' const terminalModeRaw = process.env.TERMINAL_MODE @@ -86,9 +87,31 @@ const claudeConfigDir = const codexHomeDir = process.env.CODEX_HOME || path.join(homeDir, '.codex') +const hostLabel = process.env.AGENTBOARD_HOST?.trim() || os.hostname() + +const remoteHosts = (process.env.AGENTBOARD_REMOTE_HOSTS || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + +const remotePollMsRaw = Number(process.env.AGENTBOARD_REMOTE_POLL_MS) +const remotePollMs = Number.isFinite(remotePollMsRaw) ? remotePollMsRaw : 15000 + +const remoteTimeoutMsRaw = Number(process.env.AGENTBOARD_REMOTE_TIMEOUT_MS) +const remoteTimeoutMs = Number.isFinite(remoteTimeoutMsRaw) ? remoteTimeoutMsRaw : 4000 + +const remoteStaleMsRaw = Number(process.env.AGENTBOARD_REMOTE_STALE_MS) +const remoteStaleMs = Number.isFinite(remoteStaleMsRaw) + ? remoteStaleMsRaw + : Math.max(remotePollMs * 3, 15000) + +const remoteSshOpts = process.env.AGENTBOARD_REMOTE_SSH_OPTS || '' +const remoteAllowControl = process.env.AGENTBOARD_REMOTE_ALLOW_CONTROL === 'true' + export const config = { port: Number(process.env.PORT) || 4040, hostname: process.env.HOSTNAME || '0.0.0.0', + hostLabel, tmuxSession: process.env.TMUX_SESSION || 'agentboard', refreshIntervalMs: Number(process.env.REFRESH_INTERVAL_MS) || 2000, discoverPrefixes: (process.env.DISCOVER_PREFIXES || '') @@ -119,4 +142,10 @@ export const config = { skipMatchingPatterns, logLevel, logFile, + remoteHosts, + remotePollMs, + remoteTimeoutMs, + remoteStaleMs, + remoteSshOpts, + remoteAllowControl, } diff --git a/src/server/index.ts b/src/server/index.ts index 872c041..7f2009d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -28,6 +28,7 @@ import { type DirectoryListing, type DirectoryErrorResponse, type AgentSession, + type HostStatus, type ResumeError, type Session, } from '../shared/types' @@ -42,6 +43,7 @@ import { isValidSessionId, isValidTmuxTarget, } from './validators' +import { RemoteSessionPoller } from './remoteSessions' function checkPortAvailable(port: number): void { let result: ReturnType @@ -198,6 +200,70 @@ const sessionManager = new SessionManager(undefined, { }) const registry = new SessionRegistry() +interface WSData { + terminal: ITerminalProxy | null + currentSessionId: string | null + currentTmuxTarget: string | null + connectionId: string +} + +const sockets = new Set>() +const localHostLabel = config.hostLabel + +function stampLocalSession(session: Session): Session { + return { + ...session, + host: localHostLabel, + remote: false, + } +} + +function stampLocalSessions(sessions: Session[]): Session[] { + return sessions.map(stampLocalSession) +} + +function mergeRemoteSessions(sessions: Session[]): Session[] { + const remoteSessions = remotePoller?.getSessions() ?? [] + return [...stampLocalSessions(sessions), ...remoteSessions] +} + +let hostStatuses: HostStatus[] = [] +let hostStatusSnapshot = '' + +function updateHostStatuses(remoteStatuses: HostStatus[]) { + const next = [ + { + host: localHostLabel, + ok: true, + lastUpdated: new Date().toISOString(), + }, + ...remoteStatuses, + ] + const snapshot = JSON.stringify(next) + if (snapshot === hostStatusSnapshot) return + hostStatusSnapshot = snapshot + hostStatuses = next + broadcast({ type: 'host-status', hosts: hostStatuses }) +} + +const remotePoller = config.remoteHosts.length > 0 + ? new RemoteSessionPoller({ + hosts: config.remoteHosts, + pollIntervalMs: config.remotePollMs, + timeoutMs: config.remoteTimeoutMs, + staleAfterMs: config.remoteStaleMs, + sshOptions: config.remoteSshOpts, + onUpdate: (statuses) => updateHostStatuses(statuses), + }) + : null + +if (remotePoller) { + remotePoller.start() + updateHostStatuses(remotePoller.getHostStatuses()) +} else { + updateHostStatuses([]) +} + // Lock map for Enter-key lastUserMessage capture: tmuxWindow -> expiry timestamp // Prevents stale log data from overwriting fresh terminal captures const lastUserMessageLocks = new Map() @@ -229,15 +295,6 @@ const logPoller = new LogPoller(db, registry, { }) const sessionRefreshWorker = new SessionRefreshWorkerClient() -interface WSData { - terminal: ITerminalProxy | null - currentSessionId: string | null - currentTmuxTarget: string | null - connectionId: string -} - -const sockets = new Set>() - function updateAgentSessions() { const active = db.getActiveSessions().map(toAgentSession) let inactive = db.getInactiveSessions({ maxAgeHours: runtimeInactiveMaxAgeHours }).map(toAgentSession) @@ -401,7 +458,7 @@ async function refreshSessionsAsync(): Promise { ) const hydrated = hydrateSessionsWithAgentSessions(sessions) const withOverrides = applyForceWorkingOverrides(hydrated) - registry.replaceSessions(withOverrides) + registry.replaceSessions(mergeRemoteSessions(withOverrides)) } catch (error) { // Fallback to sync on worker failure logger.warn('session_refresh_worker_error', { @@ -410,7 +467,7 @@ async function refreshSessionsAsync(): Promise { const sessions = sessionManager.listWindows() const hydrated = hydrateSessionsWithAgentSessions(sessions) const withOverrides = applyForceWorkingOverrides(hydrated) - registry.replaceSessions(withOverrides) + registry.replaceSessions(mergeRemoteSessions(withOverrides)) } finally { refreshInFlight = false } @@ -424,7 +481,7 @@ function refreshSessions() { function refreshSessionsSync({ verifyAssociations = false } = {}) { const sessions = sessionManager.listWindows() const hydrated = hydrateSessionsWithAgentSessions(sessions, { verifyAssociations }) - registry.replaceSessions(hydrated) + registry.replaceSessions(mergeRemoteSessions(hydrated)) } // Debounced refresh triggered by Enter key in terminal input @@ -834,6 +891,7 @@ Bun.serve({ open(ws) { sockets.add(ws) send(ws, { type: 'sessions', sessions: registry.getAll() }) + send(ws, { type: 'host-status', hosts: hostStatuses }) const agentSessions = registry.getAgentSessions() send(ws, { type: 'agent-sessions', @@ -868,6 +926,7 @@ function cleanupAllTerminals() { cleanupTerminals(ws) } logPoller.stop() + remotePoller?.stop() db.close() } @@ -924,11 +983,11 @@ function handleMessage( return case 'session-create': try { - const created = sessionManager.createWindow( + const created = stampLocalSession(sessionManager.createWindow( message.projectPath, message.name, message.command - ) + )) // Add session to registry immediately so terminal can attach const currentSessions = registry.getAll() registry.replaceSessions([created, ...currentSessions]) @@ -997,6 +1056,7 @@ function resolveCopyModeTarget( function handleCancelCopyMode(sessionId: string, ws: ServerWebSocket) { const session = registry.get(sessionId) if (!session) return + if (session.remote) return try { // Exit tmux copy-mode quietly. @@ -1013,6 +1073,7 @@ function handleCancelCopyMode(sessionId: string, ws: ServerWebSocket) { function handleCheckCopyMode(sessionId: string, ws: ServerWebSocket) { const session = registry.get(sessionId) if (!session) return + if (session.remote) return try { const target = resolveCopyModeTarget(sessionId, ws, session) @@ -1036,6 +1097,10 @@ function handleKill(sessionId: string, ws: ServerWebSocket) { send(ws, { type: 'kill-failed', sessionId, message: 'Session not found' }) return } + if (session.remote) { + send(ws, { type: 'kill-failed', sessionId, message: 'Remote sessions are read-only' }) + return + } if (session.source !== 'managed' && !config.allowKillExternal) { send(ws, { type: 'kill-failed', sessionId, message: 'Cannot kill external sessions' }) return @@ -1090,6 +1155,10 @@ function handleRename( return } } + if (session.remote) { + send(ws, { type: 'error', message: 'Remote sessions are read-only' }) + return + } try { sessionManager.renameWindow(session.tmuxWindow, newName) @@ -1287,12 +1356,12 @@ function handleSessionResume( '.' try { - const created = sessionManager.createWindow( + const created = stampLocalSession(sessionManager.createWindow( projectPath, message.name ?? record.displayName, command, { excludeSessionId: sessionId } - ) + )) db.updateSession(sessionId, { currentWindow: created.tmuxWindow, displayName: created.name, @@ -1406,6 +1475,10 @@ async function attachTerminalPersistent( sendTerminalError(ws, sessionId, 'ERR_INVALID_WINDOW', 'Session not found', false) return } + if (session.remote) { + sendTerminalError(ws, sessionId, 'ERR_INVALID_WINDOW', 'Remote sessions are read-only', false) + return + } const target = tmuxTarget ?? session.tmuxWindow if (!isValidTmuxTarget(target)) { @@ -1532,4 +1605,3 @@ function handleTerminalError( error instanceof Error ? error.message : 'Terminal operation failed' sendTerminalError(ws, sessionId, fallbackCode, message, true) } - diff --git a/src/server/remoteSessions.ts b/src/server/remoteSessions.ts new file mode 100644 index 0000000..f72bfc3 --- /dev/null +++ b/src/server/remoteSessions.ts @@ -0,0 +1,255 @@ +import { inferAgentType } from './agentDetection' +import { logger } from './logger' +import type { HostStatus, Session } from '../shared/types' + +const DEFAULT_SSH_OPTIONS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=3'] +const TMUX_LIST_FORMAT = + '#{session_name}\\t#{window_index}\\t#{window_id}\\t#{window_name}\\t#{pane_current_path}\\t#{window_activity}\\t#{window_creation_time}\\t#{pane_start_command}' + +interface RemoteHostSnapshot { + host: string + sessions: Session[] + ok: boolean + error?: string + updatedAt: number +} + +export interface RemoteSessionPollerOptions { + hosts: string[] + pollIntervalMs: number + timeoutMs: number + staleAfterMs: number + sshOptions?: string + onUpdate?: (hosts: HostStatus[]) => void +} + +export class RemoteSessionPoller { + private readonly hosts: string[] + private readonly pollIntervalMs: number + private readonly timeoutMs: number + private readonly staleAfterMs: number + private readonly sshOptions: string[] + private readonly onUpdate?: (hosts: HostStatus[]) => void + private timer: Timer | null = null + private inFlight = false + private lastStatusSnapshot = '' + private snapshots = new Map() + + constructor(options: RemoteSessionPollerOptions) { + this.hosts = options.hosts + this.pollIntervalMs = options.pollIntervalMs + this.timeoutMs = options.timeoutMs + this.staleAfterMs = options.staleAfterMs + this.sshOptions = [ + ...DEFAULT_SSH_OPTIONS, + ...splitSshOptions(options.sshOptions ?? ''), + ] + this.onUpdate = options.onUpdate + } + + start(): void { + if (this.timer || this.hosts.length === 0) { + return + } + void this.poll() + this.timer = setInterval(() => { + void this.poll() + }, this.pollIntervalMs) + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + getSessions(): Session[] { + const now = Date.now() + const sessions: Session[] = [] + for (const snapshot of this.snapshots.values()) { + if (!snapshot.ok) continue + if (now - snapshot.updatedAt > this.staleAfterMs) continue + sessions.push(...snapshot.sessions) + } + return sessions + } + + getHostStatuses(): HostStatus[] { + const now = Date.now() + return this.hosts.map((host) => { + const snapshot = this.snapshots.get(host) + if (!snapshot) { + return { + host, + ok: false, + lastUpdated: new Date(0).toISOString(), + } + } + const stale = now - snapshot.updatedAt > this.staleAfterMs + const ok = snapshot.ok && !stale + return { + host, + ok, + lastUpdated: new Date(snapshot.updatedAt).toISOString(), + error: ok ? undefined : snapshot.error ?? (stale ? 'stale' : undefined), + } + }) + } + + private async poll(): Promise { + if (this.inFlight) { + return + } + this.inFlight = true + try { + const results = await Promise.allSettled( + this.hosts.map((host) => pollHost(host, this.sshOptions, this.timeoutMs)) + ) + + results.forEach((result, index) => { + const host = this.hosts[index] + if (!host) return + if (result.status === 'fulfilled') { + this.snapshots.set(host, result.value) + } else { + this.snapshots.set(host, { + host, + sessions: [], + ok: false, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + updatedAt: Date.now(), + }) + } + }) + + const statuses = this.getHostStatuses() + const nextSnapshot = JSON.stringify(statuses) + if (nextSnapshot !== this.lastStatusSnapshot) { + this.lastStatusSnapshot = nextSnapshot + this.onUpdate?.(statuses) + } + } finally { + this.inFlight = false + } + } +} + +async function pollHost( + host: string, + sshOptions: string[], + timeoutMs: number +): Promise { + const startedAt = Date.now() + const args = ['ssh', ...sshOptions, host, 'tmux', 'list-windows', '-a', '-F', TMUX_LIST_FORMAT] + const proc = Bun.spawn(args, { stdout: 'pipe', stderr: 'pipe' }) + + const timeout = setTimeout(() => { + try { + proc.kill() + } catch { + // ignore + } + }, timeoutMs) + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + clearTimeout(timeout) + + if (exitCode !== 0) { + const message = stderr.trim() || `ssh exited with code ${exitCode}` + logger.warn('remote_host_poll_failed', { host, message }) + return { + host, + sessions: [], + ok: false, + error: message, + updatedAt: Date.now(), + } + } + + const sessions = parseTmuxWindows(host, stdout) + return { + host, + sessions, + ok: true, + updatedAt: Date.now(), + } +} + +function parseTmuxWindows(host: string, output: string): Session[] { + const lines = output.split('\n').map((line) => line.trim()).filter(Boolean) + const now = Date.now() + const sessions: Session[] = [] + + for (const line of lines) { + const parts = line.split('\\t') + if (parts.length < 8) { + continue + } + const [ + sessionName, + windowIndex, + windowId, + windowName, + cwd, + activityRaw, + createdRaw, + command, + ] = parts + + if (!sessionName || !windowIndex) { + continue + } + + const tmuxWindow = `${sessionName}:${windowIndex}` + const createdAt = toIsoFromSeconds(createdRaw, now) + const lastActivity = toIsoFromSeconds(activityRaw, now) + const agentType = inferAgentType(command || '') + const id = buildRemoteSessionId(host, sessionName, windowIndex, windowId) + + sessions.push({ + id, + name: windowName || tmuxWindow, + tmuxWindow, + projectPath: (cwd || '').trim(), + status: 'unknown', + lastActivity, + createdAt, + agentType, + source: 'external', + host, + remote: true, + command: command || undefined, + }) + } + + return sessions +} + +function buildRemoteSessionId( + host: string, + sessionName: string, + windowIndex: string, + windowId?: string +): string { + const suffix = windowId?.trim() ? windowId.trim() : windowIndex.trim() + return `remote:${host}:${sessionName}:${suffix}` +} + +function toIsoFromSeconds(value: string | undefined, fallbackMs: number): string { + const parsed = Number.parseInt(value ?? '', 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + return new Date(fallbackMs).toISOString() + } + return new Date(parsed * 1000).toISOString() +} + +function splitSshOptions(value: string): string[] { + if (!value.trim()) return [] + return value.split(/\s+/).map((part) => part.trim()).filter(Boolean) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 2f62f85..460c893 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -24,6 +24,8 @@ export interface Session { createdAt: string agentType?: AgentType source: SessionSource + host?: string + remote?: boolean command?: string agentSessionId?: string agentSessionName?: string @@ -41,11 +43,19 @@ export interface AgentSession { createdAt: string lastActivityAt: string isActive: boolean + host?: string lastUserMessage?: string isPinned?: boolean lastResumeError?: string } +export interface HostStatus { + host: string + ok: boolean + lastUpdated: string + error?: string +} + // Directory browser types export interface DirectoryEntry { name: string @@ -69,6 +79,7 @@ export type ServerMessage = | { type: 'session-update'; session: Session } | { type: 'session-created'; session: Session } | { type: 'session-removed'; sessionId: string } + | { type: 'host-status'; hosts: HostStatus[] } | { type: 'agent-sessions'; active: AgentSession[]; inactive: AgentSession[] } | { type: 'session-orphaned'; session: AgentSession } | { type: 'session-activated'; session: AgentSession; window: string }