diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index a6039f5e1..9f8d71e93 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -85,7 +85,8 @@ export const TERMINAL_HANDLERS: Record = { terminal: await getTerminalHandle(flags, cwd, client), text: getOptionalStringFlag(flags, 'text'), enter: flags.get('enter') === true, - interrupt: flags.get('interrupt') === true + interrupt: flags.get('interrupt') === true, + client: { id: 'orca-cli', type: 'desktop' } }) printResult(result, json, formatTerminalSend) }, diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 3440f1763..f6e99ed7e 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -127,21 +127,27 @@ function createTerminalOutputBatcher(onFlush: (data: string) => void): { } } -function isTerminalInputLockedForClient( +async function reclaimTerminalInputForClient( runtime: OrcaRuntimeService, ptyId: string, client: TerminalViewportClient | undefined -): boolean { +): Promise { if (client?.type === 'mobile') { - return false + return true } // Why: pre-refactor mobile builds did not send client metadata. Desktop // callers we control now identify as desktop, so keep legacy mobile input // working without opening the new desktop path. if (!client) { - return false + return true } - return runtime.getDriver(ptyId).kind === 'mobile' + if (runtime.getDriver(ptyId).kind !== 'mobile') { + return true + } + // Why: a live desktop typing into a remotely driven terminal is an explicit + // take-back. Otherwise a stale mobile socket can black-hole input until + // heartbeat cleanup, or forever behind a proxy that keeps it warm. + return runtime.reclaimTerminalForDesktop(ptyId) } function resolveMobileFloorClientId( @@ -535,7 +541,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime }) => { const leaf = runtime.resolveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null - if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { + if ( + leaf?.ptyId && + !(await reclaimTerminalInputForClient(runtime, leaf.ptyId, params.client)) + ) { return { send: { handle: params.terminal, @@ -790,12 +799,17 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!text) { return } - if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - void runtime - .sendTerminal(stream.terminal, { text, enter: false, interrupt: false }) - .then(async () => { + void reclaimTerminalInputForClient(runtime, stream.ptyId, stream.client) + .then((canSend) => { + if (!canSend) { + return null + } + return runtime.sendTerminal(stream.terminal, { text, enter: false, interrupt: false }) + }) + .then(async (result) => { + if (!result) { + return + } if (stream.isMobile && stream.client?.id) { await runtime.mobileTookFloor(stream.ptyId, stream.client.id) } @@ -1189,12 +1203,21 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (!text) { return } - if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - void runtime - .sendTerminal(params.terminal, { text, enter: false, interrupt: false }) - .then(async () => { + void reclaimTerminalInputForClient(runtime, ptyId, params.client) + .then((canSend) => { + if (!canSend) { + return null + } + return runtime.sendTerminal(params.terminal, { + text, + enter: false, + interrupt: false + }) + }) + .then(async (result) => { + if (!result) { + return + } if (isMobile && clientId) { await runtime.mobileTookFloor(ptyId, clientId) } diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index a8260b0e2..97557f7d4 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -288,7 +288,7 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) - it('drops desktop multiplex input while a mobile client owns the terminal floor', async () => { + it('reclaims desktop multiplex input while a mobile client owns the terminal floor', async () => { const messages: string[] = [] const handlers = new Map< number, @@ -312,6 +312,7 @@ describe('terminal multiplex RPC', () => { rows: 20 }), getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'phone-1' }), + reclaimTerminalForDesktop: vi.fn().mockResolvedValue(true), registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { cleanups.set(id, cleanup) }), @@ -383,7 +384,14 @@ describe('terminal multiplex RPC', () => { )! ) - expect(runtime.sendTerminal).not.toHaveBeenCalled() + await vi.waitFor(() => expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1')) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'typed while locked', + enter: false, + interrupt: false + }) + ) cleanups.get('terminal-multiplex:conn-locked')?.() await dispatchPromise }) diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 1950a2116..3fad5c6e1 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -36,11 +36,16 @@ describe('terminal send RPC', () => { expect(runtime.isTerminalRunningAgent).toHaveBeenCalledWith('terminal-1') }) - it('drops desktop input while a mobile client owns the terminal floor', async () => { + it('reclaims the terminal for desktop input while a mobile client owns the floor', async () => { const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), getDriver: vi.fn().mockReturnValue({ kind: 'mobile', clientId: 'mobile-1' }), - sendTerminal: vi.fn(), + reclaimTerminalForDesktop: vi.fn().mockResolvedValue(true), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 1 + }), mobileTookFloor: vi.fn() }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) @@ -57,14 +62,13 @@ describe('terminal send RPC', () => { if (!response.ok) { throw new Error(response.error.message) } - expect(response.result).toEqual({ - send: { - handle: 'terminal-1', - accepted: false, - bytesWritten: 0 - } + expect(response.result).toMatchObject({ send: { accepted: true, bytesWritten: 1 } }) + expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1') + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'x', + enter: false, + interrupt: false }) - expect(runtime.sendTerminal).not.toHaveBeenCalled() expect(runtime.mobileTookFloor).not.toHaveBeenCalled() }) 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 24d20009b..c81c01ab8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -475,6 +475,9 @@ describe('connectPanePty', () => { dispatch: vi.fn().mockResolvedValue({ delivered: true }), playSound: vi.fn().mockResolvedValue({ played: true }) }, + runtime: { + restoreTerminalFit: vi.fn().mockResolvedValue({ restored: true }) + }, agentStatus: { inferInterrupt: vi.fn().mockResolvedValue(false) } @@ -1943,7 +1946,7 @@ describe('connectPanePty', () => { expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) - it('does not infer interrupts when mobile presence lock blocks terminal input', async () => { + it('reclaims and replays terminal input when mobile presence lock is active', async () => { const { connectPanePty } = await import('./pty-connection') const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') @@ -1988,15 +1991,56 @@ describe('connectPanePty', () => { } terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) ;(onDataHandler as unknown as (data: string) => void)('\x03') - vi.advanceTimersByTime(500) + ;(onDataHandler as unknown as (data: string) => void)('x') + await flushAsyncTicks() - expect(transport.sendInput).not.toHaveBeenCalled() + expect(window.api.runtime.restoreTerminalFit).toHaveBeenCalledWith(ptyId) + expect(window.api.runtime.restoreTerminalFit).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(transport.sendInput).toHaveBeenCalledWith('x') expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() } finally { setDriverForPty(ptyId, { kind: 'idle' }) } }) + it('lets remote locked terminal input reach the runtime transport for reclaim', async () => { + const { connectPanePty } = await import('./pty-connection') + const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') + + const ptyId = 'remote:env-1@@terminal-1' + setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' }) + try { + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] }, + ptyIdsByTabId: { 'tab-1': [ptyId] } + } + + const pane = createPane(1) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('x') + await flushAsyncTicks() + + expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() + expect(transport.sendInput).toHaveBeenCalledWith('x') + } finally { + setDriverForPty(ptyId, { kind: 'idle' }) + } + }) + it('does not infer interrupts when the transport rejects terminal input', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 764de7343..acfa54bb6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -16,7 +16,7 @@ import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' import type { PtyConnectionDeps } from './pty-connection-types' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' -import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' +import { isPtyLocked, setDriverForPty } from '@/lib/pane-manager/mobile-driver-state' import { isPaneReplaying, replayIntoTerminal } from './replay-guard' import { terminalOutputPrefersDomRenderer } from '@/lib/pane-manager/terminal-complex-script' import { @@ -121,6 +121,37 @@ function isCodexStartupCommand(command: string): boolean { return executable === 'codex' || executable?.startsWith('codex-') === true } +const pendingDesktopInputReclaims = new Map>() + +async function reclaimLockedPtyForDesktopInput(ptyId: string): Promise { + if (isRemoteRuntimePtyId(ptyId)) { + return true + } + const pending = pendingDesktopInputReclaims.get(ptyId) + if (pending) { + return pending + } + const reclaim = reclaimLocalLockedPtyForDesktopInput(ptyId).finally(() => { + pendingDesktopInputReclaims.delete(ptyId) + }) + pendingDesktopInputReclaims.set(ptyId, reclaim) + return reclaim +} + +async function reclaimLocalLockedPtyForDesktopInput(ptyId: string): Promise { + try { + const restored = (await window.api.runtime.restoreTerminalFit(ptyId)).restored === true + if (restored) { + // Why: driver-change IPC is asynchronous; unblocking locally avoids + // dropping rapid follow-up keystrokes during the same desktop take-back. + setDriverForPty(ptyId, { kind: 'desktop' }) + } + return restored || !isPtyLocked(ptyId) + } catch { + return false + } +} + function shouldKeepHiddenStartupRendererQueriesLive( startup: PtyConnectionDeps['startup'] ): boolean { @@ -1202,15 +1233,24 @@ export function connectPanePty( clearPendingTerminalInputIntent() return } - // Why: presence-lock input drop. While mobile is the driver for this - // PTY, desktop keystrokes must not reach the shell — any input would - // race the mobile session and is also dimensionally wrong (PTY is at - // phone fit). Renderer-side guard belongs here so we don't even mark - // the pane as "interacted" (no unread clear, no take-floor cascade). - // The pty:write IPC has a defense-in-depth twin. See - // docs/mobile-presence-lock.md. if (currentPtyId && isPtyLocked(currentPtyId)) { clearPendingTerminalInputIntent() + // Why: typing into a mobile-driven terminal is a desktop take-back. + // Local PTYs must restore first so main's IPC defense accepts the + // replay; remote PTYs let the runtime RPC reclaim at its own seam. + void reclaimLockedPtyForDesktopInput(currentPtyId).then((reclaimed) => { + if (!reclaimed || transport.getPtyId() !== currentPtyId) { + return + } + if (transport.sendInput(data)) { + markTerminalInputSent() + observeAcceptedTerminalInput(data) + observeSentTerminalInputIntent(data) + deps.clearTerminalTabUnread(deps.tabId) + deps.clearTerminalPaneUnread(cacheKey) + deps.clearWorktreeUnread(deps.worktreeId) + } + }) return } // Why: a real keystroke into the terminal is the unambiguous "user is diff --git a/src/renderer/src/lib/active-agent-note-send.test.ts b/src/renderer/src/lib/active-agent-note-send.test.ts index 8246a79b8..18b06eaf3 100644 --- a/src/renderer/src/lib/active-agent-note-send.test.ts +++ b/src/renderer/src/lib/active-agent-note-send.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: these note-send routing cases share one mocked app store and RPC harness. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getActiveAgentNoteTarget, @@ -210,7 +211,12 @@ describe('active agent note send', () => { expect(testState.callRuntimeRpc).toHaveBeenCalledWith( { kind: 'local' }, 'terminal.send', - { terminal: 'term-1', text: 'File: src/app.ts', enter: true }, + { + terminal: 'term-1', + text: 'File: src/app.ts', + enter: true, + client: { id: 'orca-desktop', type: 'desktop' } + }, { timeoutMs: 15000 } ) }) diff --git a/src/renderer/src/lib/active-agent-note-send.ts b/src/renderer/src/lib/active-agent-note-send.ts index 0f00a8906..d379c9d25 100644 --- a/src/renderer/src/lib/active-agent-note-send.ts +++ b/src/renderer/src/lib/active-agent-note-send.ts @@ -158,7 +158,12 @@ export async function sendNotesToActiveAgentSession({ const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( runtimeTarget, 'terminal.send', - { terminal: terminal.handle, text: trimmedPrompt, enter: true }, + { + terminal: terminal.handle, + text: trimmedPrompt, + enter: true, + client: { id: 'orca-desktop', type: 'desktop' } + }, { timeoutMs: ACTIVE_AGENT_SEND_RPC_TIMEOUT_MS } ) return send.accepted ? { status: 'sent' } : { status: 'not-writable' } diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index e3dd83c46..f1997b5b5 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -51,7 +51,11 @@ describe('runtime terminal owner routing', () => { expect(runtimeCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'terminal.send', - params: { terminal: 'terminal-1', text: 'x' }, + params: { + terminal: 'terminal-1', + text: 'x', + client: { id: 'orca-desktop', type: 'desktop' } + }, timeoutMs: 15_000 }) }) diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index b292e5dad..e30e87186 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -12,6 +12,7 @@ export type RuntimeTerminalProcessInspection = { } const REMOTE_PTY_ID_PREFIX = 'remote:' +const DESKTOP_RUNTIME_CLIENT = { id: 'orca-desktop', type: 'desktop' } as const export function isRemoteRuntimePtyId(ptyId: string): boolean { return ptyId.startsWith(REMOTE_PTY_ID_PREFIX) @@ -87,7 +88,7 @@ export function sendRuntimePtyInput( void callRuntimeRpc( target, 'terminal.send', - { terminal, text: data }, + { terminal, text: data, client: DESKTOP_RUNTIME_CLIENT }, { timeoutMs: 15_000 } ).catch(() => { // Why: web session snapshots can retire a remote handle while xterm still @@ -121,7 +122,7 @@ export async function sendRuntimePtyInputVerified( const result = await callRuntimeRpc<{ send: RuntimeTerminalSend }>( target, 'terminal.send', - { terminal, text: data, client: { id: 'orca-desktop', type: 'desktop' } }, + { terminal, text: data, client: DESKTOP_RUNTIME_CLIENT }, { timeoutMs: 15_000 } ) return result.send.accepted === true