Show live agent icons for terminal helper processes

This commit is contained in:
brennanb2025
2026-06-26 14:54:10 -07:00
parent d188e7d2f6
commit c15282be2f
4 changed files with 175 additions and 27 deletions
+36
View File
@@ -352,6 +352,42 @@ describe('createPtySubprocess', () => {
}
})
it('serves Unix agent foreground when node-pty reports an agent child process', async () => {
const proc = mockPtyProcess()
proc.process = 'uv'
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'darwin' })
let resolveForeground!: (processName: string) => void
resolveAgentForegroundProcessMock.mockReturnValue(
new Promise<string>((resolve) => {
resolveForeground = resolve
})
)
try {
const handle = createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24
})
expect(handle.getForegroundProcess()).toBe('uv')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(
proc.pid,
'uv',
expect.any(Object)
)
resolveForeground('claude')
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('claude'))
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
})
it('does not let stale shell enrichment clear a newer direct agent foreground cache', async () => {
const proc = mockPtyProcess()
proc.process = 'powershell.exe'
+6 -2
View File
@@ -732,7 +732,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
}
const shouldInspectFallbackForegroundProcess = (fallbackProcess: string | null): boolean =>
fallbackProcess !== null &&
(isShellProcess(fallbackProcess) || isAgentForegroundWrapperProcess(fallbackProcess))
(isShellProcess(fallbackProcess) ||
isAgentForegroundWrapperProcess(fallbackProcess) ||
// Why: agent-spawned helper processes can become the PTY foreground
// child; the Unix process tree can still identify the parent agent.
process.platform !== 'win32')
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
if (dead || !proc.pid) {
return
@@ -756,7 +760,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
// Why: daemon foreground reads are sync and run on the IPC hot path.
// Refresh shell/wrapper-derived identities (powershell/node -> codex/etc.)
// Refresh derived identities (shell/wrapper/helper -> codex/claude/etc.)
// in the background and serve them from a short cache on later reads.
void resolveAgentForegroundProcess(proc.pid, fallbackProcess, {
contextPaths: agentForegroundContextPaths
@@ -591,6 +591,85 @@ describe('useTabAgent', () => {
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
})
it('retries helper foreground so daemon-derived agent beats stale launch identity', async () => {
vi.useFakeTimers()
getForegroundProcess.mockResolvedValueOnce('uv').mockResolvedValueOnce('claude')
try {
await renderHookProbe({ ...baseTab, launchAgent: 'opencode' })
expect(latestHookAgent).toBe('opencode')
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
await flushHookEffects()
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
expect(latestHookAgent).toBe('claude')
} finally {
vi.clearAllTimers()
vi.useRealTimers()
}
})
it('does not retry unknown helper foreground without launch intent', async () => {
vi.useFakeTimers()
getForegroundProcess.mockResolvedValue('uv')
try {
await renderHookProbe({ ...baseTab, launchAgent: undefined })
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
await flushHookEffects()
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
expect(latestHookAgent).toBeNull()
} finally {
vi.clearAllTimers()
vi.useRealTimers()
}
})
it('keeps one post-throttle shell retry to observe daemon-derived launch identity', async () => {
vi.useFakeTimers()
getForegroundProcess
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('zsh')
.mockResolvedValueOnce('claude')
try {
await renderHookProbe({ ...baseTab, launchAgent: 'opencode' })
expect(latestHookAgent).toBe('opencode')
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
await act(async () => {
await vi.advanceTimersByTimeAsync(250 + 1250 + 3500)
})
await flushHookEffects()
expect(getForegroundProcess).toHaveBeenCalledTimes(4)
expect(latestHookAgent).toBe('opencode')
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
await flushHookEffects()
expect(getForegroundProcess).toHaveBeenCalledTimes(5)
expect(latestHookAgent).toBe('claude')
} finally {
vi.clearAllTimers()
vi.useRealTimers()
}
})
it('uses completed local hook status as launch lifecycle evidence after remount', async () => {
const paneKey = makePaneKey('tab-1', LEAF_ID)
getForegroundProcess.mockResolvedValueOnce('zsh')
+54 -25
View File
@@ -34,6 +34,8 @@ const TITLE_LABEL_TO_AGENT: Partial<Record<string, TuiAgent>> = {
Pi: 'pi'
}
const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const
function agentFromTitle(title: string): TuiAgent | null {
const label = getAgentLabel(title)
return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null
@@ -234,42 +236,69 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
if (!ptyId || isRemoteLike) {
return
}
const localPtyId = ptyId
let cancelled = false
const helperForegroundRetryTimers: number[] = []
// Why: re-runs when ptyId or tab.title changes — a title change is the event
// signalling a possible foreground transition (agent start, exit, or turn).
// One RPC per transition, not a timer; cancellation coalesces rapid churn.
window.api.pty
.getForegroundProcess(ptyId)
.then((process) => {
if (cancelled) {
return
function readForeground(retryIndex = 0): void {
window.api.pty
.getForegroundProcess(localPtyId)
.then((process) => {
applyForegroundProcess(process, retryIndex)
})
.catch(() => {
if (!cancelled) {
setForeground(undefined)
}
})
}
function scheduleHelperForegroundRetry(retryIndex: number): void {
const delay = HELPER_FOREGROUND_RETRY_DELAYS_MS[retryIndex]
if (delay === undefined) {
return
}
// Why: the daemon resolves shell/helper -> agent ancestry asynchronously
// after the first foreground read, so give its short cache a bounded re-read.
const timer = window.setTimeout(() => {
readForeground(retryIndex + 1)
}, delay)
helperForegroundRetryTimers.push(timer)
}
function applyForegroundProcess(process: string | null, retryIndex: number): void {
if (cancelled) {
return
}
const recognized = recognizeAgentProcess(process)
if (recognized) {
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
setForeground(recognized.agent)
} else if (process && isShellProcess(process)) {
setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current)
setForeground(null)
if (tab.launchAgent && !hasObservedAgentSignalRef.current) {
scheduleHelperForegroundRetry(retryIndex)
}
const recognized = recognizeAgentProcess(process)
if (recognized) {
} else {
if (process && tab.launchAgent) {
// Why: for Orca-owned launches, an unrecognized non-shell process
// is enough lifecycle evidence to clear launch intent when the pane
// later returns to a shell, without using title text as identity.
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
setForeground(recognized.agent)
} else if (process && isShellProcess(process)) {
setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current)
setForeground(null)
} else {
if (process && tab.launchAgent) {
// Why: for Orca-owned launches, an unrecognized non-shell process
// is enough lifecycle evidence to clear launch intent when the pane
// later returns to a shell, without using title text as identity.
hasObservedAgentSignalRef.current = true
setHasObservedAgentSignal(true)
}
setForeground(undefined)
}
})
.catch(() => {
if (!cancelled) {
setForeground(undefined)
setForeground(undefined)
if (process && tab.launchAgent) {
scheduleHelperForegroundRetry(retryIndex)
}
})
}
}
readForeground()
return () => {
cancelled = true
helperForegroundRetryTimers.forEach((timer) => window.clearTimeout(timer))
}
}, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey])