From c15282be2fad0441ce3154d1a5ea77858e068395 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Fri, 26 Jun 2026 14:54:10 -0700 Subject: [PATCH] Show live agent icons for terminal helper processes --- src/main/daemon/pty-subprocess.test.ts | 36 ++++++++++ src/main/daemon/pty-subprocess.ts | 8 ++- src/renderer/src/lib/use-tab-agent.test.ts | 79 ++++++++++++++++++++++ src/renderer/src/lib/use-tab-agent.ts | 79 +++++++++++++++------- 4 files changed, 175 insertions(+), 27 deletions(-) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 0ee5216428b..3014e03b0f5 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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((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' diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index d7ccda8b459..b0f0084f7b3 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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 diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 4706ba32c81..c2de76a3571 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -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') diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index dd1c55a43a5..e05c7110308 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -34,6 +34,8 @@ const TITLE_LABEL_TO_AGENT: Partial> = { 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])