mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
- Register with bash-preexec's preexec_functions array to prevent its DEBUG trap re-arming from silencing Orca's command-start signals. - Serve the last-resolved identity past its cache TTL (stale-while- revalidate) while the active foreground process is a wrapper. - Reschedule confirming reads on duplicate OSC 133;D sequences to prevent nested shells from prematurely clearing tab identities. - Trigger self-limiting foreground sampling on visible PTY binding, Enter keystrokes, and pane focus changes.
1201 lines
52 KiB
Diff
1201 lines
52 KiB
Diff
diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts
|
|
index 85366a137..b23c83656 100644
|
|
--- a/src/main/daemon/pty-subprocess.test.ts
|
|
+++ b/src/main/daemon/pty-subprocess.test.ts
|
|
@@ -367,6 +367,81 @@ describe('createPtySubprocess', () => {
|
|
}
|
|
})
|
|
|
|
+ it('serves the resolved agent identity past the cache TTL while a wrapper holds the foreground', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z'))
|
|
+ const proc = mockPtyProcess()
|
|
+ proc.process = 'node'
|
|
+ spawnMock.mockReturnValue(proc)
|
|
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
+ Object.defineProperty(process, 'platform', { value: 'darwin' })
|
|
+ resolveAgentForegroundProcessMock.mockResolvedValue('grok')
|
|
+
|
|
+ try {
|
|
+ const handle = createPtySubprocess({
|
|
+ sessionId: 'test',
|
|
+ cols: 80,
|
|
+ rows: 24
|
|
+ })
|
|
+
|
|
+ expect(handle.getForegroundProcess()).toBe('node')
|
|
+ await Promise.resolve()
|
|
+ await Promise.resolve()
|
|
+ expect(handle.getForegroundProcess()).toBe('grok')
|
|
+
|
|
+ // Why: renderer reads poll slower than the 1s cache TTL — an expired
|
|
+ // cache must keep answering with the resolved identity, not the wrapper.
|
|
+ vi.advanceTimersByTime(1_500)
|
|
+ expect(handle.getForegroundProcess()).toBe('grok')
|
|
+ } finally {
|
|
+ vi.useRealTimers()
|
|
+ if (platform) {
|
|
+ Object.defineProperty(process, 'platform', platform)
|
|
+ }
|
|
+ }
|
|
+ })
|
|
+
|
|
+ it('clears an expired identity when the wrapper tree no longer resolves to an agent', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z'))
|
|
+ const proc = mockPtyProcess()
|
|
+ proc.process = 'node'
|
|
+ spawnMock.mockReturnValue(proc)
|
|
+ const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
+ Object.defineProperty(process, 'platform', { value: 'darwin' })
|
|
+ resolveAgentForegroundProcessMock.mockResolvedValueOnce('grok').mockResolvedValue('node')
|
|
+
|
|
+ try {
|
|
+ const handle = createPtySubprocess({
|
|
+ sessionId: 'test',
|
|
+ cols: 80,
|
|
+ rows: 24
|
|
+ })
|
|
+
|
|
+ expect(handle.getForegroundProcess()).toBe('node')
|
|
+ await Promise.resolve()
|
|
+ await Promise.resolve()
|
|
+ expect(handle.getForegroundProcess()).toBe('grok')
|
|
+ // Flush the first refresh's finally so the next read can revalidate.
|
|
+ await Promise.resolve()
|
|
+ await Promise.resolve()
|
|
+
|
|
+ // An unrelated wrapper (e.g. npm) now owns the pane: the stale-served
|
|
+ // identity is revalidated and dropped once the refresh finds no agent.
|
|
+ vi.advanceTimersByTime(1_500)
|
|
+ expect(handle.getForegroundProcess()).toBe('grok')
|
|
+ await Promise.resolve()
|
|
+ await Promise.resolve()
|
|
+ await Promise.resolve()
|
|
+ expect(handle.getForegroundProcess()).toBe('node')
|
|
+ } finally {
|
|
+ vi.useRealTimers()
|
|
+ if (platform) {
|
|
+ Object.defineProperty(process, 'platform', platform)
|
|
+ }
|
|
+ }
|
|
+ })
|
|
+
|
|
it('serves daemon Windows wrapper agent foreground from an async cache', async () => {
|
|
const proc = mockPtyProcess()
|
|
proc.process = 'node.exe'
|
|
diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts
|
|
index 6dd0554bb..09f66a987 100644
|
|
--- a/src/main/daemon/pty-subprocess.ts
|
|
+++ b/src/main/daemon/pty-subprocess.ts
|
|
@@ -937,6 +937,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
|
) {
|
|
cachedAgentForeground = null
|
|
startupAgentForeground = null
|
|
+ } else if (
|
|
+ cachedAgentForeground !== null &&
|
|
+ Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS &&
|
|
+ currentFallbackProcess !== null &&
|
|
+ isAgentForegroundWrapperProcess(currentFallbackProcess)
|
|
+ ) {
|
|
+ // Why: the wrapper's tree no longer resolves to an agent — an expired
|
|
+ // identity must not transfer to an unrelated wrapper (e.g. npm right
|
|
+ // after an agent exit). Fresh identities survive one-off scan hiccups.
|
|
+ cachedAgentForeground = null
|
|
}
|
|
return
|
|
}
|
|
@@ -994,6 +1004,18 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
|
) {
|
|
return cachedAgentForeground.processName
|
|
}
|
|
+ // Why: a wrapper foreground (node/python) can never identify itself, and
|
|
+ // readers poll slower than the cache TTL — returning the raw wrapper here
|
|
+ // would hide the resolved identity forever. Serve the last resolved agent
|
|
+ // while the scheduled refresh revalidates; exit truth is safe because an
|
|
+ // exited agent's foreground falls back to the shell, not a wrapper.
|
|
+ if (
|
|
+ cachedAgentForeground &&
|
|
+ fallbackProcess !== null &&
|
|
+ isAgentForegroundWrapperProcess(fallbackProcess)
|
|
+ ) {
|
|
+ return cachedAgentForeground.processName
|
|
+ }
|
|
const activeStartupAgentForeground = getActiveStartupAgentForeground(now)
|
|
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
|
|
return activeStartupAgentForeground.processName
|
|
diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts
|
|
index 860e3f27a..ce1a67da7 100644
|
|
--- a/src/main/daemon/shell-ready.test.ts
|
|
+++ b/src/main/daemon/shell-ready.test.ts
|
|
@@ -556,6 +556,34 @@ describePosix('daemon shell-ready launch config', () => {
|
|
}
|
|
)
|
|
|
|
+ itWithBash(
|
|
+ 'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt',
|
|
+ async () => {
|
|
+ const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
|
+ // Minimal bash-preexec imitation (iTerm2/starship setups): re-arms its own
|
|
+ // DEBUG trap from PROMPT_COMMAND at the first prompt — silencing Orca's
|
|
+ // trap — and dispatches preexec_functions with the command as $1.
|
|
+ writeFileSync(
|
|
+ join(userDataPath, '.bash_profile'),
|
|
+ [
|
|
+ 'preexec_functions=()',
|
|
+ '__bp_preexec_invoke_exec() {',
|
|
+ ' [[ -n "${__bp_interactive_mode:-}" ]] || return',
|
|
+ ' __bp_interactive_mode=""',
|
|
+ ' local f',
|
|
+ ' for f in "${preexec_functions[@]}"; do "$f" "$BASH_COMMAND"; done',
|
|
+ '}',
|
|
+ "__bp_arm() { __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }",
|
|
+ 'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"'
|
|
+ ].join('\n')
|
|
+ )
|
|
+
|
|
+ const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath)
|
|
+
|
|
+ expectBashOsc133Lifecycle(output)
|
|
+ }
|
|
+ )
|
|
+
|
|
itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => {
|
|
const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady()
|
|
writeFileSync(
|
|
diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts
|
|
index 7ad5a686b..e31949e5b 100644
|
|
--- a/src/main/daemon/shell-ready.ts
|
|
+++ b/src/main/daemon/shell-ready.ts
|
|
@@ -195,6 +195,23 @@ if [[ -n "$__orca_debug_trap_spec" ]]; then
|
|
fi
|
|
unset __orca_debug_trap_spec __orca_debug_trap_command
|
|
unset -f __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command
|
|
+# Why: bash-preexec (pulled in by iTerm2/starship setups) re-arms its own DEBUG
|
|
+# trap at the first prompt, silencing ours. Register with its preexec_functions
|
|
+# array too; both paths gate on __orca_in_command, so whichever mechanism ends
|
|
+# up owning the trap, C is emitted exactly once per command.
|
|
+__orca_osc133_preexec_bp() {
|
|
+ [[ -z "\${__orca_in_command:-}" ]] || return
|
|
+ [[ -z "\${__orca_in_prompt_command:-}" ]] || return
|
|
+ case "\${1:-}" in
|
|
+ *__orca_osc133_precmd*|*__orca_osc133_prompt_done*|*__orca_prompt_mark*) return ;;
|
|
+ esac
|
|
+ printf "\\033]133;C\\007"
|
|
+ __orca_in_command=1
|
|
+}
|
|
+case " \${preexec_functions[*]:-} " in
|
|
+ *" __orca_osc133_preexec_bp "*) ;;
|
|
+ *) preexec_functions+=(__orca_osc133_preexec_bp) ;;
|
|
+esac
|
|
# Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile
|
|
# commands as a foreground command and emits a fake C/D before the first prompt.
|
|
trap '__orca_osc133_preexec' DEBUG
|
|
diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts
|
|
index 5a2e8c258..ab5aaa03b 100644
|
|
--- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts
|
|
+++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts
|
|
@@ -5,12 +5,14 @@ import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracke
|
|
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
|
|
|
const COMMAND_SETTLE_MS = 350
|
|
+const VISIBLE_PTY_SETTLE_MS = 350
|
|
const WRAPPER_RESOLVE_RETRY_MS = 1200
|
|
const SECOND_WRAPPER_RETRY_MS = 3500
|
|
|
|
describe('createPaneForegroundAgentTracker', () => {
|
|
const readForegroundProcess = vi.fn<(ptyId: string) => Promise<string | null>>()
|
|
const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>()
|
|
+ const onConfirmedShellForeground = vi.fn<() => void>()
|
|
let ptyId: string | null = 'pty-1'
|
|
|
|
function makeTracker(): ReturnType<typeof createPaneForegroundAgentTracker> {
|
|
@@ -18,7 +20,8 @@ describe('createPaneForegroundAgentTracker', () => {
|
|
getPtyId: () => ptyId,
|
|
isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'),
|
|
readForegroundProcess,
|
|
- publish
|
|
+ publish,
|
|
+ onConfirmedShellForeground
|
|
})
|
|
}
|
|
|
|
@@ -30,6 +33,7 @@ describe('createPaneForegroundAgentTracker', () => {
|
|
vi.useFakeTimers()
|
|
readForegroundProcess.mockReset()
|
|
publish.mockReset()
|
|
+ onConfirmedShellForeground.mockReset()
|
|
ptyId = 'pty-1'
|
|
})
|
|
|
|
@@ -52,6 +56,72 @@ describe('createPaneForegroundAgentTracker', () => {
|
|
expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false })
|
|
})
|
|
|
|
+ it('reads the foreground for a visible PTY so restored running Codex panes regain identity', async () => {
|
|
+ readForegroundProcess.mockResolvedValue('codex')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onVisiblePtyBound()
|
|
+ expect(readForegroundProcess).not.toHaveBeenCalled()
|
|
+
|
|
+ await flushSettleRead(VISIBLE_PTY_SETTLE_MS)
|
|
+
|
|
+ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
|
+ })
|
|
+
|
|
+ it('does not retry or publish visible PTY reads for an idle shell foreground', async () => {
|
|
+ readForegroundProcess.mockResolvedValue('zsh')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onVisiblePtyBound()
|
|
+ await flushSettleRead(
|
|
+ VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + 10_000
|
|
+ )
|
|
+
|
|
+ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
|
+ expect(publish).not.toHaveBeenCalled()
|
|
+ })
|
|
+
|
|
+ it('lets command-start sampling supersede a pending visible PTY read', async () => {
|
|
+ readForegroundProcess.mockResolvedValue('codex')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onVisiblePtyBound()
|
|
+ tracker.onCommandStarted()
|
|
+ await flushSettleRead(VISIBLE_PTY_SETTLE_MS)
|
|
+
|
|
+ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
|
+ })
|
|
+
|
|
+ it('does not let visible PTY sampling downgrade pending command-start sampling', async () => {
|
|
+ readForegroundProcess.mockResolvedValueOnce('bash').mockResolvedValueOnce('codex')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onCommandStarted()
|
|
+ tracker.onVisiblePtyBound()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+ expect(readForegroundProcess).toHaveBeenCalledTimes(1)
|
|
+
|
|
+ await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS)
|
|
+ expect(readForegroundProcess).toHaveBeenCalledTimes(2)
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
|
+ })
|
|
+
|
|
+ it('retries visible PTY reads only while a foreground wrapper may resolve to an agent', async () => {
|
|
+ readForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('codex')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onVisiblePtyBound()
|
|
+ await flushSettleRead(VISIBLE_PTY_SETTLE_MS)
|
|
+ expect(readForegroundProcess).toHaveBeenCalledTimes(1)
|
|
+ expect(publish).not.toHaveBeenCalled()
|
|
+
|
|
+ await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS)
|
|
+ expect(readForegroundProcess).toHaveBeenCalledTimes(2)
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
|
+ })
|
|
+
|
|
it('re-reads on a bounded ladder while the read still sees an interpreter wrapper', async () => {
|
|
// Why: daemon shell/helper→agent ancestry resolution has been observed to
|
|
// take >1.5s for real node-wrapped CLIs, so the ladder gets two re-reads.
|
|
@@ -124,18 +194,161 @@ describe('createPaneForegroundAgentTracker', () => {
|
|
expect(readForegroundProcess).not.toHaveBeenCalled()
|
|
})
|
|
|
|
- it('cancels a pending read when the command finishes first', async () => {
|
|
+ it('confirms a rapid command start->finish instead of trusting the D', async () => {
|
|
+ // Why: a leaked nested-shell 133;C->133;D pair (or a fast real command)
|
|
+ // cancels the command-start read; confirm the foreground so a still-running
|
|
+ // manual agent with no launchAgent/hook keeps its identity.
|
|
readForegroundProcess.mockResolvedValue('claude')
|
|
const tracker = makeTracker()
|
|
|
|
tracker.onCommandStarted()
|
|
tracker.onCommandFinished()
|
|
- await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS)
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
|
|
+ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false })
|
|
+ expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true })
|
|
+ })
|
|
+
|
|
+ it('confirms duplicate 133;D pairs instead of fast-pathing past the pending confirmation', async () => {
|
|
+ // Why: user shell integrations (iTerm/VS Code) double up Orca's OSC 133, so
|
|
+ // every D arrives twice ~50ms apart. The second D cancels the first D's
|
|
+ // confirming read; it must reschedule the confirmation, not trust the D.
|
|
+ readForegroundProcess.mockResolvedValue('grok')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onCommandStarted()
|
|
+ tracker.onCommandStarted()
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(50)
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+
|
|
+ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'grok', shellForeground: false })
|
|
+ expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true })
|
|
+ })
|
|
+
|
|
+ it('still marks shell without a read for a duplicate D pair on an idle pane', async () => {
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(50)
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+
|
|
+ // Why: with no command read in flight the first D takes the no-RPC path,
|
|
+ // so its duplicate has nothing to re-confirm and stays RPC-free too.
|
|
expect(readForegroundProcess).not.toHaveBeenCalled()
|
|
+ expect(publish).toHaveBeenCalledTimes(2)
|
|
expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true })
|
|
})
|
|
|
|
+ it('marks shell on a rapid command start->finish when the foreground is a shell', async () => {
|
|
+ readForegroundProcess.mockResolvedValue('zsh')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onCommandStarted()
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+
|
|
+ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true })
|
|
+ })
|
|
+
|
|
+ it('confirms the foreground before clearing a pane an agent has owned', async () => {
|
|
+ readForegroundProcess.mockResolvedValue('codex')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onCommandStarted()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
|
+
|
|
+ publish.mockClear()
|
|
+ readForegroundProcess.mockClear()
|
|
+ tracker.onCommandFinished()
|
|
+ // Why: a leaked nested-shell 133;D must not clear Codex before the read.
|
|
+ expect(publish).not.toHaveBeenCalled()
|
|
+
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
|
+ expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true })
|
|
+ })
|
|
+
|
|
+ it('marks shell foreground when the confirming read shows the agent is gone', async () => {
|
|
+ readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onCommandStarted()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+
|
|
+ publish.mockClear()
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+
|
|
+ expect(readForegroundProcess).toHaveBeenLastCalledWith('pty-1')
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true })
|
|
+ })
|
|
+
|
|
+ it('signals confirmed shell foreground only when the read proves the agent exited', async () => {
|
|
+ // Reads: command-start=codex, first finish=codex (still running), second finish=zsh (exited).
|
|
+ readForegroundProcess
|
|
+ .mockResolvedValueOnce('codex')
|
|
+ .mockResolvedValueOnce('codex')
|
|
+ .mockResolvedValueOnce('zsh')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onCommandStarted()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+
|
|
+ // A leaked nested-shell 133;D while Codex still owns the foreground: no signal.
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+ expect(onConfirmedShellForeground).not.toHaveBeenCalled()
|
|
+
|
|
+ // Genuine exit -> read sees a shell -> signal fires exactly once.
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+ expect(onConfirmedShellForeground).toHaveBeenCalledTimes(1)
|
|
+ })
|
|
+
|
|
+ it('returns to the no-RPC finished path after the agent is confirmed gone', async () => {
|
|
+ readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh')
|
|
+ const tracker = makeTracker()
|
|
+
|
|
+ tracker.onCommandStarted()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+ tracker.onCommandFinished()
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+
|
|
+ publish.mockClear()
|
|
+ readForegroundProcess.mockClear()
|
|
+ tracker.onCommandFinished()
|
|
+
|
|
+ expect(readForegroundProcess).not.toHaveBeenCalled()
|
|
+ expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true })
|
|
+ })
|
|
+
|
|
+ it('confirms a command finished for a launch-known agent pane before any read', async () => {
|
|
+ readForegroundProcess.mockResolvedValue('codex')
|
|
+ const tracker = createPaneForegroundAgentTracker({
|
|
+ getPtyId: () => ptyId,
|
|
+ isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'),
|
|
+ readForegroundProcess,
|
|
+ publish,
|
|
+ hasKnownAgentIdentity: () => true
|
|
+ })
|
|
+
|
|
+ tracker.onCommandFinished()
|
|
+ // Why: launchAgent/hook identity means the 133;D is confirmed, not trusted.
|
|
+ expect(publish).not.toHaveBeenCalled()
|
|
+
|
|
+ await flushSettleRead(COMMAND_SETTLE_MS)
|
|
+ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
|
+ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false })
|
|
+ })
|
|
+
|
|
it('never reads or publishes for remote or ssh panes', async () => {
|
|
ptyId = 'remote:web-env-1@@terminal-1'
|
|
const tracker = makeTracker()
|
|
diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts
|
|
index 1af2f78fd..8e9d37327 100644
|
|
--- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts
|
|
+++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts
|
|
@@ -1,4 +1,7 @@
|
|
-import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition'
|
|
+import {
|
|
+ isAgentForegroundWrapperProcess,
|
|
+ recognizeAgentProcess
|
|
+} from '../../../../shared/agent-process-recognition'
|
|
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
|
|
|
// Why: the read must land after the shell has exec'd the command; and when it
|
|
@@ -6,7 +9,9 @@ import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-ag
|
|
// asynchronously — observed to take >1.5s for real node-wrapped CLIs — so
|
|
// give its cache two bounded re-reads, not an open-ended retry loop.
|
|
const COMMAND_SETTLE_MS = 350
|
|
+const VISIBLE_PTY_SETTLE_MS = 350
|
|
const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 3500] as const
|
|
+type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished'
|
|
|
|
type PaneForegroundAgentTrackerDeps = {
|
|
getPtyId: () => string | null
|
|
@@ -15,22 +20,39 @@ type PaneForegroundAgentTrackerDeps = {
|
|
isTrackablePtyId: (ptyId: string) => boolean
|
|
readForegroundProcess: (ptyId: string) => Promise<string | null>
|
|
publish: (entry: PaneForegroundAgentEntry) => void
|
|
+ /** True when the pane is otherwise known to run an agent (launchAgent, live
|
|
+ * hook status). Lets a restored agent pane confirm — rather than trust — a
|
|
+ * 133;D before any command-start read has recorded its own evidence. */
|
|
+ hasKnownAgentIdentity?: () => boolean
|
|
+ /** Fired when a confirming read proves the foreground genuinely returned to a
|
|
+ * shell (agent exited). Lets callers clear a stale agent-named tab title that
|
|
+ * the shell never repaints. */
|
|
+ onConfirmedShellForeground?: () => void
|
|
}
|
|
|
|
/**
|
|
* Publishes process-table identity for a pane at OSC 133 command boundaries:
|
|
* one foreground read when a command starts (that is when the foreground
|
|
- * changes), and a no-RPC shell-foreground mark when it finishes — 133;D is
|
|
- * the ONLY source of shell-foreground proof; reads never produce it.
|
|
+ * changes), and a shell-foreground mark when it finishes. A 133;D is normally
|
|
+ * the shell-foreground proof; for a pane an agent has owned it is confirmed by a
|
|
+ * foreground read first, because a full-screen agent's nested command shells
|
|
+ * leak their own 133;D onto the main PTY.
|
|
*/
|
|
export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTrackerDeps): {
|
|
+ onVisiblePtyBound: () => void
|
|
onCommandStarted: () => void
|
|
onCommandFinished: () => void
|
|
dispose: () => void
|
|
} {
|
|
let disposed = false
|
|
- let readTimer: number | null = null
|
|
+ let readTimer: ReturnType<typeof setTimeout> | null = null
|
|
+ let scheduledReadReason: ForegroundReadReason | null = null
|
|
+ let activeReadReason: ForegroundReadReason | null = null
|
|
let readGeneration = 0
|
|
+ // Why: a full-screen agent (Codex, etc.) runs nested command shells whose own
|
|
+ // OSC 133;D leaks onto the main PTY. For a pane an agent has owned, that D is
|
|
+ // not proof the prompt returned, so confirm the foreground before clearing.
|
|
+ let hasForegroundAgentEvidence = false
|
|
|
|
const trackablePtyId = (): string | null => {
|
|
const ptyId = deps.getPtyId()
|
|
@@ -40,30 +62,53 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
|
const cancelPendingRead = (): void => {
|
|
readGeneration += 1
|
|
if (readTimer !== null) {
|
|
- window.clearTimeout(readTimer)
|
|
+ clearTimeout(readTimer)
|
|
readTimer = null
|
|
}
|
|
+ scheduledReadReason = null
|
|
+ activeReadReason = null
|
|
}
|
|
|
|
- const scheduleRead = (delayMs: number, retryIndex: number): void => {
|
|
+ const scheduleRead = (
|
|
+ delayMs: number,
|
|
+ retryIndex: number,
|
|
+ reason: ForegroundReadReason
|
|
+ ): void => {
|
|
const generation = readGeneration
|
|
- readTimer = window.setTimeout(() => {
|
|
+ scheduledReadReason = reason
|
|
+ readTimer = setTimeout(() => {
|
|
readTimer = null
|
|
- void readForeground(generation, retryIndex)
|
|
+ scheduledReadReason = null
|
|
+ activeReadReason = reason
|
|
+ void readForeground(generation, retryIndex, reason).finally(() => {
|
|
+ if (generation === readGeneration && activeReadReason === reason) {
|
|
+ activeReadReason = null
|
|
+ }
|
|
+ })
|
|
}, delayMs)
|
|
}
|
|
|
|
- async function readForeground(generation: number, retryIndex: number): Promise<void> {
|
|
+ async function readForeground(
|
|
+ generation: number,
|
|
+ retryIndex: number,
|
|
+ reason: ForegroundReadReason
|
|
+ ): Promise<void> {
|
|
const ptyId = trackablePtyId()
|
|
if (disposed || generation !== readGeneration || !ptyId) {
|
|
return
|
|
}
|
|
- const processName = await deps.readForegroundProcess(ptyId).catch(() => null)
|
|
+ let processName: string | null = null
|
|
+ try {
|
|
+ processName = await deps.readForegroundProcess(ptyId)
|
|
+ } catch {
|
|
+ processName = null
|
|
+ }
|
|
if (disposed || generation !== readGeneration) {
|
|
return
|
|
}
|
|
const recognized = recognizeAgentProcess(processName)
|
|
if (recognized) {
|
|
+ hasForegroundAgentEvidence = true
|
|
deps.publish({ agent: recognized.agent, shellForeground: false })
|
|
return
|
|
}
|
|
@@ -72,14 +117,50 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
|
// a nested one (sh/bash without integration); marking shell-foreground
|
|
// would suppress live title identity. Only 133;D proves the prompt.
|
|
const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex]
|
|
- if (retryDelay !== undefined && processName) {
|
|
- scheduleRead(retryDelay, retryIndex + 1)
|
|
+ const shouldRetry =
|
|
+ retryDelay !== undefined &&
|
|
+ processName &&
|
|
+ (reason === 'command' || isAgentForegroundWrapperProcess(processName))
|
|
+ if (shouldRetry) {
|
|
+ scheduleRead(retryDelay, retryIndex + 1, reason)
|
|
+ return
|
|
+ }
|
|
+ if (reason === 'command') {
|
|
+ deps.publish({ agent: null, shellForeground: false })
|
|
return
|
|
}
|
|
- deps.publish({ agent: null, shellForeground: false })
|
|
+ if (reason === 'command-finished') {
|
|
+ // Why: the 133;D fired AND the foreground shows no agent — together that is
|
|
+ // real prompt proof, so the agent truly exited. Reset the evidence so the
|
|
+ // pane's ordinary shell commands go back to the no-RPC finished path.
|
|
+ hasForegroundAgentEvidence = false
|
|
+ deps.publish({ agent: null, shellForeground: true })
|
|
+ // Why: confirmed exit — let callers clear a stale agent title the shell
|
|
+ // won't repaint (a plain `codex`/`grok` leaves its OSC title behind).
|
|
+ deps.onConfirmedShellForeground?.()
|
|
+ }
|
|
}
|
|
|
|
return {
|
|
+ onVisiblePtyBound() {
|
|
+ // Why: command-start and command-finished reads own the exit decision;
|
|
+ // visibility recovery is lower-authority and must never cancel them.
|
|
+ if (
|
|
+ scheduledReadReason === 'command' ||
|
|
+ activeReadReason === 'command' ||
|
|
+ scheduledReadReason === 'command-finished' ||
|
|
+ activeReadReason === 'command-finished'
|
|
+ ) {
|
|
+ return
|
|
+ }
|
|
+ cancelPendingRead()
|
|
+ if (!trackablePtyId()) {
|
|
+ return
|
|
+ }
|
|
+ // Why: restored/manual agent panes can become visible while Codex is
|
|
+ // already foreground, so no OSC 133 command-start event will seed the tab icon.
|
|
+ scheduleRead(VISIBLE_PTY_SETTLE_MS, 0, 'visible-pty')
|
|
+ },
|
|
onCommandStarted() {
|
|
cancelPendingRead()
|
|
if (!trackablePtyId()) {
|
|
@@ -88,14 +169,39 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke
|
|
// Why: the foreground left the prompt the moment C fired — stale
|
|
// shell-foreground evidence must not clear the command that just started.
|
|
deps.publish({ agent: null, shellForeground: false })
|
|
- scheduleRead(COMMAND_SETTLE_MS, 0)
|
|
+ scheduleRead(COMMAND_SETTLE_MS, 0, 'command')
|
|
},
|
|
onCommandFinished() {
|
|
+ // Why: a rapid 133;C→133;D pair cancels the command-start read before it
|
|
+ // can identify the foreground — that pair is exactly a leaked nested-shell
|
|
+ // command under a full-screen agent (or a fast real shell command), so on a
|
|
+ // no-identity pane confirm it rather than trusting the D as a prompt return.
|
|
+ // A pending confirming read counts too: user shell integrations double up
|
|
+ // Orca's OSC 133, and the duplicate D must re-confirm, not fast-path past
|
|
+ // the in-flight confirmation it just cancelled.
|
|
+ const commandReadWasPending =
|
|
+ scheduledReadReason === 'command' ||
|
|
+ activeReadReason === 'command' ||
|
|
+ scheduledReadReason === 'command-finished' ||
|
|
+ activeReadReason === 'command-finished'
|
|
cancelPendingRead()
|
|
if (!trackablePtyId()) {
|
|
return
|
|
}
|
|
- deps.publish({ agent: null, shellForeground: true })
|
|
+ // Why: trust the 133;D and mark shell without an RPC only when nothing hints
|
|
+ // at an agent — no prior agent evidence, no launch/hook identity, and no
|
|
+ // command-start read racing this finish.
|
|
+ if (
|
|
+ !hasForegroundAgentEvidence &&
|
|
+ deps.hasKnownAgentIdentity?.() !== true &&
|
|
+ !commandReadWasPending
|
|
+ ) {
|
|
+ deps.publish({ agent: null, shellForeground: true })
|
|
+ return
|
|
+ }
|
|
+ // Why: confirm the foreground before clearing — if the agent still owns it,
|
|
+ // the read republishes its identity; only a genuine shell result clears it.
|
|
+ scheduleRead(COMMAND_SETTLE_MS, 0, 'command-finished')
|
|
},
|
|
dispose() {
|
|
disposed = true
|
|
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
|
index 383d5d141..8d42ebb39 100644
|
|
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
|
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
|
@@ -21,6 +21,7 @@ import {
|
|
beginAgentStartupDeliveryAttempt,
|
|
resetAgentStartupDelayedDeliveryForTests
|
|
} from '@/lib/agent-startup-delayed-delivery'
|
|
+import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
|
|
|
// Repro command:
|
|
// pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw"
|
|
@@ -169,6 +170,7 @@ type StoreState = {
|
|
consumePendingSnapshot: ReturnType<typeof vi.fn>
|
|
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
|
agentStatusByPaneKey: Record<string, unknown>
|
|
+ paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
|
|
sleepingAgentSessionsByPaneKey: Record<string, unknown>
|
|
agentLaunchConfigByPaneKey: Record<string, { launchConfig: unknown }>
|
|
getAgentLaunchConfigForStatusEntry: ReturnType<typeof vi.fn>
|
|
@@ -737,6 +739,7 @@ describe('connectPanePty', () => {
|
|
consumePendingSnapshot: vi.fn(() => null),
|
|
runtimePaneTitlesByTabId: {},
|
|
agentStatusByPaneKey: {},
|
|
+ paneForegroundAgentByPaneKey: {},
|
|
sleepingAgentSessionsByPaneKey: {},
|
|
agentLaunchConfigByPaneKey: {},
|
|
getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => {
|
|
@@ -771,8 +774,12 @@ describe('connectPanePty', () => {
|
|
),
|
|
removeAgentStatus: vi.fn(),
|
|
dropAgentStatus: vi.fn(),
|
|
- setPaneForegroundAgent: vi.fn(),
|
|
- clearPaneForegroundAgent: vi.fn(),
|
|
+ setPaneForegroundAgent: vi.fn((paneKey: string, entry: PaneForegroundAgentEntry) => {
|
|
+ mockStoreState.paneForegroundAgentByPaneKey[paneKey] = entry
|
|
+ }),
|
|
+ clearPaneForegroundAgent: vi.fn((paneKey: string) => {
|
|
+ delete mockStoreState.paneForegroundAgentByPaneKey[paneKey]
|
|
+ }),
|
|
markTerminalTabUnread: vi.fn(),
|
|
markTerminalPaneUnread: vi.fn(),
|
|
markAgentCompletionPaneUnread: vi.fn()
|
|
@@ -13949,6 +13956,240 @@ describe('connectPanePty', () => {
|
|
})
|
|
})
|
|
|
|
+ describe('visible foreground agent sampling (perf)', () => {
|
|
+ const VISIBLE_PTY_SETTLE_MS = 350
|
|
+
|
|
+ // Why: every connectPanePty binding in this file shares the tab-1/LEAF_1 pane
|
|
+ // key, and an undisposed reattach binding elsewhere can resolve a foreground
|
|
+ // read into this test's store slice. Give each sampling case its own tabId so
|
|
+ // no other test's publish can pollute the pane identity it asserts on.
|
|
+ async function connectRestoredPaneForForegroundSampling(args: {
|
|
+ ptyId?: string
|
|
+ tabId?: string
|
|
+ isVisibleRef?: { current: boolean }
|
|
+ } = {}): Promise<{
|
|
+ binding: { noteVisibilityResume: () => void }
|
|
+ deps: ReturnType<typeof createDeps>
|
|
+ transport: MockTransport
|
|
+ cacheKey: string
|
|
+ }> {
|
|
+ const { connectPanePty } = await import('./pty-connection')
|
|
+ const ptyId = args.ptyId ?? 'tab-pty'
|
|
+ const tabId = args.tabId ?? `tab-${ptyId}`
|
|
+ const transport = createMockTransport(ptyId)
|
|
+ transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
|
+ return sessionId ? { id: sessionId } : null
|
|
+ })
|
|
+ transportFactoryQueue.push(transport)
|
|
+ const deps = createDeps({
|
|
+ tabId,
|
|
+ restoredLeafId: LEAF_1,
|
|
+ restoredPtyIdByLeafId: { [LEAF_1]: ptyId },
|
|
+ ...(args.isVisibleRef ? { isVisibleRef: args.isVisibleRef } : {})
|
|
+ })
|
|
+ const binding = connectPanePty(
|
|
+ createPane(1) as never,
|
|
+ createManager(1) as never,
|
|
+ deps as never
|
|
+ ) as unknown as { noteVisibilityResume: () => void }
|
|
+ await vi.advanceTimersByTimeAsync(20)
|
|
+ await flushAsyncTicks(20)
|
|
+ return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) }
|
|
+ }
|
|
+
|
|
+ async function advanceVisibleForegroundRead(): Promise<void> {
|
|
+ await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS)
|
|
+ await flushAsyncTicks()
|
|
+ }
|
|
+
|
|
+ function foregroundReadCallsFor(ptyId: string): unknown[][] {
|
|
+ return vi
|
|
+ .mocked(window.api.pty.getForegroundProcess)
|
|
+ .mock.calls.filter(([calledPtyId]) => calledPtyId === ptyId)
|
|
+ }
|
|
+
|
|
+ it('does not inspect foreground process for a fresh visible spawn', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ const { connectPanePty } = await import('./pty-connection')
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const ptyId = 'pty-fresh-visible-no-sample'
|
|
+ transportFactoryQueue.push(createMockTransport(ptyId))
|
|
+ mockStoreState = {
|
|
+ ...mockStoreState,
|
|
+ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
|
+ ptyIdsByTabId: { 'tab-1': [] },
|
|
+ terminalLayoutsByTabId: {
|
|
+ 'tab-1': {
|
|
+ root: { type: 'leaf', leafId: LEAF_1 },
|
|
+ activeLeafId: LEAF_1,
|
|
+ expandedLeafId: null,
|
|
+ ptyIdsByLeafId: {}
|
|
+ }
|
|
+ }
|
|
+ } as StoreState
|
|
+
|
|
+ connectPanePty(
|
|
+ createPane(1) as never,
|
|
+ createManager(1) as never,
|
|
+ createDeps() as never
|
|
+ )
|
|
+ await vi.advanceTimersByTimeAsync(20)
|
|
+ await flushAsyncTicks(20)
|
|
+ const spawnHandler = createdTransportOptions[0]?.onPtySpawn as
|
|
+ | ((ptyId: string) => void)
|
|
+ | undefined
|
|
+ spawnHandler?.(ptyId)
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
|
+ })
|
|
+
|
|
+ it('samples exactly one visible restored PTY with no stronger identity signal', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const ptyId = 'pty-restored-visible-sample'
|
|
+
|
|
+ const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId })
|
|
+ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
|
+
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]])
|
|
+ expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
|
|
+ agent: 'codex',
|
|
+ shellForeground: false
|
|
+ })
|
|
+ })
|
|
+
|
|
+ it('does not sample hidden restored PTYs', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const ptyId = 'pty-hidden-restored-no-sample'
|
|
+
|
|
+ await connectRestoredPaneForForegroundSampling({
|
|
+ ptyId,
|
|
+ isVisibleRef: { current: false }
|
|
+ })
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
|
+ })
|
|
+
|
|
+ it('samples once when an identityless hidden pane resumes visible', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const isVisibleRef = { current: false }
|
|
+ const ptyId = 'pty-hidden-then-visible-sample'
|
|
+ const { binding } = await connectRestoredPaneForForegroundSampling({ ptyId, isVisibleRef })
|
|
+ await advanceVisibleForegroundRead()
|
|
+ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
|
+
|
|
+ isVisibleRef.current = true
|
|
+ binding.noteVisibilityResume()
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]])
|
|
+ })
|
|
+
|
|
+ it('does not sample when launch metadata already supplies tab identity', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const ptyId = 'pty-launch-identity-no-sample'
|
|
+ const tabId = `tab-${ptyId}`
|
|
+ mockStoreState.tabsByWorktree = {
|
|
+ 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }]
|
|
+ }
|
|
+
|
|
+ await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
|
+ })
|
|
+
|
|
+ it('does not sample when a live hook row already supplies pane identity', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const ptyId = 'pty-hook-identity-no-sample'
|
|
+ const tabId = `tab-${ptyId}`
|
|
+ mockStoreState.agentStatusByPaneKey[makePaneKey(tabId, LEAF_1)] = {
|
|
+ state: 'working',
|
|
+ agentType: 'codex'
|
|
+ }
|
|
+
|
|
+ await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
|
+ })
|
|
+
|
|
+ it('does not sample when process identity is already known', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const ptyId = 'pty-process-identity-no-sample'
|
|
+ const tabId = `tab-${ptyId}`
|
|
+ mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = {
|
|
+ agent: 'codex',
|
|
+ shellForeground: false
|
|
+ }
|
|
+
|
|
+ await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
|
+ })
|
|
+
|
|
+ it('does not re-sample once 133;D proved the pane is at a shell prompt', async () => {
|
|
+ vi.useFakeTimers()
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const ptyId = 'pty-shell-foreground-no-sample'
|
|
+ const tabId = `tab-${ptyId}`
|
|
+ mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = {
|
|
+ agent: null,
|
|
+ shellForeground: true
|
|
+ }
|
|
+
|
|
+ await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0)
|
|
+ })
|
|
+
|
|
+ it('re-samples a shell-marked pane a launch agent still owns', async () => {
|
|
+ // Why: a reattach or a full-screen agent's leaked nested-shell 133;D leaves
|
|
+ // shellForeground on a launchAgent pane, suppressing its icon. The launch
|
|
+ // metadata means an agent is expected, so re-read to recover its identity.
|
|
+ vi.useFakeTimers()
|
|
+ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess)
|
|
+ getForegroundProcess.mockResolvedValue('codex')
|
|
+ const ptyId = 'pty-shell-foreground-launch-agent-sample'
|
|
+ const tabId = `tab-${ptyId}`
|
|
+ mockStoreState.tabsByWorktree = {
|
|
+ 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }]
|
|
+ }
|
|
+ mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = {
|
|
+ agent: null,
|
|
+ shellForeground: true
|
|
+ }
|
|
+
|
|
+ const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId })
|
|
+ await advanceVisibleForegroundRead()
|
|
+
|
|
+ expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]])
|
|
+ expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, {
|
|
+ agent: 'codex',
|
|
+ shellForeground: false
|
|
+ })
|
|
+ })
|
|
+ })
|
|
+
|
|
describe('terminal input liveness IPC gating (perf)', () => {
|
|
// Why (perf regression guard): listSessions() is a renderer→main→daemon
|
|
// round-trip over every live session. Terminal input must never start that
|
|
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
|
|
index dffa30c47..31713d1e9 100644
|
|
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
|
|
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
|
|
@@ -5,6 +5,7 @@ import type { IBuffer, IDisposable } from '@xterm/xterm'
|
|
import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor'
|
|
import {
|
|
detectAgentStatusFromTitle,
|
|
+ agentTypeToIconAgent,
|
|
isGeminiTerminalTitle,
|
|
isClaudeAgent
|
|
} from '@/lib/agent-status'
|
|
@@ -164,6 +165,7 @@ import {
|
|
normalizeCompatibleAgentTitleForOwner,
|
|
resolveCompatibleAgentTypeForOwner
|
|
} from '../../../../shared/agent-title-owner'
|
|
+import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type'
|
|
import {
|
|
isExpectedAgentProcess,
|
|
recognizeAgentProcessFromCommandLine
|
|
@@ -666,6 +668,10 @@ let inactiveForegroundImmediateBudgetWindowStart = 0
|
|
type PanePtyBinding = IDisposable & {
|
|
syncProcessTracking: () => void
|
|
noteVisibilityResume: () => void
|
|
+ /** Re-sample process identity when the pane gains intra-tab focus: the tab
|
|
+ * icon follows the active leaf, and a shell-marked entry on a still-running
|
|
+ * agent pane has no OSC boundary left to correct it. */
|
|
+ sampleForegroundAgentOnFocus: () => void
|
|
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
|
|
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
|
|
}
|
|
@@ -1751,12 +1757,81 @@ export function connectPanePty(
|
|
}
|
|
return pendingWrite.then(() => interruptInference.flushPending())
|
|
}
|
|
+ // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a
|
|
+ // full-screen agent's leaked nested-shell 133;D can't clear its tab identity,
|
|
+ // even on a restore where no command-start read has recorded evidence yet.
|
|
+ const paneHasKnownAgentIdentity = (): boolean => {
|
|
+ const state = useAppStore.getState()
|
|
+ const entry = state.agentStatusByPaneKey[cacheKey]
|
|
+ if (entry?.state !== 'done' && agentTypeToIconAgent(entry?.agentType)) {
|
|
+ return true
|
|
+ }
|
|
+ const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
|
+ (candidate) => candidate.id === deps.tabId
|
|
+ )
|
|
+ return Boolean(
|
|
+ tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent
|
|
+ )
|
|
+ }
|
|
+ // Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints
|
|
+ // it on exit, so a confirmed return-to-shell must clear a title that still
|
|
+ // names an agent — otherwise the tab reads "grok" over a bare prompt. Only
|
|
+ // reset an agent-named title; user/shell-set titles are left untouched.
|
|
+ const clearStaleAgentTabTitleOnConfirmedShell = (): void => {
|
|
+ const state = useAppStore.getState()
|
|
+ const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
|
+ const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
|
+ (entry) => entry.id === deps.tabId
|
|
+ )
|
|
+ const title = currentTitle ?? tab?.title
|
|
+ if (!title || resolveExplicitTerminalTitleAgentType(title) === null) {
|
|
+ return
|
|
+ }
|
|
+ const neutralTitle = neutralTerminalTitle()
|
|
+ deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle)
|
|
+ if (manager.getActivePane()?.id === pane.id) {
|
|
+ deps.updateTabTitle(deps.tabId, neutralTitle)
|
|
+ }
|
|
+ }
|
|
const paneForegroundAgentTracker = createPaneForegroundAgentTracker({
|
|
getPtyId: () => transport.getPtyId(),
|
|
isTrackablePtyId: (id) => !isRemoteRuntimePtyId(id) && parseAppSshPtyId(id) === null,
|
|
readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id),
|
|
- publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry)
|
|
+ publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry),
|
|
+ hasKnownAgentIdentity: paneHasKnownAgentIdentity,
|
|
+ onConfirmedShellForeground: clearStaleAgentTabTitleOnConfirmedShell
|
|
})
|
|
+ const sampleVisiblePaneForegroundAgent = (): void => {
|
|
+ if (!deps.isVisibleRef.current) {
|
|
+ return
|
|
+ }
|
|
+ const state = useAppStore.getState()
|
|
+ const foreground = state.paneForegroundAgentByPaneKey[cacheKey]
|
|
+ // Why: live process identity already paints the icon — nothing to read.
|
|
+ if (foreground?.agent) {
|
|
+ return
|
|
+ }
|
|
+ const entry = state.agentStatusByPaneKey[cacheKey]
|
|
+ if (entry?.state !== 'done' && agentTypeToIconAgent(entry?.agentType)) {
|
|
+ return
|
|
+ }
|
|
+ const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
|
+ (candidate) => candidate.id === deps.tabId
|
|
+ )
|
|
+ const expectsAgent = Boolean(
|
|
+ tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent
|
|
+ )
|
|
+ // Why: with no shell mark yet, launchAgent bootstrap already paints the icon,
|
|
+ // so a read is pointless. Otherwise probe the foreground: a shell mark (from a
|
|
+ // reattach or a full-screen agent's leaked nested-shell 133;D) can hide a
|
|
+ // still-running agent — including a manually started one with no launchAgent or
|
|
+ // hook, which no later 133;C will reseed. A genuinely idle shell just returns a
|
|
+ // shell name and publishes nothing, so the probe is self-limiting.
|
|
+ if (!foreground?.shellForeground && expectsAgent) {
|
|
+ return
|
|
+ }
|
|
+ paneForegroundAgentTracker.onVisiblePtyBound()
|
|
+ }
|
|
const commandLifecycle = createTerminalCommandLifecycle({
|
|
onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(),
|
|
onCommandFinished: () => {
|
|
@@ -1829,6 +1904,14 @@ export function connectPanePty(
|
|
) {
|
|
return
|
|
}
|
|
+ // Why: user shell frameworks (bash-preexec/iTerm2) can replace Orca's
|
|
+ // OSC 133;C hook, so a manually launched agent produces no command-start
|
|
+ // signal at all. Enter at a shell-foreground prompt is the user-side
|
|
+ // equivalent; the sample is gated to panes with no live agent identity
|
|
+ // and publishes nothing for an idle shell.
|
|
+ if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
+ sampleVisiblePaneForegroundAgent()
|
|
+ }
|
|
deps.clearTerminalTabUnread(deps.tabId)
|
|
deps.clearTerminalPaneUnread(cacheKey)
|
|
deps.clearWorktreeUnread(deps.worktreeId)
|
|
@@ -2225,7 +2308,11 @@ export function connectPanePty(
|
|
}
|
|
const bindActivePanePty = (
|
|
ptyId: string,
|
|
- options: { seedInitialAgentStatus?: boolean; updateTabPtyId?: 'always' | 'if-missing' } = {}
|
|
+ options: {
|
|
+ seedInitialAgentStatus?: boolean
|
|
+ updateTabPtyId?: 'always' | 'if-missing'
|
|
+ sampleVisibleForegroundAgent?: boolean
|
|
+ } = {}
|
|
): void => {
|
|
if (activePanePtyBinding && activePanePtyBinding !== ptyId) {
|
|
reportPanePtyVisibility(activePanePtyBinding, false)
|
|
@@ -2248,6 +2335,11 @@ export function connectPanePty(
|
|
// frame-level sync often runs before that async result arrives.
|
|
scheduleRuntimeGraphSync()
|
|
agentCompletionCoordinator.startProcessTracking()
|
|
+ // Why: fresh spawns receive future OSC command-start events; only adopted or
|
|
+ // restored PTYs may already be inside Codex with no new foreground signal.
|
|
+ if (options.sampleVisibleForegroundAgent === true) {
|
|
+ sampleVisiblePaneForegroundAgent()
|
|
+ }
|
|
}
|
|
|
|
const onPtySpawn = (ptyId: string): void => {
|
|
@@ -3657,7 +3749,10 @@ export function connectPanePty(
|
|
) {
|
|
// Why: daemon createOrAttach can turn an apparent fresh spawn into
|
|
// a reattach; the transport skips onPtySpawn there to preserve recency.
|
|
- bindActivePanePty(resolvedPtyId, { updateTabPtyId: 'if-missing' })
|
|
+ bindActivePanePty(resolvedPtyId, {
|
|
+ updateTabPtyId: 'if-missing',
|
|
+ sampleVisibleForegroundAgent: true
|
|
+ })
|
|
}
|
|
if (resolvedPtyId) {
|
|
reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows)
|
|
@@ -5223,6 +5318,7 @@ export function connectPanePty(
|
|
deps.syncPanePtyLayoutBinding(pane.id, ptyId)
|
|
deps.updateTabPtyId(deps.tabId, ptyId)
|
|
agentCompletionCoordinator.startProcessTracking()
|
|
+ sampleVisiblePaneForegroundAgent()
|
|
|
|
// Why: mobile terminal streaming needs the exact screen state from
|
|
// xterm.js. The shared helper installs both the SerializeAddon-backed
|
|
@@ -5784,7 +5880,10 @@ export function connectPanePty(
|
|
onError: reportError
|
|
}
|
|
})
|
|
- bindActivePanePty(attachPtyId, { updateTabPtyId: 'if-missing' })
|
|
+ bindActivePanePty(attachPtyId, {
|
|
+ updateTabPtyId: 'if-missing',
|
|
+ sampleVisibleForegroundAgent: true
|
|
+ })
|
|
if (attachPtyId === eagerLivePtyId) {
|
|
registerPaneSerializerFor(attachPtyId)
|
|
}
|
|
@@ -5838,7 +5937,10 @@ export function connectPanePty(
|
|
})
|
|
// Why: this path reuses a PTY spawned by an earlier mount, so no
|
|
// later spawn event will bind this remounted pane's DOM/container.
|
|
- bindActivePanePty(spawnedPtyId, { updateTabPtyId: 'if-missing' })
|
|
+ bindActivePanePty(spawnedPtyId, {
|
|
+ updateTabPtyId: 'if-missing',
|
|
+ sampleVisibleForegroundAgent: true
|
|
+ })
|
|
})
|
|
.catch((err) => {
|
|
reportError(err instanceof Error ? err.message : String(err))
|
|
@@ -5948,6 +6050,10 @@ export function connectPanePty(
|
|
noteVisibilityResume() {
|
|
ptySizeReassertion.request({ fit: false })
|
|
consumeHibernatedAgentWake()
|
|
+ sampleVisiblePaneForegroundAgent()
|
|
+ },
|
|
+ sampleForegroundAgentOnFocus() {
|
|
+ sampleVisiblePaneForegroundAgent()
|
|
},
|
|
reconcileIfSessionDead,
|
|
reconcileIfSessionMissing,
|
|
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
|
index e037e1e7f..bcf5835a6 100644
|
|
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
|
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
|
@@ -1281,6 +1281,13 @@ export function useTerminalPaneLifecycle({
|
|
persistLayoutSnapshot()
|
|
}
|
|
reportActiveRendererPtyForPane(paneTransportsRef.current, pane.id)
|
|
+ // Why: the tab icon resolves from the active leaf's process identity;
|
|
+ // focusing a shell-marked pane whose agent is still running must
|
|
+ // re-sample it, since no further OSC boundary will.
|
|
+ const focusedBinding = panePtyBindings.get(pane.id) as
|
|
+ | (IDisposable & { sampleForegroundAgentOnFocus?: () => void })
|
|
+ | undefined
|
|
+ focusedBinding?.sampleForegroundAgentOnFocus?.()
|
|
// Why: when the user switches focus between split panes, update the
|
|
// tab title to the newly active pane's last-known title so the tab
|
|
// label reflects the focused agent — not a stale title from the
|