mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user