Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/main/ipc/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'path'
import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { loadToken } from '../linear/client'
import { getActiveMultiplexer } from './ssh'

const execFileAsync = promisify(execFile)

Expand Down Expand Up @@ -150,4 +151,22 @@ export function registerPreflightHandlers(): void {
ipcMain.handle('preflight:refreshAgents', async (): Promise<RefreshAgentsResult> => {
return refreshShellPathAndDetectAgents()
})

// Why: remote worktrees need agent detection on the SSH host, not the local
// machine. This handler forwards the same KNOWN_AGENT_COMMANDS list to the
// relay's preflight.detectAgents RPC, which runs `which` inside a login shell
// on the remote host to match the PATH users see in PTY sessions.
ipcMain.handle(
'preflight:detectRemoteAgents',
async (_event, args: { connectionId: string }): Promise<string[]> => {
const mux = getActiveMultiplexer(args.connectionId)
if (!mux || mux.isDisposed()) {
throw new Error(`No active SSH connection for "${args.connectionId}"`)
}
const result = (await mux.request('preflight.detectAgents', {
commands: KNOWN_AGENT_COMMANDS
})) as { agents: string[] }
return result.agents
}
)
}
1 change: 1 addition & 0 deletions src/preload/api-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export type PreflightApi = {
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
detectAgents: () => Promise<string[]>
refreshAgents: () => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
}

export type ExportApi = {
Expand Down
4 changes: 3 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,9 @@ const api = {
agents: string[]
addedPathSegments: string[]
shellHydrationOk: boolean
}> => ipcRenderer.invoke('preflight:refreshAgents')
}> => ipcRenderer.invoke('preflight:refreshAgents'),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectRemoteAgents', args)
},

notifications: {
Expand Down
57 changes: 57 additions & 0 deletions src/relay/preflight-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import type { RelayDispatcher } from './dispatcher'

const execFileAsync = promisify(execFile)

export class PreflightHandler {
private dispatcher: RelayDispatcher

constructor(dispatcher: RelayDispatcher) {
this.dispatcher = dispatcher
this.registerHandlers()
}

private registerHandlers(): void {
this.dispatcher.onRequest('preflight.detectAgents', (p) => this.detectAgents(p))
}

// Why: the client sends the command list rather than importing TUI_AGENT_CONFIG
// on the relay side. This keeps the relay bundle minimal and makes the protocol
// self-describing — the relay doesn't need to know the agent catalog.
private async detectAgents(params: Record<string, unknown>): Promise<{ agents: string[] }> {
const commands = params.commands as { id: string; cmd: string }[]
if (!Array.isArray(commands)) {
return { agents: [] }
}

const results = await Promise.all(
commands.map(async ({ id, cmd }) => ({
id,
installed: await this.isCommandOnPath(cmd)
}))
)

return { agents: results.filter((r) => r.installed).map((r) => r.id) }
}

// Why: SSH exec channels give the relay a minimal environment without
// .zprofile/.bash_profile sourced. Running `which` directly would miss
// agents installed via Homebrew, nvm, cargo, pipx, etc. Spawning a login
// shell (`-lc`) ensures PATH matches what the user's PTY sessions see.
private async isCommandOnPath(command: string): Promise<boolean> {
try {
const { stdout } = await execFileAsync('/bin/sh', ['-lc', `which ${command}`], {
encoding: 'utf-8',
timeout: 5000
})
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.some((line) => path.isAbsolute(line))
} catch {
return false
}
}
}
4 changes: 4 additions & 0 deletions src/relay/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RelayContext } from './context'
import { PtyHandler } from './pty-handler'
import { FsHandler } from './fs-handler'
import { GitHandler } from './git-handler'
import { PreflightHandler } from './preflight-handler'

const DEFAULT_GRACE_MS = 5 * 60 * 1000

Expand Down Expand Up @@ -61,6 +62,9 @@ function main(): void {
const _gitHandler = new GitHandler(dispatcher, context)
void _gitHandler

const _preflightHandler = new PreflightHandler(dispatcher)
void _preflightHandler

// Read framed binary data from stdin
process.stdin.on('data', (chunk: Buffer) => {
ptyHandler.cancelGraceTimer()
Expand Down
15 changes: 14 additions & 1 deletion src/renderer/src/components/tab-bar/QuickLaunchButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,20 @@ function QuickLaunchAgentMenuItemsInner({
groupId,
onFocusTerminal
}: QuickLaunchAgentMenuItemsProps): React.JSX.Element | null {
const { detectedIds } = useDetectedAgents()
// Why: must be a reactive selector (not getConnectionId() which reads a
// snapshot via getState()). This ensures the component re-renders when the
// SSH connection state changes. Returns undefined when the worktree isn't
// found (store not hydrated), null for local repos, string for remote.
const connectionId = useAppStore((s) => {
const allWorktrees = Object.values(s.worktreesByRepo ?? {}).flat()
const worktree = allWorktrees.find((w) => w.id === worktreeId)
if (!worktree) {
return undefined
}
const repo = s.repos?.find((r) => r.id === worktree.repoId)
return repo?.connectionId ?? null
})
const { detectedIds } = useDetectedAgents(connectionId)
const defaultAgent = useAppStore((s) => s.settings?.defaultTuiAgent)
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
Expand Down
31 changes: 23 additions & 8 deletions src/renderer/src/hooks/useComposerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,21 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [tuiAgent, setTuiAgent] = useState<TuiAgent>(
persistDraft ? (newWorkspaceDraft?.agent ?? fallbackDefaultAgent) : fallbackDefaultAgent
)
const detectedAgentList = useAppStore((s) => s.detectedAgentIds)
// Why: when the selected repo is remote (has a connectionId), read the
// per-connection agent list instead of the local one. This ensures the
// Create Workspace dialog shows agents installed on the SSH host, not the
// local machine. Derived from eligibleRepos directly because selectedRepo
// is declared later in this function.
const connectionId = eligibleRepos.find((r) => r.id === repoId)?.connectionId ?? null
const isRemote = typeof connectionId === 'string'
const detectedAgentList = useAppStore((s) => {
if (isRemote) {
return s.remoteDetectedAgentIds[connectionId] ?? null
}
return s.detectedAgentIds
})
const ensureDetectedAgents = useAppStore((s) => s.ensureDetectedAgents)
const ensureRemoteDetectedAgents = useAppStore((s) => s.ensureRemoteDetectedAgents)
const detectedAgentIds = useMemo<Set<TuiAgent> | null>(
() => (detectedAgentList ? new Set(detectedAgentList) : null),
[detectedAgentList]
Expand Down Expand Up @@ -419,12 +432,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [eligibleRepos, repoId, setRepoId])

// Detect installed agents once on mount via the shared store slice so the
// page composer, quick-composer modal, settings pane, and tab-bar quick-launch
// share a single IPC round-trip and stay in sync on refresh.
// Why: detect agents for the selected repo. For local repos this runs once
// on mount (deduped by the store). For remote repos it re-runs when the
// selected repo changes so the agent list matches the SSH host.
useEffect(() => {
let cancelled = false
void ensureDetectedAgents().then((ids) => {
const detect = isRemote ? ensureRemoteDetectedAgents(connectionId) : ensureDetectedAgents()
void detect.then((ids) => {
if (cancelled) {
return
}
Expand All @@ -438,10 +452,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
return () => {
cancelled = true
}
// Why: intentionally run only once on mount — detection is a best-effort
// PATH snapshot and does not need to re-run when the draft or settings change.
// Why: re-run when connectionId changes (user picks a different repo) so
// detection targets the correct host. Draft/settings deps are intentionally
// excluded — detection is a best-effort PATH snapshot.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [connectionId, isRemote])

// Per-repo: load yaml hooks + issue command template.
useEffect(() => {
Expand Down
56 changes: 48 additions & 8 deletions src/renderer/src/hooks/useDetectedAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,59 @@ export type UseDetectedAgentsResult = {
* `detect-agents-cached.ts` each ran their own detection. A tab-bar button
* that doesn't refresh when Settings → Agents refreshes would feel broken;
* centralizing the state eliminates multi-owner drift.
*
* @param connectionId — Pass a string to detect agents on a remote SSH host.
* Pass null for local detection. Pass undefined (or omit) when the connection
* context is not yet known (store not hydrated) — returns loading state.
* Backward-compatible: all existing callers pass no argument.
*/
export function useDetectedAgents(): UseDetectedAgentsResult {
const detectedIds = useAppStore((s) => s.detectedAgentIds)
const isLoading = useAppStore((s) => s.isDetectingAgents)
const isRefreshing = useAppStore((s) => s.isRefreshingAgents)
const ensure = useAppStore((s) => s.ensureDetectedAgents)
export function useDetectedAgents(
connectionId: string | null | undefined = null
): UseDetectedAgentsResult {
// Why: undefined means "store not yet hydrated" — we don't know if the
// worktree is local or remote yet. null means confirmed-local. string means
// confirmed-remote. This three-way distinction prevents flashing local agents
// for remote worktrees during hydration.
const isRemote = typeof connectionId === 'string'
const isUnknown = connectionId === undefined

const detectedIds = useAppStore((s) => {
if (isUnknown) {
return null
}
if (isRemote) {
return s.remoteDetectedAgentIds[connectionId] ?? null
}
return s.detectedAgentIds
})
const isLoading = useAppStore((s) => {
if (isUnknown) {
return true
}
if (isRemote) {
return s.isDetectingRemoteAgents[connectionId] ?? false
}
return s.isDetectingAgents
})
const isRefreshing = useAppStore((s) => (isRemote || isUnknown ? false : s.isRefreshingAgents))
const ensureLocal = useAppStore((s) => s.ensureDetectedAgents)
const ensureRemote = useAppStore((s) => s.ensureRemoteDetectedAgents)
const refresh = useAppStore((s) => s.refreshDetectedAgents)

useEffect(() => {
if (detectedIds === null) {
void ensure()
if (isUnknown) {
return
}
if (isRemote) {
if (detectedIds === null) {
void ensureRemote(connectionId)
}
} else {
if (detectedIds === null) {
void ensureLocal()
}
}
}, [detectedIds, ensure])
}, [isRemote, isUnknown, connectionId, detectedIds, ensureLocal, ensureRemote])

return { detectedIds, isLoading, isRefreshing, refresh }
}
2 changes: 2 additions & 0 deletions src/renderer/src/hooks/useIpcEvents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ describe('useIpcEvents updater integration', () => {
setSshTargetLabels: vi.fn(),
enqueueSshCredentialRequest: vi.fn(),
removeSshCredentialRequest: vi.fn(),
clearRemoteDetectedAgents: vi.fn(),
clearTabPtyId,
repos: [{ id: 'repo-1', connectionId: 'conn-1' }],
worktreesByRepo: {
Expand Down Expand Up @@ -399,6 +400,7 @@ describe('useIpcEvents updater integration', () => {
)
expect(clearTabPtyId).toHaveBeenCalledWith('tab-1')
expect(clearTabPtyId).not.toHaveBeenCalledWith('tab-2')
expect(storeState.clearRemoteDetectedAgents).toHaveBeenCalledWith('conn-1')
})

it('activates the target worktree when CLI creates a terminal there', async () => {
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/hooks/useIpcEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,12 @@ export function useIpcEvents(): void {
if (
['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)
) {
// Why: the remote agent list is tied to a live SSH connection. On
// disconnect the relay is gone, so clear the cached list and dedup
// promise. When the user reconnects and opens the quick-launch menu,
// ensureRemoteDetectedAgents will re-detect against the new relay.
store.clearRemoteDetectedAgents(data.targetId)

// Why: an explicit disconnect or terminal failure tears down the SSH
// PTY provider without emitting per-PTY exit events. Clear the stale
// PTY ids in renderer state so a later reconnect remounts TerminalPane
Expand Down
62 changes: 62 additions & 0 deletions src/renderer/src/store/slices/detected-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,21 @@ export type DetectedAgentsSlice = {
* receive the same pending promise; store fields update once on resolve so
* every subscribed surface re-renders in the same tick. */
refreshDetectedAgents: () => Promise<TuiAgent[]>

// Why: remote worktrees need per-connection agent detection. The local
// detectedAgentIds field is connection-unaware, so remote state lives in a
// separate map keyed by SSH connectionId.
remoteDetectedAgentIds: Record<string, TuiAgent[] | null>
isDetectingRemoteAgents: Record<string, boolean>
ensureRemoteDetectedAgents: (connectionId: string) => Promise<TuiAgent[]>
clearRemoteDetectedAgents: (connectionId: string) => void
}

// Why: these are module-scoped (not in the store) so we can deduplicate
// concurrent callers without storing a Promise in Zustand state.
let detectPromise: Promise<TuiAgent[]> | null = null
let refreshPromise: Promise<TuiAgent[]> | null = null
const remoteDetectPromises = new Map<string, Promise<TuiAgent[]>>()

export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedAgentsSlice> = (
set,
Expand Down Expand Up @@ -79,5 +88,58 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
})
refreshPromise = pending
return pending
},

remoteDetectedAgentIds: {},
isDetectingRemoteAgents: {},

ensureRemoteDetectedAgents: (connectionId: string) => {
const existing = get().remoteDetectedAgentIds[connectionId]
if (existing) {
return Promise.resolve(existing)
}
const inflight = remoteDetectPromises.get(connectionId)
if (inflight) {
return inflight
}

set((s) => ({
isDetectingRemoteAgents: { ...s.isDetectingRemoteAgents, [connectionId]: true }
}))

const pending = window.api.preflight
.detectRemoteAgents({ connectionId })
.then((ids) => {
const typed = ids as TuiAgent[]
set((s) => ({
remoteDetectedAgentIds: { ...s.remoteDetectedAgentIds, [connectionId]: typed },
isDetectingRemoteAgents: { ...s.isDetectingRemoteAgents, [connectionId]: false }
}))
return typed
})
.catch(() => {
// Why: allow retry on next call (SSH may reconnect). Do not cache failure.
remoteDetectPromises.delete(connectionId)
set((s) => ({
isDetectingRemoteAgents: { ...s.isDetectingRemoteAgents, [connectionId]: false }
}))
return [] as TuiAgent[]
})

remoteDetectPromises.set(connectionId, pending)
return pending
},

// Why: the remote agent list is tied to a live SSH connection. On disconnect
// the relay is gone, so clear both the cached result and the deduplication
// promise. When the user reconnects and opens the quick-launch menu,
// ensureRemoteDetectedAgents will re-detect against the new relay.
clearRemoteDetectedAgents: (connectionId: string) => {
remoteDetectPromises.delete(connectionId)
set((s) => {
const { [connectionId]: _, ...restAgents } = s.remoteDetectedAgentIds
const { [connectionId]: __, ...restLoading } = s.isDetectingRemoteAgents
return { remoteDetectedAgentIds: restAgents, isDetectingRemoteAgents: restLoading }
})
}
})
Loading