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/<semver> 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 <noreply@anthropic.com>

* fix(runtime): drop a titleless identity read if the pane rebinds; trim tautological tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-26 07:13:36 -07:00
committed by GitHub
co-authored by Claude
parent b51d904ef0
commit 6c815e80c8
6 changed files with 192 additions and 10 deletions
@@ -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<Parameters<typeof createTranscriptPane>[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<Parameters<typeof createTranscriptPane>[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())
@@ -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<string, PtyForegroundAgentRefresh>()
private readonly reads = new Map<string, PtyForegroundProcessReadEntry>()
private readonly delayedTitles = new Map<string, number>()
private readonly identifies = new Map<string, Promise<TerminalAgent | null>>()
// 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<TerminalAgent | null | undefined> | 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<boolean> | 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<boolean> {
@@ -136,6 +174,51 @@ export class RuntimePtyForegroundAgent {
return true
}
private async identify(ptyId: string): Promise<TerminalAgent | null> {
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/<semver> 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<string | null> {
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)
@@ -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,
@@ -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)
@@ -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<TerminalInteractiveWaitEvidence | undefined> {
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) =>
@@ -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<TerminalInteractiveWaitEvidence | undefined> {
// 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)
)
}
}