From 6c815e80c8c4a724c3d43366d8b06fdbebc6ddec Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:13:36 -0700 Subject: [PATCH] fix(runtime): recognize hand-typed native Claude at its startup dialogs (#33826) * fix(runtime): recognize hand-typed native Claude at its startup dialogs A shell-typed claude emits no title at the trust dialog, so nothing asked which process owned the pane, and native Claude's process name is its version (2.1.310). agentWait now identifies a title-less, unowned pane from its foreground process, confirming an unfamiliar name with one process scan whose verdict is kept while that name stays in the foreground. * fix(runtime): scan only native Claude's version-named binary, only when a dialog is on screen Review follow-ups: an untitled, unowned pane earns a foreground read only when its live screen shows a blocked dialog, so plain shells answer without a round-trip; the process scan runs only for a semver-shaped foreground name (native Claude's versions/ binary), never on win32, and only an answered scan is cached. * fix(runtime): don't persist a titleless pane's scanned identity A hand-typed native Claude identified for an interactive-wait query was stored as the pane's foregroundAgent. If the user declined the trust dialog before Claude set a title, no title transition fired, so the shell stayed labelled Claude until the next inventory refresh. The identity now answers only the current query; the per-pty scan cache (keyed by foreground name) keeps repeat queries cheap. Concurrent queries share one in-flight identify, and the version-name match is anchored. * fix(runtime): keep scanned identity out of background refresh; null for disconnected panes - load() matches main again; only identify() consults the confirm-scan cache - disconnected/unreadable panes answer null so the tail path still answers; only timeout is unknown - check pane ownership before projecting the screen - cache only recognized agents, so a not-an-agent verdict is rescanned Co-Authored-By: Claude * fix(runtime): drop a titleless identity read if the pane rebinds; trim tautological tests --------- Co-authored-by: Claude --- .../claude-dialog-interactive-wait.test.ts | 73 +++++++++++++++- .../runtime/runtime-pty-foreground-agent.ts | 85 ++++++++++++++++++- .../input/terminal-agent-prompt-resubmit.ts | 4 +- .../output/terminal-title-observation.ts | 2 +- .../terminal/wait/terminal-wait-permission.ts | 5 +- .../wait/titleless-pane-agent-identity.ts | 33 +++++++ 6 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 src/main/runtime/terminal/wait/titleless-pane-agent-identity.ts diff --git a/src/main/runtime/claude-dialog-interactive-wait.test.ts b/src/main/runtime/claude-dialog-interactive-wait.test.ts index 77fd8550e27..8f724ecaf46 100644 --- a/src/main/runtime/claude-dialog-interactive-wait.test.ts +++ b/src/main/runtime/claude-dialog-interactive-wait.test.ts @@ -11,27 +11,32 @@ vi.mock('electron', () => ({ app: { getPath: vi.fn(() => '/tmp') } })) -async function replayDialog(name: string, connectionId?: string) { +async function replayDialog( + name: string, + connectionId?: string, + pane: Partial[0]> = {} +) { const base = join(__dirname, '__fixtures__', name) const size: { cols: number; rows: number } = JSON.parse(readFileSync(`${base}.meta.json`, 'utf8')) const bytes = readFileSync(`${base}.txt`) - const pane = await createTranscriptPane({ + const transcript = await createTranscriptPane({ paneTitle: 'Claude Code', foregroundProcess: 'claude', launchAgent: 'claude', + ...pane, connectionId, size, data: '' }) const decoder = new TextDecoder() for (let offset = 0; offset < bytes.length; offset += 1024) { - pane.runtime.onPtyData( + transcript.runtime.onPtyData( TRANSCRIPT_PANE_PTY_ID, decoder.decode(bytes.subarray(offset, offset + 1024), { stream: true }), Date.now() ) } - return pane + return transcript } describe.each([undefined, 'ssh:build-host'])( @@ -53,6 +58,66 @@ describe.each([undefined, 'ssh:build-host'])( } ) +describe('hand-typed native Claude, whose process name is its version', () => { + afterEach(() => vi.useRealTimers()) + + function typedPane(overrides: Partial[0]> = {}) { + const counts = { probes: 0, scans: 0 } + const pane = replayDialog('claude-dialog-trust-workspace', undefined, { + paneTitle: 'bash', + launchAgent: undefined, + foregroundProcess: '2.1.310', + confirmedForegroundProcess: 'claude', + onForegroundProbe: () => counts.probes++, + onForegroundScan: () => counts.scans++, + ...overrides + }) + return pane.then(async (created) => { + // The dialog has been painted for a while when someone asks; let the emulator apply it. + await created.runtime['readVisibleTerminalState'](TRANSCRIPT_PANE_PTY_ID) + return { ...created, counts } + }) + } + + it('reports its trust dialog without storing the scanned identity', async () => { + const { runtime, handle, counts } = await typedPane() + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + reason: 'agent-trust-workspace' + }) + const before = runtime['ptysById'].get(TRANSCRIPT_PANE_PTY_ID)?.foregroundAgent + await runtime.refreshPtyForegroundAgentFromController(TRANSCRIPT_PANE_PTY_ID) + expect(runtime['ptysById'].get(TRANSCRIPT_PANE_PTY_ID)?.foregroundAgent).toBe(before) + expect(before).not.toBe('claude') + expect(counts.scans).toBe(1) + }) + + it('never scans for a name that is not a version', async () => { + const { runtime, handle, counts } = await typedPane({ foregroundProcess: 'vim' }) + await runtime.getTerminalInteractiveWait(handle) + expect(counts.scans).toBe(0) + }) + + it('answers unknown when the foreground read never returns', async () => { + const { runtime, handle } = await typedPane({ foregroundProbeHangs: true }) + vi.useFakeTimers() + const wait = runtime.getTerminalInteractiveWait(handle) + await vi.advanceTimersByTimeAsync(5_000) + await expect(wait).resolves.toBeUndefined() + }) + + it('does not read the foreground of a plain shell', async () => { + const counts = { probes: 0 } + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'bash', + foregroundProcess: 'bash', + onForegroundProbe: () => counts.probes++, + data: 'user@host repo $ ' + }) + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toBeNull() + expect(counts.probes).toBe(0) + }) +}) + describe('Claude dialog screen observation fences', () => { afterEach(() => vi.useRealTimers()) diff --git a/src/main/runtime/runtime-pty-foreground-agent.ts b/src/main/runtime/runtime-pty-foreground-agent.ts index 252354c0953..2fe418e9523 100644 --- a/src/main/runtime/runtime-pty-foreground-agent.ts +++ b/src/main/runtime/runtime-pty-foreground-agent.ts @@ -1,4 +1,7 @@ import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import type { TerminalAgent } from '../../shared/terminal-agent' +import { withTimeoutResult } from './runtime-async-boundaries' +import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from './orca-runtime-core' import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { PtyForegroundAgentRefresh, @@ -14,10 +17,18 @@ type Dependencies = { finishDelayedSnapshot(ptyId: string, changed: boolean): void } +const NATIVE_CLAUDE_VERSION_NAME = /^\d+\.\d+\.\d+$/ + export class RuntimePtyForegroundAgent { private readonly refreshes = new Map() private readonly reads = new Map() private readonly delayedTitles = new Map() + private readonly identifies = new Map>() + // A scan's recognized agent for a version name holds while that name stays in the foreground. + private readonly confirmedByPtyId = new Map< + string, + { foregroundName: string; agent: TerminalAgent } + >() constructor(private readonly deps: Dependencies) {} @@ -90,6 +101,32 @@ export class RuntimePtyForegroundAgent { return entry.promise } + /** + * Null when launch, foreground, or command evidence owns the pane, or `isCandidate` declines; resolves undefined only on timeout. + * Not stored on the pty: a dialog declined before any title would leave no title transition to clear it. + */ + identifyUnowned( + ptyId: string, + isCandidate: () => boolean + ): Promise | null { + const pty = this.deps.getPty(ptyId) + if ( + !pty || + (pty.launchAgent ?? pty.foregroundAgent ?? pty.launchCommandAgent) || + !isCandidate() + ) { + return null + } + let pending = this.identifies.get(ptyId) + if (!pending) { + pending = this.identify(ptyId).finally(() => this.identifies.delete(ptyId)) + this.identifies.set(ptyId, pending) + } + return withTimeoutResult(pending, TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS).then((result) => + result.ok ? result.value : undefined + ) + } + getPending(ptyId: string, afterTitle: number): Promise | undefined { return this.refreshes.has(ptyId) ? this.refresh(ptyId, afterTitle) : undefined } @@ -113,8 +150,9 @@ export class RuntimePtyForegroundAgent { return this.delayedTitles.has(ptyId) } - clearDelayedSnapshot(ptyId: string): void { + forget(ptyId: string): void { this.delayedTitles.delete(ptyId) + this.confirmedByPtyId.delete(ptyId) } private async load(ptyId: string, afterTitle: number): Promise { @@ -136,6 +174,51 @@ export class RuntimePtyForegroundAgent { return true } + private async identify(ptyId: string): Promise { + const controller = this.deps.getController() + const pty = this.deps.getPty(ptyId) + if (!controller || !pty?.connected || pty.launchAgent) { + return null + } + const result = await this.read(ptyId) + if (!result || result.controller !== this.deps.getController() || !result.available) { + return null + } + const foregroundName = result.process + const agent = recognizeAgentProcess(foregroundName)?.agent ?? null + // Native Claude's p_comm is its versions/ binary; only a process-table scan reads its argv. + if (agent || !foregroundName || !NATIVE_CLAUDE_VERSION_NAME.test(foregroundName)) { + this.confirmedByPtyId.delete(ptyId) + return agent + } + const confirmed = this.confirmedByPtyId.get(ptyId) + if (confirmed?.foregroundName === foregroundName) { + return confirmed.agent + } + // Windows native Claude is claude.exe (recognized by name), and confirm there forces a full scan. + if (process.platform === 'win32') { + return null + } + const confirmedAgent = + recognizeAgentProcess(await this.confirm(result.controller, ptyId))?.agent ?? null + if (result.controller !== this.deps.getController()) { + return null + } + // Only a recognized agent is kept, so a "not an agent" verdict is rescanned on the next dialog. + if (confirmedAgent) { + this.confirmedByPtyId.set(ptyId, { foregroundName, agent: confirmedAgent }) + } + return confirmedAgent + } + + private async confirm(controller: RuntimePtyController, ptyId: string): Promise { + try { + return (await controller.confirmForegroundProcess?.(ptyId)) ?? null + } catch { + return null + } + } + private deleteRead(ptyId: string, entry: PtyForegroundProcessReadEntry): void { if (this.reads.get(ptyId) === entry) { this.reads.delete(ptyId) diff --git a/src/main/runtime/terminal/input/terminal-agent-prompt-resubmit.ts b/src/main/runtime/terminal/input/terminal-agent-prompt-resubmit.ts index 8e3af9905b2..1c93758e59e 100644 --- a/src/main/runtime/terminal/input/terminal-agent-prompt-resubmit.ts +++ b/src/main/runtime/terminal/input/terminal-agent-prompt-resubmit.ts @@ -1,4 +1,4 @@ -import { OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission } from '../wait/terminal-wait-permission' +import { OrcaRuntimeWithTitlelessPaneAgentIdentity } from '../wait/titleless-pane-agent-identity' import type { RuntimeAgentPromptWriteOptions } from '../../runtime-terminal-contracts' import { AGENT_PROMPT_SUBMIT, @@ -18,7 +18,7 @@ type HeldAgentPrompt = { heldBudgetMs: number } -export abstract class OrcaRuntimeWithResubmitHeldAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { +export abstract class OrcaRuntimeWithResubmitHeldAgentPrompt extends OrcaRuntimeWithTitlelessPaneAgentIdentity { /** Retry blind Enter and drafts held by Windows input-record pastes or unopened dsh launchpads; retries cannot fail a submitted prompt. */ protected async resubmitHeldAgentPrompt( ptyId: string, diff --git a/src/main/runtime/terminal/output/terminal-title-observation.ts b/src/main/runtime/terminal/output/terminal-title-observation.ts index d88d4f34b53..58a1f748297 100644 --- a/src/main/runtime/terminal/output/terminal-title-observation.ts +++ b/src/main/runtime/terminal/output/terminal-title-observation.ts @@ -265,7 +265,7 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit this.observeClaudeTerminalEvidence(ptyId, { kind: 'reset' }) this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() this.ptyTitleTrackersByPtyId.delete(ptyId) - this.ptyForegroundAgent.clearDelayedSnapshot(ptyId) + this.ptyForegroundAgent.forget(ptyId) this.openCodeRunLifetime.forgetPty(ptyId) this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId) this.clientEvents.clearPtyTitleGate(ptyId) diff --git a/src/main/runtime/terminal/wait/terminal-wait-permission.ts b/src/main/runtime/terminal/wait/terminal-wait-permission.ts index 7f6b643954b..9819ade1b14 100644 --- a/src/main/runtime/terminal/wait/terminal-wait-permission.ts +++ b/src/main/runtime/terminal/wait/terminal-wait-permission.ts @@ -75,9 +75,10 @@ export abstract class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission /** Undefined when the pane changed or exited during a screen read; the caller then has no answer. */ protected readTerminalInteractiveWaitEvidence( handle: string, - ptyId: string + ptyId: string, + paneAgent: TerminalAgent | null = this.getPaneAgentForTuiIdle(ptyId) ): TerminalInteractiveWaitEvidence | Promise { - if (this.getPaneAgentForTuiIdle(ptyId) !== 'claude' || !this.headlessTerminals.has(ptyId)) { + if (paneAgent !== 'claude' || !this.headlessTerminals.has(ptyId)) { return this.buildTerminalInteractiveWaitEvidence(handle, ptyId, 'not-read') } return this.readCurrentTerminalWaitScreen(ptyId).then((screen) => diff --git a/src/main/runtime/terminal/wait/titleless-pane-agent-identity.ts b/src/main/runtime/terminal/wait/titleless-pane-agent-identity.ts new file mode 100644 index 00000000000..a0f4e68a0d4 --- /dev/null +++ b/src/main/runtime/terminal/wait/titleless-pane-agent-identity.ts @@ -0,0 +1,33 @@ +import { detectTerminalWaitBlockedReason } from '../../terminal-wait-detection' +import { + OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission, + type TerminalInteractiveWaitEvidence +} from './terminal-wait-permission' + +export abstract class OrcaRuntimeWithTitlelessPaneAgentIdentity extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { + /** A hand-typed agent's startup dialogs emit no title, so no title transition asked which process owns the pane. */ + protected override readTerminalInteractiveWaitEvidence( + handle: string, + ptyId: string + ): TerminalInteractiveWaitEvidence | Promise { + // Only an unowned, untitled pane showing a dialog earns a foreground read; others answer synchronously. + const identity = this.ptyForegroundAgent.identifyUnowned(ptyId, () => { + const { terminal } = this.getTerminalWaitPermissionInputs(handle, ptyId) + return ( + terminal.titleStatus === null && + detectTerminalWaitBlockedReason( + this.readLiveTerminalScreenLines(ptyId)?.join('\n') ?? terminal.waitText + ) !== null + ) + }) + if (!identity) { + return super.readTerminalInteractiveWaitEvidence(handle, ptyId) + } + // A timed-out scan cannot rule out a dialog, so it answers unknown rather than none. + return identity.then((agent) => + agent === undefined || this.getTerminalAgentStatusPtyId(handle) !== ptyId + ? undefined + : super.readTerminalInteractiveWaitEvidence(handle, ptyId, agent) + ) + } +}