From 1136503c6a231a16dce8f921f6fadb63d181e8db Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:16:03 -0700 Subject: [PATCH 01/33] fix(mobile): use 'unsupported' for the no-review-creation test premise (#14091) --- mobile/src/source-control/mobile-create-pr-action.test.ts | 7 +++---- mobile/src/source-control/mobile-create-pr-action.ts | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/mobile/src/source-control/mobile-create-pr-action.test.ts b/mobile/src/source-control/mobile-create-pr-action.test.ts index c665b034f7f..ec399b45913 100644 --- a/mobile/src/source-control/mobile-create-pr-action.test.ts +++ b/mobile/src/source-control/mobile-create-pr-action.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { HostedReviewCreationBlockedReason, - HostedReviewCreationEligibility, - HostedReviewProvider + HostedReviewCreationEligibility } from '../../../src/shared/hosted-review' import { buildMobileCreatePrAction, @@ -146,7 +145,7 @@ describe('buildMobileCreatePrAction', () => { it('keeps a disabled status row for providers without review creation', () => { const { descriptor } = action({ - eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider }) + eligibility: eligibility({ provider: 'unsupported' }) }) expect(descriptor).toMatchObject({ @@ -211,7 +210,7 @@ describe('cold-mount layout stability (issue #8411)', () => { 'unsupported provider', { kind: 'ready', - eligibility: eligibility({ provider: 'bitbucket' as HostedReviewProvider }) + eligibility: eligibility({ provider: 'unsupported' }) } satisfies MobileCreatePrEligibilityState ], ['eligibility error', { kind: 'error' } satisfies MobileCreatePrEligibilityState] diff --git a/mobile/src/source-control/mobile-create-pr-action.ts b/mobile/src/source-control/mobile-create-pr-action.ts index fe75cc60a58..3268a1796c3 100644 --- a/mobile/src/source-control/mobile-create-pr-action.ts +++ b/mobile/src/source-control/mobile-create-pr-action.ts @@ -72,7 +72,7 @@ export function buildMobileCreatePrAction({ } // Why: mirror desktop's structural provider gate (supportsHostedReviewCreation) // instead of relying on the host always emitting a hidden blockedReason for - // non-creatable providers like bitbucket. + // non-creatable providers. if (!supportsHostedReviewCreation(eligibility.provider)) { return { visible: true, From a868b090e0d694dfab1a4554714f50efcbd54b48 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:02:23 -0700 Subject: [PATCH 02/33] fix: connect mobile emulator in folder workspaces (#14009) --- src/cli/handlers/emulator.test.ts | 58 ++++++- src/cli/selectors.ts | 8 + src/main/emulator/emulator-bridge.test.ts | 125 ++++++++++++-- src/main/emulator/emulator-bridge.ts | 54 +++--- .../emulator/emulator-device-inventory.ts | 19 +++ .../emulator/emulator-session-registry.ts | 8 + .../emulator-start-lease-registry.test.ts | 111 ++++++++++++ .../emulator/emulator-start-lease-registry.ts | 127 ++++++++++++++ ...-runtime-emulator-folder-workspace.test.ts | 161 ++++++++++++++++++ src/main/runtime/orca-runtime-emulator.ts | 40 ++++- src/main/runtime/orca-runtime.ts | 45 +++-- 11 files changed, 699 insertions(+), 57 deletions(-) create mode 100644 src/main/emulator/emulator-device-inventory.ts create mode 100644 src/main/emulator/emulator-start-lease-registry.test.ts create mode 100644 src/main/emulator/emulator-start-lease-registry.ts create mode 100644 src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts diff --git a/src/cli/handlers/emulator.test.ts b/src/cli/handlers/emulator.test.ts index 07bdd51e2f5..ff0dc5f141a 100644 --- a/src/cli/handlers/emulator.test.ts +++ b/src/cli/handlers/emulator.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { callMock, remoteMock } = vi.hoisted(() => ({ callMock: vi.fn(), @@ -29,6 +29,9 @@ import { main } from '../index' import { okFixture, queueFixtures } from '../test-fixtures' describe('orca emulator CLI handlers', () => { + const originalWorkspaceId = process.env.ORCA_WORKSPACE_ID + const originalWorktreeId = process.env.ORCA_WORKTREE_ID + beforeEach(() => { vi.restoreAllMocks() callMock.mockReset() @@ -38,6 +41,19 @@ describe('orca emulator CLI handlers', () => { process.exitCode = undefined }) + afterEach(() => { + if (originalWorkspaceId === undefined) { + delete process.env.ORCA_WORKSPACE_ID + } else { + process.env.ORCA_WORKSPACE_ID = originalWorkspaceId + } + if (originalWorktreeId === undefined) { + delete process.env.ORCA_WORKTREE_ID + } else { + process.env.ORCA_WORKTREE_ID = originalWorktreeId + } + }) + it('resolves relative APK paths before calling the runtime', async () => { queueFixtures(callMock, okFixture('req_install', { ok: true })) @@ -73,6 +89,46 @@ describe('orca emulator CLI handlers', () => { ) }) + it('uses the folder workspace exported by the current Orca terminal', async () => { + process.env.ORCA_WORKSPACE_ID = 'folder:folder-1' + delete process.env.ORCA_WORKTREE_ID + callMock.mockResolvedValue( + okFixture('req_attach', { + attached: true, + info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' } + }) + ) + + await main(['emulator', 'attach', 'device-1'], '/folder/project') + + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith( + 'emulator.attach', + { device: 'device-1', worktree: 'folder:folder-1', focus: false }, + { timeoutMs: 180_000 } + ) + }) + + it('uses the current git worktree exported by the Orca terminal', async () => { + process.env.ORCA_WORKSPACE_ID = 'folder:stale-parent' + process.env.ORCA_WORKTREE_ID = 'repo-1::/repo/project ' + callMock.mockResolvedValue( + okFixture('req_attach', { + attached: true, + info: { deviceUdid: 'device-1', streamUrl: 'scrcpy://device-1' } + }) + ) + + await main(['emulator', 'attach', 'device-1'], '/repo/project') + + expect(callMock).toHaveBeenCalledOnce() + expect(callMock).toHaveBeenCalledWith( + 'emulator.attach', + { device: 'device-1', worktree: 'repo-1::/repo/project ', focus: false }, + { timeoutMs: 180_000 } + ) + }) + it('rejects relative APK paths for remote runtimes', async () => { remoteMock.mockReturnValue(true) diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 3729b8598a7..aef583ad0ac 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -237,6 +237,14 @@ export async function getEmulatorWorktreeSelector( if (client.isRemote) { return undefined } + const terminalWorktreeId = process.env.ORCA_WORKTREE_ID + if (terminalWorktreeId?.trim()) { + return terminalWorktreeId + } + const folderWorkspaceId = process.env.ORCA_WORKSPACE_ID?.trim() + if (folderWorkspaceId?.startsWith('folder:')) { + return folderWorkspaceId + } try { return await resolveCurrentWorktreeSelector(cwd, client) } catch { diff --git a/src/main/emulator/emulator-bridge.test.ts b/src/main/emulator/emulator-bridge.test.ts index 33b4f9d2421..a880c0f15a3 100644 --- a/src/main/emulator/emulator-bridge.test.ts +++ b/src/main/emulator/emulator-bridge.test.ts @@ -216,6 +216,95 @@ describe('EmulatorBridge helper ownership', () => { expect(bridge.getActiveForWorktree('wt-1')).toBeNull() }) + it('does not clean up a failed attach while another start claim can register the device', async () => { + const waitForEndpointReady = vi.fn(async () => true) + execServeSimCommandMock.mockResolvedValue({ + device: 'device-1', + streamUrl: 'http://127.0.0.1:3102/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3102' + }) + const bridge = new EmulatorBridge({ waitForEndpointReady }) + + const failedLease = await bridge.acquireHelperForDevice('device-1') + const survivingLease = await bridge.acquireHelperForDevice('device-1') + + await failedLease.release({ cleanupIfUnused: true }) + bridge.registerActiveEmulator('wt-surviving', survivingLease.info, { managed: true }) + await survivingLease.release() + + expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled() + expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled() + expect(bridge.getActiveForWorktree('wt-surviving')).toMatchObject({ + deviceUdid: 'device-1' + }) + }) + + it('cleans up a failed attach when no start claim or registered workspace remains', async () => { + const waitForEndpointReady = vi.fn(async () => true) + execServeSimCommandMock.mockResolvedValue({ + device: 'device-1', + streamUrl: 'http://127.0.0.1:3102/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3102' + }) + const bridge = new EmulatorBridge({ waitForEndpointReady }) + + const lease = await bridge.acquireHelperForDevice('device-1') + await lease.release({ cleanupIfUnused: true }) + + expect(killServeSimHelperProcessesForDeviceMock).toHaveBeenCalledWith('device-1', { + helperPid: undefined, + includeOrphaned: true + }) + expect(shutdownSimulatorDeviceMock).toHaveBeenCalledWith('device-1') + }) + + it('keeps a shared device alive when one registered workspace detaches', async () => { + const bridge = new EmulatorBridge() + bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true }) + bridge.registerActiveEmulator('wt-2', session('device-1'), { managed: true }) + + await bridge.stopActiveManagedForWorktree('wt-1', { shutdownDevice: true }) + + expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled() + expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled() + expect(bridge.getActiveForWorktree('wt-1')).toBeNull() + expect(bridge.getActiveForWorktree('wt-2')).toMatchObject({ deviceUdid: 'device-1' }) + }) + + it('keeps a device alive when its last registered workspace closes during another attach', async () => { + let finishStart: + | ((info: Awaited>) => void) + | undefined + execServeSimCommandMock.mockImplementation( + () => + new Promise((resolve) => { + finishStart = resolve + }) + ) + const bridge = new EmulatorBridge({ waitForEndpointReady: vi.fn(async () => true) }) + bridge.registerActiveEmulator('wt-closing', session('device-1'), { managed: true }) + + const leasePromise = bridge.acquireHelperForDevice('device-1') + await vi.waitFor(() => expect(execServeSimCommandMock).toHaveBeenCalledOnce()) + const shutdown = bridge.shutdownActiveManagedForWorktree('wt-closing') + await Promise.resolve() + + expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled() + finishStart?.({ + device: 'device-1', + streamUrl: 'http://127.0.0.1:3102/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3102' + }) + const lease = await leasePromise + bridge.registerActiveEmulator('wt-attaching', lease.info, { managed: true }) + await lease.release() + await shutdown + + expect(killServeSimHelperProcessesForDeviceMock).not.toHaveBeenCalled() + expect(shutdownSimulatorDeviceMock).not.toHaveBeenCalled() + expect(bridge.getActiveForWorktree('wt-attaching')).toMatchObject({ deviceUdid: 'device-1' }) + }) + it('reuses the active helper for the same requested device', async () => { const waitForEndpointReady = vi.fn(async () => true) const bridge = new EmulatorBridge({ waitForEndpointReady }) @@ -267,7 +356,7 @@ describe('EmulatorBridge helper ownership', () => { }) const bridge = new EmulatorBridge({ waitForEndpointReady }) - const info = await bridge.startHelperForDevice('device-1') + const { info } = await bridge.acquireHelperForDevice('device-1') expect(info.deviceUdid).toBe('device-1') expect(waitForEndpointReady).toHaveBeenCalledTimes(2) @@ -309,7 +398,7 @@ describe('EmulatorBridge helper ownership', () => { }) const bridge = new EmulatorBridge({ waitForEndpointReady }) - await expect(bridge.startHelperForDevice('device-1')).rejects.toMatchObject({ + await expect(bridge.acquireHelperForDevice('device-1')).rejects.toMatchObject({ code: 'emulator_helper_failed' }) @@ -334,7 +423,7 @@ describe('EmulatorBridge helper ownership', () => { }) const bridge = new EmulatorBridge({ waitForEndpointReady }) - await expect(bridge.startHelperForDevice('device-1')).rejects.toMatchObject({ + await expect(bridge.acquireHelperForDevice('device-1')).rejects.toMatchObject({ code: 'emulator_helper_failed' }) @@ -368,7 +457,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -401,7 +491,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -435,7 +526,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-other', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -465,7 +557,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-1', session('device-active'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -499,7 +592,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { ) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -530,7 +624,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { ) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -551,7 +646,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -575,7 +671,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { const bridge = new EmulatorBridge() const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: false, @@ -599,7 +696,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { const bridge = new EmulatorBridge({ waitForEndpointReady }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, @@ -641,7 +739,8 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => { const bridge = new EmulatorBridge({ waitForEndpointReady }) const commands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => bridge, - resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })), + resolveEmulatorWorkspaceId: vi.fn(async () => 'wt-1'), + resolveEmulatorCleanupWorkspaceId: vi.fn(async () => 'wt-1'), getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never, getSettings: () => ({ mobileEmulatorEnabled: true, diff --git a/src/main/emulator/emulator-bridge.ts b/src/main/emulator/emulator-bridge.ts index 84ecbb7025e..b201b34963a 100644 --- a/src/main/emulator/emulator-bridge.ts +++ b/src/main/emulator/emulator-bridge.ts @@ -5,6 +5,11 @@ import type { SimulatorDevice } from './simctl-simulator-devices' import type { EmulatorBridgeOptions } from './emulator-bridge-types' import type { EmulatorGesturePoint } from './emulator-gesture-sender' import { EmulatorSessionRegistry } from './emulator-session-registry' +import { + EmulatorStartLeaseRegistry, + type EmulatorStartLease +} from './emulator-start-lease-registry' +import { listAvailableEmulatorDevices } from './emulator-device-inventory' import { deriveAxUrlFromStreamUrl } from './serve-sim-detached-session' import { IosEmulatorBackend } from './backends/ios-emulator-backend' import { AndroidEmulatorBackend } from './backends/android-emulator-backend' @@ -22,6 +27,7 @@ import type { // backend a command targets (by the session's recorded backend, else by device). export class EmulatorBridge { private readonly sessionRegistry = new EmulatorSessionRegistry() + private readonly startLeases = new EmulatorStartLeaseRegistry() private readonly backends: EmulatorBackend[] private readonly iosBackend: IosEmulatorBackend private readonly androidBackend: AndroidEmulatorBackend @@ -41,19 +47,7 @@ export class EmulatorBridge { // Aggregated device list across host-supported backends (iOS simulators + // Android devices/AVDs), for the unified `orca emulator list`. async listAllDevices(): Promise { - const perBackend = await Promise.all( - this.backends.map(async (backend) => { - if (!backend.isSupportedOnHost()) { - return [] - } - try { - return await backend.listDevices() - } catch { - return [] - } - }) - ) - return perBackend.flat() + return listAvailableEmulatorDevices(this.backends) } // iOS-specific passthroughs kept for back-compat with the runtime + availability code. @@ -85,14 +79,10 @@ export class EmulatorBridge { return this.sessionRegistry.getActiveForWorktree(worktreeId) } - getActiveBackendKind(worktreeId: string): EmulatorBackendKind | null { - return this.backendForActiveWorktree(worktreeId)?.kind ?? null - } - // On a device switch, keep slow-to-boot Android emulators running for instant // switch-back; shut down other backends' devices so they are not leaked. async stopActiveForSwitch(worktreeId: string): Promise { - const keepAlive = this.getActiveBackendKind(worktreeId) === 'android' + const keepAlive = this.backendForActiveWorktree(worktreeId)?.kind === 'android' return this.stopActiveForWorktreeInternal(worktreeId, { shutdownDevice: !keepAlive }) } @@ -146,18 +136,26 @@ export class EmulatorBridge { if (!session || (options.managedOnly && !session.managed)) { return null } + if (this.sessionRegistry.hasActiveWorktreeForSession(key)) { + return session.deviceUdid + } const backend = this.backendForKind(session.backend) if (!backend) { return null } - await backend.stopHelperForDevice(session.deviceUdid, { - helperPid: session.pid, - includeOrphaned: !options.managedOnly - }) - if (options.shutdownDevice) { - await backend.shutdownDevice(session.deviceUdid).catch(() => {}) + const sessionInfo = this.sessionRegistry.toSessionInfo(session) + await this.startLeases.cleanupWhenIdle( + backend, + sessionInfo, + (info) => this.sessionRegistry.hasActiveWorktreeForSession(info.deviceUdid), + { + includeOrphaned: !options.managedOnly, + shutdownDevice: options.shutdownDevice + } + ) + if (!this.sessionRegistry.hasActiveWorktreeForSession(key)) { + this.sessionRegistry.clearSessionAndWorktrees(key) } - this.sessionRegistry.clearSessionAndWorktrees(key) return session.deviceUdid } @@ -243,9 +241,11 @@ export class EmulatorBridge { return run(backend, device) } - async startHelperForDevice(device: string): Promise { + async acquireHelperForDevice(device: string): Promise { const backend = await this.backendForDevice(device) - return backend.startSession(device) + return this.startLeases.acquire(backend, device, (info) => + this.sessionRegistry.hasActiveWorktreeForSession(info.deviceUdid) + ) } async kill(device?: string, worktreeId?: string): Promise { diff --git a/src/main/emulator/emulator-device-inventory.ts b/src/main/emulator/emulator-device-inventory.ts new file mode 100644 index 00000000000..35649b947f3 --- /dev/null +++ b/src/main/emulator/emulator-device-inventory.ts @@ -0,0 +1,19 @@ +import type { EmulatorBackend, EmulatorDevice } from './backends/emulator-backend' + +export async function listAvailableEmulatorDevices( + backends: EmulatorBackend[] +): Promise { + const perBackend = await Promise.all( + backends.map(async (backend) => { + if (!backend.isSupportedOnHost()) { + return [] + } + try { + return await backend.listDevices() + } catch { + return [] + } + }) + ) + return perBackend.flat() +} diff --git a/src/main/emulator/emulator-session-registry.ts b/src/main/emulator/emulator-session-registry.ts index b55578930e4..917b14a9e39 100644 --- a/src/main/emulator/emulator-session-registry.ts +++ b/src/main/emulator/emulator-session-registry.ts @@ -51,6 +51,14 @@ export class EmulatorSessionRegistry { return this.sessions.get(key) } + toSessionInfo(session: EmulatorSessionState): EmulatorSessionInfo { + return toSessionInfo(session) + } + + hasActiveWorktreeForSession(key: string): boolean { + return [...this.activeByWorktree.values()].some((activeKey) => activeKey === key) + } + listSessions(): EmulatorSessionState[] { return [...this.sessions.values()] } diff --git a/src/main/emulator/emulator-start-lease-registry.test.ts b/src/main/emulator/emulator-start-lease-registry.test.ts new file mode 100644 index 00000000000..f0b0e927569 --- /dev/null +++ b/src/main/emulator/emulator-start-lease-registry.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' +import type { EmulatorBackend } from './backends/emulator-backend' +import { EmulatorStartLeaseRegistry } from './emulator-start-lease-registry' +import type { EmulatorSessionInfo } from './emulator-types' + +function makeBackend(): { + backend: EmulatorBackend + resolveDeviceId: ReturnType + startSession: ReturnType + stopHelperForDevice: ReturnType + shutdownDevice: ReturnType +} { + const resolveDeviceId = vi.fn(async () => { + throw new Error('shutdown AVD cannot resolve before boot') + }) + const startSession = vi.fn( + async (): Promise => ({ + deviceUdid: 'emulator-5554', + streamUrl: 'scrcpy://emulator-5554', + wsUrl: '', + streamCodec: 'h264', + backend: 'android' + }) + ) + const stopHelperForDevice = vi.fn(async () => {}) + const shutdownDevice = vi.fn(async () => {}) + const backend: EmulatorBackend = { + kind: 'android', + streamCodec: 'h264', + capabilities: { + install: false, + launch: false, + permissions: false, + accessibilityTree: false, + logcat: false + }, + isSupportedOnHost: () => true, + checkAvailability: vi.fn(), + listDevices: vi.fn(async () => []), + ownsDevice: vi.fn(async () => true), + resolveDeviceId, + startSession, + stopHelperForDevice, + shutdownDevice, + isSessionReusable: vi.fn(async () => true), + tap: vi.fn(async () => {}), + gesture: vi.fn(async () => {}), + type: vi.fn(async () => {}), + button: vi.fn(async () => {}), + rotate: vi.fn(async () => {}), + exec: vi.fn(async () => undefined) + } + return { backend, resolveDeviceId, startSession, stopHelperForDevice, shutdownDevice } +} + +describe('EmulatorStartLeaseRegistry', () => { + it('lets the backend boot a shutdown device before a canonical id exists', async () => { + const { backend, resolveDeviceId, startSession } = makeBackend() + const registry = new EmulatorStartLeaseRegistry() + + const lease = await registry.acquire(backend, 'Pixel_Tablet', () => false) + await lease.release() + + expect(resolveDeviceId).not.toHaveBeenCalled() + expect(startSession).toHaveBeenCalledWith('Pixel_Tablet') + expect(lease.info.deviceUdid).toBe('emulator-5554') + }) + + it('waits for pending cleanup before starting a new lease', async () => { + const { backend, startSession, stopHelperForDevice, shutdownDevice } = makeBackend() + let finishCleanup: (() => void) | undefined + stopHelperForDevice.mockImplementation( + () => + new Promise((resolve) => { + finishCleanup = resolve + }) + ) + const registry = new EmulatorStartLeaseRegistry() + const first = await registry.acquire(backend, 'Pixel_Tablet', () => false) + + const cleanup = first.release({ cleanupIfUnused: true }) + await vi.waitFor(() => expect(stopHelperForDevice).toHaveBeenCalledOnce()) + const second = registry.acquire(backend, 'Pixel_Tablet', () => false) + await Promise.resolve() + expect(startSession).toHaveBeenCalledOnce() + + finishCleanup?.() + await cleanup + const secondLease = await second + + expect(shutdownDevice).toHaveBeenCalledOnce() + expect(startSession).toHaveBeenCalledTimes(2) + await secondLease.release() + }) + + it('claims synchronously before an earlier lease can start cleanup', async () => { + const { backend, stopHelperForDevice, shutdownDevice } = makeBackend() + const registry = new EmulatorStartLeaseRegistry() + const first = await registry.acquire(backend, 'Pixel_Tablet', () => false) + + const second = registry.acquire(backend, 'Pixel_Tablet', () => false) + await first.release({ cleanupIfUnused: true }) + + expect(stopHelperForDevice).not.toHaveBeenCalled() + const secondLease = await second + await secondLease.release() + + expect(stopHelperForDevice).toHaveBeenCalledOnce() + expect(shutdownDevice).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/emulator/emulator-start-lease-registry.ts b/src/main/emulator/emulator-start-lease-registry.ts new file mode 100644 index 00000000000..94b1e6ce992 --- /dev/null +++ b/src/main/emulator/emulator-start-lease-registry.ts @@ -0,0 +1,127 @@ +import type { EmulatorBackend } from './backends/emulator-backend' +import type { EmulatorSessionInfo } from './emulator-types' + +export type EmulatorStartLease = { + info: EmulatorSessionInfo + release(options?: { cleanupIfUnused?: boolean }): Promise +} + +type PendingCleanup = { + info: EmulatorSessionInfo + isRegistered: (info: EmulatorSessionInfo) => boolean + includeOrphaned: boolean + shutdownDevice: boolean +} + +export class EmulatorStartLeaseRegistry { + private readonly claimsByBackend = new Map() + private readonly cleanupByBackend = new Map>() + private readonly pendingCleanupByBackend = new Map>() + + async acquire( + backend: EmulatorBackend, + device: string, + isRegistered: (info: EmulatorSessionInfo) => boolean + ): Promise { + this.claimsByBackend.set(backend, (this.claimsByBackend.get(backend) ?? 0) + 1) + try { + await this.cleanupByBackend.get(backend) + const info = await backend.startSession(device) + let released = false + return { + info, + release: async (options = {}) => { + if (released) { + return + } + released = true + if (options.cleanupIfUnused) { + this.addPendingCleanup(backend, info, isRegistered, { + includeOrphaned: true, + shutdownDevice: true + }) + } + await this.release(backend) + } + } + } catch (error) { + await this.release(backend) + throw error + } + } + + async cleanupWhenIdle( + backend: EmulatorBackend, + info: EmulatorSessionInfo, + isRegistered: (info: EmulatorSessionInfo) => boolean, + options: { includeOrphaned?: boolean; shutdownDevice?: boolean } = {} + ): Promise { + this.addPendingCleanup(backend, info, isRegistered, options) + await this.drainCleanup(backend) + } + + private async release(backend: EmulatorBackend): Promise { + const remaining = this.decrement(backend) + if (remaining > 0) { + return + } + await this.drainCleanup(backend) + } + + private async drainCleanup(backend: EmulatorBackend): Promise { + await this.cleanupByBackend.get(backend) + if ((this.claimsByBackend.get(backend) ?? 0) > 0) { + return + } + const pending = this.pendingCleanupByBackend.get(backend) + this.pendingCleanupByBackend.delete(backend) + if (!pending) { + return + } + const cleanup = Promise.allSettled( + [...pending.values()].map(async ({ info, isRegistered, includeOrphaned, shutdownDevice }) => { + if (isRegistered(info)) { + return + } + await backend.stopHelperForDevice(info.deviceUdid, { + helperPid: info.helperPid, + includeOrphaned + }) + if (shutdownDevice) { + await backend.shutdownDevice(info.deviceUdid) + } + }) + ) + .then(() => undefined) + .finally(() => this.cleanupByBackend.delete(backend)) + this.cleanupByBackend.set(backend, cleanup) + await cleanup + } + + private addPendingCleanup( + backend: EmulatorBackend, + info: EmulatorSessionInfo, + isRegistered: (info: EmulatorSessionInfo) => boolean, + options: { includeOrphaned?: boolean; shutdownDevice?: boolean } + ): void { + const pending = this.pendingCleanupByBackend.get(backend) ?? new Map() + const existing = pending.get(info.deviceUdid) + pending.set(info.deviceUdid, { + info, + isRegistered, + includeOrphaned: options.includeOrphaned === true || existing?.includeOrphaned === true, + shutdownDevice: options.shutdownDevice === true || existing?.shutdownDevice === true + }) + this.pendingCleanupByBackend.set(backend, pending) + } + + private decrement(backend: EmulatorBackend): number { + const next = Math.max(0, (this.claimsByBackend.get(backend) ?? 1) - 1) + if (next === 0) { + this.claimsByBackend.delete(backend) + } else { + this.claimsByBackend.set(backend, next) + } + return next + } +} diff --git a/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts b/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts new file mode 100644 index 00000000000..23f7adc4df8 --- /dev/null +++ b/src/main/runtime/orca-runtime-emulator-folder-workspace.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest' +import type { EmulatorSessionInfo } from '../emulator/emulator-types' +import type { FolderWorkspace } from '../../shared/types' +import { OrcaRuntimeService } from './orca-runtime' + +const FOLDER_WORKSPACE_ID = 'folder-workspace-1' +const FOLDER_WORKSPACE_KEY = `folder:${FOLDER_WORKSPACE_ID}` +const EMULATOR_INFO: EmulatorSessionInfo = { + deviceUdid: 'emulator-5554', + streamUrl: 'scrcpy://emulator-5554', + wsUrl: '', + streamCodec: 'h264', + backend: 'android' +} + +function makeFolderWorkspace(): FolderWorkspace { + return { + id: FOLDER_WORKSPACE_ID, + projectGroupId: 'project-group-1', + name: 'Mobile app', + folderPath: '/tmp/mobile-app', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } +} + +describe('RuntimeEmulatorCommands folder workspace routing', () => { + it.each([FOLDER_WORKSPACE_KEY, `id:${FOLDER_WORKSPACE_KEY}`])( + 'registers and publishes an attached device for selector %s', + async (selector) => { + const bridge = { + acquireHelperForDevice: vi.fn(async () => ({ + info: EMULATOR_INFO, + release: vi.fn(async () => {}) + })), + getReusableActiveForWorktree: vi.fn(async () => null), + registerActiveEmulator: vi.fn(), + stopActiveForSwitch: vi.fn(async () => null) + } + const send = vi.fn() + const runtime = new OrcaRuntimeService({ + getFolderWorkspaces: () => [makeFolderWorkspace()], + getAllWorktreeMeta: () => new Map(), + getRepo: () => null, + getRepos: () => [], + getSettings: () => ({ + mobileEmulatorEnabled: true, + mobileEmulatorDefaultDeviceUdid: null, + androidSdkPath: null + }) + } as never) + runtime.setEmulatorBridge(bridge as never) + Object.assign(runtime, { + getAuthoritativeWindow: () => ({ webContents: { send } }) + }) + + await expect( + runtime.emulatorAttach({ + device: 'emulator-5554', + worktree: selector, + focus: true + }) + ).resolves.toEqual({ attached: true, info: EMULATOR_INFO }) + + expect(bridge.acquireHelperForDevice).toHaveBeenCalledOnce() + expect(bridge.registerActiveEmulator).toHaveBeenCalledWith( + FOLDER_WORKSPACE_KEY, + EMULATOR_INFO, + { managed: true } + ) + expect(send.mock.calls).toEqual([ + ['ui:emulatorAutoAttach', { worktreeId: FOLDER_WORKSPACE_KEY, info: EMULATOR_INFO }], + ['emulator:pane-focus', { worktreeId: FOLDER_WORKSPACE_KEY }] + ]) + } + ) + + it.each([FOLDER_WORKSPACE_KEY, `id:${FOLDER_WORKSPACE_KEY}`])( + 'cleans up an active emulator after selector %s is deleted', + async (selector) => { + let folderWorkspaces = [makeFolderWorkspace()] + const shutdownActiveManagedForWorktree = vi.fn(async () => EMULATOR_INFO.deviceUdid) + const runtime = new OrcaRuntimeService({ + getFolderWorkspaces: () => folderWorkspaces, + getAllWorktreeMeta: () => new Map(), + getRepo: () => null, + getRepos: () => [], + getSettings: () => ({ androidSdkPath: null }) + } as never) + runtime.setEmulatorBridge({ shutdownActiveManagedForWorktree } as never) + + folderWorkspaces = [] + + await expect( + runtime.emulatorShutdown({ worktree: selector, managedOnly: true }) + ).resolves.toEqual({ ok: true, deviceUdid: EMULATOR_INFO.deviceUdid }) + expect(shutdownActiveManagedForWorktree).toHaveBeenCalledWith(FOLDER_WORKSPACE_KEY) + } + ) + + it('cleans up an attach that finishes after its folder workspace is deleted', async () => { + let folderWorkspaces = [makeFolderWorkspace()] + const release = vi.fn(async () => {}) + let finishHelperStart: + | ((lease: { info: EmulatorSessionInfo; release: typeof release }) => void) + | undefined + const bridge = { + acquireHelperForDevice: vi.fn( + () => + new Promise<{ info: EmulatorSessionInfo; release: typeof release }>((resolve) => { + finishHelperStart = resolve + }) + ), + getReusableActiveForWorktree: vi.fn(async () => null), + registerActiveEmulator: vi.fn(), + shutdownActiveManagedForWorktree: vi.fn(async () => null), + stopActiveForSwitch: vi.fn(async () => null) + } + const runtime = new OrcaRuntimeService({ + getFolderWorkspaces: () => folderWorkspaces, + getAllWorktreeMeta: () => new Map(), + getRepo: () => null, + getRepos: () => [], + getSettings: () => ({ + mobileEmulatorEnabled: true, + mobileEmulatorDefaultDeviceUdid: null, + androidSdkPath: null + }) + } as never) + runtime.setEmulatorBridge(bridge as never) + Object.assign(runtime, { + getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) + }) + + const attach = runtime.emulatorAttach({ + device: EMULATOR_INFO.deviceUdid, + worktree: FOLDER_WORKSPACE_KEY + }) + await vi.waitFor(() => expect(bridge.acquireHelperForDevice).toHaveBeenCalledOnce()) + folderWorkspaces = [] + await expect( + runtime.emulatorShutdown({ worktree: FOLDER_WORKSPACE_KEY, managedOnly: true }) + ).resolves.toEqual({ ok: true, deviceUdid: undefined }) + finishHelperStart?.({ info: EMULATOR_INFO, release }) + await expect(attach).rejects.toMatchObject({ + code: 'emulator_no_active', + message: 'The workspace changed while the emulator was starting. Reattach the emulator.' + }) + + expect(bridge.registerActiveEmulator).not.toHaveBeenCalled() + expect(bridge.shutdownActiveManagedForWorktree).toHaveBeenCalledWith(FOLDER_WORKSPACE_KEY) + expect(release).toHaveBeenCalledWith({ cleanupIfUnused: true }) + }) +}) diff --git a/src/main/runtime/orca-runtime-emulator.ts b/src/main/runtime/orca-runtime-emulator.ts index ac67464a533..7c82980c2a3 100644 --- a/src/main/runtime/orca-runtime-emulator.ts +++ b/src/main/runtime/orca-runtime-emulator.ts @@ -22,7 +22,8 @@ type EmulatorHostSettings = Pick< // Why: dedicated file for "one surface" separation (emulator), parallel to orca-runtime-browser.ts. Keeps OrcaRuntimeService focused; emulator routing easy to scan. No max-lines disable (split further if grows; per AGENTS + plan Phase 3). export type RuntimeEmulatorCommandHost = { getEmulatorBridge(): EmulatorBridge | null - resolveWorktreeSelector(selector: string): Promise<{ id: string }> + resolveEmulatorWorkspaceId(selector: string): Promise + resolveEmulatorCleanupWorkspaceId(selector: string): Promise getAuthoritativeWindow(): BrowserWindow getSettings(): EmulatorHostSettings } @@ -157,13 +158,36 @@ export class RuntimeEmulatorCommands { // slow-to-boot Android emulator alive for instant switch-back. await bridge.stopActiveForSwitch(worktreeId) } - const info = await bridge.startHelperForDevice(device) + const lease = await bridge.acquireHelperForDevice(device) + const { info } = lease if (worktreeId) { + try { + const currentWorktreeId = await this.resolveWorktreeId(params.worktree) + if (currentWorktreeId !== worktreeId) { + throw new EmulatorError( + 'emulator_no_active', + 'The workspace changed while the emulator was starting. Reattach the emulator.' + ) + } + } catch (error) { + // Why: the workspace can disappear while a slow Android device boots. + await lease.release({ cleanupIfUnused: true }).catch(() => {}) + if (error instanceof Error && error.message === 'selector_not_found') { + throw new EmulatorError( + 'emulator_no_active', + 'The workspace changed while the emulator was starting. Reattach the emulator.' + ) + } + throw error + } bridge.registerActiveEmulator(worktreeId, info, { managed: true }) + await lease.release() this.notifyRendererEmulatorAutoAttach(worktreeId, info) if (params.focus) { this.notifyRendererEmulatorPaneFocus(worktreeId) } + } else { + await lease.release() } // Default: no auto steal (mirror browser tab create/switch). --focus sends emulator:pane-focus only when requested. return { attached: true, info } @@ -176,7 +200,7 @@ export class RuntimeEmulatorCommands { async emulatorUnregisterActive(params: { worktree?: string }): Promise<{ ok: true }> { const bridge = this.requireEmulatorBridge() - const worktreeId = await this.resolveWorktreeId(params.worktree) + const worktreeId = await this.resolveCleanupWorktreeId(params.worktree) if (worktreeId) { bridge.unregisterActiveEmulator(worktreeId) } @@ -203,7 +227,11 @@ export class RuntimeEmulatorCommands { } private async resolveWorktreeId(worktree?: string): Promise { - return worktree ? (await this.host.resolveWorktreeSelector(worktree)).id : undefined + return worktree ? await this.host.resolveEmulatorWorkspaceId(worktree) : undefined + } + + private async resolveCleanupWorktreeId(worktree?: string): Promise { + return worktree ? await this.host.resolveEmulatorCleanupWorkspaceId(worktree) : undefined } async emulatorInstall( @@ -272,7 +300,7 @@ export class RuntimeEmulatorCommands { worktree?: string }): Promise<{ ok: true; deviceUdid: string }> { const bridge = this.requireEmulatorBridge() - const worktreeId = await this.resolveWorktreeId(params.worktree) + const worktreeId = await this.resolveCleanupWorktreeId(params.worktree) const killedUdid = await bridge.kill(params.device ?? params.emulator, worktreeId) return { ok: true, deviceUdid: killedUdid } } @@ -284,7 +312,7 @@ export class RuntimeEmulatorCommands { managedOnly?: boolean }): Promise<{ ok: true; deviceUdid?: string }> { const bridge = this.requireEmulatorBridge() - const worktreeId = await this.resolveWorktreeId(params.worktree) + const worktreeId = await this.resolveCleanupWorktreeId(params.worktree) if (params.managedOnly && worktreeId && !params.device && !params.emulator) { const shutdownUdid = await bridge.shutdownActiveManagedForWorktree(worktreeId) return { ok: true, deviceUdid: shutdownUdid ?? undefined } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6e81da239ce..395992632a8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -28355,16 +28355,9 @@ export class OrcaRuntimeService { private async resolveFolderWorkspaceLaunchScope( selector: string ): Promise { - const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector - const parsed = parseWorkspaceKey(workspaceSelector) - if (parsed?.type !== 'folder') { - return null - } - const workspace = this.store - ?.getFolderWorkspaces?.() - .find((entry) => entry.id === parsed.folderWorkspaceId) + const workspace = this.resolveFolderWorkspaceSelector(selector) if (!workspace) { - throw new Error('selector_not_found') + return null } if (!this.store) { throw new Error('runtime_unavailable') @@ -28384,6 +28377,36 @@ export class OrcaRuntimeService { } } + private resolveFolderWorkspaceSelector(selector: string): FolderWorkspace | null { + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + if (parsed?.type !== 'folder') { + return null + } + const workspace = this.store + ?.getFolderWorkspaces?.() + .find((entry) => entry.id === parsed.folderWorkspaceId) + if (!workspace) { + throw new Error('selector_not_found') + } + return workspace + } + + private async resolveEmulatorWorkspaceId(selector: string): Promise { + const folderWorkspace = this.resolveFolderWorkspaceSelector(selector) + return folderWorkspace + ? folderWorkspaceKey(folderWorkspace.id) + : (await this.resolveWorktreeSelector(selector)).id + } + + private async resolveEmulatorCleanupWorkspaceId(selector: string): Promise { + const workspaceSelector = selector.startsWith('id:') ? selector.slice(3) : selector + const parsed = parseWorkspaceKey(workspaceSelector) + return parsed?.type === 'folder' + ? folderWorkspaceKey(parsed.folderWorkspaceId) + : this.resolveEmulatorWorkspaceId(selector) + } + private folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { const worktree = folderWorkspaceToWorktree(folderWorkspace) return { @@ -35058,7 +35081,9 @@ export class OrcaRuntimeService { private readonly emulatorCommands = new RuntimeEmulatorCommands({ getEmulatorBridge: () => this.emulatorBridge, - resolveWorktreeSelector: (selector) => this.resolveWorktreeSelector(selector), + resolveEmulatorWorkspaceId: (selector) => this.resolveEmulatorWorkspaceId(selector), + resolveEmulatorCleanupWorkspaceId: (selector) => + this.resolveEmulatorCleanupWorkspaceId(selector), getAuthoritativeWindow: () => this.getAuthoritativeWindow(), getSettings: () => this.requireStore().getSettings() }) From 7b326e212f4eae5647a18b1d5c3b40bbb575a59f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:48:01 -0700 Subject: [PATCH 03/33] Gate WSL transcript filesystem ops with timeout and capacity limits Why: WSL filesystem access can stall when a distro hangs, and stalled operations must degrade gracefully rather than misreport as "not found". Distinguish gate refusals (timeout, capacity, unavailable) from actual missing files so callers can retry or fall through to alternatives. Fail fast when stuck I/O holds a permit to prevent caller pileup. Route-level sequencing and waiter deadlines keep one stalled distro from blocking others. --- ...session-scanner-discovery-wsl-gate.test.ts | 28 ++ .../ai-vault/session-scanner-discovery.ts | 8 +- .../ai-vault/session-title-request-paths.ts | 11 +- ...t-readable-transcript-path-fs-gate.test.ts | 24 + .../host-readable-transcript-path.test.ts | 33 ++ .../host-readable-transcript-path.ts | 20 +- ...ession-file-resolver-wsl-scan-gate.test.ts | 81 ++- src/main/native-chat/session-file-resolver.ts | 67 ++- src/main/native-chat/transcript-read-cache.ts | 10 +- src/main/native-chat/transcript-reader.ts | 10 +- .../transcript-tail-reader-wsl-gate.test.ts | 34 ++ .../native-chat/transcript-tail-reader.ts | 15 +- .../transcript-watch-resolve-poll.test.ts | 36 ++ src/main/native-chat/transcript-watch.ts | 12 +- .../wsl-transcript-fs-gate.test.ts | 468 +++++++++++++++++- .../native-chat/wsl-transcript-fs-gate.ts | 195 +++++++- 16 files changed, 989 insertions(+), 63 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts create mode 100644 src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts diff --git a/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts new file mode 100644 index 00000000000..ce24afccc2c --- /dev/null +++ b/src/main/ai-vault/session-scanner-discovery-wsl-gate.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import { walkSessionFiles } from './session-scanner-discovery' + +describe('walkSessionFiles WSL gate refusals', () => { + it('rethrows gate refusals instead of reporting an empty tree', async () => { + const refusal = new WslTranscriptFsError('timeout', 'slow share') + await expect( + walkSessionFiles('\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions', 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory: async () => { + throw refusal + } + }) + ).rejects.toBe(refusal) + }) + + it('still treats ordinary readdir failures as an empty tree', async () => { + await expect( + walkSessionFiles('/missing/root', 'codex', [], { + extensions: new Set(['.jsonl']), + readDirectory: async () => { + throw Object.assign(new Error('no such directory'), { code: 'ENOENT' }) + } + }) + ).resolves.toEqual([]) + }) +}) diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index 031f94f3619..7f72082a266 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs' import { readdir, stat } from 'node:fs/promises' import { extname, join } from 'node:path' import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' @@ -64,8 +65,13 @@ export async function walkSessionFiles( entries = options.readDirectory ? await options.readDirectory(dirPath) : await readdir(dirPath, { withFileTypes: true }) - } catch { + } catch (error) { options.signal?.throwIfAborted() + // Why: a gate refusal means the scan could not run, not that the tree is + // empty — swallowing it would misreport a stalled distro as "no transcript". + if (error instanceof WslTranscriptFsError) { + throw error + } return [] } diff --git a/src/main/ai-vault/session-title-request-paths.ts b/src/main/ai-vault/session-title-request-paths.ts index 86e78ebc397..5e25e512d92 100644 --- a/src/main/ai-vault/session-title-request-paths.ts +++ b/src/main/ai-vault/session-title-request-paths.ts @@ -1,5 +1,6 @@ import type { AiVaultSessionTitleRequest } from '../../shared/ai-vault-session-title' import { toHostReadableTranscriptPath } from '../native-chat/host-readable-transcript-path' +import { wslTranscriptFsRefusal } from '../native-chat/wsl-transcript-fs-gate' export function resolveHostReadableAiVaultTitleRequests( requests: AiVaultSessionTitleRequest[], @@ -10,7 +11,15 @@ export function resolveHostReadableAiVaultTitleRequests( if (!request.transcriptPath || signal?.aborted) { return request } - const transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath) + let transcriptPath: string | null + try { + transcriptPath = await toHostReadableTranscriptPath(request.transcriptPath) + } catch (error) { + // Why: one stalled distro's path must not fail the whole titles batch; + // degrade to the id-only shape like any unreadable path. + void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal + transcriptPath = null + } return transcriptPath && !signal?.aborted ? { ...request, transcriptPath } : { agent: request.agent, sessionId: request.sessionId } diff --git a/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts b/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts index 30bd3716d3e..5043bd14f6c 100644 --- a/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts +++ b/src/main/native-chat/host-readable-transcript-path-fs-gate.test.ts @@ -20,6 +20,7 @@ import { resetHostReadableTranscriptPathCacheForTests, toHostReadableTranscriptPath } from './host-readable-transcript-path' +import { WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS } from './wsl-transcript-fs-gate' type AccessControl = { resolve: () => void @@ -97,4 +98,27 @@ describe('WSL transcript filesystem gate', () => { releaseWsl?.() await expect(wslProbe).resolves.toBe(firstUncPath) }) + + // Last on purpose: the never-settling Ubuntu probe leaves its gate permit + // stuck for the rest of this module's lifetime. + it('falls through to the next distro when a probe exceeds the gate deadline', async () => { + vi.useFakeTimers() + const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\ada' + const debianUncPath = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions\\first.jsonl' + try { + fsMocks.access.mockImplementation((path) => + path === firstUncPath ? new Promise(() => {}) : Promise.resolve() + ) + const resolved = toHostReadableTranscriptPath(firstGuestPath, { + platform: 'win32', + listWslHomeDirs: async () => [UBUNTU_HOME, DEBIAN_HOME] + }) + + await vi.waitFor(() => expect(fsMocks.access).toHaveBeenCalledWith(firstUncPath)) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await expect(resolved).resolves.toBe(debianUncPath) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/native-chat/host-readable-transcript-path.test.ts b/src/main/native-chat/host-readable-transcript-path.test.ts index 043c77ee4c0..4d19b3fa8e8 100644 --- a/src/main/native-chat/host-readable-transcript-path.test.ts +++ b/src/main/native-chat/host-readable-transcript-path.test.ts @@ -7,6 +7,7 @@ import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' const UBUNTU_HOME = '\\\\wsl.localhost\\Ubuntu\\home\\ada' const DEBIAN_HOME = '\\\\wsl.localhost\\Debian\\home\\other' @@ -109,6 +110,38 @@ describe('toHostReadableTranscriptPath', () => { ).resolves.toBeNull() }) + it('prefers a later distro hit over an earlier gate refusal', async () => { + // Guest path under Debian's $HOME so the refusing Debian probe ranks first. + await expect( + toHostReadableTranscriptPath('/home/other/x.jsonl', { + platform: 'win32', + pathExists: async (candidate) => { + if (candidate.includes('Debian')) { + throw new WslTranscriptFsError('timeout', 'slow share') + } + return candidate.includes('Ubuntu') + }, + listWslHomeDirs: async () => [DEBIAN_HOME, UBUNTU_HOME] + }) + ).resolves.toBe('\\\\wsl.localhost\\Ubuntu\\home\\other\\x.jsonl') + }) + + it('reports unavailability, not a miss, when a refused distro was never probed', async () => { + const refusal = new WslTranscriptFsError('timeout', 'slow share') + await expect( + toHostReadableTranscriptPath('/home/other/x.jsonl', { + platform: 'win32', + pathExists: async (candidate) => { + if (candidate.includes('Debian')) { + throw refusal + } + return false + }, + listWslHomeDirs: async () => [DEBIAN_HOME, UBUNTU_HOME] + }) + ).rejects.toBe(refusal) + }) + it('does not translate guest paths off Windows', async () => { await expect( toHostReadableTranscriptPath('/home/ada/rollout.jsonl', { diff --git a/src/main/native-chat/host-readable-transcript-path.ts b/src/main/native-chat/host-readable-transcript-path.ts index 2672040e38b..4d919099536 100644 --- a/src/main/native-chat/host-readable-transcript-path.ts +++ b/src/main/native-chat/host-readable-transcript-path.ts @@ -4,7 +4,11 @@ import { isAbsolute } from 'node:path' import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' -import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate' +import { + runWslTranscriptFsTask, + wslTranscriptFsRefusal, + type WslTranscriptFsError +} from './wsl-transcript-fs-gate' /** * True for guest-absolute Linux paths that Win32 cannot open as-is. @@ -136,12 +140,22 @@ export async function toHostReadableTranscriptPath( const homeDirs = await wslHomeDirs(deps.listWslHomeDirs ?? defaultListWslHomeDirs) // Sequential on purpose: the ranked order picks the owning distro, and probing // every distro at once would fan out 9P calls to ones the user left stopped. + let unavailable: WslTranscriptFsError | undefined for (const distro of rankDistrosForGuestPath(homeDirs, path)) { const uncPath = toWindowsWslPath(path, distro) - if (await pathExists(uncPath)) { - return uncPath + try { + if (await pathExists(uncPath)) { + return uncPath + } + } catch (error) { + // Why: one stalled distro must not hide another distro's hit. + unavailable = wslTranscriptFsRefusal(error) } } + // No hit and at least one distro never probed: "couldn't look", not "absent". + if (unavailable) { + throw unavailable + } return null } diff --git a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts index 07ccc107d65..af14882b371 100644 --- a/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts +++ b/src/main/native-chat/session-file-resolver-wsl-scan-gate.test.ts @@ -1,10 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as WslTranscriptFsGateModule from './wsl-transcript-fs-gate' const WSL_SESSIONS_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex\\sessions' +const DEBIAN_SESSIONS_DIR = '\\\\wsl.localhost\\Debian\\home\\ada\\.codex\\sessions' const LOCAL_SESSIONS_DIR = 'C:\\Users\\ada\\.codex\\sessions' const mocks = vi.hoisted(() => ({ - gate: vi.fn(async () => []), + gate: vi.fn(async (_options: { path: string }) => []), walk: vi.fn( async ( dir: string, @@ -18,7 +20,8 @@ const mocks = vi.hoisted(() => ({ ) })) -vi.mock('./wsl-transcript-fs-gate', () => ({ +vi.mock('./wsl-transcript-fs-gate', async (importOriginal) => ({ + ...(await importOriginal()), runWslTranscriptFsTask: mocks.gate })) vi.mock('../ai-vault/session-scanner-discovery', () => ({ @@ -26,6 +29,7 @@ vi.mock('../ai-vault/session-scanner-discovery', () => ({ })) import { resolveSessionFilePath } from './session-file-resolver' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' beforeEach(() => { mocks.gate.mockClear() @@ -53,4 +57,77 @@ describe('Codex WSL scan gate', () => { expect(mocks.gate).not.toHaveBeenCalled() expect(mocks.walk).toHaveBeenCalledTimes(1) }) + + it("still surfaces a later root's hit when an earlier root is gate-refused", async () => { + const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl` + mocks.gate.mockImplementation(async (options) => { + if (options.path.includes('Ubuntu')) { + throw new WslTranscriptFsError('timeout', 'slow share') + } + return [] + }) + mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => { + await options.readDirectory?.(dir) + return dir === DEBIAN_SESSIONS_DIR ? [hit] : [] + }) + + await expect( + resolveSessionFilePath('codex', 'session-id', { + codexSessionsDirs: [WSL_SESSIONS_DIR, DEBIAN_SESSIONS_DIR] + }) + ).resolves.toBe(hit) + }) + + it('reports unavailability, not a miss, when every scanned root is gate-refused', async () => { + const refusal = new WslTranscriptFsError('unavailable', 'stuck permits') + mocks.gate.mockRejectedValue(refusal) + + await expect( + resolveSessionFilePath('codex', 'session-id', { + codexSessionsDirs: [WSL_SESSIONS_DIR] + }) + ).rejects.toBe(refusal) + }) + + it('falls through a gate-refused hook path to an id-based hit', async () => { + const hit = `${DEBIAN_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl` + mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => { + if (options.operation === 'access') { + throw new WslTranscriptFsError('timeout', 'slow share') + } + return [] + }) + mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => { + await options.readDirectory?.(dir) + return dir === DEBIAN_SESSIONS_DIR ? [hit] : [] + }) + + await expect( + resolveSessionFilePath('codex', 'session-id', { + transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`, + codexSessionsDirs: [DEBIAN_SESSIONS_DIR] + }) + ).resolves.toBe(hit) + }) + + it('surfaces the hook-path refusal when the id search also misses', async () => { + const refusal = new WslTranscriptFsError('unavailable', 'stuck permits') + mocks.gate.mockImplementation(async (options: { operation?: string; path: string }) => { + if (options.operation === 'access') { + throw refusal + } + return [] + }) + mocks.walk.mockImplementation(async (dir, _agent, _issues, options) => { + await options.readDirectory?.(dir) + return [] + }) + + await expect( + resolveSessionFilePath('codex', 'session-id', { + transcriptPath: `${WSL_SESSIONS_DIR}\\2026\\rollout-1-session-id.jsonl`, + codexSessionsDirs: [LOCAL_SESSIONS_DIR] + }) + ).rejects.toBe(refusal) + }) }) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index f597529d933..aa70d126c1d 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -1,7 +1,10 @@ import { homedir } from 'node:os' import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' -import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import { + resolveNativeChatTranscriptAgent, + type NativeChatTranscriptAgent +} from '../../shared/native-chat-agent-support' import { isWslUncPath } from '../../shared/wsl-paths' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from '../ai-vault/session-scanner-omp-subagent-transcripts' @@ -13,6 +16,7 @@ import { } from '../../shared/grok-session-paths' import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path' import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' +import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads // run in the main process against the runtime's own home directory; over SSH @@ -95,14 +99,34 @@ export async function resolveSessionFilePath( // beats reconstructing a path from the session id. Route it through the host // readability check so a WSL guest path becomes an openable UNC on Windows; // stale/missing paths fall through to the id-based search. + let unavailable: WslTranscriptFsError | undefined const hookPath = options.transcriptPath?.trim() if (hookPath && extname(hookPath) === '.jsonl') { - const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal }) - if (hostReadable) { - return hostReadable + try { + const hostReadable = await toHostReadableTranscriptPath(hookPath, { signal }) + if (hostReadable) { + return hostReadable + } + } catch (error) { + // Why: the id-based search may still hit; surface the refusal only when + // it does not, so a stalled distro reads as unavailable, never "missing". + unavailable = wslTranscriptFsRefusal(error) } } + const resolved = await resolveSessionFileById(transcriptAgent, sessionId, options, signal) + if (!resolved && unavailable) { + throw unavailable + } + return resolved +} + +async function resolveSessionFileById( + transcriptAgent: NativeChatTranscriptAgent, + sessionId: string, + options: ResolveSessionFileOptions, + signal?: AbortSignal +): Promise { const trimmedId = sessionId.trim() if (!trimmedId) { return null @@ -129,10 +153,7 @@ export async function resolveSessionFilePath( if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir(), signal) } - if (transcriptAgent === 'omp') { - return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal) - } - return null + return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal) } async function resolveClaudeSessionFile( @@ -178,6 +199,7 @@ async function findCodexRolloutInDirs( sessionsDirs: string[], signal?: AbortSignal ): Promise { + let unavailable: WslTranscriptFsError | undefined for (const sessionsDir of sessionsDirs) { // Why: no existence pre-check — walkSessionFiles already yields [] for a // missing/unreadable root, and a sync probe would block the main thread on a @@ -190,18 +212,27 @@ async function findCodexRolloutInDirs( } } const isWslRoot = isWslUncPath(sessionsDir) - const files = isWslRoot - ? await findWslCodexSessionPath(sessionsDir, sessionId, signal) - : ( - await walkSessionFiles(sessionsDir, 'codex', [], { - ...scanOptions, - signal - }) - )[0] - if (files) { - return files + try { + const files = isWslRoot + ? await findWslCodexSessionPath(sessionsDir, sessionId, signal) + : ( + await walkSessionFiles(sessionsDir, 'codex', [], { + ...scanOptions, + signal + }) + )[0] + if (files) { + return files + } + } catch (error) { + // Why: one stalled distro must not hide another root's hit. + unavailable = wslTranscriptFsRefusal(error) } } + // No hit and at least one root never scanned: "couldn't look", not "missing". + if (unavailable) { + throw unavailable + } return null } diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 8943c223dd2..d2a4b4d3de2 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -2,6 +2,7 @@ import { stat } from 'node:fs/promises' import type { AgentType } from '../../shared/native-chat-types' import { resolveSessionFilePath } from './session-file-resolver' import { readNativeChatTranscript, type ReadTranscriptResult } from './transcript-reader' +import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' // Why: both the desktop IPC handler and the runtime RPC handler read the same // host-filesystem transcript, so a single process-global cache keyed by the @@ -89,7 +90,14 @@ export async function readNativeChatTranscriptCached( /** Hook-reported authoritative transcript path, preferred over the id glob. */ transcriptPath?: string ): Promise { - const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) + let filePath: string | null + try { + filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) + } catch (err) { + // Why: gate refusal is transient unavailability — report it without caching + // or `notFound` so the next call re-resolves. + return { error: wslTranscriptFsRefusal(err).message } + } if (!filePath) { // Not cached (see below): a not-yet-flushed transcript should be re-checked // on the next call, not pinned as a settled miss (#8401). diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 8cb57fdb91d..a8e14e0e180 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -7,6 +7,7 @@ import type { import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' +import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine, @@ -41,7 +42,14 @@ export async function readNativeChatTranscript( sessionId: string, options: ReadTranscriptOptions = {} ): Promise { - const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) + let filePath: string | null + try { + filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) + } catch (err) { + // Why: gate refusal is transient unavailability with retry guidance — + // `notFound` would settle callers into a false "missing" state. + return { error: wslTranscriptFsRefusal(err).message } + } if (!filePath) { return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } diff --git a/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts b/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts new file mode 100644 index 00000000000..ff5194668cd --- /dev/null +++ b/src/main/native-chat/transcript-tail-reader-wsl-gate.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolve: vi.fn<() => Promise>() +})) + +vi.mock('./session-file-resolver', () => ({ + resolveSessionFilePath: mocks.resolve +})) + +import { readNativeChatTranscriptTail } from './transcript-tail-reader' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' + +describe('native chat transcript tail under WSL gate refusals', () => { + beforeEach(() => { + mocks.resolve.mockReset() + }) + + it('reports a gate refusal as a retryable error, not a missing transcript', async () => { + mocks.resolve.mockRejectedValueOnce(new WslTranscriptFsError('timeout', 'slow share')) + + await expect( + readNativeChatTranscriptTail({ agent: 'codex', sessionId: 'session-id', limit: 10 }) + ).resolves.toEqual({ error: 'slow share' }) + }) + + it('rethrows non-gate resolver failures', async () => { + mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed')) + + await expect( + readNativeChatTranscriptTail({ agent: 'codex', sessionId: 'session-id', limit: 10 }) + ).rejects.toThrow('resolver crashed') + }) +}) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index fd590855ac7..1264c2e3d98 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -17,6 +17,7 @@ import { nativeChatTurnLifecycleDecoderForAgent, type NativeChatTurnLifecycleDecoder } from './transcript-turn-lifecycle' +import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 const TAIL_CHUNK_BYTES = 64 * 1024 @@ -231,12 +232,20 @@ export async function readNativeChatTranscriptTail( > { const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) - const filePath = - args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal)) - signal?.throwIfAborted() if (!decode) { return { error: 'Transcript unavailable' } } + let filePath: string | null + try { + filePath = + args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args, signal)) + } catch (error) { + signal?.throwIfAborted() + // Why: gate refusal is transient unavailability with retry guidance — + // `notFound` would settle callers into a false "missing" state. + return { error: wslTranscriptFsRefusal(error).message } + } + signal?.throwIfAborted() // Why: a new agent session can report its id before the first JSONL flush; // callers keep that miss in loading/retry rather than showing a false error. if (!filePath) { diff --git a/src/main/native-chat/transcript-watch-resolve-poll.test.ts b/src/main/native-chat/transcript-watch-resolve-poll.test.ts index 70a264b93f2..e5ec941e5e9 100644 --- a/src/main/native-chat/transcript-watch-resolve-poll.test.ts +++ b/src/main/native-chat/transcript-watch-resolve-poll.test.ts @@ -20,6 +20,7 @@ vi.mock('./host-readable-transcript-path', async (importOriginal) => ({ })) import { subscribeNativeChatTranscript } from './transcript-watch' +import { WslTranscriptFsError } from './wsl-transcript-fs-gate' const realPlatform = process.platform @@ -125,6 +126,41 @@ describe('native chat transcript resolve polling', () => { subscription.unsubscribe() }) + it('degrades a gate-refused initial resolve to the poll fallback instead of failing', async () => { + const engine = { unsubscribe: vi.fn(), watching: true } + mocks.resolve + .mockRejectedValueOnce(new WslTranscriptFsError('unavailable', 'stuck permits')) + .mockResolvedValue('/home/ada/found.jsonl') + mocks.install.mockImplementation((filePath: string) => (filePath ? engine : null)) + + const subscription = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + resolvePollIntervalMs: 10, + onAppend: () => {} + }) + expect(subscription.watching).toBe(true) + + await vi.advanceTimersByTimeAsync(20) + expect( + mocks.install.mock.calls.some(([filePath]) => filePath === '/home/ada/found.jsonl') + ).toBe(true) + subscription.unsubscribe() + expect(engine.unsubscribe).toHaveBeenCalled() + }) + + it('still fails subscribe on non-gate resolver errors', async () => { + mocks.resolve.mockRejectedValueOnce(new Error('resolver crashed')) + + await expect( + subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'session-id', + onAppend: () => {} + }) + ).rejects.toThrow('resolver crashed') + }) + it('cancels queued WSL resolution when the subscription closes', async () => { setPlatform('win32') let receivedSignal: AbortSignal | undefined diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index fff5bc60599..2370d7b6410 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -11,6 +11,7 @@ import type { SubscribeNativeChatTranscriptArgs } from './transcript-watch-contract' import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' +import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' export { readNativeChatTranscriptTail } from './transcript-tail-reader' export { getActiveNativeChatWatcherCount } from './transcript-watch-engine' @@ -207,7 +208,16 @@ export async function subscribeNativeChatTranscript( return { unsubscribe: () => {}, watching: false } } - const installed = await attemptInstall(args, decode, setupSignal) + let installed: NativeChatTranscriptSubscription | null + try { + installed = await attemptInstall(args, decode, setupSignal) + } catch (error) { + setupSignal?.throwIfAborted() + // Why: a gate-refused resolve (stalled WSL distro) must degrade to the + // resolve-poll fallback below, not fail the subscribe outright. + void wslTranscriptFsRefusal(error) // rethrows anything that is not a gate refusal + installed = null + } if (installed) { return installed } diff --git a/src/main/native-chat/wsl-transcript-fs-gate.test.ts b/src/main/native-chat/wsl-transcript-fs-gate.test.ts index b434d13cc4e..1f82ab25277 100644 --- a/src/main/native-chat/wsl-transcript-fs-gate.test.ts +++ b/src/main/native-chat/wsl-transcript-fs-gate.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, it, vi } from 'vitest' -import { runWslTranscriptFsTask } from './wsl-transcript-fs-gate' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { + runWslTranscriptFsTask, + WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS, + WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS, + WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK, + WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS, + WslTranscriptFsError +} from './wsl-transcript-fs-gate' + +const SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' +const CAPACITY_MESSAGE = + 'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.' function deferred(): { promise: Promise @@ -28,6 +40,16 @@ function run( } describe('WSL transcript filesystem task scheduling', () => { + let warnSpy: MockInstance + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + it('does not block an exact probe behind a running scan on the same route', async () => { const stalled = deferred() const scanTask = vi.fn(() => stalled.promise) @@ -97,8 +119,9 @@ describe('WSL transcript filesystem task scheduling', () => { const abandonedTask = vi.fn(async () => 'unused') const abandoned = run('\\\\wsl.localhost\\Fedora\\a', 'scan', abandonedTask, controller.signal) - controller.abort(new Error('closed subscription')) - await expect(abandoned).rejects.toThrow('closed subscription') + const reason = new Error('closed subscription') + controller.abort(reason) + await expect(abandoned).rejects.toBe(reason) ubuntu.resolve('ubuntu') debian.resolve('debian') await Promise.all([first, second]) @@ -125,8 +148,9 @@ describe('WSL transcript filesystem task scheduling', () => { const second = run('\\\\wsl.localhost\\Ubuntu\\shared', 'exact', task, secondController.signal) await vi.waitFor(() => expect(task).toHaveBeenCalledOnce()) - firstController.abort(new Error('first closed')) - await expect(first).rejects.toThrow('first closed') + const reason = new Error('first closed') + firstController.abort(reason) + await expect(first).rejects.toBe(reason) pending.resolve('second') await expect(second).resolves.toBe('second') expect(task).toHaveBeenCalledOnce() @@ -175,4 +199,436 @@ describe('WSL transcript filesystem task scheduling', () => { await expect(Promise.all(tasks)).resolves.toEqual(['0', '1', '2', '3']) }) + + it.each([ + ['exact', WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS], + ['scan', WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS] + ] as const)('rejects a stalled %s waiter at its deadline', async (priority, deadlineMs) => { + vi.useFakeTimers() + const stalled = deferred() + try { + const task = vi.fn(() => stalled.promise) + const pending = run(`\\\\wsl.localhost\\Deadline-${priority}\\a`, priority, task) + const rejected = expect(pending).rejects.toMatchObject({ + name: 'WslTranscriptFsError', + code: 'timeout', + message: SLOW_MESSAGE + }) + let settled = false + void pending.then( + () => (settled = true), + () => (settled = true) + ) + + await vi.advanceTimersByTimeAsync(0) + expect(task).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await rejected + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it.each([ + ['exact', WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS], + ['scan', WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS] + ] as const)('completes healthy %s work below its deadline', async (priority, deadlineMs) => { + vi.useFakeTimers() + const work = deferred() + try { + const pending = run(`\\\\wsl.localhost\\Healthy-${priority}\\a`, priority, () => work.promise) + + await vi.advanceTimersByTimeAsync(deadlineMs - 1) + work.resolve('healthy') + await expect(pending).resolves.toBe('healthy') + expect(vi.getTimerCount()).toBe(0) + await vi.advanceTimersByTimeAsync(deadlineMs) + expect(vi.getTimerCount()).toBe(0) + } finally { + work.resolve('healthy') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('does not let one timed-out waiter settle a later shared waiter', async () => { + vi.useFakeTimers() + const work = deferred() + const task = vi.fn(() => work.promise) + try { + const path = '\\\\wsl.localhost\\Ubuntu\\staggered' + const first = run(path, 'exact', task) + const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(10_000) + const second = run(path, 'exact', task) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000) + await firstRejected + work.resolve('shared') + await expect(second).resolves.toBe('shared') + expect(task).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } finally { + work.resolve('shared') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('rejects newest work when the pending queue is full', async () => { + vi.useFakeTimers() + const ubuntu = deferred() + const debian = deferred() + const queuedController = new AbortController() + try { + const ubuntuTask = vi.fn(() => ubuntu.promise) + const debianTask = vi.fn(() => debian.promise) + const first = run('\\\\wsl.localhost\\Ubuntu\\occupied', 'exact', ubuntuTask) + const second = run('\\\\wsl.localhost\\Debian\\occupied', 'exact', debianTask) + await vi.advanceTimersByTimeAsync(0) + + const queued = Array.from({ length: WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS }, (_, index) => + run( + `\\\\wsl.localhost\\Queued-${index}\\a`, + 'exact', + async () => String(index), + index === 0 ? queuedController.signal : undefined + ) + ) + const overflowTask = vi.fn(async () => 'overflow') + await expect( + run('\\\\wsl.localhost\\Overflow\\a', 'exact', overflowTask) + ).rejects.toMatchObject({ code: 'capacity', message: CAPACITY_MESSAGE }) + expect(overflowTask).not.toHaveBeenCalled() + + const reason = new Error('queued caller closed') + queuedController.abort(reason) + await expect(queued[0]).rejects.toBe(reason) + const replacement = run('\\\\wsl.localhost\\Replacement\\a', 'exact', async () => 'new') + + ubuntu.resolve('ubuntu') + debian.resolve('debian') + await expect( + Promise.all([first, second, ...queued.slice(1), replacement]) + ).resolves.toHaveLength(WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS + 2) + expect(vi.getTimerCount()).toBe(0) + } finally { + ubuntu.resolve('ubuntu') + debian.resolve('debian') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('caps callers waiting on shared work', async () => { + vi.useFakeTimers() + const stalled = deferred() + const task = vi.fn(() => stalled.promise) + const firstController = new AbortController() + try { + const path = '\\\\wsl.localhost\\Ubuntu\\shared-cap' + const waiters = Array.from({ length: WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK }, (_, index) => + run(path, 'exact', task, index === 0 ? firstController.signal : undefined) + ) + await vi.advanceTimersByTimeAsync(0) + + await expect(run(path, 'exact', task)).rejects.toMatchObject({ + code: 'capacity', + message: CAPACITY_MESSAGE + }) + const reason = new Error('first waiter closed') + firstController.abort(reason) + await expect(waiters[0]).rejects.toBe(reason) + const replacement = run(path, 'exact', task) + stalled.resolve('shared') + await expect(Promise.all([...waiters.slice(1), replacement])).resolves.toEqual( + Array(WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK).fill('shared') + ) + expect(task).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + } finally { + stalled.resolve('shared') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('fails new work fast after both running permits stall', async () => { + vi.useFakeTimers() + const ubuntu = deferred() + const debian = deferred() + try { + const first = run('\\\\wsl.localhost\\Ubuntu\\stalled', 'exact', () => ubuntu.promise) + const second = run('\\\\wsl.localhost\\Debian\\stalled', 'exact', () => debian.promise) + const firstRejected = expect(first).rejects.toBeInstanceOf(WslTranscriptFsError) + const secondRejected = expect(second).rejects.toBeInstanceOf(WslTranscriptFsError) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await Promise.all([firstRejected, secondRejected]) + const laterTask = vi.fn(async () => 'later') + await expect( + run('\\\\wsl.localhost\\Fedora\\later', 'exact', laterTask) + ).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE }) + expect(laterTask).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + ubuntu.resolve('ubuntu') + debian.resolve('debian') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('fails only work needing a stuck lane fast, keeping the other permit usable', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\lane-stuck', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stuckRejected + + const sameLaneTask = vi.fn(async () => 'same-lane') + await expect( + run('\\\\wsl.localhost\\Ubuntu\\lane-stuck-sibling', 'exact', sameLaneTask) + ).rejects.toMatchObject({ code: 'unavailable', message: SLOW_MESSAGE }) + expect(sameLaneTask).not.toHaveBeenCalled() + await expect( + run('\\\\wsl.localhost\\Debian\\healthy-lane', 'exact', async () => 'debian') + ).resolves.toBe('debian') + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('fails scans and same-route probes fast while a scan is stuck', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuckScan = run('\\\\wsl.localhost\\Ubuntu\\stuck-tree', 'scan', () => stalled.promise) + const stuckRejected = expect(stuckScan).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS) + await stuckRejected + + const otherScanTask = vi.fn(async () => 'other-scan') + await expect( + run('\\\\wsl.localhost\\Debian\\other-tree', 'scan', otherScanTask) + ).rejects.toMatchObject({ code: 'unavailable' }) + expect(otherScanTask).not.toHaveBeenCalled() + // The whole Ubuntu mount is hung — an exact probe there would only burn + // the remaining permit on it. + await expect( + run('\\\\wsl.localhost\\Ubuntu\\live-probe', 'exact', async () => 'exact') + ).rejects.toMatchObject({ code: 'unavailable' }) + await expect( + run('\\\\wsl.localhost\\Debian\\live-probe', 'exact', async () => 'exact') + ).resolves.toBe('exact') + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('keeps a stuck exact probe from feeding a doomed scan the second permit', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\hung-file', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stuckRejected + + const doomedScanTask = vi.fn(async () => 'doomed') + await expect( + run('\\\\wsl.localhost\\Ubuntu\\tree', 'scan', doomedScanTask) + ).rejects.toMatchObject({ code: 'unavailable' }) + expect(doomedScanTask).not.toHaveBeenCalled() + await expect( + run('\\\\wsl.localhost\\Debian\\tree', 'scan', async () => 'debian-scan') + ).resolves.toBe('debian-scan') + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('admits new work again once stuck work finally settles', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\recovering', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await stuckRejected + await expect( + run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'blocked') + ).rejects.toMatchObject({ code: 'unavailable' }) + + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + await expect( + run('\\\\wsl.localhost\\Ubuntu\\recovering-next', 'exact', async () => 'recovered') + ).resolves.toBe('recovered') + expect(vi.getTimerCount()).toBe(0) + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('refuses to join in-flight work already past its own deadline', async () => { + vi.useFakeTimers() + const work = deferred() + const task = vi.fn(() => work.promise) + try { + const path = '\\\\wsl.localhost\\Ubuntu\\join-stuck' + const first = run(path, 'exact', task) + const firstRejected = expect(first).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(10_000) + const second = run(path, 'exact', task) + const secondRejected = expect(second).rejects.toMatchObject({ code: 'timeout' }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 10_000) + await firstRejected + const joinTask = vi.fn(async () => 'join') + await expect(run(path, 'exact', joinTask)).rejects.toMatchObject({ code: 'unavailable' }) + expect(joinTask).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(10_000) + await secondRejected + } finally { + work.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('fails joins onto queued work fast when stuck I/O keeps it from running', async () => { + vi.useFakeTimers() + const stalled = deferred() + const queuedTask = vi.fn(async () => 'queued') + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\trap-hung', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(5_000) + // Queued behind the same route before the hang is detected. + const queuedPath = '\\\\wsl.localhost\\Ubuntu\\trap-queued' + const queued = run(queuedPath, 'exact', queuedTask) + const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'timeout' }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS - 5_000) + await stuckRejected + // Re-requests must not keep the doomed queued task alive with fresh + // deadlines — that would defeat the fail-fast for as long as I/O hangs. + await expect(run(queuedPath, 'exact', queuedTask)).rejects.toMatchObject({ + code: 'unavailable' + }) + await vi.advanceTimersByTimeAsync(5_000) + await queuedRejected + expect(queuedTask).not.toHaveBeenCalled() + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('does not feed a stuck route the other permit from the queue', async () => { + vi.useFakeTimers() + const scanWork = deferred() + const debianWork = deferred() + const ubuntuTask = vi.fn(async () => 'ubuntu') + try { + const scanU = run( + '\\\\wsl.localhost\\Ubuntu\\queued-route-tree', + 'scan', + () => scanWork.promise + ) + const exactD = run( + '\\\\wsl.localhost\\Debian\\queued-route-d', + 'exact', + () => debianWork.promise + ) + const scanRejected = expect(scanU).rejects.toMatchObject({ code: 'timeout' }) + const exactDRejected = expect(exactD).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(35_000) + await exactDRejected + + // Queued before Ubuntu's hang is detected at the 60s scan deadline. + const queuedU = run('\\\\wsl.localhost\\Ubuntu\\queued-route-file', 'exact', ubuntuTask) + const queuedURejected = expect(queuedU).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(25_000) + await scanRejected + + debianWork.resolve('debian') + await vi.advanceTimersByTimeAsync(0) + expect(ubuntuTask).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5_000) + await queuedURejected + expect(ubuntuTask).not.toHaveBeenCalled() + } finally { + scanWork.resolve('late') + debianWork.resolve('debian') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('warns once per task that outlives its deadline while running', async () => { + vi.useFakeTimers() + const stalled = deferred() + try { + const stuck = run('\\\\wsl.localhost\\Ubuntu\\logged', 'exact', () => stalled.promise) + const stuckRejected = expect(stuck).rejects.toMatchObject({ code: 'timeout' }) + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS * 3) + await stuckRejected + expect(warnSpy).toHaveBeenCalledOnce() + expect(warnSpy.mock.calls[0][0]).toContain('exact') + expect(warnSpy.mock.calls[0][0]).toContain('logged') + } finally { + stalled.resolve('late') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) + + it('does not mark a scan permit stuck before the scan deadline', async () => { + vi.useFakeTimers() + const exactWork = deferred() + const scanWork = deferred() + try { + const exact = run( + '\\\\wsl.localhost\\Ubuntu\\stalled-exact', + 'exact', + () => exactWork.promise + ) + const scanTask = vi.fn(() => scanWork.promise) + const scanPath = '\\\\wsl.localhost\\Debian\\healthy-scan' + const scan = run(scanPath, 'scan', scanTask) + const exactRejected = expect(exact).rejects.toMatchObject({ code: 'timeout' }) + + await vi.advanceTimersByTimeAsync(WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS) + await exactRejected + const duplicateTask = vi.fn(async () => 'duplicate') + const duplicate = run(scanPath, 'scan', duplicateTask) + + scanWork.resolve('scan') + await expect(Promise.all([scan, duplicate])).resolves.toEqual(['scan', 'scan']) + expect(scanTask).toHaveBeenCalledOnce() + expect(duplicateTask).not.toHaveBeenCalled() + } finally { + exactWork.resolve('late') + scanWork.resolve('scan') + await vi.advanceTimersByTimeAsync(0) + vi.useRealTimers() + } + }) }) diff --git a/src/main/native-chat/wsl-transcript-fs-gate.ts b/src/main/native-chat/wsl-transcript-fs-gate.ts index 03225b1d3de..52b4b22f81b 100644 --- a/src/main/native-chat/wsl-transcript-fs-gate.ts +++ b/src/main/native-chat/wsl-transcript-fs-gate.ts @@ -1,6 +1,35 @@ import { parseWslUncPath } from '../../shared/wsl-paths' const MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS = 2 +export const WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS = 30_000 +export const WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS = 60_000 +// Burst bounds keep polling fan-out from growing retained tasks or callers indefinitely. +export const WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS = 64 +export const WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK = 64 +const WSL_TRANSCRIPT_FS_SLOW_MESSAGE = + 'WSL transcript files are temporarily unavailable because filesystem access is taking too long. Try again shortly or restart Orca if the issue continues.' +const WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE = + 'WSL transcript discovery is temporarily unavailable because too many filesystem requests are already waiting. Try again shortly or restart Orca if the issue continues.' + +export type WslTranscriptFsFailureCode = 'timeout' | 'capacity' | 'unavailable' + +export class WslTranscriptFsError extends Error { + constructor( + readonly code: WslTranscriptFsFailureCode, + message: string + ) { + super(message) + this.name = 'WslTranscriptFsError' + } +} + +/** Narrow a caught error to a gate refusal, rethrowing anything else. */ +export function wslTranscriptFsRefusal(error: unknown): WslTranscriptFsError { + if (error instanceof WslTranscriptFsError) { + return error + } + throw error +} export type WslTranscriptFsTaskPriority = 'exact' | 'scan' @@ -9,30 +38,39 @@ type TaskWaiter = { reject: (error: unknown) => void signal?: AbortSignal onAbort?: () => void + timeout?: ReturnType } type ScheduledTask = { key: string laneKey: string + route: string priority: WslTranscriptFsTaskPriority operation: (signal: AbortSignal) => Promise controller: AbortController waiters: Set> state: 'queued' | 'running' | 'settled' + // Set by the deadline timer, sharing the waiters' monotonic clock — wall time + // would misjudge stuckness across laptop sleep or NTP steps. + stuck: boolean + stuckTimer?: ReturnType } type UnknownScheduledTask = ScheduledTask -let activeTaskCount = 0 let activeScanCount = 0 const activeLaneKeys = new Set() const queuedTasks: UnknownScheduledTask[] = [] const inFlightTasks = new Map() +const activeTasks = new Set() function abortReason(signal: AbortSignal): unknown { return signal.reason ?? new Error('WSL transcript filesystem task aborted') } +// wsl$ and wsl.localhost spellings of one distro stay distinct routes on +// purpose (provider spelling can change behavior), so a stuck spelling never +// fast-fails its twin — the twin is bounded by its own waiter deadlines. function routeKey(path: string): string { const normalized = path.replace(/\\/g, '/') const match = normalized.match(/^\/\/(wsl\.localhost|wsl\$)\/([^/]+)/i) @@ -55,11 +93,11 @@ function clearTask(task: UnknownScheduledTask): void { } } -function abandonTaskIfUnused(task: UnknownScheduledTask): void { +function abandonTaskIfUnused(task: UnknownScheduledTask, reason?: unknown): void { if (task.waiters.size > 0 || task.state === 'settled') { return } - task.controller.abort() + task.controller.abort(reason) // Running I/O keeps its permit; new callers need a reusable controller. clearTask(task) if (task.state === 'queued') { @@ -69,24 +107,91 @@ function abandonTaskIfUnused(task: UnknownScheduledTask): void { } } -function attachWaiter(task: ScheduledTask, signal?: AbortSignal): Promise { - if (signal?.aborted) { - abandonTaskIfUnused(task as UnknownScheduledTask) - return Promise.reject(abortReason(signal)) +function removeWaiter(task: ScheduledTask, waiter: TaskWaiter): boolean { + if (!task.waiters.delete(waiter)) { + return false + } + if (waiter.signal && waiter.onAbort) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + } + if (waiter.timeout) { + clearTimeout(waiter.timeout) + } + return true +} + +function timeoutMs(priority: WslTranscriptFsTaskPriority): number { + return priority === 'exact' + ? WSL_TRANSCRIPT_FS_EXACT_TIMEOUT_MS + : WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS +} + +function timeoutError(): WslTranscriptFsError { + return new WslTranscriptFsError('timeout', WSL_TRANSCRIPT_FS_SLOW_MESSAGE) +} + +function capacityError(): WslTranscriptFsError { + return new WslTranscriptFsError('capacity', WSL_TRANSCRIPT_FS_CAPACITY_MESSAGE) +} + +function unavailableError(): WslTranscriptFsError { + return new WslTranscriptFsError('unavailable', WSL_TRANSCRIPT_FS_SLOW_MESSAGE) +} + +/** + * The stuck running task that would doom a new task's admission: one on the + * same route (the whole distro mount is hung, whatever the priority — spending + * the other permit on it escalates one bad distro into a global outage), one + * holding the single scan slot, or (with every permit stuck) any permit. + */ +function stuckBlocker( + route: string, + priority: WslTranscriptFsTaskPriority +): UnknownScheduledTask | undefined { + const stuck = [...activeTasks].filter((task) => task.stuck) + if (stuck.length === MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) { + return stuck[0] + } + return stuck.find( + (task) => task.route === route || (priority === 'scan' && task.priority === 'scan') + ) +} + +// Caller-abort pre-checks live in runWslTranscriptFsTask; nothing here yields +// before the waiter is attached, so no aborted-signal recheck is needed. +function attachWaiter( + task: ScheduledTask, + priority: WslTranscriptFsTaskPriority, + signal?: AbortSignal +): Promise { + if (task.waiters.size >= WSL_TRANSCRIPT_FS_MAX_WAITERS_PER_TASK) { + return Promise.reject(capacityError()) } return new Promise((resolve, reject) => { const waiter: TaskWaiter = { resolve, reject, signal } + task.waiters.add(waiter) if (signal) { waiter.onAbort = () => { - if (!task.waiters.delete(waiter)) { + const reason = abortReason(signal) + if (!removeWaiter(task, waiter)) { return } - reject(abortReason(signal)) - abandonTaskIfUnused(task as UnknownScheduledTask) + reject(reason) + abandonTaskIfUnused(task as UnknownScheduledTask, reason) } signal.addEventListener('abort', waiter.onAbort, { once: true }) } - task.waiters.add(waiter) + // UNC/9P calls cannot be aborted; two blocked calls can retain both slots until settling or + // Orca restarts, while caller and queue retention remains bounded. + waiter.timeout = setTimeout(() => { + const error = timeoutError() + if (!removeWaiter(task, waiter)) { + return + } + reject(error) + abandonTaskIfUnused(task as UnknownScheduledTask, error) + }, timeoutMs(priority)) + waiter.timeout.unref?.() }) } @@ -95,16 +200,15 @@ function settleTask(task: ScheduledTask, result: { value: T } | { error: u return } task.state = 'settled' - activeTaskCount -= 1 if (task.priority === 'scan') { activeScanCount -= 1 } activeLaneKeys.delete(task.laneKey) + activeTasks.delete(task as UnknownScheduledTask) + clearTimeout(task.stuckTimer) clearTask(task as UnknownScheduledTask) for (const waiter of task.waiters) { - if (waiter.signal && waiter.onAbort) { - waiter.signal.removeEventListener('abort', waiter.onAbort) - } + removeWaiter(task, waiter) if ('value' in result) { waiter.resolve(result.value) } else { @@ -116,13 +220,21 @@ function settleTask(task: ScheduledTask, result: { value: T } | { error: u } function nextTaskIndex(): number { + // Tasks queued before their route hung must not be fed the other permit; + // their waiters drain by deadline while healthy routes use the slot. + const stuckRoutes = new Set( + [...activeTasks].filter((task) => task.stuck).map((task) => task.route) + ) for (const priority of ['exact', 'scan'] as const) { // Why: keep one libuv slot available for a live transcript probe. if (priority === 'scan' && activeScanCount > 0) { continue } const index = queuedTasks.findIndex( - (task) => task.priority === priority && !activeLaneKeys.has(task.laneKey) + (task) => + task.priority === priority && + !activeLaneKeys.has(task.laneKey) && + !stuckRoutes.has(task.route) ) if (index !== -1) { return index @@ -132,7 +244,7 @@ function nextTaskIndex(): number { } function pumpTasks(): void { - while (activeTaskCount < MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) { + while (activeTasks.size < MAX_CONCURRENT_WSL_TRANSCRIPT_FS_TASKS) { const index = nextTaskIndex() if (index === -1) { return @@ -142,11 +254,21 @@ function pumpTasks(): void { continue } task.state = 'running' - activeTaskCount += 1 if (task.priority === 'scan') { activeScanCount += 1 } activeLaneKeys.add(task.laneKey) + activeTasks.add(task) + // Anchored at run start: queue wait says nothing about the I/O itself, so + // the fail-fast may lag admission by at most one deadline period. + task.stuckTimer = setTimeout(() => { + task.stuck = true + console.warn( + `[wsl-transcript-fs-gate] ${task.priority} filesystem task still running after ` + + `${timeoutMs(task.priority)}ms and holding a permit: ${task.key}` + ) + }, timeoutMs(task.priority)) + task.stuckTimer.unref?.() void Promise.resolve() .then(() => { task.controller.signal.throwIfAborted() @@ -169,28 +291,49 @@ export function runWslTranscriptFsTask( }, task: (signal: AbortSignal) => Promise ): Promise { + if (options.signal?.aborted) { + return Promise.reject(abortReason(options.signal)) + } // Why: route spelling and Linux path case can change provider behavior, so // only byte-identical filesystem requests are safe to share. const key = JSON.stringify([options.operation, options.path]) const existing = inFlightTasks.get(key) as ScheduledTask | undefined if (existing) { - return attachWaiter(existing, options.signal) + // A stuck target — or a queued target that stuck I/O keeps from ever + // running — dooms every joiner to a slow timeout, and each fresh joiner + // would keep the queued task alive past its own abandonment. Fail fast. + if ( + existing.stuck || + (existing.state === 'queued' && stuckBlocker(existing.route, existing.priority)) + ) { + return Promise.reject(unavailableError()) + } + return attachWaiter(existing, options.priority, options.signal) + } + const route = routeKey(options.path) + // Fail fast when the permit, route, or scan slot this task needs is held by + // stuck I/O — queueing would only burn the caller's full deadline. + if (stuckBlocker(route, options.priority)) { + return Promise.reject(unavailableError()) + } + if (queuedTasks.length >= WSL_TRANSCRIPT_FS_MAX_PENDING_TASKS) { + return Promise.reject(capacityError()) } const scheduled: ScheduledTask = { key, - laneKey: `${routeKey(options.path)}:${options.priority}`, + laneKey: `${route}:${options.priority}`, + route, priority: options.priority, operation: task, controller: new AbortController(), waiters: new Set(), - state: 'queued' + state: 'queued', + stuck: false } inFlightTasks.set(key, scheduled as UnknownScheduledTask) - const result = attachWaiter(scheduled, options.signal) - if (scheduled.state === 'queued' && scheduled.waiters.size > 0) { - queuedTasks.push(scheduled as UnknownScheduledTask) - queueMicrotask(pumpTasks) - } + const result = attachWaiter(scheduled, options.priority, options.signal) + queuedTasks.push(scheduled as UnknownScheduledTask) + queueMicrotask(pumpTasks) return result } From 8ffed254a4300c798c2f6b1fee42c9009d47c849 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:52:13 -0700 Subject: [PATCH 04/33] Guard the transcript-agent dispatch against silent OMP fallthrough Why: the resolver refactor replaced the explicit `omp` branch with an unconditional `return resolveOmpSessionFile(...)`. That is behaviour-identical today, but a future member of NativeChatTranscriptAgent would silently scan OMP's session root with a foreign session id, and the tail return stays well-typed so nothing fails the build. Restore the explicit branch and add a `satisfies never` check so adding an agent is a compile error at this line instead of a wrong resolve at runtime. --- src/main/native-chat/session-file-resolver.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index aa70d126c1d..f0376e01f02 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -153,7 +153,14 @@ async function resolveSessionFileById( if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir(), signal) } - return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal) + if (transcriptAgent === 'omp') { + return resolveOmpSessionFile(trimmedId, options.ompSessionsDir ?? ompSessionsDir(), signal) + } + // Why: a new transcript agent must pick its own resolver. Falling through to + // OMP's scan would search the wrong root with a foreign session id, so fail + // the build here instead of resolving silently wrong at runtime. + transcriptAgent satisfies never + return null } async function resolveClaudeSessionFile( From fc7c2a1cd5a4d0194bbf269ed7a963051b23b5b0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:18:10 -0700 Subject: [PATCH 05/33] fix(browser): preserve partitioned Google cookies on import (#14086) --- ...ser-cookie-import-google-exclusion.test.ts | 30 +-- ...r-cookie-import-partition.electron.test.ts | 194 ++++++++++++++++++ .../browser-cookie-import-policy.test.ts | 149 ++++++++------ .../browser/browser-cookie-import-policy.ts | 89 ++++---- .../browser/browser-cookie-import.test.ts | 3 +- src/main/browser/browser-cookie-import.ts | 6 +- 6 files changed, 343 insertions(+), 128 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-partition.electron.test.ts diff --git a/src/main/browser/browser-cookie-import-google-exclusion.test.ts b/src/main/browser/browser-cookie-import-google-exclusion.test.ts index 5de16182bf9..d1e13737b1d 100644 --- a/src/main/browser/browser-cookie-import-google-exclusion.test.ts +++ b/src/main/browser/browser-cookie-import-google-exclusion.test.ts @@ -166,7 +166,7 @@ describe('native Chromium import excludes the Google cookie family', () => { createChromiumCookieTestDatabase(targetCookiesPath, rows).close() } - it('bulk clears once and restores the live Google cookies before importing', async () => { + it('never clears or reconstructs live Google cookies before importing', async () => { const sourceCookiesPath = seedSource([ { domain: '.google.com', name: 'SID', value: 'transplanted-sid' }, { domain: '.example.com', name: 'session', value: 'new' } @@ -186,13 +186,10 @@ describe('native Chromium import excludes the Google cookie family', () => { googleCookiesSkipped: 1, domains: ['example.com'] }) - expect(clearStorageDataMock).toHaveBeenCalledOnce() - expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) - expect(cookiesRemoveMock).not.toHaveBeenCalled() - expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual([ - '.google.com', - '.example.com' - ]) + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).toHaveBeenCalledOnce() + expect(cookiesRemoveMock).toHaveBeenCalledWith('https://example.com/', 'stale') + expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com']) }) it('reports an excluded Google row even when its encrypted value is invalid', async () => { @@ -270,24 +267,29 @@ describe('native Chromium import excludes the Google cookie family', () => { ]) }) - it('fails the import and restores the full snapshot when the bulk clear rejects', async () => { + it('fails the import without reconstructing Google cookies when selective removal rejects', async () => { const sourceCookiesPath = seedSource([ { domain: '.example.com', name: 'session', value: 'new' } ]) seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }]) cookiesGetMock.mockResolvedValue([ existingCookie('.google.com', 'SID'), - existingCookie('.example.com', 'stale') + existingCookie('.example.com', 'removed-first'), + existingCookie('.other.test', 'stale') ]) - clearStorageDataMock.mockRejectedValue(new Error('cookie store unavailable')) + cookiesRemoveMock.mockImplementation(async (_url: string, name: string) => { + if (name === 'stale') { + throw new Error('cookie store unavailable') + } + }) const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') expect(result).toMatchObject({ ok: false }) expect(result.ok || result.reason).toContain('Could not clear existing cookies') - expect(clearStorageDataMock).toHaveBeenCalledOnce() - expect(cookiesRemoveMock).not.toHaveBeenCalled() - expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'stale']) + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['removed-first', 'stale']) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['removed-first']) expect(setPendingCookieImportMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/browser/browser-cookie-import-partition.electron.test.ts b/src/main/browser/browser-cookie-import-partition.electron.test.ts new file mode 100644 index 00000000000..494ebbd9e2b --- /dev/null +++ b/src/main/browser/browser-cookie-import-partition.electron.test.ts @@ -0,0 +1,194 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { build as buildVite } from 'vite' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' + +const electronBinary = createRequire(import.meta.url)('electron') as string +const fixtureRoots: string[] = [] + +afterAll(() => { + for (const root of fixtureRoots) { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + } +}) + +type FixtureResult = { + before: Record + after: Record + electronCookieKeys: string[] + importResult: Record + remainingNames: string[] +} + +function buildFixtureMain( + importPath: string, + resultPath: string, + sourceCookiesPath: string +): string { + return ` +const { app, BrowserWindow, session } = require('electron') +const { writeFileSync } = require('node:fs') +const { importCookiesFromBrowser } = require(${JSON.stringify(importPath)}) +const resultPath = ${JSON.stringify(resultPath)} +let currentStep = 'starting' +const mark = (step) => { + currentStep = step + writeFileSync(resultPath, JSON.stringify({ step })) +} + +async function run() { + const timeout = setTimeout(() => { + writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep })) + app.exit(1) + }, 15000) + await app.whenReady() + mark('ready') + const partition = 'persist:partition-cookie-test' + const targetSession = session.fromPartition(partition) + const window = new BrowserWindow({ show: false, webPreferences: { partition } }) + mark('window created') + await window.loadURL('data:text/html,cookie fixture') + mark('window loaded') + const debug = window.webContents.debugger + debug.attach('1.3') + mark('debugger attached') + await debug.sendCommand('Network.enable') + mark('network enabled') + await debug.sendCommand('Network.setCookie', { + url: 'https://accounts.google.com/', + name: 'partitioned-google', + value: 'live-value', + secure: true, + httpOnly: true, + sameSite: 'None', + partitionKey: { topLevelSite: 'https://example.com', hasCrossSiteAncestor: true } + }) + mark('partitioned cookie set') + await targetSession.cookies.set({ + url: 'https://stale.example/', + name: 'stale', + value: 'remove-me', + secure: true + }) + mark('ordinary cookie set') + + const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies + const before = beforeCookies.find((cookie) => cookie.name === 'partitioned-google') + const electronCookie = (await targetSession.cookies.get({ name: 'partitioned-google' }))[0] + if (!before || !electronCookie) throw new Error('Partitioned fixture cookie was not created') + mark('cookies read') + + const importResult = await importCookiesFromBrowser({ + family: 'chrome', + label: 'Chromium fixture', + cookiesPath: ${JSON.stringify(sourceCookiesPath)}, + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + }, partition) + if (!importResult.ok) throw new Error('Native import failed: ' + importResult.reason) + mark('native import complete') + + const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies + const after = afterCookies.find((cookie) => cookie.name === 'partitioned-google') + if (!after) throw new Error('Partitioned Google cookie was removed') + clearTimeout(timeout) + writeFileSync(resultPath, JSON.stringify({ + before, + after, + electronCookieKeys: Object.keys(electronCookie).sort(), + importResult, + remainingNames: afterCookies.map((cookie) => cookie.name).sort() + })) + debug.detach() + window.destroy() + app.exit(0) +} + +run().catch((error) => { + writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) })) + app.exit(1) +}) +` +} + +async function runFixture(): Promise { + const root = mkdtempSync(join(tmpdir(), 'orca-partition-cookie-')) + fixtureRoots.push(root) + const importPath = join(root, 'browser-cookie-import.cjs') + const registryStubPath = join(root, 'browser-session-registry.cjs') + const resultPath = join(root, 'result.json') + const sourceCookiesPath = join(root, 'source', 'Network', 'Cookies') + const fixturePath = join(root, 'main.cjs') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.example.com', name: 'imported', value: 'new-value' } + ]).close() + writeFileSync( + registryStubPath, + 'exports.browserSessionRegistry = { clearPendingCookieImport() {}, setPendingCookieImport() {} }\n' + ) + await buildVite({ + configFile: false, + logLevel: 'silent', + plugins: [ + { + name: 'stub-browser-session-registry', + resolveId(source, importer) { + return source === './browser-session-registry' && + importer?.endsWith('browser-cookie-import.ts') + ? registryStubPath + : null + } + } + ], + build: { + emptyOutDir: false, + lib: { + entry: join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'), + formats: ['cjs'], + fileName: () => 'browser-cookie-import.cjs' + }, + outDir: root, + target: 'node20', + rollupOptions: { external: ['electron', /^node:/] } + } + }) + writeFileSync(fixturePath, buildFixtureMain(importPath, resultPath, sourceCookiesPath)) + const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env + const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`] + const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary + const args = + process.platform === 'linux' + ? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox'] + : electronArgs + const run = spawnSync(executable, args, { + encoding: 'utf8', + env, + timeout: 60_000 + }) + const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result' + expect(run.error).toBeUndefined() + expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0) + return JSON.parse(fixtureResult) as FixtureResult +} + +describe('native Chromium excluded partition cookie under Electron', () => { + it('survives selective clearing unchanged without a lossy Electron reconstruction', async () => { + const result = await runFixture() + + expect(result.before.partitionKey).toEqual({ + topLevelSite: 'https://example.com', + hasCrossSiteAncestor: true + }) + expect(result.electronCookieKeys).not.toContain('partitionKey') + expect(result.after).toEqual(result.before) + expect(result.importResult).toMatchObject({ + ok: true, + summary: { importedCookies: 1, domains: ['example.com'] } + }) + expect(result.remainingNames).toEqual(['imported', 'partitioned-google']) + }, 90_000) +}) diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts index 02d656fd3bc..96d023fd2f5 100644 --- a/src/main/browser/browser-cookie-import-policy.test.ts +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { DatabaseSync } from 'node:sqlite' import type { Cookie } from 'electron' import { - bulkClearCookiesExcept, isGoogleSourceBoundCookie, isNonTransplantableCookieDomain, NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, + removeAllCookiesExcept, replaceCookiesForImportedDomains } from './browser-cookie-import-policy' @@ -207,87 +207,110 @@ describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => { }) }) -describe('bulkClearCookiesExcept', () => { - it('bulk clears a large jar once and restores only excluded cookies', async () => { - const existing = [ - cookie('.google.com', 'SID'), - cookie('accounts.google.com', 'ACCOUNT'), - ...Array.from({ length: 1_000 }, (_, index) => - cookie(`site-${index}.example`, `session-${index}`) - ) - ] - const get = vi.fn().mockResolvedValue(existing) +describe('removeAllCookiesExcept', () => { + it('never removes or reconstructs excluded Google cookies', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.google.com', 'SID'), + cookie('accounts.google.com', 'ACCOUNT'), + cookie('.example.com', 'session'), + cookie('other.test', 'tracker', '/scoped') + ]) + const remove = vi.fn().mockResolvedValue(undefined) const set = vi.fn().mockResolvedValue(undefined) - const clearStorageData = vi.fn().mockResolvedValue(undefined) - await bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, (existingCookie) => + await removeAllCookiesExcept({ get, remove, set }, (existingCookie) => isNonTransplantableCookieDomain(existingCookie.domain ?? '') ) - expect(get).toHaveBeenCalledOnce() - expect(get).toHaveBeenCalledWith({}) - expect(clearStorageData).toHaveBeenCalledOnce() - expect(clearStorageData).toHaveBeenCalledWith({ storages: ['cookies'] }) - expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'ACCOUNT']) + expect(remove.mock.calls).toEqual([ + ['https://example.com/', 'session'], + ['https://other.test/scoped', 'tracker'] + ]) + expect(set).not.toHaveBeenCalled() }) - it('restores the complete snapshot when the bulk clear rejects', async () => { - const existing = [ - cookie('.google.com', 'SID'), - cookie('.example.com', 'first'), - cookie('.other.test', 'second') - ] - const get = vi.fn().mockResolvedValue(existing) + it('restores removed non-Google cookies when another removal fails', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.google.com', 'SID'), + cookie('.example.com', 'first', '/one'), + cookie('.example.com', 'second', '/two'), + cookie('.example.com', 'third', '/three') + ]) + const remove = vi.fn().mockImplementation(async (_url: string, name: string) => { + if (name === 'second') { + throw new Error('store unavailable') + } + }) const set = vi.fn().mockResolvedValue(undefined) - const clearStorageData = vi.fn().mockRejectedValue(new Error('store unavailable')) await expect( - bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, () => true) + removeAllCookiesExcept({ get, remove, set }, (existingCookie) => + isNonTransplantableCookieDomain(existingCookie.domain ?? '') + ) ).rejects.toThrow('Could not clear existing cookies') - - expect(clearStorageData).toHaveBeenCalledOnce() - expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'first', 'second']) + expect(remove).toHaveBeenCalledTimes(3) + expect(set.mock.calls.map(([details]) => details.name).sort()).toEqual(['first', 'third']) }) - it('rolls back the complete snapshot when excluded-cookie restoration fails', async () => { - const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')] - const get = vi.fn().mockResolvedValue(existing) - let googleAttempts = 0 - const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => { - if (name === 'SID' && googleAttempts++ === 0) { - throw new Error('transient restore failure') - } - }) - const clearStorageData = vi.fn().mockResolvedValue(undefined) - - await expect( - bulkClearCookiesExcept( - { cookies: { get, set }, clearStorageData }, - (existingCookie) => existingCookie.domain === '.google.com' + it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => { + const get = vi + .fn() + .mockResolvedValue( + Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`)) ) - ).rejects.toThrow('Could not preserve excluded cookies') + let releaseRemovals: (() => void) | undefined + const removalsReleased = new Promise((resolve) => { + releaseRemovals = resolve + }) + let active = 0 + let maxActive = 0 + const remove = vi.fn().mockImplementation(async () => { + active++ + maxActive = Math.max(maxActive, active) + await removalsReleased + active-- + }) + const set = vi.fn().mockResolvedValue(undefined) - expect(clearStorageData).toHaveBeenCalledOnce() - expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session']) + const clearing = removeAllCookiesExcept({ get, remove, set }, () => false) + await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8)) + expect(maxActive).toBe(8) + releaseRemovals?.() + await clearing + + expect(remove).toHaveBeenCalledTimes(12) + expect(set).not.toHaveBeenCalled() }) - it('reports when the complete-snapshot rollback also fails', async () => { - const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')] - const get = vi.fn().mockResolvedValue(existing) - const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => { - if (name === 'SID') { - throw new Error('persistent restore failure') - } + it('serializes cookies that share Electron removal coordinates', async () => { + const get = vi + .fn() + .mockResolvedValue([ + cookie('.example.com', 'session'), + { ...cookie('example.com', 'session'), hostOnly: true } + ]) + let releaseFirst: (() => void) | undefined + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve }) - const clearStorageData = vi.fn().mockResolvedValue(undefined) + const remove = vi + .fn() + .mockImplementationOnce(() => firstReleased) + .mockResolvedValueOnce(undefined) + const set = vi.fn().mockResolvedValue(undefined) - await expect( - bulkClearCookiesExcept( - { cookies: { get, set }, clearStorageData }, - (existingCookie) => existingCookie.domain === '.google.com' - ) - ).rejects.toThrow('Cookie preservation and rollback failed') + const clearing = removeAllCookiesExcept({ get, remove, set }, () => false) + await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()) + releaseFirst?.() + await clearing - expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session']) + expect(remove.mock.calls).toEqual([ + ['https://example.com/', 'session'], + ['https://example.com/', 'session'] + ]) }) }) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts index d1cd02402fe..4fa7f44ddd9 100644 --- a/src/main/browser/browser-cookie-import-policy.ts +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -1,5 +1,6 @@ -import type { Cookie, Cookies, Session } from 'electron' +import type { Cookie, Cookies } from 'electron' import { parse as parseDomain } from 'psl' +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ 'SIDCC', @@ -63,6 +64,7 @@ export function normalizeCookieImportDomain(domain: string): string | null { // its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports // users actually asked for. const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const +const COOKIE_CLEAR_CONCURRENCY = 8 export function isNonTransplantableCookieDomain(domain: string): boolean { const normalized = normalizeCookieDomain(domain) @@ -194,55 +196,52 @@ export async function restoreImportedDomainCookies( } } -type CookieClearSession = { - cookies: Pick - clearStorageData: Session['clearStorageData'] -} - -async function restoreCookieClearSnapshot( - store: Pick, - snapshot: readonly Cookie[], - originalError: unknown, - rollbackMessage: string -): Promise { - try { - await restoreImportedDomainCookies(store, snapshot) - } catch (rollbackError) { - throw new AggregateError([originalError, rollbackError], rollbackMessage) - } - throw originalError -} - -// Why: after a bulk clear starts, Electron cannot reveal whether a rejected operation mutated -// the jar, so keep the complete snapshot until excluded cookies have been restored. -export async function bulkClearCookiesExcept( - targetSession: CookieClearSession, +// Why: Electron cannot round-trip partition identity, so excluded cookies must never be removed. +export async function removeAllCookiesExcept( + store: Pick, isExcluded: (cookie: Cookie) => boolean ): Promise { - const snapshot = await targetSession.cookies.get({}) - const excludedCookies = snapshot.filter(isExcluded) - - try { - await targetSession.clearStorageData({ storages: ['cookies'] }) - } catch (clearError) { - await restoreCookieClearSnapshot( - targetSession.cookies, - snapshot, - new AggregateError([clearError], 'Could not clear existing cookies'), - 'Cookie bulk clear and rollback failed' - ) + const existingCookies = await store.get({}) + const removableGroups = new Map() + for (const cookie of existingCookies) { + if (isExcluded(cookie)) { + continue + } + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + const url = domain ? cookieRemovalUrl(cookie, domain) : null + if (!url) { + continue + } + const key = JSON.stringify([url, cookie.name]) + const group = removableGroups.get(key) ?? [] + group.push({ cookie, url }) + removableGroups.set(key, group) } - try { - await restoreImportedDomainCookies(targetSession.cookies, excludedCookies) - } catch (preservationError) { - await restoreCookieClearSnapshot( - targetSession.cookies, - snapshot, - new AggregateError([preservationError], 'Could not preserve excluded cookies'), - 'Cookie preservation and rollback failed' - ) + const removedCookies: Cookie[] = [] + const results = await mapSettledWithConcurrency( + [...removableGroups.values()], + COOKIE_CLEAR_CONCURRENCY, + async (group) => { + // Why: identical removal coordinates must stay ordered instead of racing. + for (const { cookie, url } of group) { + await store.remove(url, cookie.name) + removedCookies.push(cookie) + } + } + ) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length === 0) { + return } + try { + await restoreImportedDomainCookies(store, removedCookies) + } catch (restoreError) { + throw new AggregateError([...failures, restoreError], 'Cookie clearing and rollback failed') + } + throw new AggregateError(failures, 'Could not clear existing cookies') } export async function replaceCookiesForImportedDomains( diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 5a22727bf2b..d2d4ffd4836 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -511,8 +511,7 @@ describe('importCookiesFromBrowser Chromium', () => { ['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix)) ).toEqual(sourceFilesBefore) expect(cookiesRemoveMock).not.toHaveBeenCalled() - expect(clearStorageDataMock).toHaveBeenCalledOnce() - expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) + expect(clearStorageDataMock).not.toHaveBeenCalled() // Why: STA-3514 — imports must never impersonate the source browser; the // session keeps the engine UA the registry set at startup. expect(setUserAgentMock).not.toHaveBeenCalled() diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 50ec961eb7f..2a7bed19950 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -75,12 +75,12 @@ import type { } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' import { - bulkClearCookiesExcept, isGoogleSourceBoundCookie, isNonTransplantableCookieDomain, NON_TRANSPLANTABLE_HOST_KEY_SQL, normalizeCookieDomain, normalizeCookieImportDomain, + removeAllCookiesExcept, replaceCookiesForImportedDomains, restoreImportedDomainCookies, type CookieImportMode @@ -1780,11 +1780,9 @@ export async function importCookiesFromBrowser( // Why: clear stale cookies first; mixing them with the imported set makes sites reject the // session. Non-transplantable families are exempt — nothing was imported for them, and their // live session is the only one that works. - await bulkClearCookiesExcept(targetSession, (cookie) => + await removeAllCookiesExcept(targetSession.cookies, (cookie) => isNonTransplantableCookieDomain(cookie.domain ?? '') ) - // Why: after excluded cookies survive this boundary, restart staging owns later set failures; - // restoring stale non-Google cookies would recreate the mixed jar this import must replace. diag( ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` ) From ebe5125476188b8de5aca5d9829f66b2427be321 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:42:42 -0700 Subject: [PATCH 06/33] Gate paired web creation actions by provider (#13909) --- src/renderer/src/components/Terminal.tsx | 76 +++++-- .../src/components/WorktreeJumpPalette.tsx | 42 ++-- .../components/browser-pane/BrowserPane.tsx | 16 +- .../cmd-j/quick-action-context.test.ts | 37 +++ .../components/cmd-j/quick-action-context.ts | 18 ++ .../src/components/cmd-j/quick-actions.ts | 17 +- .../feature-wall/FeatureWallBrowserAction.tsx | 12 +- .../FloatingTerminalPanel.tsx | 57 +++-- .../GitHistoryCommitContextMenu.tsx | 23 +- .../right-sidebar/PortsPanel.test.tsx | 36 ++- .../components/right-sidebar/PortsPanel.tsx | 12 +- .../useGitHistoryCommitActions.ts | 14 +- .../settings/FloatingWorkspacePane.tsx | 6 +- .../src/components/settings/ShortcutsPane.tsx | 116 ++++------ .../settings/floating-workspace-search.ts | 145 ++++++------ .../settings/shortcut-row-visibility.test.ts | 39 ++++ .../settings/shortcut-row-visibility.ts | 96 ++++++++ .../components/settings/shortcuts-search.ts | 44 +++- .../src/components/tab-bar/BrowserTab.tsx | 16 +- .../tab-bar/TabBar.context-menu.test.ts | 20 ++ .../src/components/tab-bar/TabBar.tsx | 2 + .../tab-bar/tab-bar-item-surface.tsx | 6 +- .../tab-bar/tab-bar-static-create-menu.tsx | 28 ++- .../components/tab-bar/tab-bar-surface.tsx | 4 + .../tab-bar/tab-create-menu-options.test.ts | 19 ++ .../use-tab-bar-create-menu-controller.ts | 14 +- .../tab-bar/use-tab-bar-runtime-model.ts | 16 +- .../tab-group/useTabGroupWorkspaceModel.ts | 33 ++- src/renderer/src/hooks/useIpcEvents.ts | 43 ++-- .../useSettingsNavigationMetadata.test.ts | 35 ++- .../hooks/useSettingsNavigationMetadata.ts | 41 +++- src/renderer/src/i18n/locales/en.json | 3 +- .../lib/client-creation-action-policy.test.ts | 210 ++++++++++++++++++ .../src/lib/client-creation-action-policy.ts | 116 ++++++++++ .../lib/floating-workspace-tab-creation.ts | 6 + .../src/lib/open-mobile-emulator-tab.test.ts | 22 +- .../src/lib/open-mobile-emulator-tab.ts | 2 + .../lib/workspace-browser-tab-open.test.ts | 25 ++- .../src/lib/workspace-browser-tab-open.ts | 26 ++- .../src/lib/workspace-port-actions.ts | 22 +- .../src/runtime/web-runtime-session.test.ts | 33 +++ .../src/runtime/web-runtime-session.ts | 2 + src/renderer/src/store/slices/browser.test.ts | 158 ++++++++++++- src/renderer/src/store/slices/browser.ts | 33 ++- .../store/slices/cmd-j-create-actions.test.ts | 46 ++-- src/shared/protocol-version.ts | 3 +- .../multi-client-navigation-isolation.spec.ts | 49 ++++ 47 files changed, 1507 insertions(+), 332 deletions(-) create mode 100644 src/renderer/src/components/settings/shortcut-row-visibility.test.ts create mode 100644 src/renderer/src/components/settings/shortcut-row-visibility.ts create mode 100644 src/renderer/src/lib/client-creation-action-policy.test.ts create mode 100644 src/renderer/src/lib/client-creation-action-policy.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index a8938a4e507..d555a92d22c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -12,6 +12,7 @@ import { } from '@/constants/terminal' import { useAppStore } from '../store' import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { useAllWorktrees } from '../store/selectors' import { getConnectionId } from '../lib/connection-context' import { basename } from '../lib/path' @@ -181,6 +182,7 @@ import { useManualTerminalWorktreeParking } from './terminal-pane/use-manual-terminal-worktree-parking' import { EDITOR_TAB_CONTENT_TYPES, getEditorCmdSaveFileId } from './editor/editor-cmd-save-target' +import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' const EditorPanel = lazy(() => import('./editor/EditorPanel')) @@ -188,6 +190,10 @@ const EditorPanel = lazy(() => import('./editor/EditorPanel')) const CLOSE_DIALOG_DEBOUNCE_MS = 200 type TerminalStoreSnapshot = ReturnType +function showClientCreationActionError(error: unknown): void { + toast.error(error instanceof Error ? error.message : String(error)) +} + function haveSameIdSet(left: ReadonlySet, right: ReadonlySet): boolean { if (left.size !== right.size) { return false @@ -1597,7 +1603,7 @@ function Terminal(): React.JSX.Element | null { void openMobileEmulatorTab(activeWorktreeId, { placement: 'rightSplit', targetGroupId: targetGroupId ?? undefined - }) + }).catch(showClientCreationActionError) }, [activeWorktreeId]) const handleNewBrowserTab = useCallback(() => { @@ -1608,22 +1614,31 @@ function Terminal(): React.JSX.Element | null { useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id if (targetGroupId) { - void openNewBrowserTabInActiveWorkspace(targetGroupId) + void openNewBrowserTabInActiveWorkspace(targetGroupId).catch(showClientCreationActionError) return } - const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank' + const state = useAppStore.getState() + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } + const defaultUrl = state.browserDefaultUrl ?? 'about:blank' const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) - if (isWebRuntimeSessionActive(runtimeEnvironmentId)) { + if (browserAvailability.provider === 'paired-runtime' && runtimeEnvironmentId) { void createWebRuntimeSessionBrowserTab({ worktreeId: activeWorktreeId, environmentId: runtimeEnvironmentId, url: defaultUrl - }) + }).catch(showClientCreationActionError) return } createBrowserTab(activeWorktreeId, defaultUrl, { title: translate('auto.components.Terminal.37da0d736f', 'New Browser Tab'), - focusAddressBar: true + focusAddressBar: true, + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) }) }, [activeWorktreeId, createBrowserTab, openNewBrowserTabInActiveWorkspace]) @@ -1643,8 +1658,16 @@ function Terminal(): React.JSX.Element | null { return } const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) + const browserAvailability = getClientCreationActionPolicy(state, activeWorktreeId)[ + 'managed-browser' + ] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } if ( - isWebRuntimeSessionActive(runtimeEnvironmentId) && + browserAvailability.provider === 'paired-runtime' && + runtimeEnvironmentId && browserWorkspaceHasRemoteOwner(state, source.id, runtimeEnvironmentId) ) { void createWebRuntimeSessionBrowserTab({ @@ -1652,12 +1675,17 @@ function Terminal(): React.JSX.Element | null { environmentId: runtimeEnvironmentId, url: source.url, profileId: source.sessionProfileId - }) + }).catch(showClientCreationActionError) return } - createBrowserTab(activeWorktreeId, source.url, { - ...buildDuplicatedBrowserTabOptions(source) - }) + try { + createBrowserTab(activeWorktreeId, source.url, { + ...buildDuplicatedBrowserTabOptions(source), + ...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}) + }) + } catch (error) { + showClientCreationActionError(error) + } }, [activeWorktreeId, createBrowserTab] ) @@ -2032,7 +2060,11 @@ function Terminal(): React.JSX.Element | null { if (!e.repeat && matchShortcut('tab.reopenClosed')) { e.preventDefault() notifyTerminalCapture('tab.reopenClosed') - useAppStore.getState().reopenClosedTab(activeWorktreeId) + try { + useAppStore.getState().reopenClosedTab(activeWorktreeId) + } catch (error) { + showClientCreationActionError(error) + } return } @@ -2040,8 +2072,18 @@ function Terminal(): React.JSX.Element | null { if (!e.repeat && matchShortcut('tab.newBrowser')) { e.preventDefault() notifyTerminalCapture('tab.newBrowser') + const browserAvailability = getClientCreationActionPolicy( + useAppStore.getState(), + floatingWorkspaceFocused ? FLOATING_TERMINAL_WORKTREE_ID : activeWorktreeId + )['managed-browser'] + if (browserAvailability.state !== 'enabled') { + toast.error(browserAvailability.reason) + return + } if (floatingWorkspaceFocused) { - void createFloatingWorkspaceBrowserTab(useAppStore.getState()) + void createFloatingWorkspaceBrowserTab(useAppStore.getState()).catch( + showClientCreationActionError + ) return } handleNewBrowserTab() @@ -2052,6 +2094,14 @@ function Terminal(): React.JSX.Element | null { if (!e.repeat && mobileEmulatorEnabled && matchShortcut('tab.newSimulator')) { e.preventDefault() notifyTerminalCapture('tab.newSimulator') + const simulatorAvailability = getClientCreationActionPolicy( + useAppStore.getState(), + activeWorktreeId + )['mobile-emulator'] + if (simulatorAvailability.state !== 'enabled') { + toast.error(simulatorAvailability.reason) + return + } if (!floatingWorkspaceFocused) { handleNewSimulatorTab() } diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index cee7c9bf4ce..45a31847834 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1480,23 +1480,37 @@ function WorktreeJumpPaletteContent({ // Why: filtering via buildQuickActionContext() inside a memo with stable primitive deps // instead of calling it inline every render — a fresh context object as a useMemo dep // defeated the middleItems memo (new identity every keystroke). + const quickActionAvailabilityInputs = useMemo( + () => ({ + activeView, + activeWorktreeId, + worktreesByRepo, + repos, + sshConnectionStates, + activeGroupIdByWorktree, + groupsByWorktree, + isLoading, + activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, + runtimeStatusByEnvironmentId + }), + [ + activeView, + activeWorktreeId, + worktreesByRepo, + repos, + sshConnectionStates, + activeGroupIdByWorktree, + groupsByWorktree, + isLoading, + settings?.activeRuntimeEnvironmentId, + runtimeStatusByEnvironmentId + ] + ) const availableActionResults = useMemo(() => { + void quickActionAvailabilityInputs const ctx = buildQuickActionContext() return actionResults.filter((action) => action.isAvailable(ctx).available) - }, [ - actionResults, - buildQuickActionContext, - // oxlint-disable-next-line react-hooks/exhaustive-deps -- these are the availability-determining primitives buildQuickActionContext reads from the store; listing them ensures the memo recomputes when availability actually changes, not on every render. - activeView, - activeWorktreeId, - worktreesByRepo, - repos, - sshConnectionStates, - activeGroupIdByWorktree, - groupsByWorktree, - isLoading, - settings?.activeRuntimeEnvironmentId - ]) + }, [actionResults, buildQuickActionContext, quickActionAvailabilityInputs]) const middleItems = useMemo<(SettingsPaletteItem | QuickActionPaletteItem)[]>( () => diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index d8fd4b91405..825b0ceb3e8 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -189,6 +189,7 @@ import { shouldPollChromiumErrorPage } from './chromium-error-page-polling' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' import { isBrowserPagePanePaintable } from './browser-page-paintability' +import { openWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-open' import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode' import { MarkupOverlay } from './markup/MarkupOverlay' import { MarkupDrawButton } from './markup/MarkupDrawButton' @@ -935,7 +936,6 @@ function RemoteBrowserPagePane({ const remotePageHandle = useAppStore( (s) => s.remoteBrowserPageHandlesByPageId[browserTab.id] ?? null ) - const createBrowserTab = useAppStore((s) => s.createBrowserTab) const closeBrowserPage = useAppStore((s) => s.closeBrowserPage) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) const keybindings = useAppStore((state) => state.keybindings) @@ -2037,10 +2037,18 @@ function RemoteBrowserPagePane({ role="menuitem" className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14" onClick={() => { - createBrowserTab(worktreeId, contextMenu.linkUrl!, { - title: contextMenu.linkUrl! - }) + const linkUrl = contextMenu.linkUrl! setContextMenu(null) + void openWorkspaceBrowserTab({ + workspaceId: worktreeId, + url: linkUrl, + intent: { kind: 'url' } + }).catch((error) => { + setPaneNotice({ + kind: 'direct', + text: error instanceof Error ? error.message : String(error) + }) + }) }} > {translate( diff --git a/src/renderer/src/components/cmd-j/quick-action-context.test.ts b/src/renderer/src/components/cmd-j/quick-action-context.test.ts index 5e753b42372..7ce2e107613 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.test.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.test.ts @@ -278,6 +278,43 @@ describe('Cmd+J quick action context', () => { expect(calls).toEqual([]) }) + it('omits unsupported paired-web browser execution without affecting terminal or markdown', async () => { + const calls: string[] = [] + const actions = new Map(getCmdJQuickActions().map((action) => [action.id, action])) + const context = { + ...ctx({}), + activeWorktree: null, + runtimeMode: 'paired-web' as const, + managedBrowserCreationEnabled: false, + openNewBrowserTab: async () => { + calls.push('browser') + }, + openNewMarkdownFile: async () => { + calls.push('markdown') + }, + openNewTerminalTab: async () => { + calls.push('terminal') + }, + openCreateWorkspace: () => {}, + deleteActiveWorkspace: () => {}, + openAddQuickCommand: () => {} + } satisfies CmdJQuickActionContext + + expect(actions.get('new-browser-tab')?.isAvailable(context)).toEqual({ + available: false, + reason: 'client-action-unsupported' + }) + await expect(actions.get('new-browser-tab')?.run(context)).resolves.toEqual({ + status: 'unavailable', + reason: 'client-action-unsupported' + }) + expect(actions.get('new-markdown-file')?.isAvailable(context)).toEqual({ available: true }) + expect(actions.get('new-terminal-tab')?.isAvailable(context)).toEqual({ available: true }) + await actions.get('new-markdown-file')?.run(context) + await actions.get('new-terminal-tab')?.run(context) + expect(calls).toEqual(['markdown', 'terminal']) + }) + it('runtime re-check invokes the current workspace delete action when available', async () => { const calls: string[] = [] const action = getCmdJQuickActions().find((entry) => entry.id === 'delete-workspace') diff --git a/src/renderer/src/components/cmd-j/quick-action-context.ts b/src/renderer/src/components/cmd-j/quick-action-context.ts index bf3e764f5ab..79007eae750 100644 --- a/src/renderer/src/components/cmd-j/quick-action-context.ts +++ b/src/renderer/src/components/cmd-j/quick-action-context.ts @@ -2,12 +2,14 @@ import type { AppState } from '@/store/types' import { findWorktreeById } from '@/store/slices/worktree-helpers' import type { Worktree } from '../../../../shared/types' import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' export type CmdJUnavailableReason = | 'loading' | 'no-active-workspace' | 'ssh-disconnected' | 'no-active-group' + | 'client-action-unsupported' export type CmdJQuickActionAvailability = | { available: true } @@ -25,6 +27,7 @@ export type CmdJQuickActionContext = { isLoading: boolean sshStatus: SshConnectionStatus | null runtimeMode: 'local-desktop' | 'paired-web' + managedBrowserCreationEnabled?: boolean activeGroupId: string | null openNewBrowserTab: (groupId: string) => Promise openNewMarkdownFile: (groupId: string) => Promise @@ -110,6 +113,15 @@ export function getWorkspaceScopedActionAvailability( return { available: true } } +export function getBrowserWorkspaceActionAvailability( + ctx: CmdJQuickActionContext +): CmdJQuickActionAvailability { + if (ctx.managedBrowserCreationEnabled === false) { + return { available: false, reason: 'client-action-unsupported' } + } + return getWorkspaceScopedActionAvailability(ctx) +} + export function getCurrentWorkspaceActionAvailability( ctx: Pick ): CmdJQuickActionAvailability { @@ -151,6 +163,9 @@ export function buildCmdJQuickActionContext(args: { args.state.settings?.activeRuntimeEnvironmentId?.trim() ? 'paired-web' : 'local-desktop' + const managedBrowserCreationEnabled = + getClientCreationActionPolicy(args.state, activeWorktreeId)['managed-browser'].state === + 'enabled' return { activeView: args.state.activeView, @@ -159,6 +174,7 @@ export function buildCmdJQuickActionContext(args: { isLoading, sshStatus: getActiveWorktreeSshStatus(args.state, activeWorktree), runtimeMode, + managedBrowserCreationEnabled, activeGroupId, openNewBrowserTab: args.openNewBrowserTab, openNewMarkdownFile: args.openNewMarkdownFile, @@ -182,5 +198,7 @@ export function getUnavailableQuickActionMessage( return `Can't ${actionTitle.toLowerCase()} — workspace is disconnected.` case 'no-active-group': return `Can't ${actionTitle.toLowerCase()} — no tab group is available.` + case 'client-action-unsupported': + return `Can't ${actionTitle.toLowerCase()} — this client and runtime do not support it.` } } diff --git a/src/renderer/src/components/cmd-j/quick-actions.ts b/src/renderer/src/components/cmd-j/quick-actions.ts index 8ad3f957a16..70f0e5f6859 100644 --- a/src/renderer/src/components/cmd-j/quick-actions.ts +++ b/src/renderer/src/components/cmd-j/quick-actions.ts @@ -3,6 +3,7 @@ import type { LucideIcon } from 'lucide-react' import type { CmdJQuickActionAvailability, CmdJQuickActionContext } from './quick-action-context' import { getCurrentWorkspaceActionAvailability, + getBrowserWorkspaceActionAvailability, getWorkspaceScopedActionAvailability } from './quick-action-context' import { translate } from '@/i18n/i18n' @@ -38,6 +39,12 @@ function currentWorkspaceActionAvailability( return getCurrentWorkspaceActionAvailability(ctx) } +function browserWorkspaceActionAvailability( + ctx: CmdJQuickActionContext +): CmdJQuickActionAvailability { + return getBrowserWorkspaceActionAvailability(ctx) +} + async function runWorkspaceAction( ctx: CmdJQuickActionContext, run: (groupId: string) => Promise @@ -72,8 +79,14 @@ export const getCmdJQuickActions = createLocalizedCatalog((): CmdJQuickAction[] translate('auto.components.cmd.j.quick.actions.verbs.openBrowser', 'open browser'), translate('auto.components.cmd.j.quick.actions.verbs.browserTab', 'browser tab') ], - isAvailable: workspaceActionAvailability, - run: (ctx) => runWorkspaceAction(ctx, ctx.openNewBrowserTab) + isAvailable: browserWorkspaceActionAvailability, + run: async (ctx) => { + const availability = browserWorkspaceActionAvailability(ctx) + if (!availability.available) { + return { status: 'unavailable', reason: availability.reason } + } + return runWorkspaceAction(ctx, ctx.openNewBrowserTab) + } }, { id: 'new-markdown-file', diff --git a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx index 2e039c2f2ce..b6d185e9092 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallBrowserAction.tsx @@ -16,9 +16,15 @@ import { promptForSetupGuideProject, useSetupTargetWorktree } from './FeatureWallSetupWorkflowActions' +import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy' export function BrowserAction(props: { done: boolean }): React.JSX.Element { const targetWorktree = useSetupTargetWorktree() + const browserCreationEnabled = useAppStore( + (state) => + getClientCreationActionPolicy(state, targetWorktree?.id ?? null)['managed-browser'].state === + 'enabled' + ) const openModal = useAppStore((s) => s.openModal) const closeModal = useAppStore((s) => s.closeModal) const openNewBrowserTabInActiveWorkspace = useAppStore( @@ -39,7 +45,9 @@ export function BrowserAction(props: { done: boolean }): React.JSX.Element { state.activeGroupIdByWorktree[targetWorktree.id] ?? state.groupsByWorktree[targetWorktree.id]?.[0]?.id if (groupId) { - void openNewBrowserTabInActiveWorkspace(groupId) + void openNewBrowserTabInActiveWorkspace(groupId).catch((error) => { + toast.error(error instanceof Error ? error.message : String(error)) + }) } else { toast.warning( translate( @@ -58,7 +66,7 @@ export function BrowserAction(props: { done: boolean }): React.JSX.Element { return (
- {props.done ? null : ( + {props.done || !browserCreationEnabled ? null : ( - + {showNewBrowser ? ( + + ) : null} + + ) : ( + <> + + : + + patchTime({ minute })} + /> + + )} +
+ + ) +} diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts index 747bc615782..2abf4511388 100644 --- a/src/renderer/src/components/automations/automation-draft-model.ts +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -17,8 +17,8 @@ export function formatTimeInput(hour: number, minute: number): string { export function parseDraftTime(time: string): { hour: number; minute: number } { const [rawHour, rawMinute] = time.split(':').map((part) => Number(part)) return { - hour: Number.isFinite(rawHour) ? rawHour : 9, - minute: Number.isFinite(rawMinute) ? rawMinute : 0 + hour: Number.isInteger(rawHour) && rawHour >= 0 && rawHour <= 23 ? rawHour : 9, + minute: Number.isInteger(rawMinute) && rawMinute >= 0 && rawMinute <= 59 ? rawMinute : 0 } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cbef1beb341..13baa5dc5f0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14362,9 +14362,7 @@ "33741dd973": "Back to runs" }, "AutomationSchedulePicker": { - "22359b186a": "AM or PM", "9e677335b0": "Minute", - "6b802ecc99": "Hour", "d90981f766": "Time", "6b914c5fbb": "Day", "233b8c94b6": "Cadence", @@ -14374,6 +14372,11 @@ "837d902bba": "Weekly", "ddba78647e": "Custom cron" }, + "AutomationTimeField": { + "39ec1383f6": "AM or PM", + "32a5e4e35e": "Minute", + "aa593eb5e2": "Hour" + }, "AutomationSessionField": { "f3c76dce51": "Reuse", "c90888ee94": "Fresh", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a14e64ec424..7d189faeb25 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13647,9 +13647,7 @@ "33741dd973": "Volver a carreras" }, "AutomationSchedulePicker": { - "22359b186a": "mañana o tarde", "9e677335b0": "Minuto", - "6b802ecc99": "Hora", "d90981f766": "Tiempo", "6b914c5fbb": "Día", "233b8c94b6": "Cadencia", @@ -13659,6 +13657,11 @@ "837d902bba": "Semanal", "ddba78647e": "Cron personalizado" }, + "AutomationTimeField": { + "39ec1383f6": "mañana o tarde", + "32a5e4e35e": "Minuto", + "aa593eb5e2": "Hora" + }, "AutomationSessionField": { "f3c76dce51": "Reutilizar", "c90888ee94": "Fresco", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8e9b313dd4c..2b9f42e51b1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13647,9 +13647,7 @@ "33741dd973": "実行一覧に戻る" }, "AutomationSchedulePicker": { - "22359b186a": "午前または午後", "9e677335b0": "分", - "6b802ecc99": "時間", "d90981f766": "時間", "6b914c5fbb": "日", "233b8c94b6": "ケイデンス", @@ -13659,6 +13657,11 @@ "837d902bba": "毎週", "ddba78647e": "カスタム cron" }, + "AutomationTimeField": { + "39ec1383f6": "午前または午後", + "32a5e4e35e": "分", + "aa593eb5e2": "時間" + }, "AutomationSessionField": { "f3c76dce51": "再利用", "c90888ee94": "新規", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 364bc1860a0..4a34c5639b0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13658,9 +13658,7 @@ "33741dd973": "실행으로 돌아가기" }, "AutomationSchedulePicker": { - "22359b186a": "오전 또는 오후", "9e677335b0": "분", - "6b802ecc99": "시간", "d90981f766": "시간", "6b914c5fbb": "요일", "233b8c94b6": "반복 주기", @@ -13670,6 +13668,11 @@ "837d902bba": "매주", "ddba78647e": "사용자 지정 cron" }, + "AutomationTimeField": { + "39ec1383f6": "오전 또는 오후", + "32a5e4e35e": "분", + "aa593eb5e2": "시간" + }, "AutomationSessionField": { "f3c76dce51": "재사용", "c90888ee94": "새로 시작", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2e85a5074b1..8fa0cf2b3e1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13667,9 +13667,7 @@ "33741dd973": "返回执行" }, "AutomationSchedulePicker": { - "22359b186a": "上午或下午", "9e677335b0": "分钟", - "6b802ecc99": "小时", "d90981f766": "时间", "6b914c5fbb": "天", "233b8c94b6": "节奏", @@ -13679,6 +13677,11 @@ "837d902bba": "每周", "ddba78647e": "自定义 cron" }, + "AutomationTimeField": { + "39ec1383f6": "上午或下午", + "32a5e4e35e": "分钟", + "aa593eb5e2": "小时" + }, "AutomationSessionField": { "f3c76dce51": "重复利用", "c90888ee94": "新鲜的", From d349f9a97268e282e5e291b074486d8df47afda2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:03:29 -0700 Subject: [PATCH 10/33] Use provider-neutral Opus alias in orchestration guide (#14119) --- config/scripts/orchestration-skill-guidance.test.mjs | 2 +- skill-guides/orchestration.md | 2 +- src/cli/bundled-skill-guides.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 080cb4fa785..b9a88ae12ad 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -281,7 +281,7 @@ describe('orchestration skill guidance', () => { expect(workerLoop).toContain('opaque provider model id with `--model`') expect(workerLoop).toContain('`--effort` requires `--model`') expect(workerLoop).toContain('neither option can combine with `--terminal`') - expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json') + expect(workerLoop).toContain('--agent claude --model opus --effort high --json') expect(workerLoop).toContain('`launch.requested` and `launch.effective`') }) diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index bc7b1cbbe3e..20b70a15b63 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -195,7 +195,7 @@ orca orchestration worker-start --task --worktree current --agent claud For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt: ```bash -orca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json +orca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json ``` `--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option. diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 487c9152b89..e0a65fc876f 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore From 564968c31e572d8215e7c04fb88c0ade47357f75 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:05:46 -0700 Subject: [PATCH 11/33] fix(browser): guide Google sign-in after cookie import (STA-4019) (#14085) * fix(browser): guide Google sign-in after cookie import * fix(browser): show runtime name in import guidance * fix(browser): honor host display labels in import guidance --- .../browser-pane/BrowserImportHintButton.tsx | 4 +- .../browser-pane/BrowserToolbarMenu.tsx | 4 +- .../components/settings/BrowserProfileRow.tsx | 4 +- .../settings/BrowserUseCookieImportStep.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 4 +- .../lib/browser-cookie-import-toast.test.ts | 145 +++--------------- .../src/lib/browser-cookie-import-toast.ts | 55 ++----- src/renderer/src/store/slices/browser.test.ts | 53 ++++++- src/renderer/src/store/slices/browser.ts | 78 ++++++++-- 9 files changed, 154 insertions(+), 197 deletions(-) diff --git a/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx b/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx index 18f5cf28e1c..0f318fb8107 100644 --- a/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx +++ b/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx @@ -102,7 +102,7 @@ export function BrowserImportHintButton({ value2: browserProfile ? ` (${browserProfile})` : '' } ), - result.profileId + result.executionHostLabel ) return } @@ -123,7 +123,7 @@ export function BrowserImportHintButton({ 'Imported {{value0}} cookies from file.', { value0: result.summary.importedCookies } ), - result.profileId + result.executionHostLabel ) return } diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx index 9d250d20221..f712d33abd2 100644 --- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -206,7 +206,7 @@ export function BrowserToolbarMenu({ value1: browser?.label ?? browserFamily } ), - result.profileId + result.executionHostLabel ) } else { toast.error(result.reason) @@ -223,7 +223,7 @@ export function BrowserToolbarMenu({ 'Imported {{value0}} cookies from file.', { value0: result.summary.importedCookies } ), - result.profileId + result.executionHostLabel ) } else if (result.reason !== 'canceled') { toast.error(result.reason) diff --git a/src/renderer/src/components/settings/BrowserProfileRow.tsx b/src/renderer/src/components/settings/BrowserProfileRow.tsx index 1240a37ed0f..a6324661901 100644 --- a/src/renderer/src/components/settings/BrowserProfileRow.tsx +++ b/src/renderer/src/components/settings/BrowserProfileRow.tsx @@ -82,7 +82,7 @@ export function BrowserProfileRow({ value2: profile.label } ), - result.profileId + result.executionHostLabel ) } else { toast.error(result.reason) @@ -99,7 +99,7 @@ export function BrowserProfileRow({ 'Imported {{value0}} cookies from file into {{value1}}.', { value0: result.summary.importedCookies, value1: profile.label } ), - result.profileId + result.executionHostLabel ) } else if (result.reason !== 'canceled') { toast.error(result.reason) diff --git a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx index 0fcb3eb4e81..28f6f5221c9 100644 --- a/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx +++ b/src/renderer/src/components/settings/BrowserUseCookieImportStep.tsx @@ -60,7 +60,7 @@ export function BrowserUseCookieImportStep({ value2: browserProfile ? ` (${browserProfile})` : '' } ), - result.profileId + result.executionHostLabel ) } else { toast.error(result.reason) @@ -77,7 +77,7 @@ export function BrowserUseCookieImportStep({ 'Imported {{value0}} cookies from file.', { value0: result.summary.importedCookies } ), - result.profileId + result.executionHostLabel ) } else if (result.reason !== 'canceled') { toast.error(result.reason) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 13baa5dc5f0..2c98b1b7ca0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -757,9 +757,7 @@ "toast": { "restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.", "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.", - "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.", - "googleDirectSignInAction": "Sign in to Google", - "googleDirectSignInUnavailable": "Could not open the browser profile. Open it and sign in at accounts.google.com." + "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google." } } } diff --git a/src/renderer/src/lib/browser-cookie-import-toast.test.ts b/src/renderer/src/lib/browser-cookie-import-toast.test.ts index 3fbd97d6d4b..40f2800102e 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.test.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.test.ts @@ -1,18 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { successToastMock, warningToastMock, errorToastMock, getStateMock } = vi.hoisted(() => ({ +const { successToastMock, warningToastMock } = vi.hoisted(() => ({ successToastMock: vi.fn(), - warningToastMock: vi.fn(), - errorToastMock: vi.fn(), - getStateMock: vi.fn() + warningToastMock: vi.fn() })) vi.mock('sonner', () => ({ - toast: { success: successToastMock, warning: warningToastMock, error: errorToastMock } -})) - -vi.mock('@/store', () => ({ - useAppStore: { getState: getStateMock } + toast: { success: successToastMock, warning: warningToastMock } })) import type { BrowserCookieImportSummary } from '../../../shared/types' @@ -29,13 +23,6 @@ describe('emitBrowserCookieImportToast', () => { beforeEach(() => { successToastMock.mockReset() warningToastMock.mockReset() - errorToastMock.mockReset() - getStateMock.mockReset() - getStateMock.mockReturnValue({ - activeWorktreeId: 'worktree-1', - closeSettingsPage: vi.fn(), - openBrowserProfileTabInActiveWorkspace: vi.fn().mockResolvedValue(true) - }) }) it('shows the localized total-failure warning', () => { @@ -49,7 +36,7 @@ describe('emitBrowserCookieImportToast', () => { } }, 'Imported 3 cookies.', - 'profile-1' + 'Local Mac' ) expect(warningToastMock).toHaveBeenCalledWith( @@ -58,45 +45,25 @@ describe('emitBrowserCookieImportToast', () => { expect(successToastMock).not.toHaveBeenCalled() }) - it('shows the localized partial-failure warning', () => { - emitBrowserCookieImportToast( - { - ...summary, - warning: { - code: 'restart-fallback-unavailable', - loadedCookies: 2, - failedCookies: 1 - } - }, - 'Imported 3 cookies.', - 'profile-1' - ) - - expect(warningToastMock).toHaveBeenCalledWith( - 'Imported 2 of 3 cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.' - ) - expect(successToastMock).not.toHaveBeenCalled() - }) - it('shows success when the import has no warning', () => { - emitBrowserCookieImportToast(summary, 'Imported 3 cookies.', 'profile-1') + emitBrowserCookieImportToast(summary, 'Imported 3 cookies.', 'Local Mac') expect(successToastMock).toHaveBeenCalledWith('Imported 3 cookies.') expect(warningToastMock).not.toHaveBeenCalled() }) - it('shows a separate Google sign-in warning after the concise success toast', () => { + it('shows separate host-specific Google guidance after success', () => { emitBrowserCookieImportToast( { ...summary, importedCookies: 2, skippedCookies: 1, googleCookiesSkipped: 1 }, 'Imported 2 cookies.', - 'profile-1' + 'Remote Mac' ) expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.') - expect(warningToastMock.mock.calls[0][0]).toBe( - 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' + expect(warningToastMock).toHaveBeenCalledWith( + 'Google cookies were not imported. Open a browser in Orca on Remote Mac with this profile, then sign into Google.', + { duration: 12000 } ) - expect(warningToastMock.mock.calls[0][1].action.label).toBe('Sign in to Google') expect(successToastMock.mock.invocationCallOrder[0]).toBeLessThan( warningToastMock.mock.invocationCallOrder[0] ) @@ -106,7 +73,7 @@ describe('emitBrowserCookieImportToast', () => { emitBrowserCookieImportToast( { ...summary, importedCookies: 2, skippedCookies: 1 }, 'Imported 2 cookies.', - 'profile-1' + 'Local Mac' ) expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.') @@ -127,90 +94,18 @@ describe('emitBrowserCookieImportToast', () => { } }, 'Imported 1 cookie.', - 'profile-1' + 'Remote Mac' ) expect(successToastMock).not.toHaveBeenCalled() - expect(warningToastMock.mock.calls.map(([message]) => message)).toEqual([ - 'Imported 1 of 2 cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.', - 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' + expect(warningToastMock.mock.calls).toEqual([ + [ + 'Imported 1 of 2 cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.' + ], + [ + 'Google cookies were not imported. Open a browser in Orca on Remote Mac with this profile, then sign into Google.', + { duration: 12000 } + ] ]) }) - - it('opens Google with the imported profile from the warning action', async () => { - const closeSettingsPage = vi.fn() - const openBrowserProfileTabInActiveWorkspace = vi.fn().mockResolvedValue(true) - getStateMock.mockReturnValue({ - activeWorktreeId: 'worktree-1', - closeSettingsPage, - openBrowserProfileTabInActiveWorkspace - }) - - emitBrowserCookieImportToast( - { ...summary, googleCookiesSkipped: 1 }, - 'Imported 2 cookies.', - 'profile-1' - ) - warningToastMock.mock.calls[0][1].action.onClick() - - await vi.waitFor(() => expect(closeSettingsPage).toHaveBeenCalledTimes(1)) - expect(openBrowserProfileTabInActiveWorkspace).toHaveBeenCalledWith( - 'https://accounts.google.com/', - 'profile-1' - ) - }) - - it('keeps guidance but omits the action without an active worktree', () => { - getStateMock.mockReturnValue({ activeWorktreeId: null }) - - emitBrowserCookieImportToast( - { ...summary, googleCookiesSkipped: 1 }, - 'Imported 2 cookies.', - 'profile-1' - ) - - expect(warningToastMock).toHaveBeenLastCalledWith( - 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' - ) - }) - - it('reports when the profile tab cannot be opened', async () => { - const openBrowserProfileTabInActiveWorkspace = vi.fn().mockResolvedValue(false) - getStateMock.mockReturnValue({ - activeWorktreeId: 'worktree-1', - closeSettingsPage: vi.fn(), - openBrowserProfileTabInActiveWorkspace - }) - - emitBrowserCookieImportToast( - { ...summary, googleCookiesSkipped: 1 }, - 'Imported 2 cookies.', - 'profile-1' - ) - warningToastMock.mock.calls[0][1].action.onClick() - - await vi.waitFor(() => expect(errorToastMock).toHaveBeenCalledTimes(1)) - expect(errorToastMock).toHaveBeenCalledWith( - 'Could not open the browser profile. Open it and sign in at accounts.google.com.' - ) - }) - - it('reports when opening the profile tab rejects', async () => { - getStateMock.mockReturnValue({ - activeWorktreeId: 'worktree-1', - closeSettingsPage: vi.fn(), - openBrowserProfileTabInActiveWorkspace: vi - .fn() - .mockRejectedValue(new Error('runtime unavailable')) - }) - - emitBrowserCookieImportToast( - { ...summary, googleCookiesSkipped: 1 }, - 'Imported 2 cookies.', - 'profile-1' - ) - warningToastMock.mock.calls[0][1].action.onClick() - - await vi.waitFor(() => expect(errorToastMock).toHaveBeenCalledTimes(1)) - }) }) diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts index 2bd4538007a..ced4c3cfdbd 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -1,7 +1,6 @@ import { toast } from 'sonner' import type { BrowserCookieImportSummary } from '../../../shared/types' import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' type CookieImportWarning = NonNullable @@ -25,55 +24,21 @@ function formatCookieImportWarning(warning: CookieImportWarning): string { } } -const GOOGLE_SIGN_IN_URL = 'https://accounts.google.com/' - -function googleSignInErrorMessage(): string { - return translate( - 'auto.lib.browser.cookie.import.toast.googleDirectSignInUnavailable', - 'Could not open the browser profile. Open it and sign in at accounts.google.com.' - ) -} - function emitGoogleCookieImportWarning( summary: BrowserCookieImportSummary, - profileId: string + executionHostLabel: string ): void { if (!summary.googleCookiesSkipped) { return } - const message = translate( - 'auto.lib.browser.cookie.import.toast.googleCookiesSkipped', - 'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.' + toast.warning( + translate( + 'auto.lib.browser.cookie.import.toast.googleCookiesSkipped', + 'Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google.', + { value0: executionHostLabel } + ), + { duration: 12000 } ) - if (!useAppStore.getState().activeWorktreeId) { - toast.warning(message) - return - } - toast.warning(message, { - duration: 12000, - action: { - label: translate( - 'auto.lib.browser.cookie.import.toast.googleDirectSignInAction', - 'Sign in to Google' - ), - onClick: () => { - void useAppStore - .getState() - .openBrowserProfileTabInActiveWorkspace(GOOGLE_SIGN_IN_URL, profileId) - .then((opened) => { - if (!opened) { - toast.error(googleSignInErrorMessage()) - return - } - // Why: Settings would cover the newly opened browser tab. - useAppStore.getState().closeSettingsPage() - }) - .catch(() => { - toast.error(googleSignInErrorMessage()) - }) - } - } - }) } // Why: a degraded import returns ok:true with a warning, so every call site must route it to a @@ -81,7 +46,7 @@ function emitGoogleCookieImportWarning( export function emitBrowserCookieImportToast( summary: BrowserCookieImportSummary, successMessage: string, - profileId: string + executionHostLabel: string ): void { const warning = summary.warning if (warning) { @@ -89,5 +54,5 @@ export function emitBrowserCookieImportToast( } else { toast.success(successMessage) } - emitGoogleCookieImportWarning(summary, profileId) + emitGoogleCookieImportWarning(summary, executionHostLabel) } diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index 7517b1bf796..5239a5537ab 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -10,6 +10,7 @@ import { import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { getExecutionHostLabel } from '../../../../shared/execution-host' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() @@ -842,7 +843,10 @@ describe('createBrowserSlice runtime guard', () => { }) }) const store = createTestStore() - store.setState({ browserSessionHostIdOverride: 'runtime:windows-2' }) + store.setState({ + browserSessionHostIdOverride: 'runtime:windows-2', + runtimeEnvironments: [{ id: 'windows-2', name: 'Windows Server' }] as never + }) const importing = store .getState() @@ -861,7 +865,12 @@ describe('createBrowserSlice runtime guard', () => { _meta: { runtimeId: 'runtime-windows' } }) - await expect(importing).resolves.toMatchObject({ ok: true, profileId: 'windows-profile' }) + await expect(importing).resolves.toMatchObject({ + ok: true, + profileId: 'windows-profile', + executionHostId: 'runtime:windows-2', + executionHostLabel: 'Windows Server' + }) expect(store.getState().browserSessionHostIdOverride).toBe('runtime:linux-3') expect(store.getState().browserSessionImportState).toBeNull() expect(runtimeEnvironmentCall.mock.calls).toHaveLength(callsBeforeCompletion) @@ -1242,13 +1251,51 @@ describe('createBrowserSlice runtime guard', () => { const result = await store.getState().importCookiesToProfile('default') expect(mockApi.browser.sessionImportCookies).not.toHaveBeenCalled() - expect(result.ok).toBe(false) + expect(result).toMatchObject({ + ok: false, + executionHostId: 'runtime:env-1', + executionHostLabel: 'env-1' + }) expect(store.getState().browserSessionImportState).toMatchObject({ profileId: 'default', status: 'error' }) }) + it('retains the host display label used by browser settings', async () => { + const store = createTestStore() + store.setState({ + settings: { + ...settingsWithRuntime('env-1')!, + hostSettingOverrides: { + 'runtime:env-1': { displayLabel: 'Production Browser Host' } + } + }, + runtimeEnvironments: [{ id: 'env-1', name: 'Remote Mac' }] as never + }) + + const result = await store.getState().importCookiesToProfile('default') + + expect(result.executionHostLabel).toBe('Production Browser Host') + }) + + it('retains the local execution host for a successful browser import', async () => { + mockApi.browser.sessionImportFromBrowser.mockResolvedValueOnce({ + ok: true, + profileId: 'default', + summary: { totalCookies: 2, importedCookies: 2, skippedCookies: 0, domains: [] } + }) + const store = createTestStore() + + const result = await store.getState().importCookiesFromBrowser('default', 'chrome', 'Default') + + expect(result).toMatchObject({ + ok: true, + executionHostId: 'local', + executionHostLabel: getExecutionHostLabel('local') + }) + }) + it('uses local browser IPC when no runtime environment is active', async () => { const store = createTestStore() mockApi.browser.sessionListProfiles.mockResolvedValueOnce([ diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 56e8abafb03..6f3c739308a 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -44,12 +44,14 @@ import type { import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' import { + getExecutionHostLabel, getSettingsFocusedExecutionHostId, LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { getHostSettingOverride } from '../../../../shared/host-setting-overrides' import { getExecutionHostIdForWorktree, getRuntimeEnvironmentIdForWorktree @@ -121,6 +123,19 @@ export type RemoteBrowserPageHandle = { remotePageId: string } +export type BrowserCookieImportExecutionResult = BrowserCookieImportResult & { + executionHostId: ExecutionHostId + executionHostLabel: string +} + +function retainCookieImportExecutionHost( + result: BrowserCookieImportResult, + executionHostId: ExecutionHostId, + executionHostLabel: string +): BrowserCookieImportExecutionResult { + return { ...result, executionHostId, executionHostLabel } +} + export type BrowserSlice = { browserTabsByWorktree: Record browserPagesByWorkspace: Record @@ -205,7 +220,7 @@ export type BrowserSlice = { options?: BrowserSessionProfileCreateOptions ) => Promise deleteBrowserSessionProfile: (profileId: string) => Promise - importCookiesToProfile: (profileId: string) => Promise + importCookiesToProfile: (profileId: string) => Promise clearBrowserSessionImportState: () => void detectedBrowsers: { family: string @@ -219,7 +234,7 @@ export type BrowserSlice = { profileId: string, browserFamily: string, browserProfile?: string - ) => Promise + ) => Promise clearDefaultSessionCookies: () => Promise browserUrlHistory: BrowserHistoryEntry[] addBrowserHistoryEntry: (url: string, title: string) => void @@ -258,6 +273,23 @@ function getBrowserSettingsHostId( return state.browserSessionHostIdOverride ?? getSettingsFocusedExecutionHostId(state.settings) } +function getBrowserSettingsHostLabel(state: AppState, hostId: ExecutionHostId): string { + const override = getHostSettingOverride(state.settings, hostId, 'displayLabel') + if (override) { + return override + } + const parsed = parseExecutionHostId(hostId) + if (parsed?.kind === 'runtime') { + const name = state.runtimeEnvironments + ?.find((environment) => environment.id === parsed.environmentId) + ?.name.trim() + if (name) { + return name + } + } + return getExecutionHostLabel(hostId) +} + function getBrowserSettingsRuntimeEnvironmentId( state: Pick ): string | null { @@ -2009,8 +2041,10 @@ export const createBrowserSlice: StateCreator = }, importCookiesToProfile: async (profileId) => { - const hostId = getBrowserSettingsHostId(get()) - if (getBrowserSettingsRuntimeEnvironmentId(get())) { + const initialState = get() + const hostId = getBrowserSettingsHostId(initialState) + const executionHostLabel = getBrowserSettingsHostLabel(initialState, hostId) + if (getBrowserSettingsRuntimeEnvironmentId(initialState)) { const reason = translate( 'auto.store.slices.browser.remoteCookieImportUnavailable', 'Manual cookie file import is unavailable while a remote runtime is active.' @@ -2023,7 +2057,11 @@ export const createBrowserSlice: StateCreator = error: reason }) ) - return { ok: false as const, reason } + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel + ) } set((state) => browserImportStateForHostUpdate(state, hostId, { @@ -2062,7 +2100,7 @@ export const createBrowserSlice: StateCreator = }) ) } - return result + return retainCookieImportExecutionHost(result, hostId, executionHostLabel) } catch (err) { const reason = String((err as Error)?.message ?? err) set((state) => @@ -2073,7 +2111,11 @@ export const createBrowserSlice: StateCreator = error: reason }) ) - return { ok: false as const, reason } + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel + ) } }, @@ -2131,8 +2173,10 @@ export const createBrowserSlice: StateCreator = }, importCookiesFromBrowser: async (profileId, browserFamily, browserProfile?) => { - const hostId = getBrowserSettingsHostId(get()) - const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(get()) + const initialState = get() + const hostId = getBrowserSettingsHostId(initialState) + const executionHostLabel = getBrowserSettingsHostLabel(initialState, hostId) + const runtimeEnvironmentId = getBrowserSettingsRuntimeEnvironmentId(initialState) if (runtimeEnvironmentId) { set((state) => browserImportStateForHostUpdate(state, hostId, { @@ -2173,7 +2217,7 @@ export const createBrowserSlice: StateCreator = }) ) } - return result + return retainCookieImportExecutionHost(result, hostId, executionHostLabel) } catch (err) { const reason = String((err as Error)?.message ?? err) set((state) => @@ -2184,7 +2228,11 @@ export const createBrowserSlice: StateCreator = error: reason }) ) - return { ok: false as const, reason } + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel + ) } } set((state) => @@ -2226,7 +2274,7 @@ export const createBrowserSlice: StateCreator = }) ) } - return result + return retainCookieImportExecutionHost(result, hostId, executionHostLabel) } catch (err) { const reason = String((err as Error)?.message ?? err) set((state) => @@ -2237,7 +2285,11 @@ export const createBrowserSlice: StateCreator = error: reason }) ) - return { ok: false as const, reason } + return retainCookieImportExecutionHost( + { ok: false as const, reason }, + hostId, + executionHostLabel + ) } }, From 9a81577e913acb46d3a8a03ac43fda368afa84e3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:07:08 -0700 Subject: [PATCH 12/33] fix(browser): disable unsupported FedCM (#14023) --- src/main/index.ts | 2 + src/main/startup/configure-process.test.ts | 40 ++++++ src/main/startup/configure-process.ts | 20 ++- tests/e2e/browser-fedcm-fallback.spec.ts | 139 +++++++++++++++++++++ 4 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/browser-fedcm-fallback.spec.ts diff --git a/src/main/index.ts b/src/main/index.ts index 3f076c2e908..f91a531ce7b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -119,6 +119,7 @@ import { configureElectronNetworkCompatibility, configureDevUserDataPath, configureOrcaUserDataPathEnv, + disableUnsupportedChromiumFeatures, enableMainProcessGpuFeatures, installDevParentDisconnectQuit, installDevParentSignalQuit, @@ -846,6 +847,7 @@ if (hasSingleInstanceLock) { platform: process.platform, ...getMainProcessLifecycleIdentity() }) + disableUnsupportedChromiumFeatures() configureElectronNetworkCompatibility() enableRendererHeapHeadroom() maybeApplyGpuFallbackForThisLaunch() diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index eaf9f42aeec..54ecdc61424 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -396,6 +396,46 @@ describe('configureElectronNetworkCompatibility', () => { }) }) +describe('disableUnsupportedChromiumFeatures', () => { + it('disables FedCM before Chromium sessions are created', async () => { + const { app } = await import('electron') + const { disableUnsupportedChromiumFeatures } = await import('./configure-process') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + disableUnsupportedChromiumFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-features', 'FedCm') + }) + + it('preserves existing disabled Chromium features', async () => { + const { app } = await import('electron') + const { disableUnsupportedChromiumFeatures } = await import('./configure-process') + + vi.mocked(app.commandLine.getSwitchValue).mockReturnValueOnce('ExistingFeature') + vi.mocked(app.commandLine.appendSwitch).mockClear() + disableUnsupportedChromiumFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'disable-features', + 'FedCm,ExistingFeature' + ) + }) + + it('does not duplicate FedCM when disable-features already includes it', async () => { + const { app } = await import('electron') + const { disableUnsupportedChromiumFeatures } = await import('./configure-process') + + vi.mocked(app.commandLine.getSwitchValue).mockReturnValueOnce('FedCm,ExistingFeature') + vi.mocked(app.commandLine.appendSwitch).mockClear() + disableUnsupportedChromiumFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'disable-features', + 'FedCm,ExistingFeature' + ) + }) +}) + describe('enableMainProcessGpuFeatures', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index e545d89da9c..e39963bc9f2 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -66,6 +66,20 @@ export function configureElectronNetworkCompatibility( app.commandLine.appendSwitch('disable-http2') } +export function disableUnsupportedChromiumFeatures(): void { + appendDisabledChromiumFeatures(['FedCm']) +} + +function appendDisabledChromiumFeatures(features: string[]): void { + const existingFeatures = app.commandLine + .getSwitchValue('disable-features') + .split(',') + .map((feature) => feature.trim()) + .filter(Boolean) + const disabledFeatures = Array.from(new Set([...features, ...existingFeatures])).join(',') + app.commandLine.appendSwitch('disable-features', disabledFeatures) +} + function getProcessPathDelimiter(): string { return process.platform === 'win32' ? ';' : ':' } @@ -300,11 +314,7 @@ export function enableMainProcessGpuFeatures(): void { app.commandLine.appendSwitch('enable-features', features) } - const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features') // Why: IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, delaying agent-done/bell notifications ~60s. // This opt-out is skipped under GPU fallback (win32-only today); if throttling ever reaches Windows it must move out of this path. - const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures] - .filter(Boolean) - .join(',') - app.commandLine.appendSwitch('disable-features', disabledFeatures) + appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling']) } diff --git a/tests/e2e/browser-fedcm-fallback.spec.ts b/tests/e2e/browser-fedcm-fallback.spec.ts new file mode 100644 index 00000000000..c782bfed5a6 --- /dev/null +++ b/tests/e2e/browser-fedcm-fallback.spec.ts @@ -0,0 +1,139 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, getActiveWorktreeId } from './helpers/store' + +async function closeServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) +} + +async function startFedCmFallbackServer(): Promise<{ + url: string + close: () => Promise +}> { + const server = createServer((request, response) => { + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + const pathname = new URL(request.url ?? '/', origin).pathname + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + if (pathname === '/popup') { + response.end( + 'Popup fallbackPopup fallback' + ) + return + } + response.end(` + + + FedCM fallback oracle + + + + pending + + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { url: `http://127.0.0.1:${port}/`, close: () => closeServer(server) } +} + +test('embedded browser omits unusable FedCM and reaches the popup fallback', async ({ + electronApp, + orcaPage +}) => { + const server = await startFedCmFallbackServer() + try { + await ensureTerminalVisible(orcaPage) + const worktreeId = await getActiveWorktreeId(orcaPage) + expect(worktreeId).not.toBeNull() + const browserTabId = await orcaPage.evaluate( + ({ targetWorktreeId, url }) => { + const tab = window.__store!.getState().createBrowserTab(targetWorktreeId!, url, { + title: 'FedCM fallback oracle', + activate: true + }) + return tab.id + }, + { targetWorktreeId: worktreeId, url: server.url } + ) + const readGuest = async (expression: string): Promise => + orcaPage.evaluate( + async ({ targetBrowserTabId, script }) => { + const slot = document.querySelector( + `[data-browser-overlay-tab-id="${targetBrowserTabId}"]` + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`) + } + return (await webview.executeJavaScript(script)) as T + }, + { targetBrowserTabId: browserTabId, script: expression } + ) + + await expect + .poll(() => readGuest('document.title'), { timeout: 10_000 }) + .toBe('FedCM fallback oracle') + const capabilities = await readGuest<{ + hasIdentityCredential: boolean + hasIdentityProvider: boolean + }>('JSON.parse(document.querySelector("#capabilities").textContent)') + expect.soft(capabilities).toEqual({ + hasIdentityCredential: false, + hasIdentityProvider: false + }) + + await readGuest('document.querySelector("#sign-in").click()') + const path = await readGuest('document.querySelector("#path").textContent') + expect.soft(path).toBe('popup-live') + await expect + .poll(() => + electronApp.evaluate(async ({ webContents }) => { + const popup = webContents.getAllWebContents().find((contents) => { + return contents.getURL().endsWith('/popup') + }) + if (!popup) { + return null + } + return { + openerLive: await popup.executeJavaScript('Boolean(window.opener)'), + title: await popup.executeJavaScript('document.title'), + url: popup.getURL() + } + }) + ) + .toEqual({ + openerLive: true, + title: 'Popup fallback', + url: `${server.url}popup` + }) + await expect + .poll(() => readGuest('window.popupMessages')) + .toEqual(['popup-opener-live']) + } finally { + await server.close() + } +}) From e8350f19903f46f4689ab7c52597fe44817bb00e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:15:39 -0700 Subject: [PATCH 13/33] fix(browser): route remote terminal links to owning host (#14117) --- config/reliability-gates.jsonc | 130 +++++++ ...Control.hosted-review-header-link.test.tsx | 3 +- .../terminal-link-action-routing.test.ts | 43 +- .../terminal-link-open-hints.test.ts | 44 ++- .../terminal-pane/terminal-link-open-hints.ts | 30 +- ...terminal-runtime-host-link-routing.test.ts | 37 +- .../terminal-url-link-hit-testing.ts | 26 +- .../use-terminal-pane-lifecycle.ts | 32 +- .../lib/http-link-modifier-routing.test.ts | 55 ++- .../src/lib/http-link-routing.test.ts | 71 +++- src/renderer/src/lib/http-link-routing.ts | 52 ++- .../lib/workspace-browser-tab-open.test.ts | 53 ++- .../src/lib/workspace-browser-tab-open.ts | 37 ++ src/renderer/src/store/index.ts | 9 +- ...aired-remote-terminal-browser-link.spec.ts | 368 ++++++++++++++++++ 15 files changed, 924 insertions(+), 66 deletions(-) create mode 100644 tests/e2e/paired-remote-terminal-browser-link.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 3d3b7274d4b..8956aa3fa50 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1458,6 +1458,136 @@ ], "demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect." }, + { + "id": "browser-session.remote-terminal-link-ownership", + "title": "Paired terminal links open one browser on their owning runtime", + "maturity": "experimental", + "protection": "partial", + "owner": "browser-runtime", + "layer": "renderer-paired-runtime-routing", + "surfaces": [ + "paired remote terminal HTTP links", + "terminal link action destinations", + "runtime-owned managed browser creation" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["remote-runtime", "ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["remote-runtime", "ssh"], + "coverageNotes": "A headed macOS desktop host and separate paired Electron client exercise a real mirrored terminal link, owner-asserted host browser creation, exact host/client page identity, rendered DOM, explicit browser-tab activation, client external-browser suppression, exact host/client close convergence, and continued original-PTY liveness. Unit contracts cover OSC, WebLinksAddon, click fallback, explicit System Browser routing, failed creation, incapable runtimes, and unchanged direct SSH behavior.", + "motivatingLinks": [ + "https://stablygroup.slack.com/archives/C0AK2T3JEF4/p1786567689897109", + "https://github.com/stablyai/orca/issues/12418" + ], + "invariant": "Selecting Orca Browser for an HTTP link in a runtime-owned terminal creates exactly one browser page on that owning runtime and exactly one mirrored client browser workspace, with matching remote page identity and no client external-browser call. Explicit System Browser remains client-owned, direct SSH remains system-browser-only, and unknown or mismatched owners fail closed.", + "oracle": "Use the focused routing contracts as the byte-identical baseline/candidate oracle. Require runtime OSC, WebLinksAddon, click fallback, action-menu, and modifier paths to target the exact owner without client fallback, while direct SSH and generic remote document links stay external. Separately run one byte-identical headed paired Electron journey on the candidate and with only runtime browser registration disabled. Require one new host browser tab, one new client mirror with exact page identity, rendered fixture DOM, explicit browser-tab activation, zero client shell.openExternal calls, exact close convergence, unchanged terminal counts, and continued output on the original PTY.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts src/renderer/src/lib/http-link-routing.test.ts src/renderer/src/lib/http-link-modifier-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts", + "src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts", + "src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts", + "src/renderer/src/lib/http-link-routing.test.ts", + "src/renderer/src/lib/http-link-modifier-routing.test.ts", + "src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "tests/e2e/paired-remote-terminal-browser-link.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts", + "assertions": [ + "OSC, WebLinksAddon, and click-fallback activations route through the exact runtime owner", + "direct SSH continues to use only the system browser" + ] + }, + { + "file": "src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "assertions": [ + "an asserted runtime cannot fall back to a client browser", + "mismatched and incapable runtime owners fail closed" + ] + }, + { + "file": "tests/e2e/paired-remote-terminal-browser-link.spec.ts", + "assertions": [ + "one terminal action produces one host page and one client mirror with exact identity", + "the host browser renders the fixture while client external navigation stays empty", + "an explicit browser-tab click reveals the exact mirror", + "closing the mirror returns host and client inventories to baseline", + "the original remote PTY accepts and renders fresh output after browser close" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "result": "failed", + "durationSeconds": 2.58, + "summary": "Latest main e4c278eb523 failed 6/25 owner-routing assertions: runtime terminal links called the viewing client's external browser and the managed-browser helper did not enforce an asserted runtime owner." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts src/renderer/src/lib/http-link-routing.test.ts src/renderer/src/lib/http-link-modifier-routing.test.ts src/renderer/src/lib/workspace-browser-tab-open.test.ts", + "result": "passed", + "durationSeconds": 1.83, + "summary": "Candidate passed 84/84 across six focused routing, modifier, availability, owner assertion, failure, SSH, and system-browser contract files." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 32.9, + "summary": "Candidate created one host browser and one client mirror with matching page identity, rendered the fixture, activated on an explicit tab click, made zero client external-browser calls, converged both inventories after exact UI close, and delivered fresh output on the original remote PTY." + }, + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "failed", + "durationSeconds": 60, + "summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "six focused renderer contract files plus one isolated headed paired-runtime journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The focused contracts are deterministic and the headed paired-runtime journey passed locally after its PTY namespace oracle was corrected; longer CI and soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The focused owner-routing oracle is red on latest main e4c278eb523 and green on the candidate. The byte-identical headed Electron oracle is green on the candidate and red when the sole source delta replaces the runtime browser opener registration with registerRuntimeHttpLinkBrowserOpener(null); candidate evidence proves exact host/client identity, rendered content, explicit activation, exact close convergence, original-PTY survival, and absence of client external navigation." + }, + "performanceBudget": { + "required": true, + "evidence": "The production change adds no timer, polling, retry, subprocess, stream opcode, RPC method, or host publication. Each link hover or activation performs bounded in-memory owner and capability lookups; an Orca Browser action issues the same single browser creation and snapshot reconciliation already used by the tab bar." + }, + "promotionCriteria": [ + "Collect Linux and physical Windows paired-server evidence.", + "Collect a live headless-serve terminal-link journey.", + "Collect 100 consecutive focused CI passes or 14 days without an unexplained flake." + ], + "knownGaps": [ + "Live paired-server validation currently covers macOS only.", + "A live headless-serve journey and multiple simultaneous viewers are not exercised by the Electron journey.", + "Direct SSH intentionally retains its existing system-browser-only semantics.", + "Terminal-link activation does not change the existing asynchronous host/client focus policy." + ], + "demotionRule": "Demote if one runtime terminal link creates a client-owned or duplicate browser, targets a runtime other than the pane owner, invokes the client external browser for Orca Browser, or removes the source terminal." + }, { "id": "browser-session.remote-html-preview-ownership", "title": "Paired HTML previews keep one host page and one stable client split", diff --git a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx index ee90c607ada..c3544b4ff1f 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.hosted-review-header-link.test.tsx @@ -8,7 +8,8 @@ const { openHttpLinkMock } = vi.hoisted(() => ({ openHttpLinkMock: vi.fn() })) vi.mock('@/lib/http-link-routing', () => ({ openHttpLink: openHttpLinkMock, - registerHttpLinkStoreAccessor: vi.fn() + registerHttpLinkStoreAccessor: vi.fn(), + registerRuntimeHttpLinkBrowserOpener: vi.fn() })) function makeReview(overrides: Partial = {}): HostedReviewInfo { diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts index 5535e7bde9f..8350ec17fec 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { + registerHttpLinkStoreAccessor, + registerRuntimeHttpLinkBrowserOpener +} from '@/lib/http-link-routing' import { closeTerminalLinkActionRequest, requestTerminalLinkAction, @@ -12,6 +15,7 @@ import { handleTerminalHttpLink } from './terminal-url-link-hit-testing' const openUrl = vi.fn() const createBrowserTab = vi.fn() const setActiveWorktree = vi.fn() +const openRuntimeBrowserTab = vi.fn(() => Promise.resolve()) function plainEvent(): MouseEvent { return { @@ -44,9 +48,11 @@ beforeEach(() => { setActiveWorktree, createBrowserTab })) + registerRuntimeHttpLinkBrowserOpener(openRuntimeBrowserTab) }) afterEach(() => { + registerRuntimeHttpLinkBrowserOpener(null) vi.clearAllMocks() vi.unstubAllGlobals() }) @@ -181,6 +187,28 @@ describe('terminal link action routing', () => { expect(request.mock.calls[0][0].alternate).toBeUndefined() }) + it('routes an explicit Orca Browser action to the owning runtime', () => { + const request = vi.fn() + + handleTerminalHttpLink('https://example.com/path', plainEvent(), { + worktreeId: 'wt-1', + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' }, + linkActionContext: actionContext(request), + actionDestinations: { primary: 'system', alternate: 'orca' } + }) + + request.mock.calls[0][0].primary.run() + expect(openUrl).toHaveBeenCalledWith('https://example.com/path') + request.mock.calls[0][0].alternate.run() + expect(openRuntimeBrowserTab).toHaveBeenCalledWith({ + workspaceId: 'wt-1', + url: 'https://example.com/path', + intent: { kind: 'url' }, + expectedRuntimeEnvironmentId: 'env-1' + }) + expect(createBrowserTab).not.toHaveBeenCalled() + }) + it('uses Shift+modifier for the alternate local destination', () => { const event = { ...plainEvent(), metaKey: true, shiftKey: true } @@ -195,6 +223,19 @@ describe('terminal link action routing', () => { expect(openUrl).not.toHaveBeenCalled() }) + it('keeps Shift+modifier on the only capability-backed destination', () => { + const event = { ...plainEvent(), metaKey: true, shiftKey: true } + + handleTerminalHttpLink('https://example.com/path', event, { + worktreeId: 'wt-1', + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' }, + actionDestinations: { primary: 'system' } + }) + + expect(openUrl).toHaveBeenCalledWith('https://example.com/path') + expect(openRuntimeBrowserTab).not.toHaveBeenCalled() + }) + it('exposes the actual hidden OSC 8 destination', () => { const request = vi.fn() const destination = 'https://example.com' diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts index 801eec81c98..514f36aad6d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getTerminalUrlOpenHint, terminalUrlOpenHintOptionsFor } from './terminal-link-open-hints' +import { + getTerminalUrlOpenHint, + terminalHttpLinkActionDestinationsFor, + terminalUrlOpenHintOptionsFor +} from './terminal-link-open-hints' function stubPlatform(isMac: boolean): void { vi.stubGlobal('navigator', { userAgent: isMac ? 'Mac OS X' : 'Windows NT 10.0' }) @@ -138,4 +142,42 @@ describe('terminalUrlOpenHintOptionsFor', () => { expect(options.modifierInverts).toBe(true) expect(getTerminalUrlOpenHint(options)).toContain('to open in Orca') }) + + it('keeps inversion for a runtime pane when its host can open an Orca browser', () => { + const options = terminalUrlOpenHintOptionsFor( + { + openLinksInApp: false, + openLinksInAppModifierInverts: true + }, + { kind: 'runtime', runtimeEnvironmentId: 'env-1' }, + true + ) + + expect(options.modifierInverts).toBe(true) + }) +}) + +describe('terminalHttpLinkActionDestinationsFor', () => { + it('offers both destinations for a capable runtime and follows the preference', () => { + const owner = { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const + + expect(terminalHttpLinkActionDestinationsFor({ openLinksInApp: true }, owner, true)).toEqual({ + primary: 'orca', + alternate: 'system' + }) + expect(terminalHttpLinkActionDestinationsFor({ openLinksInApp: false }, owner, true)).toEqual({ + primary: 'system', + alternate: 'orca' + }) + }) + + it.each([ + ['incapable runtime', { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const], + ['SSH', { kind: 'ssh', connectionId: 'ssh-1' } as const], + ['unknown owner', { kind: 'unknown' } as const] + ])('offers only the system browser for an %s', (_label, owner) => { + expect(terminalHttpLinkActionDestinationsFor({ openLinksInApp: true }, owner, false)).toEqual({ + primary: 'system' + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index e1a5f4ff81f..7820f28341f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -1,4 +1,5 @@ import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' +import type { TerminalHttpLinkActionDestinations } from './terminal-url-link-hit-testing' export function isMacPlatform(): boolean { return navigator.userAgent.includes('Mac') @@ -36,10 +37,22 @@ export type TerminalUrlOpenHintOptions = { showActions?: boolean } -// Why: openHttpLink only routes to Orca when the source is local, so a remote pane -// pins every link to the system browser and inverting cannot reach Orca there. The -// clicked pane's owner decides that, not the global active runtime — a workspace-bound -// remote pane is remote even when no runtime is globally active. +export function terminalHttpLinkActionDestinationsFor( + settings: { openLinksInApp?: boolean } | null | undefined, + sourceOwner: HttpLinkSourceOwner, + canOpenRuntimeBrowser: boolean +): TerminalHttpLinkActionDestinations { + const canOpenInOrca = + sourceOwner.kind === 'local' || (sourceOwner.kind === 'runtime' && canOpenRuntimeBrowser) + if (!canOpenInOrca) { + return { primary: 'system' } + } + return settings?.openLinksInApp === true + ? { primary: 'orca', alternate: 'system' } + : { primary: 'system', alternate: 'orca' } +} + +// Why: only a capability-verified runtime can advertise the in-app destination. export function terminalUrlOpenHintOptionsFor( settings: | { @@ -49,14 +62,15 @@ export function terminalUrlOpenHintOptionsFor( } | null | undefined, - sourceOwner?: HttpLinkSourceOwner + sourceOwner?: HttpLinkSourceOwner, + canOpenRuntimeBrowser = false ): TerminalUrlOpenHintOptions { - const sourceIsLocal = sourceOwner - ? sourceOwner.kind === 'local' + const sourceCanOpenInOrca = sourceOwner + ? sourceOwner.kind === 'local' || (sourceOwner.kind === 'runtime' && canOpenRuntimeBrowser) : !settings?.activeRuntimeEnvironmentId?.trim() return { openLinksInApp: settings?.openLinksInApp === true, - modifierInverts: settings?.openLinksInAppModifierInverts === true && sourceIsLocal + modifierInverts: settings?.openLinksInAppModifierInverts === true && sourceCanOpenInOrca } } diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts index 0113ea31a3d..01896238c05 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts @@ -1,6 +1,9 @@ import type { IBufferLine, Terminal } from '@xterm/xterm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { + registerHttpLinkStoreAccessor, + registerRuntimeHttpLinkBrowserOpener +} from '@/lib/http-link-routing' import { handleOscLink } from './terminal-osc-link-routing' import { handleTerminalWebLinkClick } from './terminal-web-link-click' import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing' @@ -12,6 +15,7 @@ const ROWS = 24 const openUrlMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() +const openRuntimeBrowserTabMock = vi.fn(() => Promise.resolve()) const runtimeSourceOwner = { kind: 'runtime', runtimeEnvironmentId: 'env-1' } as const const sshSourceOwner = { kind: 'ssh', connectionId: 'ssh-1' } as const @@ -104,26 +108,34 @@ beforeEach(() => { setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock })) + registerRuntimeHttpLinkBrowserOpener(openRuntimeBrowserTabMock) }) afterEach(() => { + registerRuntimeHttpLinkBrowserOpener(null) vi.unstubAllGlobals() }) describe('terminal HTTP links on a runtime-hosted pane', () => { const baseDeps = { worktreeId: 'wt-1', worktreePath: '/tmp', startupCwd: '/tmp' } - it('sends an OSC 8 hyperlink to the system browser', () => { + it('opens an OSC 8 hyperlink on the owning runtime', () => { expect(handleOscLink(URL, clickEvent(), { ...baseDeps, sourceOwner: runtimeSourceOwner })).toBe( true ) - expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith({ + workspaceId: 'wt-1', + url: URL, + intent: { kind: 'url' }, + expectedRuntimeEnvironmentId: 'env-1' + }) + expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).not.toHaveBeenCalled() expect(setActiveWorktreeMock).not.toHaveBeenCalled() }) - it('sends a WebLinksAddon click to the system browser', () => { + it('opens a WebLinksAddon click on the owning runtime', () => { const { terminal } = makeTerminal() expect( @@ -134,11 +146,14 @@ describe('terminal HTTP links on a runtime-hosted pane', () => { }) ).toBe(true) - expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith( + expect.objectContaining({ expectedRuntimeEnvironmentId: 'env-1', url: URL }) + ) + expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).not.toHaveBeenCalled() }) - it('sends a click-fallback activation to the system browser', () => { + it('opens a click-fallback activation on the owning runtime', () => { const { terminal, registrations } = makeTerminal() const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1', @@ -149,12 +164,15 @@ describe('terminal HTTP links on a runtime-hosted pane', () => { ([name, _listener, options]) => name === 'mouseup' && options === undefined )?.[1](clickEvent()) - expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith( + expect.objectContaining({ expectedRuntimeEnvironmentId: 'env-1', url: URL }) + ) + expect(openUrlMock).not.toHaveBeenCalled() expect(createBrowserTabMock).not.toHaveBeenCalled() disposable.dispose() }) - it('never prompts for the in-app routing preference it could not honor', () => { + it('uses the persisted routing preference without prompting again', () => { const requestOpenLinksInAppPreference = vi.fn(() => Promise.resolve(true)) handleOscLink(URL, clickEvent(), { @@ -164,7 +182,8 @@ describe('terminal HTTP links on a runtime-hosted pane', () => { }) expect(requestOpenLinksInAppPreference).not.toHaveBeenCalled() - expect(openUrlMock).toHaveBeenCalledWith(URL) + expect(openRuntimeBrowserTabMock).toHaveBeenCalledOnce() + expect(openUrlMock).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 98824d140f4..fad5301c804 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -72,7 +72,7 @@ export function handleTerminalHttpLink( ): boolean { if (isTerminalHttpLinkActivation(event)) { const forceDestination = event?.shiftKey - ? deps.actionDestinations?.alternate + ? (deps.actionDestinations?.alternate ?? deps.actionDestinations?.primary) : deps.actionDestinations?.primary openTerminalHttpLink(url, { ...deps, @@ -273,11 +273,11 @@ function rangeContainsBufferPosition( } export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { - // Why: Orca browser tabs are local-only, so a link clicked in a runtime-hosted - // pane must be classified by its pane's host, not the global active runtime. + // Why: pane ownership beats the global active runtime for both local and remote routes. const sourceOwner = deps.sourceOwner ?? { kind: 'local' } if (deps.forceDestination) { openHttpLink(url, { + allowRuntimeInApp: true, worktreeId: deps.worktreeId, forceInApp: deps.forceDestination === 'orca', forceSystemBrowser: deps.forceDestination === 'system', @@ -288,16 +288,20 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi if (deps.modifierHeld) { // Why: the modifier states a destination outright, so it also skips the // one-time routing prompt; openHttpLink resolves which destination it means. - openHttpLink(url, { worktreeId: deps.worktreeId, modifierHeld: true, sourceOwner }) + openHttpLink(url, { + allowRuntimeInApp: true, + worktreeId: deps.worktreeId, + modifierHeld: true, + sourceOwner + }) return } - // Why: a runtime-hosted link can only reach the system browser, so prompting - // would persist an in-app preference this click cannot honor. + // Why: remote panes use the persisted routing preference and never prompt the viewing client. const preferenceDecision = sourceOwner.kind === 'local' ? deps.requestOpenLinksInAppPreference?.(url) : null if (preferenceDecision === null || preferenceDecision === undefined) { - openHttpLink(url, { worktreeId: deps.worktreeId, sourceOwner }) + openHttpLink(url, { allowRuntimeInApp: true, worktreeId: deps.worktreeId, sourceOwner }) return } @@ -307,12 +311,18 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi void Promise.resolve(preferenceDecision) .then((openInOrca) => { openHttpLink(url, { + allowRuntimeInApp: true, worktreeId: deps.worktreeId, forceSystemBrowser: !openInOrca, sourceOwner }) }) .catch(() => { - openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true, sourceOwner }) + openHttpLink(url, { + allowRuntimeInApp: true, + worktreeId: deps.worktreeId, + forceSystemBrowser: true, + sourceOwner + }) }) } 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 8375a350a1b..24ec93b974a 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 @@ -32,7 +32,10 @@ import { getTerminalUrlOpenHint, installFilePathLinkClickFallback } from './terminal-link-handlers' -import { terminalUrlOpenHintOptionsFor } from './terminal-link-open-hints' +import { + terminalHttpLinkActionDestinationsFor, + terminalUrlOpenHintOptionsFor +} from './terminal-link-open-hints' import { createTerminalHandleLinkProvider } from './terminal-handle-links' import type { LinkHandlerDeps } from './terminal-link-handlers' import { handleOscLink } from './terminal-osc-link-routing' @@ -53,6 +56,7 @@ import { type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { resolveTerminalHttpLinkSourceOwner } from './terminal-http-link-source-owner' +import { canOpenWorkspaceBrowserTabOnRuntime } from '@/lib/workspace-browser-tab-open' import type { GlobalSettings, SetupSplitDirection, @@ -742,15 +746,24 @@ export function useTerminalPaneLifecycle({ resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd) const getHttpLinkSourceOwnerForPane = (paneId: number) => resolveTerminalHttpLinkSourceOwner(paneTransportsRef.current.get(paneId)) + const canOpenRuntimeBrowserForPane = (paneId: number): boolean => { + const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) + return ( + sourceOwner.kind === 'runtime' && + canOpenWorkspaceBrowserTabOnRuntime( + useAppStore.getState(), + worktreeId, + sourceOwner.runtimeEnvironmentId + ) + ) + } const getHttpLinkActionDestinations = (paneId: number): TerminalHttpLinkActionDestinations => { const sourceOwner = getHttpLinkSourceOwnerForPane(paneId) - if (sourceOwner.kind !== 'local') { - return { primary: 'system' } - } - const options = terminalUrlOpenHintOptionsFor(settingsRef.current, sourceOwner) - return options.openLinksInApp - ? { primary: 'orca', alternate: 'system' } - : { primary: 'system', alternate: 'orca' } + return terminalHttpLinkActionDestinationsFor( + settingsRef.current, + sourceOwner, + canOpenRuntimeBrowserForPane(paneId) + ) } const getLinkActionContext = (paneId: number): TerminalLinkActionContext | null => { if (settingsRef.current?.terminalLinkActionPopoverEnabled === false) { @@ -901,7 +914,8 @@ export function useTerminalPaneLifecycle({ getTerminalUrlOpenHint({ ...terminalUrlOpenHintOptionsFor( settingsRef.current, - getHttpLinkSourceOwnerForPane(paneId) + getHttpLinkSourceOwnerForPane(paneId), + canOpenRuntimeBrowserForPane(paneId) ), showActions: settingsRef.current?.terminalLinkActionPopoverEnabled !== false }) diff --git a/src/renderer/src/lib/http-link-modifier-routing.test.ts b/src/renderer/src/lib/http-link-modifier-routing.test.ts index c7da95ae4c2..fb3be988a4f 100644 --- a/src/renderer/src/lib/http-link-modifier-routing.test.ts +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { openHttpLink, registerHttpLinkStoreAccessor, + registerRuntimeHttpLinkBrowserOpener, resolveModifierRouting } from './http-link-routing' @@ -58,6 +59,7 @@ describe('modifier routing across link source owners', () => { const openUrlMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() + const openRuntimeBrowserTabMock = vi.fn(() => Promise.resolve()) const storeState = { settings: {} as { openLinksInApp?: boolean @@ -71,10 +73,12 @@ describe('modifier routing across link source owners', () => { beforeEach(() => { vi.clearAllMocks() registerHttpLinkStoreAccessor(() => storeState) + registerRuntimeHttpLinkBrowserOpener(openRuntimeBrowserTabMock) vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) }) afterEach(() => { + registerRuntimeHttpLinkBrowserOpener(null) vi.unstubAllGlobals() }) @@ -92,23 +96,40 @@ describe('modifier routing across link source owners', () => { }) }) - it('never lets a modifier pull a runtime-owned link into Orca', () => { - for (const inverts of [true, false]) { - vi.clearAllMocks() - storeState.settings = { - openLinksInApp: false, - openLinksInAppModifierInverts: inverts, - activeRuntimeEnvironmentId: null - } - - openHttpLink('https://example.com/', { - worktreeId: 'wt-1', - modifierHeld: true, - sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } - }) - - expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') - expect(createBrowserTabMock).not.toHaveBeenCalled() + it('lets an inverting modifier reach Orca on the owning runtime', () => { + storeState.settings = { + openLinksInApp: false, + openLinksInAppModifierInverts: true, + activeRuntimeEnvironmentId: null } + + openHttpLink('https://example.com/', { + allowRuntimeInApp: true, + worktreeId: 'wt-1', + modifierHeld: true, + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } + }) + + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith( + expect.objectContaining({ expectedRuntimeEnvironmentId: 'env-1' }) + ) + expect(openUrlMock).not.toHaveBeenCalled() + }) + + it('keeps the legacy modifier on the system browser when inversion is off', () => { + storeState.settings = { + openLinksInApp: false, + openLinksInAppModifierInverts: false, + activeRuntimeEnvironmentId: null + } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + modifierHeld: true, + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } + }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(openRuntimeBrowserTabMock).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 64991900a25..1be44f90732 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -4,13 +4,19 @@ import type { WorkspacePortScanResult } from '../../../shared/workspace-ports' import { openHttpLink, registerHttpLinkStoreAccessor, + registerRuntimeHttpLinkBrowserOpener, resolveLocalhostHttpLinkDisplayUrl } from './http-link-routing' +const toastErrorMock = vi.hoisted(() => vi.fn()) + +vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) + const openUrlMock = vi.fn() const registerLocalhostLabelMock = vi.fn() const setActiveWorktreeMock = vi.fn() const createBrowserTabMock = vi.fn() +const openRuntimeBrowserTabMock = vi.fn(() => Promise.resolve()) const storeState = { settings: undefined as @@ -42,6 +48,7 @@ beforeEach(() => { storeState.settings = undefined storeState.workspacePortScansByKey = {} registerHttpLinkStoreAccessor(() => storeState) + registerRuntimeHttpLinkBrowserOpener(openRuntimeBrowserTabMock) vi.stubGlobal('window', { api: { shell: { @@ -55,6 +62,7 @@ beforeEach(() => { }) afterEach(() => { + registerRuntimeHttpLinkBrowserOpener(null) vi.unstubAllGlobals() }) @@ -155,10 +163,11 @@ describe('openHttpLink', () => { expect(openUrlMock).not.toHaveBeenCalled() }) - it('routes explicit runtime and SSH document owners to the exact system URL', () => { + it('routes runtime and SSH document owners to their distinct destinations', () => { storeState.settings = { openLinksInApp: true, localhostWorktreeLabelsEnabled: true } openHttpLink('http://localhost:5180/runtime', { + allowRuntimeInApp: true, worktreeId: 'wt-1', sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } }) @@ -167,25 +176,75 @@ describe('openHttpLink', () => { sourceOwner: { kind: 'ssh', connectionId: 'ssh-1' } }) - expect(openUrlMock).toHaveBeenNthCalledWith(1, 'http://localhost:5180/runtime') - expect(openUrlMock).toHaveBeenNthCalledWith(2, 'http://localhost:5180/ssh') + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith({ + workspaceId: 'wt-1', + url: 'http://localhost:5180/runtime', + intent: { kind: 'url' }, + expectedRuntimeEnvironmentId: 'env-1' + }) + expect(openUrlMock).toHaveBeenCalledWith('http://localhost:5180/ssh') expect(createBrowserTabMock).not.toHaveBeenCalled() expect(registerLocalhostLabelMock).not.toHaveBeenCalled() }) // Why: runtimes bind per workspace, so activeRuntimeEnvironmentId is commonly // null while a pane is remote — ownership must come from the click source. - it('keeps a runtime-owned link out of Orca when no runtime is globally active', () => { + it('uses the pane runtime owner when no runtime is globally active', () => { storeState.settings = { openLinksInApp: true, activeRuntimeEnvironmentId: null } + openHttpLink('https://example.com/', { + allowRuntimeInApp: true, + worktreeId: 'wt-1', + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } + }) + + expect(openRuntimeBrowserTabMock).toHaveBeenCalledWith( + expect.objectContaining({ expectedRuntimeEnvironmentId: 'env-1' }) + ) + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(setActiveWorktreeMock).not.toHaveBeenCalled() + }) + + it('fails closed instead of falling back to the client system browser', async () => { + storeState.settings = { openLinksInApp: true } + openRuntimeBrowserTabMock.mockRejectedValueOnce(new Error('runtime unavailable')) + + openHttpLink('https://example.com/', { + allowRuntimeInApp: true, + worktreeId: 'wt-1', + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } + }) + + await vi.waitFor(() => expect(openRuntimeBrowserTabMock).toHaveBeenCalledOnce()) + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + expect(toastErrorMock).toHaveBeenCalledWith('runtime unavailable') + }) + + it('keeps an explicit runtime system-browser action on the viewing client', () => { + storeState.settings = { openLinksInApp: true } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + forceSystemBrowser: true, + sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } + }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(openRuntimeBrowserTabMock).not.toHaveBeenCalled() + }) + + it('keeps generic runtime-owned document links in the system browser', () => { + storeState.settings = { openLinksInApp: true } + openHttpLink('https://example.com/', { worktreeId: 'wt-1', sourceOwner: { kind: 'runtime', runtimeEnvironmentId: 'env-1' } }) expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') - expect(createBrowserTabMock).not.toHaveBeenCalled() - expect(setActiveWorktreeMock).not.toHaveBeenCalled() + expect(openRuntimeBrowserTabMock).not.toHaveBeenCalled() }) it('labels explicit local links from the local scan instead of a merged remote port', async () => { diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 0f010f05348..4b4a6a22a23 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -1,3 +1,4 @@ +import { translate } from '@/i18n/i18n' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseLoopbackUrlWithPort, @@ -5,9 +6,12 @@ import { } from '../../../shared/localhost-worktree-labels' import type { GlobalSettings } from '../../../shared/types' import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/workspace-ports' +import { toast } from 'sonner' export type OpenHttpLinkOptions = { worktreeId?: string | null + /** Terminal-only opt-in for routing a runtime-owned source through its managed browser. */ + allowRuntimeInApp?: boolean /** Unconditional: always use the system browser regardless of settings. */ forceSystemBrowser?: boolean /** Unconditional for local sources: open inside Orca regardless of settings. */ @@ -43,6 +47,15 @@ type StoreAccessor = () => { workspacePortScansByKey?: Record } +type RuntimeHttpLinkBrowserRequest = { + workspaceId: string + url: string + intent: { kind: 'url' } + expectedRuntimeEnvironmentId: string +} + +type RuntimeHttpLinkBrowserOpener = (request: RuntimeHttpLinkBrowserRequest) => Promise + type LocalhostLinkRepo = { id: string displayName: string @@ -61,11 +74,18 @@ type LocalhostLinkWorktree = { // the break, several renderer test files that load this module first see // `createEditorSlice` as undefined at store/index.ts initialization. let storeAccessor: StoreAccessor | null = null +let runtimeHttpLinkBrowserOpener: RuntimeHttpLinkBrowserOpener | null = null export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void { storeAccessor = fn } +export function registerRuntimeHttpLinkBrowserOpener( + fn: RuntimeHttpLinkBrowserOpener | null +): void { + runtimeHttpLinkBrowserOpener = fn +} + // Scope: http(s) URLs only. file: URIs and in-worktree markdown targets are // owned by resolveMarkdownLinkTarget and must stay on that path — this helper // is only invoked on target.kind === 'external' (and for the terminal's http @@ -93,7 +113,14 @@ export function resolveModifierRouting( } export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void { - const { worktreeId, forceSystemBrowser, forceInApp, modifierHeld, sourceOwner } = opts + const { + worktreeId, + allowRuntimeInApp, + forceSystemBrowser, + forceInApp, + modifierHeld, + sourceOwner + } = opts if (sourceOwner?.kind === 'unknown') { return } @@ -106,14 +133,31 @@ export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void openLinksInApp, state?.settings?.openLinksInAppModifierInverts === true ) - const routeToOrca = - sourceIsLocal && + const wantsOrca = !forceSystemBrowser && !modifier.wantsSystemBrowser && Boolean(worktreeId) && (forceInApp || openLinksInApp || modifier.wantsOrca) - if (routeToOrca && worktreeId && state) { + if (wantsOrca && allowRuntimeInApp && worktreeId && sourceOwner?.kind === 'runtime') { + if (runtimeHttpLinkBrowserOpener) { + void runtimeHttpLinkBrowserOpener({ + workspaceId: worktreeId, + url, + intent: { kind: 'url' }, + expectedRuntimeEnvironmentId: sourceOwner.runtimeEnvironmentId + }).catch((error) => { + toast.error( + error instanceof Error + ? error.message + : translate('auto.lib.workspace.browser.tab.open.urlFailed', 'Unable to open URL.') + ) + }) + } + return + } + + if (wantsOrca && sourceIsLocal && worktreeId && state) { // Why: http clicks from inside a worktree should not push a worktree-switch // history entry — the user isn't changing worktrees, they're opening a tab // in the one they're already in. activateAndRevealWorktree is reserved for diff --git a/src/renderer/src/lib/workspace-browser-tab-open.test.ts b/src/renderer/src/lib/workspace-browser-tab-open.test.ts index 9738c271d19..d414316b52f 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.test.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.test.ts @@ -4,7 +4,10 @@ import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' -import { openWorkspaceBrowserTab } from './workspace-browser-tab-open' +import { + canOpenWorkspaceBrowserTabOnRuntime, + openWorkspaceBrowserTab +} from './workspace-browser-tab-open' const mocks = vi.hoisted(() => ({ createRemote: vi.fn(), @@ -94,6 +97,10 @@ describe('openWorkspaceBrowserTab', () => { defaultBrowserSessionProfileIdByHostId: {} } + expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.state as never, WORKSPACE_ID, 'hub-a')).toBe( + true + ) + await openWorkspaceBrowserTab({ workspaceId: WORKSPACE_ID, url: 'https://example.com/docs?token=secret', @@ -113,6 +120,50 @@ describe('openWorkspaceBrowserTab', () => { expect(createBrowserTab).not.toHaveBeenCalled() }) + it('fails closed instead of opening on a runtime other than the asserted owner', async () => { + mocks.state = { + ...ownerState(toRuntimeExecutionHostId('hub-b')), + ...browserCapableRuntime('hub-b'), + createBrowserTab: vi.fn(), + defaultBrowserSessionProfileId: 'client-profile', + defaultBrowserSessionProfileIdByHostId: {} + } + + await expect( + openWorkspaceBrowserTab({ + workspaceId: WORKSPACE_ID, + url: 'https://example.com/', + intent: { kind: 'url' }, + expectedRuntimeEnvironmentId: 'hub-a' + }) + ).rejects.toThrow('Unable to open URL.') + expect(mocks.createRemote).not.toHaveBeenCalled() + expect(mocks.state.createBrowserTab).not.toHaveBeenCalled() + }) + + it('does not fall back to a client browser for an incapable asserted runtime', async () => { + mocks.state = { + ...ownerState(toRuntimeExecutionHostId('hub-a')), + createBrowserTab: vi.fn(), + defaultBrowserSessionProfileId: 'client-profile', + defaultBrowserSessionProfileIdByHostId: {} + } + + expect(canOpenWorkspaceBrowserTabOnRuntime(mocks.state as never, WORKSPACE_ID, 'hub-a')).toBe( + false + ) + await expect( + openWorkspaceBrowserTab({ + workspaceId: WORKSPACE_ID, + url: 'https://example.com/', + intent: { kind: 'url' }, + expectedRuntimeEnvironmentId: 'hub-a' + }) + ).rejects.toThrow('Unable to open URL.') + expect(mocks.createRemote).not.toHaveBeenCalled() + expect(mocks.state.createBrowserTab).not.toHaveBeenCalled() + }) + // Two dev servers on one host must not share a tab title. it('keeps the port in local-dev tab titles', async () => { const createBrowserTab = vi.fn() diff --git a/src/renderer/src/lib/workspace-browser-tab-open.ts b/src/renderer/src/lib/workspace-browser-tab-open.ts index 12091f9eee9..0ef67b1cfce 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.ts @@ -21,6 +21,36 @@ export type OpenWorkspaceBrowserTabRequest = { targetGroupId?: string url: string intent: WorkspaceBrowserTabIntent + expectedRuntimeEnvironmentId?: string +} + +function isExpectedRuntimeBrowserRoute( + availability: ClientCreationActionAvailability, + route: ReturnType, + expectedRuntimeEnvironmentId: string +): boolean { + if (availability.state !== 'enabled' || availability.provider !== 'paired-runtime' || !route) { + return false + } + const environmentId = route.runtimeEnvironmentId?.trim() || null + if (environmentId !== expectedRuntimeEnvironmentId.trim()) { + return false + } + const host = parseExecutionHostId(route.executionHostId) + return ( + !route.executionHostId || + Boolean(host && (host.kind !== 'runtime' || host.environmentId === environmentId)) + ) +} + +export function canOpenWorkspaceBrowserTabOnRuntime( + state: AppState, + workspaceId: string, + expectedRuntimeEnvironmentId: string +): boolean { + const availability = getClientCreationActionPolicy(state, workspaceId)['managed-browser'] + const route = resolveWorktreeOperationRoute(state, workspaceId) + return isExpectedRuntimeBrowserRoute(availability, route, expectedRuntimeEnvironmentId) } // Why: concurrent URL tabs are indistinguishable under a shared "Open URL" @@ -133,6 +163,13 @@ export async function openWorkspaceBrowserTab( throw openFailure(presentation.error, 'no active worktree route') } const environmentId = route.runtimeEnvironmentId?.trim() || null + const expectedEnvironmentId = request.expectedRuntimeEnvironmentId?.trim() || null + if ( + expectedEnvironmentId && + !isExpectedRuntimeBrowserRoute(availability, route, expectedEnvironmentId) + ) { + throw openFailure(presentation.error, 'asserted runtime cannot provide this managed browser') + } const host = parseExecutionHostId(route.executionHostId) if (!environmentId) { if (!host || host.kind === 'runtime') { diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index e0e2a5e88a6..ebc4691da30 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -46,7 +46,10 @@ import { createRemoteServerUpdatesSlice } from './slices/remote-server-updates' import { createTerminalQuickCommandHostsSlice } from './slices/terminal-quick-command-hosts' import { e2eConfig } from '@/lib/e2e-config' import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-session' -import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { + registerHttpLinkStoreAccessor, + registerRuntimeHttpLinkBrowserOpener +} from '@/lib/http-link-routing' import { installStoreListenerCensus } from './store-listener-census' import { registerRendererMemoryProfileContributor, @@ -104,6 +107,10 @@ export const useAppStore = create()((...a) => { }) registerHttpLinkStoreAccessor(() => useAppStore.getState()) +registerRuntimeHttpLinkBrowserOpener(async (request) => { + const { openWorkspaceBrowserTab } = await import('@/lib/workspace-browser-tab-open') + await openWorkspaceBrowserTab(request) +}) // Why: names the fattest store slices in renderer_memory_highwater breadcrumbs // so OOM crash reports identify what grew without a local repro. diff --git a/tests/e2e/paired-remote-terminal-browser-link.spec.ts b/tests/e2e/paired-remote-terminal-browser-link.spec.ts new file mode 100644 index 00000000000..44d0d3484f1 --- /dev/null +++ b/tests/e2e/paired-remote-terminal-browser-link.spec.ts @@ -0,0 +1,368 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { + readPanelNavigationObserver, + startPanelNavigationObserver, + stopPanelNavigationObserver +} from './helpers/plugin-panel-navigation-observer' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +const PAGE_MARKER = 'remote terminal browser owner' +const TERMINAL_MARKER = 'remote-terminal-still-live-after-browser-close' + +async function readTabInventory( + page: Page, + environmentId: string, + worktreeId: string +): Promise<{ + clientBrowserWorkspaces: number + clientBrowserTabs: number + clientTerminalTabs: number + hostBrowserTabs: number + hostTerminalTabs: number +}> { + return page.evaluate( + async ({ environmentId, worktreeId }) => { + const state = window.__store?.getState() + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + return { + clientBrowserWorkspaces: (state?.browserTabsByWorktree[worktreeId] ?? []).length, + clientBrowserTabs: (state?.unifiedTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.contentType === 'browser' + ).length, + clientTerminalTabs: (state?.tabsByWorktree[worktreeId] ?? []).length, + hostBrowserTabs: response.ok + ? response.result.tabs.filter((tab) => tab.type === 'browser').length + : -1, + hostTerminalTabs: response.ok + ? response.result.tabs.filter((tab) => tab.type === 'terminal').length + : -1 + } + }, + { environmentId, worktreeId } + ) +} + +async function startPageServer(): Promise<{ server: Server; url: string }> { + const server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end(`

${PAGE_MARKER}

`) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() as AddressInfo + return { server, url: `http://127.0.0.1:${address.port}/remote-terminal` } +} + +async function closePageServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) +} + +async function findTerminalLinkTarget(page: Page, link: string): Promise<{ x: number; y: number }> { + return page.evaluate((targetLink) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = worktreeId ? state?.activeTabIdByWorktree?.[worktreeId] : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') ?? null + if (!pane || !screen) { + throw new Error('paired terminal screen unavailable') + } + const buffer = pane.terminal.buffer.active + for (let row = 0; row < pane.terminal.rows; row += 1) { + const text = buffer.getLine(buffer.viewportY + row)?.translateToString(false) ?? '' + const column = text.indexOf(targetLink) + if (column === -1) { + continue + } + const cell = pane.terminal.dimensions?.css.cell + if (!cell?.width || !cell.height) { + throw new Error('paired terminal cell dimensions unavailable') + } + const rect = screen.getBoundingClientRect() + return { + x: rect.left + (column + targetLink.length / 2) * cell.width, + y: rect.top + (row + 0.5) * cell.height + } + } + throw new Error('paired terminal link unavailable') + }, link) +} + +function remoteTerminalHandle(ptyId: string): string { + const separator = ptyId.indexOf('@@') + if (!ptyId.startsWith('remote:') || separator === -1) { + throw new Error(`Expected runtime-owned PTY id, received ${ptyId}`) + } + return decodeURIComponent(ptyId.slice(separator + 2)) +} + +test('opens a paired-runtime terminal link on its owning host', async ({ + orcaPage, + testRepoPath +}, testInfo) => { + test.setTimeout(240_000) + const fixture = await startPageServer() + let client: PairedElectronClient | null = null + let observerActive = false + try { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const hostPtyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, hostPtyId, `printf '%s\\n' ${JSON.stringify(fixture.url)}`) + await waitForTerminalOutput(orcaPage, fixture.url) + + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + client = await launchPairedElectronClient(offer, testInfo, 'Remote terminal browser link') + const page = client.page + const worktreeId = await expect + .poll( + () => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, testRepoPath), + { timeout: 60_000, message: 'paired client never received the host worktree' } + ) + .not.toBeNull() + .then(() => + page.evaluate((repoPath) => { + const state = window.__store?.getState() + return state?.allWorktrees().find((worktree) => worktree.path === repoPath)?.id ?? null + }, testRepoPath) + ) + if (!worktreeId) { + throw new Error('paired client worktree disappeared after discovery') + } + await page.evaluate( + async ({ environmentId, worktreeId }) => { + const state = window.__store?.getState() + state?.setActiveWorktree(worktreeId, `runtime:${environmentId}`) + await state?.updateSettings({ + openLinksInApp: true, + terminalLinkActionPopoverEnabled: true + }) + }, + { environmentId: client.environmentId, worktreeId } + ) + await ensureTerminalVisible(page, 30_000) + await waitForActiveTerminalManager(page, 30_000) + await waitForTerminalOutput(page, fixture.url, 30_000) + const clientPtyId = await waitForActivePanePtyId(page, 30_000) + expect(clientPtyId.startsWith(`remote:${client.environmentId}@@`)).toBe(true) + + const baseline = await readTabInventory(page, client.environmentId, worktreeId) + await startPanelNavigationObserver(client.app, page.url()) + observerActive = true + + const target = await findTerminalLinkTarget(page, fixture.url) + await page.mouse.move(target.x, target.y) + await expect(page.locator('.xterm-hover')).toHaveCount(1) + await page.mouse.click(target.x, target.y) + const actionPopover = page.locator('[data-terminal-link-action-popover]') + await expect(actionPopover).toBeVisible() + await expect(actionPopover.locator('[data-terminal-link-destination]')).toHaveText(fixture.url) + await expect( + actionPopover.getByRole('button').filter({ hasText: 'System Browser' }) + ).toBeVisible() + const orcaBrowserAction = actionPopover.getByRole('button').filter({ hasText: 'Orca Browser' }) + await expect(orcaBrowserAction).toBeVisible() + await orcaBrowserAction.click() + + const identity = await expect + .poll( + () => + page.evaluate( + async ({ environmentId, url, worktreeId }) => { + const state = window.__store?.getState() + const workspace = (state?.browserTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.url === url + ) + const browserPage = workspace + ? (state?.browserPagesByWorkspace[workspace.id] ?? [])[0] + : null + const handle = browserPage + ? state?.remoteBrowserPageHandlesByPageId[browserPage.id] + : null + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + const hostTab = response.ok + ? response.result.tabs.find((tab) => tab.type === 'browser' && tab.url === url) + : null + return workspace && browserPage && handle && hostTab?.type === 'browser' + ? { + clientPageId: browserPage.id, + clientRuntimeId: browserPage.browserRuntimeEnvironmentId, + clientWorkspaceId: workspace.id, + hostPageId: hostTab.browserPageId, + hostTabId: hostTab.id, + remoteEnvironmentId: handle.environmentId, + remotePageId: handle.remotePageId + } + : null + }, + { environmentId: client!.environmentId, url: fixture.url, worktreeId } + ), + { timeout: 60_000, message: 'terminal link never became one host-owned browser tab' } + ) + .not.toBeNull() + .then(() => + page.evaluate( + async ({ environmentId, url, worktreeId }) => { + const state = window.__store!.getState() + const workspace = state.browserTabsByWorktree[worktreeId]!.find( + (tab) => tab.url === url + )! + const browserPage = state.browserPagesByWorkspace[workspace.id]![0]! + const handle = state.remoteBrowserPageHandlesByPageId[browserPage.id]! + const response = await window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'session.tabs.list', + params: { worktree: `id:${worktreeId}` }, + timeoutMs: 15_000 + }) + if (!response.ok) { + throw new Error('host tab inventory unavailable') + } + const hostTab = response.result.tabs.find( + (tab) => tab.type === 'browser' && tab.url === url + )! + return { + clientPageId: browserPage.id, + clientRuntimeId: browserPage.browserRuntimeEnvironmentId, + clientWorkspaceId: workspace.id, + hostPageId: hostTab.type === 'browser' ? hostTab.browserPageId : null, + hostTabId: hostTab.id, + remoteEnvironmentId: handle.environmentId, + remotePageId: handle.remotePageId + } + }, + { environmentId: client!.environmentId, url: fixture.url, worktreeId } + ) + ) + + expect(identity.clientRuntimeId).toBe(client.environmentId) + expect(identity.remoteEnvironmentId).toBe(client.environmentId) + expect(identity.remotePageId).toBe(identity.hostPageId) + const finalInventory = await readTabInventory(page, client.environmentId, worktreeId) + expect(finalInventory).toEqual({ + clientBrowserWorkspaces: baseline.clientBrowserWorkspaces + 1, + clientBrowserTabs: baseline.clientBrowserTabs + 1, + clientTerminalTabs: baseline.clientTerminalTabs, + hostBrowserTabs: baseline.hostBrowserTabs + 1, + hostTerminalTabs: baseline.hostTerminalTabs + }) + expect((await readPanelNavigationObserver(client.app)).externalUrls).toEqual([]) + + const content = await page.evaluate( + async ({ browserPageId, environmentId, worktreeId }) => + window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'browser.eval', + params: { + worktree: `id:${worktreeId}`, + page: browserPageId, + expression: 'document.querySelector("h1")?.textContent' + }, + timeoutMs: 15_000 + }), + { + browserPageId: identity.hostPageId!, + environmentId: client.environmentId, + worktreeId + } + ) + expect(content).toMatchObject({ ok: true, result: { result: PAGE_MARKER } }) + + const browserTab = page.locator(`[data-tab-id="${identity.clientWorkspaceId}"]`) + await browserTab.click() + await expect + .poll(() => + page.evaluate((worktreeId) => { + const state = window.__store?.getState() + return { + browserWorkspaceId: state?.activeBrowserTabIdByWorktree[worktreeId] ?? null, + tabType: state?.activeTabTypeByWorktree[worktreeId] ?? null + } + }, worktreeId) + ) + .toEqual({ browserWorkspaceId: identity.clientWorkspaceId, tabType: 'browser' }) + await expect( + page.locator(`[data-browser-overlay-tab-id="${identity.clientWorkspaceId}"]`) + ).toHaveCSS('opacity', '1') + + await browserTab.hover() + await browserTab.locator('button').click() + await expect + .poll(() => readTabInventory(page, client!.environmentId, worktreeId), { + timeout: 30_000, + message: 'host and client browser inventories did not return to baseline' + }) + .toEqual(baseline) + + await ensureTerminalVisible(page, 30_000) + await waitForActiveTerminalManager(page, 30_000) + expect(await waitForActivePanePtyId(page, 30_000)).toBe(clientPtyId) + const send = await page.evaluate( + async ({ environmentId, terminal, text }) => + window.api.runtimeEnvironments.call({ + selector: environmentId, + method: 'terminal.send', + params: { + terminal, + text, + enter: true, + client: { id: 'remote-browser-close-e2e', type: 'desktop' } + }, + timeoutMs: 15_000 + }), + { + environmentId: client.environmentId, + terminal: remoteTerminalHandle(clientPtyId), + text: `printf '%s\\n' ${JSON.stringify(TERMINAL_MARKER)}` + } + ) + expect(send).toMatchObject({ ok: true, result: { send: { accepted: true } } }) + await waitForTerminalOutput(page, TERMINAL_MARKER, 30_000) + expect(await readTabInventory(page, client.environmentId, worktreeId)).toEqual(baseline) + expect((await readPanelNavigationObserver(client.app)).externalUrls).toEqual([]) + } finally { + if (observerActive && client) { + await stopPanelNavigationObserver(client.app).catch(() => undefined) + } + await client?.dispose() + await closePageServer(fixture.server) + } +}) From cb41f3c5f467173bf4d0d4ac9309df078b15c0fe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:22:06 -0700 Subject: [PATCH 14/33] fix(terminal): require agent identity for guarded sends (STA-4028) (#14092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): require agent identity for guarded sends (STA-4028) Quarter-circle spinner glyphs (U+25D0-U+25D3) started classifying a title as "working" in #13925, and that status alone authorized guarded agent sends — which auto-submit with Enter — into any pane whose TUI animates those generic progress frames. Keep the glyphs as an activity signal, but stop treating a title whose only agent evidence is a quarter circle as proof an agent owns the pane: send authorization now falls through to recognized-agent identity in the title or a recognized foreground process. Braille-spinner behavior is unchanged. * fix(terminal): preserve verified managed busy identity * fix(terminal): bind busy identity to process incarnation * chore(test): avoid duplicate terminal gate suite --- config/reliability-gates.jsonc | 31 ++- src/main/runtime/orca-runtime.ts | 62 ++++- ...er-circle-title-send-authorization.test.ts | 212 ++++++++++++++++++ src/shared/agent-detection.ts | 1 + src/shared/agent-title-status.ts | 13 ++ 5 files changed, 304 insertions(+), 15 deletions(-) create mode 100644 src/main/runtime/quarter-circle-title-send-authorization.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 8956aa3fa50..c9bf94a1731 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -7760,14 +7760,16 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon"], - "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.", + "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, verified managed-Claude launch identity, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal when no verified launch identity exists.", "motivatingLinks": ["https://github.com/stablyai/orca/issues/8303"], - "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", - "oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; a generic busy title requires independent agent identity from the title, a verified managed launch bound to the current PTY incarnation, or the foreground process; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", + "oracle": "A quarter-circle Claude task title authorizes without a provider lookup only on the exact PTY incarnation carrying verified managed-Claude launch identity; the same title in a bare pane, with an unverified launch hint, or after PTY incarnation replacement is refused while current Claude activity remains a working signal. Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" ], "testFiles": [ + "src/main/runtime/quarter-circle-title-send-authorization.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/main/runtime/rpc/terminal-send.test.ts", "src/main/ipc/pty.test.ts", @@ -7780,6 +7782,14 @@ "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" ], "assertionRefs": [ + { + "file": "src/main/runtime/quarter-circle-title-send-authorization.test.ts", + "assertions": [ + "a verified managed-Claude launch authorizes a task-text quarter-circle title without foreground-process work", + "a bare pane, an unverified launch hint, and a replacement PTY incarnation remain refused", + "quarter circles remain working signals and braille authorization is unchanged" + ] + }, { "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ @@ -7847,6 +7857,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-12", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/quarter-circle-title-send-authorization.test.ts", + "result": "passed", + "durationSeconds": 20.9, + "summary": "The focused file passed 9 tests, including current-incarnation managed-Claude authorization with zero foreground calls and fail-closed bare, unverified, and replacement-incarnation quarter-circle titles." + }, { "date": "2026-07-11", "runner": "local", @@ -7867,11 +7886,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing." + "evidence": "Before the verified-launch fallback, the managed-Claude unavailable-foreground case failed with isRunningAgent false; the focused file passed 7 tests after the fix. Removing the launch-incarnation comparison makes the replacement-incarnation refusal fail. Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; physical Windows evidence remains missing." }, "performanceBudget": { "required": true, - "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." + "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove verified managed-Claude launch identity, permission/title blockers, and ordinary recognized-agent evidence add zero provider calls; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." }, "promotionCriteria": [ "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", @@ -7884,7 +7903,7 @@ "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." ], - "demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect." + "demotionRule": "Keep non-blocking or demote to protection none if a generic busy title authorizes without exact title, verified-launch, or foreground identity; provider confirmation becomes unconditional; exact-PTY mismatch can write bytes; unsupported providers fail open; or the focused gate flakes without an actionable product or harness defect." }, { "id": "terminal-input.agent-prompt-injection", diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e5a87f80783..bcf6840448d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7,6 +7,7 @@ import { isCursorAgentTitle, isCursorNativeAgentTitle, isOpenCodeNativeTitle, + isQuarterCircleSpinnerOnlyAgentTitle, isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' @@ -1317,6 +1318,8 @@ type RuntimePtyWorktreeRecord = { paneKey: string | null launchConfig: SleepingAgentLaunchConfig | null launchToken: string | null + // Why: provider PTY IDs can be reused; launch identity belongs only to the process that received the token. + launchIncarnationId: PtyIncarnationId | null launchAgent: TuiAgent | null foregroundAgent: TuiAgent | null connected: boolean @@ -9393,6 +9396,7 @@ export class OrcaRuntimeService { isTuiAgent(agentLaunchAuthority.launchAgent) ) { pty.launchToken = agentLaunchAuthority.launchToken + pty.launchIncarnationId = binding.incarnationId pty.launchAgent = agentLaunchAuthority.launchAgent } const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) @@ -12402,6 +12406,7 @@ export class OrcaRuntimeService { } this.restoredOrchestrationAuthorityByPtyId.delete(ptyId) pty.launchToken = null + pty.launchIncarnationId = null const paneKeys = new Set() if (pty.paneKey && parsePaneKey(pty.paneKey)) { paneKeys.add(pty.paneKey) @@ -16795,7 +16800,12 @@ export class OrcaRuntimeService { } } if (terminal.titleStatus) { - if (isOpenCodeNativeTitle(terminal.title)) { + // Why: an OpenCode marker and a lone quarter-circle spinner (STA-4028) are activity, + // not identity, so resolve both through the identity/foreground evidence path. + if ( + isOpenCodeNativeTitle(terminal.title) || + isQuarterCircleSpinnerOnlyAgentTitle(terminal.title) + ) { const isRunningAgent = await this.isTerminalRunningAgent(handle) this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { @@ -25776,6 +25786,7 @@ export class OrcaRuntimeService { ? copySleepingAgentLaunchConfig(effectiveLaunchConfig) : null pty.launchToken = launchToken ?? null + pty.launchIncarnationId = launchToken ? pty.incarnationId : null pty.launchAgent = launchOpts.launchAgent ?? null } pty.tabId = tabId @@ -29423,6 +29434,7 @@ export class OrcaRuntimeService { paneKey: state.paneKey ?? null, launchConfig: null, launchToken: null, + launchIncarnationId: null, launchAgent: null, foregroundAgent: null, connected: state.connected ?? true, @@ -31564,15 +31576,24 @@ export class OrcaRuntimeService { return await this.isPtyRunningAgent(pty.pty, leaf) } const { leaf } = this.getLiveLeafForHandle(handle) + const trackedPty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : null // Why: check the leaf pane title and the tab title, which already carries OSC-enriched agent indicators (e.g. ✳ prefix). const paneTitle = getLatestLeafTitle(leaf, null) const paneTitleClassification = classifyAgentTitle(paneTitle) - if (paneTitleClassification === 'agent' && !isOpenCodeNativeTitle(paneTitle)) { + if ( + trackedPty + ? ptyTitleProvesAgentPresence(trackedPty, paneTitle, paneTitleClassification) + : agentTitleProvesAgentPresence(paneTitle, paneTitleClassification) + ) { return true } const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral' - if (tabTitleClassification === 'agent' && !isOpenCodeNativeTitle(tabTitle)) { + if ( + trackedPty + ? ptyTitleProvesAgentPresence(trackedPty, tabTitle, tabTitleClassification) + : agentTitleProvesAgentPresence(tabTitle, tabTitleClassification) + ) { return true } const openCodeMarkerTitle = paneTitle ?? tabTitle @@ -31621,7 +31642,7 @@ export class OrcaRuntimeService { ) : null const leafTitleClassification = classifyAgentTitle(leafTitle) - if (leafTitleClassification === 'agent' && !isOpenCodeNativeTitle(leafTitle)) { + if (ptyTitleProvesAgentPresence(pty, leafTitle, leafTitleClassification)) { return true } const ptyTitle = getLatestAgentCandidateTitle( @@ -31629,11 +31650,7 @@ export class OrcaRuntimeService { { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } ) const ptyTitleClassification = classifyAgentTitle(ptyTitle) - if ( - leafTitle === null && - ptyTitleClassification === 'agent' && - !isOpenCodeNativeTitle(ptyTitle) - ) { + if (leafTitle === null && ptyTitleProvesAgentPresence(pty, ptyTitle, ptyTitleClassification)) { return true } const managementTitleClassification = classifyLatestAgentTitle({ @@ -37595,6 +37612,33 @@ function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): s ) } +// Why: an 'agent' title only proves an agent owns the pane when something other than a +// quarter-circle spinner carries it — those glyphs are generic progress frames (STA-4028). +function agentTitleProvesAgentPresence( + title: string | null, + classification: 'agent' | 'management' | 'neutral' +): boolean { + return ( + classification === 'agent' && + !isOpenCodeNativeTitle(title) && + !isQuarterCircleSpinnerOnlyAgentTitle(title) + ) +} + +function ptyTitleProvesAgentPresence( + pty: RuntimePtyWorktreeRecord, + title: string | null, + classification: 'agent' | 'management' | 'neutral' +): boolean { + return ( + agentTitleProvesAgentPresence(title, classification) || + (isQuarterCircleSpinnerOnlyAgentTitle(title) && + pty.launchAgent === 'claude' && + pty.launchToken !== null && + pty.launchIncarnationId === pty.incarnationId) + ) +} + function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' { if (!title) { return 'neutral' diff --git a/src/main/runtime/quarter-circle-title-send-authorization.test.ts b/src/main/runtime/quarter-circle-title-send-authorization.test.ts new file mode 100644 index 00000000000..d8e58073cb0 --- /dev/null +++ b/src/main/runtime/quarter-circle-title-send-authorization.test.ts @@ -0,0 +1,212 @@ +// STA-4028 (regression from #13925): quarter circles are ordinary progress glyphs — +// ora, installers, any TUI animates them — so a title carrying nothing else must not +// authorize a guarded send, which auto-submits with Enter into whatever owns the pane. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' +import { detectAgentStatusFromTitle } from '../../shared/agent-detection' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'wt-1' +const PTY_ID = 'pty-1' + +// Captured from Claude Code 2.1.228 while it read this repository's package.json. +const SPINNER_ONLY_TITLE = '◑ Check package version in package.json' +const SPINNER_WITH_IDENTITY_TITLE = '◐ Claude Code' +const BRAILLE_SPINNER_ONLY_TITLE = '⠂ Deploying release 4.2' + +async function createRuntimeWithTitle( + paneTitle: string, + foregroundProcess: string | null, + launchAgent?: 'claude', + verifiedLaunch = true +): Promise<{ + runtime: OrcaRuntimeService + handle: string + getForegroundProcess: ReturnType +}> { + const runtime = new OrcaRuntimeService(null) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: WORKTREE_ID, + path: '/repo/app', + connectionId: null, + repo: null, + folderWorkspace: null + }) + const getForegroundProcess = vi.fn(async () => foregroundProcess) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: PTY_ID, incarnationId: 'initial-incarnation' }), + write: () => true, + kill: () => true, + getForegroundProcess + }) + const terminal = await runtime.createTerminal(`id:${WORKTREE_ID}`, { + tabId: TAB_ID, + leafId: LEAF_ID, + title: 'Terminal', + ...(launchAgent + ? { + launchAgent, + ...(verifiedLaunch + ? { launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: {} } } + : {}) + } + : {}) + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + title: 'Terminal', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle + } + ] + }) + return { runtime, handle: terminal.handle, getForegroundProcess } +} + +const AUTHORIZED = 'authorized' + +async function guardedSendResult(runtime: OrcaRuntimeService, handle: string): Promise { + try { + await assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + return AUTHORIZED + } catch (error) { + return error instanceof Error ? error.message : String(error) + } +} + +describe('quarter-circle title send authorization (STA-4028)', () => { + it('refuses a guarded send when a quarter-circle spinner is the only agent evidence', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'node') + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('refuses a guarded send when the foreground process cannot be read at all', async () => { + // Why: SSH and folder-workspace panes can fail the foreground read; no evidence + // stays a refusal rather than falling back to the glyph. + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('authorizes a guarded send when the foreground process is a recognized agent', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, 'claude') + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('authorizes a managed Claude launch when the foreground process is unavailable', async () => { + const { runtime, handle, getForegroundProcess } = await createRuntimeWithTitle( + SPINNER_ONLY_TITLE, + null, + 'claude' + ) + expect( + ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get(PTY_ID) + ).toMatchObject({ launchAgent: 'claude', launchToken: expect.any(String) }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + + it('does not trust an unverified Claude launch hint', async () => { + const { runtime, handle } = await createRuntimeWithTitle( + SPINNER_ONLY_TITLE, + null, + 'claude', + false + ) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('does not carry managed Claude identity into a replacement PTY incarnation', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_ONLY_TITLE, null, 'claude') + const pty = ( + runtime as unknown as { + ptysById: Map< + string, + { incarnationId: string | null; launchIncarnationId: string | null; launchToken: string } + > + } + ).ptysById.get(PTY_ID) + expect(pty).toMatchObject({ + incarnationId: 'initial-incarnation', + launchIncarnationId: 'initial-incarnation' + }) + + runtime.onPtySpawned(PTY_ID, 'replacement-incarnation', { awaitsRegistration: false }) + + expect(pty).toMatchObject({ + incarnationId: 'replacement-incarnation', + launchIncarnationId: 'initial-incarnation', + launchToken: expect.any(String) + }) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe('terminal_guard_no_agent') + }) + + it('authorizes a guarded send when the busy title itself names the agent', async () => { + const { runtime, handle } = await createRuntimeWithTitle(SPINNER_WITH_IDENTITY_TITLE, null) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('leaves braille-spinner authorization unchanged', async () => { + const { runtime, handle } = await createRuntimeWithTitle(BRAILLE_SPINNER_ONLY_TITLE, null) + + await expect(guardedSendResult(runtime, handle)).resolves.toBe(AUTHORIZED) + }) + + it('keeps the quarter-circle glyph a working activity signal (#13889)', async () => { + expect(detectAgentStatusFromTitle(SPINNER_ONLY_TITLE)).toBe('working') + expect(detectAgentStatusFromTitle(SPINNER_WITH_IDENTITY_TITLE)).toBe('working') + }) +}) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index a2e419c4ff3..c603914b450 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -25,6 +25,7 @@ export { clearWorkingIndicators, createAgentStatusTracker, detectAgentStatusFromTitle, + isQuarterCircleSpinnerOnlyAgentTitle, normalizeTerminalTitle } from './agent-title-status' diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 1a0785aa368..5c13bb56e95 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -16,6 +16,7 @@ import { containsAgentName, containsAgentSpinnerGlyph, containsAny, + containsQuarterCircleSpinner, containsLegacyAgentName, isClaudeManagementTitle, isGeminiTerminalTitle, @@ -223,3 +224,15 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return 'idle' } + +/** + * True when a quarter-circle spinner frame is the only agent evidence a title carries. + * Any TUI animates those glyphs, so they prove activity, not identity — callers that + * authorize writes into the pane need independent evidence (STA-4028, regression #13925). + */ +export function isQuarterCircleSpinnerOnlyAgentTitle(title: string | null | undefined): boolean { + if (!title || !containsQuarterCircleSpinner(title)) { + return false + } + return detectAgentStatusFromTitle(title.replace(QUARTER_CIRCLE_SPINNER_RE, '').trim()) === null +} From 0ae0fd0434d9e0f96e311b308b5ef4323917fb64 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:23:18 -0700 Subject: [PATCH 15/33] fix(mobile-ios): set app_platform for distribute-only TestFlight upload (#14087) --- mobile/fastlane/Fastfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile index 6e66a72bada..204c14672b6 100644 --- a/mobile/fastlane/Fastfile +++ b/mobile/fastlane/Fastfile @@ -253,6 +253,9 @@ platform :ios do upload_to_testflight( api_key: api_key, app_identifier: BUNDLE_ID, + # distribute_only means there is no local .ipa to sniff, so fastlane would + # otherwise fall through to an interactive platform prompt and crash in CI. + app_platform: "ios", app_version: version, build_number: build_number, distribute_only: true, From ef8eea51af1c5248ba0a6a659af0926dbd2c4835 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:46:27 -0700 Subject: [PATCH 16/33] fix(terminal): retain alt frames through park reveal (#13882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): repaint an alt frame the fit never lands for #13014 skips a too-wide alt frame on snapshot replay and relies on a post-fit repaint. For a pane visible but permanently below the 48x24px fit floor, readProposedTerminalCols is forever undefined, so skipIfTargetUnknown drops the frame — and the only compensating repaint lives in a safe-fit continuation that is failed rather than run for such a pane. The frame is lost until the user widens the pane. Adds a deferred repaint gated on display:none rather than on target-unknown, preserving #13014's no-clip guarantee for a pane that later fits narrower. Does NOT fix artificial-opencode-terminal-load.spec.ts:761. Two open concerns: the repaint waits out the full 40-frame safe-fit retry budget (~1.3s) against that spec's own 2s restore budget, and the cold-park reveal path at pty-connection.ts:8222/8289 still has no deferral net. Co-authored-by: Orca * fix(terminal): restore the deferred PTY input hop Inlining forwardPtyInput into the onData handler dropped the deps.deferPtyInput dispatch, leaving the callback and its type declared and supplied but never called. Link-click mouse suppression silently stopped withholding mouse-report frames, so a click in a mouse-tracking TUI reached the PTY and the link-open fallback never fired. Covers both branches, which had no test at all. * fix(terminal): retain alt frames until final fit * fix(terminal): preserve park intent across effect replay * test(terminal): satisfy native static analysis * fix(terminal): consume park intent after layout replay * fix(terminal): re-read park intent instead of caching it per pane useTerminalParkMountIntent cached park intent in a ref for the lifetime of the component. The ref was never load-bearing: React finishes the render phase before any effect runs, so StrictMode's passive-effect replay already observes the same render-phase value. What makes the reveal work is reading at render time rather than at connect time, which a plain call already does. The ref did go stale. The pane lifecycle effect re-runs on [tabId, cwd] for the same component instance, so a worktree path or startupCwd change resupplied mountFollowsTerminalPark: true long after the park reveal, since the post-replay consume clears the per-run ptyDeps copy and not the ref. Each spurious run re-armed the pre-paint snapshot prefetch. Also scope the post-replay consume behind a tested one-shot helper so splits created after the restored-layout replay keep ordinary reconnect semantics, and correct two comments that described the removed ref. --------- Co-authored-by: Orca Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../components/terminal-pane/TerminalPane.tsx | 1 + .../terminal-pane/pty-connection-types.ts | 3 + .../terminal-pane/pty-connection.test.ts | 181 ++++++++++++------ .../terminal-pane/pty-connection.ts | 18 +- .../terminal-parked-watcher-registry.ts | 8 +- .../terminal-snapshot-replay-paint.test.ts | 35 +++- .../terminal-snapshot-replay-paint.ts | 11 +- .../use-terminal-pane-lifecycle.test.ts | 30 +++ .../use-terminal-pane-lifecycle.ts | 20 +- ...rminal-park-mount-intent.react185.test.tsx | 76 ++++++++ .../use-terminal-park-mount-intent.ts | 9 + 11 files changed, 301 insertions(+), 91 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.ts diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4498dbe6bef..323a3dc497e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -1562,6 +1562,7 @@ function TerminalPane( worktreeId, cwd, startup: CODEX_ACCOUNT_RESTART_STARTUP, + mountFollowsTerminalPark: false, paneTransportsRef, paneMode2031Ref, paneKittyKeyboardModesRef, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 18cae8d72c2..b2b5163ac93 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -48,6 +48,9 @@ export type PtyConnectionDeps = { } | null restoredLeafId?: string | null restoredPtyIdByLeafId?: Record + /** Park intent sampled at render time, before the host disposes the tab's + * watchers; consumed once the restored layout has been replayed. */ + mountFollowsTerminalPark: boolean paneTransportsRef: React.RefObject> paneMode2031Ref: React.RefObject> /** Per-pane mirror of the kitty keyboard flags the pane's application 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 fb6a97b8de6..37a000f16a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -62,18 +62,6 @@ async function drainFakeTimerWork(limit = 20): Promise { vi.clearAllTimers() } -// Why: a reveal remount reads the still-live park watcher entry at connect time to -// tell itself apart from an in-place reattach; the host disposes it a beat later. -async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise { - const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry') - parkedWatchersByTabId.set(tabId, { - worktreeId, - tabPtyId: ptyId, - paneIdByPtyId: new Map([[ptyId, 1]]), - disposersByPtyId: new Map([[ptyId, () => {}]]) - }) -} - async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100): Promise { let iterations = 0 while (pendingTimeouts.length > 0) { @@ -624,6 +612,7 @@ function createDeps(overrides: Record = {}) { startup: null, restoredLeafId: null, restoredPtyIdByLeafId: {}, + mountFollowsTerminalPark: false, paneTransportsRef: { current: new Map() }, paneMode2031Ref: { current: new Map() }, paneKittyKeyboardModesRef: { current: new Map() }, @@ -3148,6 +3137,39 @@ describe('connectPanePty', () => { expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) }) + it('routes terminal input through deferPtyInput so the host can withhold it', async () => { + // Regression: inlining forwardPtyInput into onData dropped this hop, silently disabling link-click mouse suppression. + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + transportFactoryQueue.push(transport) + const deferPtyInput = vi.fn() + + connectPanePty(pane as never, createManager(1) as never, createDeps({ deferPtyInput }) as never) + await flushAsyncTicks() + sendTerminalInputThroughPane(pane, 'a') + + expect(deferPtyInput).toHaveBeenCalledWith(1, 'a', expect.any(Function)) + expect(transport.sendInput).not.toHaveBeenCalled() + + const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void + forward('a') + expect(transport.sendInput).toHaveBeenCalledWith('a') + }) + + it('forwards terminal input directly when the host supplies no deferPtyInput', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + sendTerminalInputThroughPane(pane, 'a') + + expect(transport.sendInput).toHaveBeenCalledWith('a') + }) + it('does not let OpenTUI-style small ANSI redraw bursts monopolize foreground writes', async () => { const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) @@ -8758,7 +8780,7 @@ describe('connectPanePty', () => { expect(writes.join('')).not.toContain('ALT-FRAME-BODY') }) - it('drops a live daemon alt frame until a hidden pane has a final grid', async () => { + it('keeps a daemon alt frame at its capture grid while the target grid is unknown', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => @@ -8796,7 +8818,8 @@ describe('connectPanePty', () => { const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) expect(writes.join('')).toContain('PREFIX-SCROLLBACK') - expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + expect(writes.join('')).toContain('ALT-FRAME-BODY') + expect(writes.filter((write) => write.includes('ALT-FRAME-BODY'))).toHaveLength(1) }) it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => { @@ -15446,9 +15469,8 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('pulses a skipped hidden alt frame when reveal keeps the snapshot grid', async () => { + it('keeps a hidden-output alt frame at the capture grid below the fit floor', async () => { const { connectPanePty } = await import('./pty-connection') - const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') const transport = createMockTransport('pty-id') let onData: ConnectCallbacks['onData'] transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { @@ -15470,41 +15492,36 @@ describe('connectPanePty', () => { scrollbackAnsi: 'preserved history' }) const pane = createPane(1) - let paneRect = createRect(0, 0) - pane.container.getBoundingClientRect = vi.fn(() => paneRect) - let xtermContainerDisplay = 'none' - const xtermContainer = new EventTarget() as HTMLElement - Object.defineProperty(xtermContainer, 'parentElement', { value: null }) - Object.defineProperty(xtermContainer, 'ownerDocument', { - value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + pane.terminal.cols = 80 + pane.terminal.rows = 24 + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() }) - ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + pane.container.getBoundingClientRect = vi.fn(() => createRect(4, 3)) const deps = createDeps({ isVisibleRef: { current: false } }) - const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) await flushAsyncTicks(6) onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: hidden.length + 1 + }) ;(deps.isVisibleRef as { current: boolean }).current = true - onData?.('x', { seq: hidden.length + 1, rawLength: 1 }) + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) await flushAsyncTicks(20) - const writes = (pane.terminal.write as ReturnType).mock.calls.map( - (call) => call[0] - ) - expect(writes.join('')).not.toContain('TOO-WIDE-ALT-FRAME') - expect(writes.join('')).toContain('\x1b[?1004h\x1b[?25l') + expect(writes.join('')).toContain('TOO-WIDE-ALT-FRAME') + expect(writes.join('')).toContain('preserved history') + expect(writes.filter((write) => write.includes('TOO-WIDE-ALT-FRAME'))).toHaveLength(1) + expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40) expect(transport.resize).not.toHaveBeenCalled() - - xtermContainerDisplay = 'block' - paneRect = createRect(800, 600) - safeFit(pane as never) - await flushAsyncTicks(12) - - const pulseStart = transport.resize.mock.calls.findIndex( - ([cols, rows]) => cols === 119 && rows === 40 - ) - expect(pulseStart).toBeGreaterThanOrEqual(0) - expect(transport.resize.mock.calls[pulseStart + 1]).toEqual([120, 40]) - disposable.dispose() + binding.dispose() }) it('drains foreground output after a renderer-sourced hidden-backlog snapshot without seq', async () => { @@ -21401,7 +21418,6 @@ describe('connectPanePty', () => { alternateScreen: true, scrollbackAnsi: 'PARKED-SSH-SCROLLBACK\r\n' }) - await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] }, @@ -21419,6 +21435,7 @@ describe('connectPanePty', () => { pane as never, createManager(1) as never, createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } }) as never @@ -21464,7 +21481,6 @@ describe('connectPanePty', () => { transportFactoryQueue.push(createMockTransport()) vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise) vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) - await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId, generation: 7 }] }, @@ -21491,6 +21507,7 @@ describe('connectPanePty', () => { pane as never, createManager(1) as never, createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } }) as never @@ -21543,7 +21560,6 @@ describe('connectPanePty', () => { seq: 125, source: 'headless' }) - await parkTabForReveal('tab-1', foreignPtyId) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: foreignPtyId }] }, @@ -21560,6 +21576,7 @@ describe('connectPanePty', () => { pane as never, createManager(1) as never, createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: foreignPtyId } }) as never @@ -21595,7 +21612,6 @@ describe('connectPanePty', () => { }) transportFactoryQueue.push(transport) vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise) - await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { ...mockStoreState, tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] }, @@ -21612,6 +21628,7 @@ describe('connectPanePty', () => { pane as never, createManager(1) as never, createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } }) as never @@ -21649,7 +21666,6 @@ describe('connectPanePty', () => { source: 'headless' }) transportFactoryQueue.push(transport) - await parkTabForReveal('tab-1', remotePtyId) mockStoreState = { ...mockStoreState, runtimeStatusByEnvironmentId: new Map([ @@ -21665,7 +21681,7 @@ describe('connectPanePty', () => { const pane = createPane(1) const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) - const deps = createDeps() + const deps = createDeps({ mountFollowsTerminalPark: true }) connectPanePty(pane as never, createManager(1) as never, deps as never) for (let step = 0; step < 30; step += 1) { @@ -21680,7 +21696,7 @@ describe('connectPanePty', () => { expect(transport.getPtyId).toHaveReturnedWith(remotePtyId) }) - it('restores a local main-model snapshot after a contentless park reattach', async () => { + it('keeps a parked main-model alt frame at its capture grid while hidden', async () => { const { connectPanePty } = await import('./pty-connection') const localPtyId = 'global-floating-terminal@@terminal-1' const transport = createMockTransport(localPtyId) @@ -21691,13 +21707,15 @@ describe('connectPanePty', () => { transportFactoryQueue.push(transport) const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot) getMainBufferSnapshot.mockResolvedValue({ - data: 'FLOATING-PARK-RESTORE-OK\r\n', + data: 'FLOATING-PARK-ALT-FRAME', + frameRestoreAnsi: '\x1b[?25l', cols: 113, rows: 32, seq: 558, - source: 'headless' + source: 'headless', + alternateScreen: true, + scrollbackAnsi: 'FLOATING-PARK-HISTORY\r\n' }) - await parkTabForReveal('tab-1', localPtyId, 'global-floating-terminal') mockStoreState = { ...mockStoreState, tabsByWorktree: { @@ -21715,8 +21733,10 @@ describe('connectPanePty', () => { } const pane = createPane(1) + pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) const deps = createDeps({ + mountFollowsTerminalPark: true, worktreeId: 'global-floating-terminal', restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: localPtyId } @@ -21733,7 +21753,55 @@ describe('connectPanePty', () => { expect.objectContaining({ sessionId: localPtyId }) ) expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId) - expect(writes.join('')).toContain('FLOATING-PARK-RESTORE-OK') + expect(writes.join('')).toContain('FLOATING-PARK-HISTORY') + expect(writes.join('')).toContain('FLOATING-PARK-ALT-FRAME') + expect(writes.filter((write) => write.includes('FLOATING-PARK-ALT-FRAME'))).toHaveLength(1) + expect(pane.terminal.resize).toHaveBeenCalledWith(113, 32) + }) + + it('restores a parked SSH alt frame after StrictMode watcher disposal', async () => { + const { connectPanePty } = await import('./pty-connection') + const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-static-alt') + const transport = createMockTransport(sshPtyId) + transport.connect.mockImplementation(async () => { + transport.getPtyId.mockReturnValue(sshPtyId) + return { id: sshPtyId, isReattach: true, replay: '' } + }) + transportFactoryQueue.push(transport) + vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ + data: '\x1b[0m\x1b[?1049h\x1b[HSTATIC-SSH-ALT-FRAME', + frameRestoreAnsi: '\x1b[0m\x1b[?1049h\x1b[?6l\x1b[1;21H', + cols: 100, + rows: 30, + seq: 512, + source: 'headless', + alternateScreen: true, + scrollbackAnsi: 'PRESERVED-SSH-HISTORY\r\n' + }) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId } + } + + const pane = createPane(1) + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + mountFollowsTerminalPark: true, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } + }) + connectPanePty(pane as never, createManager(1) as never, deps as never) + for (let step = 0; step < 30; step += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + + expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce() + expect(writes.join('')).toContain('PRESERVED-SSH-HISTORY') + expect(writes.filter((write) => write.includes('STATIC-SSH-ALT-FRAME'))).toHaveLength(1) }) it('falls back to relay replay when the SSH model snapshot stalls', async () => { @@ -21747,9 +21815,7 @@ describe('connectPanePty', () => { }) transportFactoryQueue.push(transport) vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(new Promise(() => {})) - // Why parked: only a reveal remount probes main's model — the pane reads the - // still-live park watcher entry at connect time to tell the two apart. - await parkTabForReveal('tab-1', sshPtyId) + // Why parked: only a reveal remount probes main's model. mockStoreState = { ...mockStoreState, @@ -21762,6 +21828,7 @@ describe('connectPanePty', () => { const pane = createPane(1) const { writes } = captureCallbackTerminalWrites(pane) const deps = createDeps({ + mountFollowsTerminalPark: true, restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f5d3a596cdf..b9a94ad2dfa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -280,7 +280,6 @@ import { setCommandCodeDoneSettleExecutor } from './command-code-done-settle' import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' -import { isTerminalTabParked } from './terminal-parked-watcher-registry' import { getExecutionHostIdForWorktree, getSettingsForWorktreeRuntimeOwner @@ -1096,10 +1095,7 @@ export function connectPanePty( // settles after remount must not remount its already-replaced successor. const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(deps.tabId) const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId) - // Why sampled here: the host disposes this tab's park watcher in the effect - // that follows this mount, so connect time is the only moment a pane can tell - // a reveal remount from an in-place reattach. - let mountFollowsTerminalPark = isTerminalTabParked(deps.tabId) + let mountFollowsTerminalPark = deps.mountFollowsTerminalPark let authoritativeReattachGeneration = 0 exposeE2eTerminalPtyOutputDebug() let disposed = false @@ -7303,9 +7299,7 @@ export function connectPanePty( skippedAltFrame = snapshot.alternateScreen === true && snapshot.frameRestoreAnsi !== undefined && - shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane), { - skipIfTargetUnknown: true - }) + shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane)) for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame: skippedAltFrame })) { @@ -8256,8 +8250,7 @@ export function connectPanePty( typeof snapshotFrameRestoreAnsi === 'string' && shouldSkipAltFrameForWidthMismatch( connectResult.snapshotCols, - readProposedTerminalCols(pane), - { skipIfTargetUnknown: true } + readProposedTerminalCols(pane) ) writeReplayData( daemonAltFrameSkippable @@ -8326,10 +8319,7 @@ export function connectPanePty( for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, { skipAltFrame: shouldSkipAltFrameForWidthMismatch( modelCols, - readProposedTerminalCols(pane), - { - skipIfTargetUnknown: true - } + readProposedTerminalCols(pane) ) })) { writeReplayData(replayChunk) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index 1f4b522040a..b2fb9b3de07 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -58,10 +58,10 @@ export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable): } /** - * Whether this tab is parked right now — the reveal remount's own mount effect - * runs before the host effect that disposes the watcher (child effects first), - * so a pane reading this at connect time can tell a park-reveal from an - * in-place reattach. Empty entries are pinned-close tombstones, not live parks. + * Whether this tab is parked right now — the reveal remount renders before the + * host effect that disposes the watcher, so a pane reading this at render time + * can tell a park-reveal from an in-place reattach. Empty entries are + * pinned-close tombstones, not live parks. */ export function isTerminalTabParked(tabId: string): boolean { return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0 diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts index 9a3aa6621c5..042f6bed3d8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts @@ -89,18 +89,35 @@ describe('shouldSkipAltFrameForWidthMismatch', () => { expect(shouldSkipAltFrameForWidthMismatch(Number.NaN, 128)).toBe(false) expect(shouldSkipAltFrameForWidthMismatch(Number.POSITIVE_INFINITY, 128)).toBe(false) }) - - it('can conservatively skip a live frame until a hidden pane has a final grid', () => { - expect(shouldSkipAltFrameForWidthMismatch(135, undefined, { skipIfTargetUnknown: true })).toBe( - true - ) - expect( - shouldSkipAltFrameForWidthMismatch(undefined, undefined, { skipIfTargetUnknown: true }) - ).toBe(false) - }) }) describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { + it('restores the exact capture-grid alt frame while the target grid is unknown', async () => { + const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 }) + const snapshot = { + data: '\x1b[1;1HTOP---------\x1b[2;1HMIDDLE------\x1b[3;1HBOTTOM------', + frameRestoreAnsi: '\x1b[?25l', + alternateScreen: true, + scrollbackAnsi: 'history' + } + + try { + const skipAltFrame = shouldSkipAltFrameForWidthMismatch(12, undefined) + for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame })) { + await writeTerminal(terminal, chunk) + } + + expect( + Array.from({ length: 3 }, (_, row) => + terminal.buffer.active.getLine(row)?.translateToString(true) + ) + ).toEqual(['TOP---------', 'MIDDLE------', 'BOTTOM------']) + expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('history') + } finally { + terminal.dispose() + } + }) + it('keeps normal history and a clean alt grid through the real resize path', async () => { const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 }) const snapshot = { diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index 643ca0de925..aa41ff6d645 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -35,8 +35,8 @@ export function resolvePositiveTerminalDimensions( * The column count the post-replay fit will land on. Why not terminal.cols: a * pane that has not been fitted yet still reads xterm's 80-column default, so * comparing against it would drop frames whose width actually matches the - * container. Returns undefined when the pane cannot be measured; callers with - * a repaint owner can clear and defer, while offline preconnect paint cannot. + * container. Returns undefined when the pane cannot be measured, so replay can + * retain the frame at its capture grid until a final fit exists. */ export function readProposedTerminalCols(pane: ManagedPane): number | undefined { return readProposedPaneFitDimensions(pane)?.cols @@ -44,15 +44,14 @@ export function readProposedTerminalCols(pane: ManagedPane): number | undefined export function shouldSkipAltFrameForWidthMismatch( snapshotCols: number | undefined, - targetCols: number | undefined, - options: { skipIfTargetUnknown?: boolean } = {} + targetCols: number | undefined ): boolean { if (typeof snapshotCols !== 'number' || !Number.isFinite(snapshotCols) || snapshotCols <= 0) { return false } if (typeof targetCols !== 'number' || !Number.isFinite(targetCols) || targetCols <= 0) { - // A hidden pane has no final grid; its live app can repaint after the deferred fit. - return options.skipIfTargetUnknown === true + // Keep the frame at its capture grid until a real fit can replace that grid. + return false } // Fixed-grid alt rows clip at narrower columns; normal history remains reflowable. return snapshotCols > targetCols diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index 74e53a0b6be..bc0582ca9fe 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -9,6 +9,7 @@ import { resolvePaneLinkCwd, resolvePaneSeedCwd, resolveQueuedInitialCwd, + replayLayoutWithOneShotParkIntent, resetTerminalKeyboardProtocolAfterInterrupt, retireMountedTerminalPaneSurface, shouldDetachPaneTransportOnUnmount, @@ -248,6 +249,35 @@ describe('splitPaneWithOneShotStartup', () => { }) }) +describe('replayLayoutWithOneShotParkIntent', () => { + it('exposes park intent to replayed panes and clears it before later splits', () => { + const deps = { mountFollowsTerminalPark: true } + const observedByReplayedPane: boolean[] = [] + + const restored = replayLayoutWithOneShotParkIntent(deps, () => { + observedByReplayedPane.push(deps.mountFollowsTerminalPark) + return 'restored-panes' + }) + + expect(restored).toBe('restored-panes') + expect(observedByReplayedPane).toEqual([true]) + // A split after replay reads the same deps object, so it must see ordinary reconnect semantics. + expect(deps.mountFollowsTerminalPark).toBe(false) + }) + + it('clears park intent even when layout replay throws', () => { + const deps = { mountFollowsTerminalPark: true } + + expect(() => + replayLayoutWithOneShotParkIntent(deps, () => { + throw new Error('replay failed') + }) + ).toThrow('replay failed') + + expect(deps.mountFollowsTerminalPark).toBe(false) + }) +}) + describe('applyTerminalScrollbackRowsToMountedPanes', () => { it('updates mounted pane xterm scrollback options only when needed', () => { const firstOptions = { scrollback: 1_000 } 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 24ec93b974a..31928913df5 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 @@ -130,6 +130,7 @@ import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-i import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' +import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent' import { e2eConfig } from '@/lib/e2e-config' import { PRIMARY_SELECTION_MAX_LENGTH, @@ -483,6 +484,19 @@ export function splitPaneWithOneShotStartup( } } +/** Scopes `deps.mountFollowsTerminalPark` to the restored-layout replay. */ +export function replayLayoutWithOneShotParkIntent( + deps: { mountFollowsTerminalPark: boolean }, + replayLayout: () => TRestored +): TRestored { + // Why: only panes reconstructed by this replay belong to the park reveal; later splits must use ordinary reconnect semantics. + try { + return replayLayout() + } finally { + deps.mountFollowsTerminalPark = false + } +} + export function shouldDetachPaneTransportOnUnmount(args: { tabStillExists: boolean tabId: string @@ -665,6 +679,7 @@ export function useTerminalPaneLifecycle({ const systemPrefersDarkRef = useRef(systemPrefersDark) systemPrefersDarkRef.current = systemPrefersDark const previousVisibleForReconcileRef = useRef(null) + const mountFollowsTerminalPark = useTerminalParkMountIntent(tabId) const linkProviderDisposablesRef = useRef(new Map()) const terminalHandleLinkDisposablesRef = useRef(new Map()) const linkifierClickPrimingDisposablesRef = useRef(new Map()) @@ -854,6 +869,7 @@ export function useTerminalPaneLifecycle({ worktreeId, cwd: startupCwd, startup: startupWithSetupSplitWait, + mountFollowsTerminalPark, paneTransportsRef, paneMode2031Ref, paneKittyKeyboardModesRef, @@ -1575,7 +1591,9 @@ export function useTerminalPaneLifecycle({ window.__paneManagers = window.__paneManagers ?? new Map() window.__paneManagers.set(tabId, manager) } - const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive) + const restoredPaneByLeafId = replayLayoutWithOneShotParkIntent(ptyDeps, () => + replayTerminalLayout(manager, initialLayoutRef.current, isActive) + ) const restoredBuffers = initialLayoutRef.current.buffersByLeafId restoreScrollbackBuffers( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx b/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx new file mode 100644 index 00000000000..7ab3ac8ae30 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.react185.test.tsx @@ -0,0 +1,76 @@ +/** @vitest-environment happy-dom */ +import { StrictMode, act, useEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { disposeParkedTabWatchers, parkedWatchersByTabId } from './terminal-parked-watcher-registry' +import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const TAB_ID = 'strict-park-reveal' +const observations: boolean[] = [] + +function Child(): null { + const mountFollowsTerminalPark = useTerminalParkMountIntent(TAB_ID) + useEffect(() => { + observations.push(mountFollowsTerminalPark) + }, [mountFollowsTerminalPark]) + return null +} + +function Parent() { + useEffect(() => { + disposeParkedTabWatchers(TAB_ID) + }, []) + return +} + +function parkTab(): void { + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'wt-1', + tabPtyId: 'pty-1', + paneIdByPtyId: new Map([['pty-1', 1]]), + disposersByPtyId: new Map([['pty-1', () => {}]]) + }) +} + +afterEach(() => { + observations.length = 0 + disposeParkedTabWatchers(TAB_ID) +}) + +describe('useTerminalParkMountIntent', () => { + it('survives StrictMode effect replay after the parent disposes the watcher', () => { + parkTab() + const container = document.createElement('div') + const root = createRoot(container) + + act(() => { + root.render( + + + + ) + }) + + expect(observations).toEqual([true, true]) + act(() => root.unmount()) + }) + + // Why: the lifecycle effect re-runs on cwd changes for the same component + // instance, so intent cached per instance would resupply a stale park reveal. + it('re-reads park intent when the same instance re-renders after disposal', () => { + parkTab() + const container = document.createElement('div') + const root = createRoot(container) + + act(() => root.render()) + expect(observations).toEqual([true]) + + disposeParkedTabWatchers(TAB_ID) + act(() => root.render()) + + expect(observations).toEqual([true, false]) + act(() => root.unmount()) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.ts b/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.ts new file mode 100644 index 00000000000..dba51fcc8ea --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-park-mount-intent.ts @@ -0,0 +1,9 @@ +import { isTerminalTabParked } from './terminal-parked-watcher-registry' + +// Why render time: the host effect that disposes the watcher runs after every +// child render, so reading here — not at connect time — is what lets a reveal +// remount still see the park. Caching it per instance would instead resupply a +// stale reveal on later same-instance effect re-runs (cwd change). +export function useTerminalParkMountIntent(tabId: string): boolean { + return isTerminalTabParked(tabId) +} From 4665bd2797b144799730b77efe3e1d372c24e353 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:27:10 -0700 Subject: [PATCH 17/33] test(agent-hooks): stop the GROK_HOME probe test from spawning a real shell (#14120) resolveRelayGrokHome probes a login shell and folds every spawn failure into its fallback, so the test was asserting the runner's scheduling latency rather than the parser. On a loaded 16-way sharded CI box the 8s timeout expires, the probe falls back, and the first case fails with '/home/orca/.grok' instead of '/srv/grok'. The failing job's teardown terminated orphaned `sh` and `head` processes -- the probe's own command, still live at job end. Stub node:child_process so the parse, the shell-mode choice, and the fallback are deterministic. Adds the probe-failure case, which is the branch that was silently absorbing the flake, and pins -c for sh/dash versus -lc elsewhere. --- .../agent-hooks/managed-hook-runtime.test.ts | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts index 456a741df87..5422de4e692 100644 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ b/src/main/agent-hooks/managed-hook-runtime.test.ts @@ -1,5 +1,38 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveRelayGrokHome } from './managed-hook-runtime' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeChildProcess from 'node:child_process' + +// Why: the probe spawns a real login shell, and `resolveRelayGrokHome` swallows every +// spawn failure into its fallback. Left unmocked this asserts the runner's scheduling +// latency, not the parser: on a loaded sharded CI box the 8s timeout expires and the +// first case silently flips to the fallback path. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, execFile: vi.fn() } +}) + +const { execFile } = await import('node:child_process') +const execFileMock = vi.mocked(execFile) +const { resolveRelayGrokHome } = await import('./managed-hook-runtime') + +type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void + +function stubProbeOutput(stdout: string): void { + execFileMock.mockImplementation(((...args: unknown[]) => { + ;(args.at(-1) as ExecFileCallback)(null, { stdout, stderr: '' }) + return undefined + }) as unknown as typeof execFile) +} + +function stubProbeFailure(error: Error): void { + execFileMock.mockImplementation(((...args: unknown[]) => { + ;(args.at(-1) as ExecFileCallback)(error) + return undefined + }) as unknown as typeof execFile) +} + +beforeEach(() => { + execFileMock.mockReset() +}) afterEach(() => { vi.unstubAllEnvs() @@ -8,14 +41,39 @@ afterEach(() => { describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => { it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { vi.stubEnv('SHELL', '/bin/sh') - vi.stubEnv('GROK_HOME', '/srv/grok///') + stubProbeOutput('/srv/grok///\n') await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const [shell, args] = execFileMock.mock.calls[0] ?? [] + expect(shell).toBe('/bin/sh') + // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. + expect(args?.[0]).toBe('-c') + }) + + it('passes -lc to a login shell that supports it', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('/srv/grok\n') + + await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const [shell, args] = execFileMock.mock.calls[0] ?? [] + expect(shell).toBe('/bin/zsh') + expect(args?.[0]).toBe('-lc') }) it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { vi.stubEnv('SHELL', '/bin/sh') - vi.stubEnv('GROK_HOME', '../relative') + stubProbeOutput('../relative\n') + + await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) + + // Why: this is the branch that made the old test flaky — pin it so a probe failure is + // an asserted fallback rather than an invisible substitution for a real answer. + it('falls back when the probe fails or times out', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') }) From 1192f73f4d9e97428bcae4dd4964086510c325df Mon Sep 17 00:00:00 2001 From: erish Date: Thu, 13 Aug 2026 09:32:36 +0900 Subject: [PATCH 18/33] docs(i18n): sync Korean README with current content (#14124) --- docs/readme/README.ko.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index e247ace7d7c..d02a025cb5f 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -36,7 +36,7 @@ 휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile) @@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   OpenCode logo OpenCode   + MiMo Code logo MiMo Code   Amp logo Amp   OpenClaude logo OpenClaude   Antigravity logo Antigravity   Pi logo Pi   oh-my-pi logo oh-my-pi   Hermes Agent logo Hermes Agent   + Devin logo Devin   Goose logo Goose   Auggie logo Auggie   Autohand Code logo Autohand Code   @@ -211,6 +213,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 - **[onOrca.dev에서 다운로드](https://onorca.dev/download)** - 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest) +- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요. _또는 패키지 매니저로 설치:_ @@ -226,8 +229,8 @@ yay -S stably-orca-bin 데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요. -- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) -- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) +- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA) +- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) --- @@ -235,6 +238,10 @@ yay -S stably-orca-bin - **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요. - **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요. +- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요. + + Orca 커뮤니티 WeChat 그룹 7 QR 코드 + - **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요. - **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요. - **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요. @@ -249,6 +256,14 @@ yay -S stably-orca-bin Orca 기여자 +

+ stablyai/orca GitHub Star 히스토리 차트 +

+ +## 서명된 빌드 + +Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다. + ## 라이선스 Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다. From 9772da844a4834ff6e1dcf739983591822dc1c0f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:46:25 -0700 Subject: [PATCH 19/33] perf(renderer): publish agent status bursts once (#13974) Agent-status events fan out multiplicatively: every event pays an O(worktrees x tabs) pane-routing scan and its own zustand publication, and WorktreeList's unconditional sortEpoch subscription re-renders the whole sidebar root on each one. A 256-pane reconnect replay meant 256 full sidebar re-renders. Fold a burst into one status publication (plus one generated-title and one tab-title publication - three total, not 2N) via transactAgentStatuses, and build the pane-routing index once per batch. Coalescing is only safe for level-triggered consumers. Two edge-triggered ones needed work: - useAutomationDispatchEvents diffed entry.state, so a swallowed intermediate `done` lost the run's completion output. It now walks the newly appended stateHistory rows and reads the completed turn's output from the entry-level lastCompletedAssistantMessage slot (one message per pane - putting it on every history row would retain 20 transcripts per live status and reprise the renderer OOM in #9872). - pty-connection's native-Windows ConPTY reset compared `state` against a closure-local previous value, so a coalesced done -> [working, done] never re-fired RESET_TERMINAL_CURSOR_STYLE / RESET_KITTY_KEYBOARD_PROTOCOL and left the pane with kitty keyboard protocol armed, corrupting plain input. It now tracks the newest completed turn's start across stateHistory, which also keeps same-turn `done` repaints from re-resetting. The transaction commits as a MERGE patch of the keys the fold changed, not a REPLACE of its snapshot: a batched action reaching another slice through get() writes straight to the real store, and a REPLACE would silently revert it. The shadowed `set` is typed without zustand's `replace` parameter so no call site can reintroduce semantics the merge cannot express. --- .../terminal-pane/pty-connection.test.ts | 153 +++ .../terminal-pane/pty-connection.ts | 34 +- .../hooks/useAutomationDispatchEvents.test.ts | 155 ++- .../src/hooks/useAutomationDispatchEvents.ts | 61 + src/renderer/src/hooks/useIpcEvents.test.ts | 1093 ++++++++++++++++- src/renderer/src/hooks/useIpcEvents.ts | 443 +++++-- .../store/slices/agent-status-batch.test.ts | 506 ++++++++ src/renderer/src/store/slices/agent-status.ts | 273 +++- .../runtime-pane-title-sort-epoch.test.ts | 36 + .../slices/terminal-tab-title-batch.test.ts | 266 ++++ .../store/slices/terminal-tab-title-batch.ts | 242 ++++ src/renderer/src/store/slices/terminals.ts | 217 +--- src/shared/agent-status-types.ts | 10 +- 13 files changed, 3196 insertions(+), 293 deletions(-) create mode 100644 src/renderer/src/store/slices/agent-status-batch.test.ts create mode 100644 src/renderer/src/store/slices/terminal-tab-title-batch.test.ts create mode 100644 src/renderer/src/store/slices/terminal-tab-title-batch.ts 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 37a000f16a2..3191bd99a0c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -23154,6 +23154,159 @@ describe('connectPanePty', () => { } }) + it('resets stale keyboard state when a batched done→working→done burst lands as one publication', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const firstDoneAt = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'first turn', + updatedAt: firstDoneAt, + stateStartedAt: firstDoneAt, + agentType: 'codex', + paneKey, + stateHistory: [{ state: 'working', prompt: 'first turn', startedAt: firstDoneAt - 5_000 }] + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + expect(pane.terminal.write).not.toHaveBeenCalled() + + // The burst's intermediate `working` never publishes, so the entry stays `done` end-to-end; + // only the stateHistory row proves a second turn ran and re-armed the kitty protocol. + const secondDoneAt = firstDoneAt + 10_000 + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'second turn', + updatedAt: secondDoneAt, + stateStartedAt: secondDoneAt, + agentType: 'codex', + paneKey, + stateHistory: [ + { state: 'working', prompt: 'first turn', startedAt: firstDoneAt - 5_000 }, + { state: 'done', prompt: 'first turn', startedAt: firstDoneAt }, + { state: 'working', prompt: 'second turn', startedAt: secondDoneAt - 2_000 } + ] + } + notifyStoreSubscribers() + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + expect.any(Function) + ) + } finally { + restoreUserAgent() + } + }) + + it('resets stale keyboard state when a batched burst ends on working after a completed turn', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const startedAt = Date.now() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'ship it', + updatedAt: startedAt, + stateStartedAt: startedAt, + agentType: 'codex', + paneKey, + stateHistory: [] + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + notifyStoreSubscribers() + expect(pane.terminal.write).not.toHaveBeenCalled() + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'follow-up', + updatedAt: startedAt + 8_000, + stateStartedAt: startedAt + 8_000, + agentType: 'codex', + paneKey, + stateHistory: [ + { state: 'working', prompt: 'ship it', startedAt }, + { state: 'done', prompt: 'ship it', startedAt: startedAt + 4_000 } + ] + } + notifyStoreSubscribers() + + expect(pane.terminal.write).toHaveBeenCalledWith( + `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}`, + expect.any(Function) + ) + } finally { + restoreUserAgent() + } + }) + + it('keeps native Windows same-turn done repaints from re-resetting keyboard state', async () => { + const restoreUserAgent = temporarilySetNavigatorUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + ) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + try { + const paneKey = makePaneKey('tab-1', LEAF_1) + const doneAt = Date.now() + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'ship it', + updatedAt: doneAt, + stateStartedAt: doneAt, + agentType: 'codex', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + const writesAfterDone = (pane.terminal.write as ReturnType).mock.calls.length + + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'ship it', + updatedAt: doneAt + 1_000, + stateStartedAt: doneAt, + agentType: 'codex', + paneKey, + stateHistory: [] + } + notifyStoreSubscribers() + + expect((pane.terminal.write as ReturnType).mock.calls.length).toBe( + writesAfterDone + ) + } finally { + restoreUserAgent() + } + }) + it('drops native Windows Codex focus reports while a history resume is idle', async () => { const restoreUserAgent = temporarilySetNavigatorUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b9a94ad2dfa..98a3ce1b66f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1081,6 +1081,25 @@ function isSetupSplitGeometryReady( ) } +/** Start time of the newest completed turn, counting turns already folded into history. + * Why: batched publications coalesce done→working→done into a single store notification, + * so the done EDGE survives only in stateHistory; comparing `state` alone misses it. */ +function resolveLatestAgentDoneStartedAt(entry: AgentStatusEntry | undefined): number | undefined { + if (!entry) { + return undefined + } + if (entry.state === 'done') { + return entry.stateStartedAt + } + const history = entry.stateHistory ?? [] + for (let index = history.length - 1; index >= 0; index -= 1) { + if (history[index].state === 'done') { + return history[index].startedAt + } + } + return undefined +} + /** * Establishes a binding between a terminal pane and its corresponding PTY stream, * managing input, output, title synchronization, and agent status tracking. @@ -3595,10 +3614,10 @@ export function connectPanePty( const shouldApplyNativeWindowsRewriteRefresh = isNativeWindowsConpty const shouldApplyWindowsRendererUnicodeRefresh = CLIENT_PLATFORM === 'win32' const shouldProtectNativeWindowsSynchronizedOutput = isNativeWindowsConpty - let lastAgentStatusState = state.agentStatusByPaneKey[cacheKey]?.state let unsubscribeWindowsDoneTerminalModeReset: (() => void) | null = null if (isNativeWindowsConpty) { const initialAgentStatus = state.agentStatusByPaneKey[cacheKey] + let lastAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(initialAgentStatus) if ( !initialAgentStatus && paneStartup?.telemetry?.launch_source === 'sidebar' && @@ -3617,13 +3636,18 @@ export function connectPanePty( const nextAgentStatusState = nextAgentStatus?.state if (nextAgentStatusState === 'done') { setFocusReportSuppressionForAgentCompletion(undefined, nextAgentStatus.agentType) - if (lastAgentStatusState !== 'done') { - queueAgentIdleTerminalModeReset() - } } else if (nextAgentStatusState) { suppressNativeWindowsIdleCodexFocusReports = false } - lastAgentStatusState = nextAgentStatusState + const nextAgentDoneStartedAt = resolveLatestAgentDoneStartedAt(nextAgentStatus) + // Why: a NEW completed turn — same-state `done` pings keep stateStartedAt, so they no-op. + if ( + nextAgentDoneStartedAt !== undefined && + nextAgentDoneStartedAt !== lastAgentDoneStartedAt + ) { + queueAgentIdleTerminalModeReset() + } + lastAgentDoneStartedAt = nextAgentDoneStartedAt }) } diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 46585b0091c..557663a8a25 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -5,6 +5,7 @@ const mockLaunchAgentBackgroundSession = vi.fn() const mockLaunchWorktreeBackgroundTerminals = vi.fn() const mockFindReusableAutomationSession = vi.fn() const mockObserveExistingAutomationSession = vi.fn() +const mockSubmitPromptToAgentPty = vi.fn() const mockCreateWorktree = vi.fn() const mockMarkDispatchResult = vi.fn() const mockOnDispatchRequested = vi.fn() @@ -14,6 +15,11 @@ const mockReleaseTerminalOwnership = vi.fn() const mockSshNeedsPassphrasePrompt = vi.fn() const mockSshGetState = vi.fn() const mockSshConnect = vi.fn() +let latestStoreSubscriber: (() => void) | null = null +const mockStoreSubscribe = vi.fn((listener: () => void) => { + latestStoreSubscriber = listener + return () => {} +}) const setupLaunch = { runnerScriptPath: '/tmp/setup.sh', @@ -124,7 +130,9 @@ vi.mock('@/lib/launch-worktree-background-terminals', () => ({ launchWorktreeBackgroundTerminals: mockLaunchWorktreeBackgroundTerminals })) -vi.mock('@/lib/agent-paste-draft', () => ({})) +vi.mock('@/lib/agent-paste-draft', () => ({ + submitPromptToAgentPty: mockSubmitPromptToAgentPty +})) vi.mock('@/lib/automation-session-reuse', () => ({ findReusableAutomationSession: mockFindReusableAutomationSession @@ -137,9 +145,20 @@ vi.mock('@/lib/automation-session-observer', () => ({ vi.mock('@/components/automations/automation-run-output-snapshot', () => ({ createAutomationRunOutputSnapshotBuffer: () => ({ append: vi.fn(), - snapshot: () => '' + snapshot: () => null }), - selectAutomationRunOutputSnapshot: () => null + selectAutomationRunOutputSnapshot: ( + assistantMessage: string | null | undefined, + terminalSnapshot: unknown + ) => + assistantMessage + ? { + format: 'plain_text', + content: assistantMessage, + capturedAt: 1, + truncated: false + } + : terminalSnapshot })) vi.mock('@/i18n/i18n', () => ({ @@ -153,7 +172,7 @@ vi.mock('@/lib/browser-uuid', () => ({ vi.mock('@/store', () => ({ useAppStore: { getState: () => state, - subscribe: vi.fn(() => () => {}) + subscribe: mockStoreSubscribe } })) @@ -171,6 +190,7 @@ describe('useAutomationDispatchEvents setup launch', () => { state.projectGroups = [] state.worktreesByRepo = {} state.agentStatusByPaneKey = {} + latestStoreSubscriber = null state.allWorktrees.mockReturnValue([]) state.getKnownWorktreeById.mockReturnValue(undefined) mockCreateWorktree.mockResolvedValue({ worktree: createdWorktree, setup: setupLaunch }) @@ -189,6 +209,7 @@ describe('useAutomationDispatchEvents setup launch', () => { mockSshNeedsPassphrasePrompt.mockResolvedValue(false) mockSshGetState.mockResolvedValue({ status: 'connected' }) mockSshConnect.mockResolvedValue({ status: 'connected' }) + mockSubmitPromptToAgentPty.mockResolvedValue(true) vi.stubGlobal('window', { api: { automations: { @@ -581,6 +602,132 @@ describe('useAutomationDispatchEvents setup launch', () => { await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) }) + it('persists assistant output from a batched working→done→working transition', async () => { + const paneKey = 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d' + + await registerAndDispatch() + const transitionStartedAt = Date.now() + 1 + state.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'second turn', + agentType: 'claude', + updatedAt: transitionStartedAt + 2, + stateStartedAt: transitionStartedAt + 2, + lastCompletedAssistantMessage: 'Summary.\n\nDetails.', + stateHistory: [ + { state: 'working', prompt: 'first turn', startedAt: transitionStartedAt }, + { state: 'done', prompt: 'first turn', startedAt: transitionStartedAt + 1 } + ] + } + } + if (!latestStoreSubscriber) { + throw new Error('agent status observer was not registered') + } + latestStoreSubscriber() + + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'run-1', + status: 'completed', + outputSnapshot: { + format: 'plain_text', + content: 'Summary.\n\nDetails.', + capturedAt: 1, + truncated: false + } + }) + ) + }) + + it('does not let later working authorize an earlier historical done on rescan', async () => { + const paneKey = 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d' + mockFindReusableAutomationSession.mockReturnValue({ + tabId: 'agent-tab', + paneKey, + ptyId: 'agent-pty' + }) + mockObserveExistingAutomationSession.mockResolvedValue(() => {}) + + await registerAndDispatch(makeAutomation({ reuseSession: true })) + const transitionStartedAt = Date.now() + 1 + state.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'new turn', + agentType: 'claude', + updatedAt: transitionStartedAt + 1, + stateStartedAt: transitionStartedAt + 1, + stateHistory: [{ state: 'done', prompt: 'old turn', startedAt: transitionStartedAt }] + } + } + if (!latestStoreSubscriber) { + throw new Error('agent status observer was not registered') + } + + latestStoreSubscriber() + latestStoreSubscriber() + await Promise.resolve() + + expect(mockMarkDispatchResult).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ) + }) + + // Why: transport loss, PTY exit and cap eviction all drop and recreate the live + // entry with an empty stateHistory. The working edge is only in the observer's + // own bookkeeping by then, so it must survive a zero-overlap rescan. + it('completes a reuse-session run when the entry is recreated with no history', async () => { + const paneKey = 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d' + mockFindReusableAutomationSession.mockReturnValue({ + tabId: 'agent-tab', + paneKey, + ptyId: 'agent-pty' + }) + mockObserveExistingAutomationSession.mockResolvedValue(() => {}) + + await registerAndDispatch(makeAutomation({ reuseSession: true })) + const workingStartedAt = Date.now() + 1 + state.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'turn', + agentType: 'claude', + updatedAt: workingStartedAt, + stateStartedAt: workingStartedAt, + stateHistory: [{ state: 'working', prompt: 'turn', startedAt: workingStartedAt }] + } + } + if (!latestStoreSubscriber) { + throw new Error('agent status observer was not registered') + } + latestStoreSubscriber() + await Promise.resolve() + + // The entry is dropped and recreated: same pane, now done, history gone. + state.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'turn', + agentType: 'claude', + updatedAt: workingStartedAt + 2, + stateStartedAt: workingStartedAt + 2, + stateHistory: [] + } + } + latestStoreSubscriber() + await Promise.resolve() + + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ) + }) + it('consumes duplicate done and zero-exit completion through one finalizer', async () => { let launchArgs: { onAgentStatus?: (payload: { state: string }) => void diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index f4ae2067688..a7741ec959e 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -32,6 +32,7 @@ import { } from '../../../shared/execution-host' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' +import type { AgentStateHistoryEntry } from '../../../shared/agent-status-types' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' const activeReuseDispatchTabIds = new Set() @@ -54,6 +55,37 @@ function buildAutomationWorkspaceName(runTitle: string, scheduledFor: number): s return `auto-${slug || 'run'}-${stamp}` } +function agentStateHistoryEntriesEqual( + left: AgentStateHistoryEntry, + right: AgentStateHistoryEntry +): boolean { + return ( + left.state === right.state && + left.prompt === right.prompt && + left.startedAt === right.startedAt && + left.interrupted === right.interrupted + ) +} + +function getAgentStateHistoryOverlap( + previous: AgentStateHistoryEntry[], + current: AgentStateHistoryEntry[] +): number { + for (let overlap = Math.min(previous.length, current.length); overlap > 0; overlap -= 1) { + const previousOffset = previous.length - overlap + if ( + current + .slice(0, overlap) + .every((entry, index) => + agentStateHistoryEntriesEqual(entry, previous[previousOffset + index]) + ) + ) { + return overlap + } + } + return 0 +} + export function useAutomationDispatchEvents(): void { useEffect(() => { const unsubscribe = window.api.automations.onDispatchRequested( @@ -405,12 +437,41 @@ export function useAutomationDispatchEvents(): void { options?: { requireWorkingAfterStart?: boolean } ): void => { let sawWorkingAfterStart = false + let observedStateHistory: AgentStateHistoryEntry[] = [] const checkCurrentStatus = (): void => { const { agentStatusByPaneKey } = useAppStore.getState() for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { if (paneKey !== targetPaneKey || entry.updatedAt < startedAfter) { continue } + const historyOverlap = getAgentStateHistoryOverlap( + observedStateHistory, + entry.stateHistory + ) + // Why: sawWorkingAfterStart stays monotonic — a recreated entry + // (transport loss, PTY exit, cap eviction) arrives with an empty + // stateHistory, so clearing it here would strand reuseSession runs + // with nothing left to re-derive the working edge from. + for (const historicalState of entry.stateHistory.slice(historyOverlap)) { + if (historicalState.startedAt < startedAfter) { + continue + } + if (historicalState.state === 'working') { + sawWorkingAfterStart = true + } + if ( + historicalState.state === 'done' && + (!options?.requireWorkingAfterStart || sawWorkingAfterStart) + ) { + // Why: this `done` already rolled out of the live entry, so its output + // survives only in the entry-level completed slot. + latestAssistantMessage = + entry.lastCompletedAssistantMessage?.trim() || latestAssistantMessage + handleAgentDone() + return + } + } + observedStateHistory = [...entry.stateHistory] if (entry.state === 'working') { sawWorkingAfterStart = true } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 679ac28509c..2e916dd4f89 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -22,11 +22,21 @@ import { makeWorktree, TEST_REPO } from '../store/slices/store-test-helpers' +import type { + AgentStatusBatchTransaction, + AgentStatusBatchUpdate, + AgentStatusUpdate +} from '../store/slices/agent-status' +import type { AppState } from '../store/types' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' -import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' -import type { TuiAgent } from '../../../shared/types' +import type { + AgentStatusClearIpcPayload, + MigrationUnsupportedPtyEntry +} from '../../../shared/agent-status-types' +import type { TerminalPaneLayoutNode, TuiAgent } from '../../../shared/types' import type * as CmdJRowIndexJump from '@/lib/cmd-j-row-index-jump' import { makePaneKey } from '../../../shared/stable-pane-id' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' import { FLOATING_WORKSPACE_GUEST_CLOSE_EVENT, @@ -5000,6 +5010,7 @@ describe('useIpcEvents agent status snapshot integration', () => { terminalHandle?: string launchToken?: string providerSession?: { key: 'session_id'; id: string } + providerSessionOnly?: boolean orchestration?: { taskId?: string dispatchId?: string @@ -5030,11 +5041,72 @@ describe('useIpcEvents agent status snapshot integration', () => { driver: { kind: 'mobile'; clientId: string } }) => void + function applyMockAgentStatusUpdate(state: StoreLike, update: AgentStatusBatchUpdate): boolean { + const statuses = state.agentStatusByPaneKey as Record< + string, + | ({ agentType?: string; state?: string; updatedAt?: number } & Record) + | undefined + > + const existing = statuses[update.paneKey] + const updatedAt = update.timing?.updatedAt + if ( + existing?.updatedAt !== undefined && + updatedAt !== undefined && + updatedAt < existing.updatedAt + ) { + return false + } + const next = { ...statuses } + if (update.kind === 'providerSession') { + delete next[update.paneKey] + } else { + next[update.paneKey] = { + ...existing, + ...update.payload, + updatedAt, + providerSession: update.metadata?.providerSession + } + } + state.agentStatusByPaneKey = next + return true + } + + function installMockAgentStatusTransaction(state: StoreLike): void { + state.transactAgentStatuses = ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ): Result => { + const stagedState = { + ...state, + agentStatusByPaneKey: { + ...(state.agentStatusByPaneKey as Record) + } + } + const attemptedUpdates: AgentStatusBatchUpdate[] = [] + const effects: (() => void)[] = [] + const result = operation({ + getState: () => stagedState as AppState, + apply: (update) => { + attemptedUpdates.push(update) + return applyMockAgentStatusUpdate(stagedState, update) + }, + afterCommit: (effect) => effects.push(effect) + }) + const setStatuses = state.setAgentStatuses as ( + updates: readonly AgentStatusBatchUpdate[] + ) => readonly boolean[] + setStatuses(attemptedUpdates) + for (const effect of effects) { + effect() + } + return result + } + } + function buildStoreState(overrides: StoreLike): StoreLike { // Why: copy the defensive set of getState() fields the hook touches during // mount so individual tests only need to override workspaceSessionReady, // tabsByWorktree, and setAgentStatus. - return { + const state: StoreLike = { setUpdateStatus: vi.fn(), fetchRepos: vi.fn(), fetchWorktrees: vi.fn(), @@ -5064,9 +5136,12 @@ describe('useIpcEvents agent status snapshot integration', () => { removeSshCredentialRequest: vi.fn(), clearTabPtyId: vi.fn(), updateTabTitle: vi.fn(), + updateTabTitles: vi.fn(), runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {}, agentStatusByPaneKey: {}, + setAgentStatuses: vi.fn(() => []), + recordAgentProviderSession: vi.fn(), clearTransientAgentStatuses: vi.fn(), getAgentLaunchConfigForStatusMetadata: vi.fn(() => undefined), recentlyClosedAgentStatusTabIds: {}, @@ -5078,6 +5153,45 @@ describe('useIpcEvents agent status snapshot integration', () => { settings: { terminalFontSize: 13 }, ...overrides } + if (!('updateTabTitles' in overrides)) { + state.updateTabTitles = vi.fn((updates: readonly { tabId: string; title: string }[]) => { + const updateTitle = state.updateTabTitle as AppState['updateTabTitle'] + for (const { tabId, title } of updates) { + updateTitle(tabId, title) + } + }) + } + if (!('setAgentStatuses' in overrides)) { + state.setAgentStatuses = vi.fn((updates: readonly AgentStatusBatchUpdate[]) => + updates.map((update) => { + if (update.kind === 'providerSession') { + const recordProviderSession = + state.recordAgentProviderSession as AppState['recordAgentProviderSession'] + recordProviderSession( + update.paneKey, + update.agent, + update.providerSession, + update.timing, + update.routing, + update.metadata + ) + } else { + const setStatus = state.setAgentStatus as AppState['setAgentStatus'] + setStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + } + return true + }) + ) + } + installMockAgentStatusTransaction(state) + return state } function buildWindowApi(args: { @@ -5087,6 +5201,7 @@ describe('useIpcEvents agent status snapshot integration', () => { cb: (data: { paneKey: string; resolution: 'adopted' | 'exited' }) => void ) => () => void getSnapshot?: () => Promise + getMigrationUnsupportedSnapshot?: () => Promise drop?: (paneKey: string) => void remoteWorkspace?: Record runtime?: Record @@ -5200,6 +5315,9 @@ describe('useIpcEvents agent status snapshot integration', () => { onLegacyWorkerTerminalRecovery: args.onLegacyWorkerTerminalRecovery ?? vi.fn(() => () => {}), getSnapshot: args.getSnapshot ?? vi.fn(() => Promise.resolve([])), + ...(args.getMigrationUnsupportedSnapshot + ? { getMigrationUnsupportedSnapshot: args.getMigrationUnsupportedSnapshot } + : {}), drop: args.drop ?? vi.fn() }, remoteWorkspace: args.remoteWorkspace @@ -6136,6 +6254,7 @@ describe('useIpcEvents agent status snapshot integration', () => { tabsByWorktree: {}, workspaceSessionReady: false }) + const setAgentStatuses = vi.mocked(storeState.setAgentStatuses as AppState['setAgentStatuses']) stubReactSyncEffect() vi.doMock('../store', () => ({ @@ -6201,6 +6320,8 @@ describe('useIpcEvents agent status snapshot integration', () => { await Promise.resolve() expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatuses).toHaveBeenCalledTimes(1) + expect(setAgentStatuses.mock.calls[0]?.[0]).toHaveLength(1) expect(setAgentStatus).toHaveBeenCalledWith( FUTURE_PANE_KEY, expect.objectContaining({ state: 'working', prompt: 'p', agentType: 'claude' }), @@ -6211,18 +6332,582 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('applies a large snapshot with one status and two bounded title publications', async () => { + const paneCount = 100 + const snapshot: AgentStatusSetData[] = [] + const worktrees: AppState['worktreesByRepo'][string] = [] + const tabsByWorktree: AppState['tabsByWorktree'] = {} + const terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] = {} + for (let index = 0; index < paneCount; index += 1) { + const worktreeId = `wt-snapshot-${index}` + const tabId = `tab-snapshot-${index}` + const leafId = `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` + const paneKey = makePaneKey(tabId, leafId) + worktrees.push(makeWorktree({ id: worktreeId, repoId: TEST_REPO.id })) + tabsByWorktree[worktreeId] = [ + makeTab({ id: tabId, worktreeId, title: 'Pi', ptyId: `pty-${index}` }) + ] + terminalLayoutsByTabId[tabId] = { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null + } + snapshot.push({ + paneKey, + worktreeId, + state: 'waiting', + prompt: `remote prompt ${index}`, + agentType: 'pi', + receivedAt: 1_700_000_000_000 + index, + stateStartedAt: 1_700_000_000_000 + index + }) + } + let resolveSnapshot: ((entries: AgentStatusSetData[]) => void) | undefined + const getSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const store = createTestStore() + store.setState({ + workspaceSessionReady: true, + repos: [TEST_REPO], + worktreesByRepo: { [TEST_REPO.id]: worktrees }, + tabsByWorktree, + terminalLayoutsByTabId, + activeWorktreeId: worktrees[0]?.id ?? null, + settings: { + ...store.getState().settings, + tabAutoGenerateTitle: true, + terminalFontSize: 13 + } + } as Partial) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: store.subscribe, + getState: store.getState, + setState: store.setState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + let relevantPublications = 0 + const unsubscribe = store.subscribe((state, previousState) => { + if ( + state.agentStatusByPaneKey !== previousState.agentStatusByPaneKey || + state.tabsByWorktree !== previousState.tabsByWorktree + ) { + relevantPublications += 1 + } + }) + resolveSnapshot?.(snapshot) + + await vi.waitFor(() => { + expect(Object.keys(store.getState().agentStatusByPaneKey)).toHaveLength(paneCount) + expect(store.getState().tabsByWorktree['wt-snapshot-99']?.[0]?.title).toBe( + 'Pi - action required' + ) + expect(store.getState().tabsByWorktree['wt-snapshot-99']?.[0]?.generatedTitle).toBeTruthy() + }) + + expect(relevantPublications).toBe(3) + unsubscribe() + }) + + it('indexes one 100-pane tab and its split tree once per snapshot', async () => { + const paneCount = 100 + const tabId = 'tab-indexed-snapshot' + const worktreeId = 'wt-indexed-snapshot' + const leafIds = Array.from( + { length: paneCount }, + (_, index) => `00000000-0000-4000-8001-${String(index).padStart(12, '0')}` + ) + let leafIdLookupCount = 0 + const leaves = leafIds.map((leafId) => { + const leaf = { type: 'leaf' } as TerminalPaneLayoutNode + Object.defineProperty(leaf, 'leafId', { + enumerable: true, + get: () => { + leafIdLookupCount += 1 + return leafId + } + }) + return leaf + }) + const root = leaves + .slice(1) + .reduce( + (tree, leaf) => ({ type: 'split', direction: 'vertical', first: tree, second: leaf }), + leaves[0]! + ) + let tabIdLookupCount = 0 + const tab = makeTab({ id: tabId, worktreeId, title: 'Workspace', ptyId: 'pty-indexed' }) + Object.defineProperty(tab, 'id', { + enumerable: true, + get: () => { + tabIdLookupCount += 1 + return tabId + } + }) + const snapshot = leafIds.map( + (leafId, index): AgentStatusSetData => ({ + paneKey: makePaneKey(tabId, leafId), + worktreeId, + state: 'working', + prompt: `indexed prompt ${index}`, + agentType: 'claude', + receivedAt: 1_700_000_000_000 + index, + stateStartedAt: 1_700_000_000_000 + index + }) + ) + let resolveSnapshot!: (entries: AgentStatusSetData[]) => void + const getSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + const store = createTestStore() + store.setState({ + workspaceSessionReady: true, + repos: [TEST_REPO], + worktreesByRepo: { + [TEST_REPO.id]: [makeWorktree({ id: worktreeId, repoId: TEST_REPO.id })] + }, + tabsByWorktree: { [worktreeId]: [tab] }, + terminalLayoutsByTabId: { + [tabId]: { + root, + activeLeafId: leafIds[0]!, + expandedLeafId: null + } + }, + setGeneratedTabTitlesFromAgentPrompts: vi.fn(), + settings: { ...store.getState().settings, terminalFontSize: 13 } + } as Partial) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: store.subscribe, + getState: store.getState, + setState: store.setState + } + })) + stubAuxiliaryModules() + vi.stubGlobal('window', buildWindowApi({ getSnapshot, onSet: () => () => {} })) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + tabIdLookupCount = 0 + leafIdLookupCount = 0 + resolveSnapshot(snapshot) + + await vi.waitFor(() => { + expect(Object.keys(store.getState().agentStatusByPaneKey)).toHaveLength(paneCount) + }) + expect(tabIdLookupCount).toBe(1) + expect(leafIdLookupCount).toBe(paneCount) + }) + + it('indexes 100 distinct tab owners once per snapshot', async () => { + const paneCount = 100 + const worktreeId = 'wt-indexed-tabs' + const tabs: AppState['tabsByWorktree'][string] = [] + const snapshot: AgentStatusSetData[] = [] + const unsupportedSnapshot: MigrationUnsupportedPtyEntry[] = [] + let tabIdLookupCount = 0 + for (let index = 0; index < paneCount; index += 1) { + const tabId = `tab-indexed-${index}` + const leafId = `00000000-0000-4000-8003-${String(index).padStart(12, '0')}` + const tab = makeTab({ + id: tabId, + worktreeId, + title: 'Workspace', + ptyId: `pty-indexed-${index}` + }) + Object.defineProperty(tab, 'id', { + enumerable: true, + get: () => { + tabIdLookupCount += 1 + return tabId + } + }) + tabs.push(tab) + snapshot.push({ + paneKey: makePaneKey(tabId, leafId), + worktreeId, + state: 'working', + prompt: `indexed tab prompt ${index}`, + agentType: 'claude', + receivedAt: 1_700_000_000_500 + index, + stateStartedAt: 1_700_000_000_500 + index + }) + unsupportedSnapshot.push({ + ptyId: `pty-indexed-${index}`, + worktreeId, + tabId, + leafId, + paneKey: makePaneKey(tabId, leafId), + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1_700_000_000_500 + index + }) + } + let resolveSnapshot!: (entries: AgentStatusSetData[]) => void + const getSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + let resolveUnsupportedSnapshot!: (entries: MigrationUnsupportedPtyEntry[]) => void + const getMigrationUnsupportedSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveUnsupportedSnapshot = resolve + }) + ) + const store = createTestStore() + store.setState({ + workspaceSessionReady: true, + repos: [TEST_REPO], + worktreesByRepo: { + [TEST_REPO.id]: [makeWorktree({ id: worktreeId, repoId: TEST_REPO.id })] + }, + tabsByWorktree: { [worktreeId]: tabs }, + terminalLayoutsByTabId: {}, + setGeneratedTabTitlesFromAgentPrompts: vi.fn(), + settings: { + ...store.getState().settings, + terminalFontSize: 13, + tabAutoGenerateTitle: false + } + } as Partial) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: store.subscribe, + getState: store.getState, + setState: store.setState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + getMigrationUnsupportedSnapshot, + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + tabIdLookupCount = 0 + resolveSnapshot(snapshot) + + await vi.waitFor(() => { + expect(Object.keys(store.getState().agentStatusByPaneKey)).toHaveLength(paneCount) + }) + expect(tabIdLookupCount).toBe(paneCount) + expect(getMigrationUnsupportedSnapshot).toHaveBeenCalledTimes(1) + + tabIdLookupCount = 0 + resolveUnsupportedSnapshot(unsupportedSnapshot) + await vi.waitFor(() => { + expect(Object.keys(store.getState().migrationUnsupportedByPtyId)).toHaveLength(paneCount) + }) + expect(tabIdLookupCount).toBe(paneCount) + }) + + it('preserves inactive, SSH, and folder routing parity in one batch', async () => { + type RoutingCase = { + tabId: string + leafId: string + ownerId: string + payloadWorktreeId: string + connectionId?: string + rootless?: boolean + expected: boolean + } + const folderId = 'folder-routing' + const folderKey = folderWorkspaceKey(folderId) + const cases: RoutingCase[] = [ + { + tabId: 'tab-routing-inactive', + leafId: '00000000-0000-4000-8002-000000000001', + ownerId: 'wt-routing-local', + payloadWorktreeId: 'wt-routing-local', + rootless: true, + expected: true + }, + { + tabId: 'tab-routing-ssh-match', + leafId: '00000000-0000-4000-8002-000000000002', + ownerId: 'wt-routing-ssh', + payloadWorktreeId: 'wt-routing-ssh', + connectionId: 'ssh-live', + expected: true + }, + { + tabId: 'tab-routing-ssh-mismatch', + leafId: '00000000-0000-4000-8002-000000000003', + ownerId: 'wt-routing-ssh', + payloadWorktreeId: 'wt-routing-ssh', + connectionId: 'ssh-stale', + expected: false + }, + { + tabId: 'tab-routing-hydrating', + leafId: '00000000-0000-4000-8002-000000000004', + ownerId: 'wt-routing-hydrating', + payloadWorktreeId: 'wt-routing-hydrating', + connectionId: 'ssh-hydrating', + expected: true + }, + { + tabId: 'tab-routing-hydrating-mismatch', + leafId: '00000000-0000-4000-8002-000000000005', + ownerId: 'wt-routing-hydrating', + payloadWorktreeId: 'wt-routing-other', + connectionId: 'ssh-hydrating', + expected: false + }, + { + tabId: 'tab-routing-folder', + leafId: '00000000-0000-4000-8002-000000000006', + ownerId: folderKey, + payloadWorktreeId: folderKey, + connectionId: 'ssh-folder-stale', + expected: true + } + ] + const tabsByWorktree: AppState['tabsByWorktree'] = {} + const terminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] = {} + for (const entry of cases) { + ;(tabsByWorktree[entry.ownerId] ??= []).push( + makeTab({ + id: entry.tabId, + worktreeId: entry.ownerId, + title: 'Workspace', + ptyId: `pty-${entry.tabId}` + }) + ) + terminalLayoutsByTabId[entry.tabId] = { + root: entry.rootless ? null : { type: 'leaf', leafId: entry.leafId }, + activeLeafId: entry.rootless ? null : entry.leafId, + expandedLeafId: null + } + } + const snapshot = cases.map( + (entry, index): AgentStatusSetData => ({ + paneKey: makePaneKey(entry.tabId, entry.leafId), + worktreeId: entry.payloadWorktreeId, + ...(entry.connectionId ? { connectionId: entry.connectionId } : {}), + state: 'working', + prompt: `routing case ${index}`, + agentType: 'claude', + receivedAt: 1_700_000_001_000 + index, + stateStartedAt: 1_700_000_001_000 + index + }) + ) + const localRepo = { ...TEST_REPO, id: 'repo-routing-local', connectionId: null } + const sshRepo = { ...TEST_REPO, id: 'repo-routing-ssh', connectionId: 'ssh-live' } + const store = createTestStore() + store.setState({ + workspaceSessionReady: true, + repos: [localRepo, sshRepo], + worktreesByRepo: { + [localRepo.id]: [makeWorktree({ id: 'wt-routing-local', repoId: localRepo.id })], + [sshRepo.id]: [makeWorktree({ id: 'wt-routing-ssh', repoId: sshRepo.id })] + }, + folderWorkspaces: [ + { + id: folderId, + projectGroupId: 'group-routing', + name: 'Folder routing', + folderPath: '/folder-routing', + connectionId: 'ssh-folder-live', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + ], + tabsByWorktree, + terminalLayoutsByTabId, + activeWorktreeId: null, + settings: { ...store.getState().settings, terminalFontSize: 13 } + } as Partial) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: store.subscribe, + getState: store.getState, + setState: store.setState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ getSnapshot: vi.fn().mockResolvedValue(snapshot), onSet: () => () => {} }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + await vi.waitFor(() => { + expect( + cases + .filter((entry) => entry.expected) + .every( + (entry) => store.getState().agentStatusByPaneKey[makePaneKey(entry.tabId, entry.leafId)] + ) + ).toBe(true) + }) + for (const entry of cases) { + expect( + store.getState().agentStatusByPaneKey[makePaneKey(entry.tabId, entry.leafId)] !== undefined + ).toBe(entry.expected) + } + }) + + it('projects ordered tab titles across panes in an inactive split snapshot', async () => { + const tabId = 'tab-inactive-split' + const worktreeId = 'wt-inactive-split' + const waitingPaneKey = makePaneKey(tabId, '00000000-0000-4000-8000-000000000001') + const donePaneKey = makePaneKey(tabId, '00000000-0000-4000-8000-000000000002') + const getSnapshot = vi.fn().mockResolvedValue([ + { + paneKey: waitingPaneKey, + worktreeId, + state: 'waiting', + prompt: 'waiting turn', + agentType: 'pi', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_700_000_000_000 + }, + { + paneKey: donePaneKey, + worktreeId, + state: 'done', + prompt: 'completed turn', + agentType: 'pi', + receivedAt: 1_700_000_000_001, + stateStartedAt: 1_700_000_000_001 + } + ] satisfies AgentStatusSetData[]) + const store = createTestStore() + store.setState({ + workspaceSessionReady: true, + repos: [TEST_REPO], + worktreesByRepo: { + [TEST_REPO.id]: [makeWorktree({ id: worktreeId, repoId: TEST_REPO.id })] + }, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: tabId, worktreeId, title: 'My Project' })] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: null, + activeLeafId: null, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + }, + activeWorktreeId: null, + settings: { ...store.getState().settings, terminalFontSize: 13 } + } as Partial) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: store.subscribe, + getState: store.getState, + setState: store.setState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + await vi.waitFor(() => { + expect(store.getState().agentStatusByPaneKey[waitingPaneKey]?.state).toBe('waiting') + expect(store.getState().agentStatusByPaneKey[donePaneKey]?.state).toBe('done') + expect(store.getState().tabsByWorktree[worktreeId]?.[0]?.title).toBe('Pi ready') + }) + }) + it('applies a burst leading-edge first, then coalesces the rest into one deferred batch', async () => { // Why: each live status event is its own IPC task, so N events used to pay // N full render passes (STA-3328 mechanism 2). The leading event must stay // synchronous (zero added latency); followers within the burst window must // apply together on one later task, in arrival order. vi.useFakeTimers() - const setAgentStatus = vi.fn() + let storeState: StoreLike + let publicationCount = 0 + const applyStatus = (paneKey: string, payload: unknown): void => { + storeState.agentStatusByPaneKey = { + ...(storeState.agentStatusByPaneKey as Record), + [paneKey]: payload + } + } + const setAgentStatus = vi.fn((paneKey: string, payload: unknown) => { + applyStatus(paneKey, payload) + publicationCount += 1 + }) + const setAgentStatuses = vi.fn((updates: readonly AgentStatusBatchUpdate[]) => { + for (const update of updates) { + if (update.kind === 'providerSession') { + const next = { ...(storeState.agentStatusByPaneKey as Record) } + delete next[update.paneKey] + storeState.agentStatusByPaneKey = next + } else { + applyStatus(update.paneKey, update.payload) + } + } + publicationCount += 1 + return updates.map(() => true) + }) + const recordAgentProviderSession = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { current: null } - const storeState: StoreLike = buildStoreState({ + storeState = buildStoreState({ setAgentStatus, + setAgentStatuses, + recordAgentProviderSession, workspaceSessionReady: true, tabsByWorktree: { 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] @@ -6235,7 +6920,6 @@ describe('useIpcEvents agent status snapshot integration', () => { } } }) - stubReactSyncEffect() vi.doMock('../store', () => ({ useAppStore: { @@ -6273,15 +6957,275 @@ describe('useIpcEvents agent status snapshot integration', () => { emit(1_700_000_000_000, 'first') expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(publicationCount).toBe(1) emit(1_700_000_000_001, 'second') emit(1_700_000_000_002, 'third') + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'provider identity', + agentType: 'pi', + providerSession: { key: 'session_id', id: 'pi-session' }, + providerSessionOnly: true, + receivedAt: 1_700_000_000_003, + stateStartedAt: 1_700_000_000_003 + }) expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatuses).not.toHaveBeenCalled() vi.advanceTimersByTime(40) - expect(setAgentStatus).toHaveBeenCalledTimes(3) - expect(setAgentStatus.mock.calls[1][1]).toEqual(expect.objectContaining({ prompt: 'second' })) - expect(setAgentStatus.mock.calls[2][1]).toEqual(expect.objectContaining({ prompt: 'third' })) + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatuses).toHaveBeenCalledTimes(1) + expect(setAgentStatuses.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + paneKey: FUTURE_PANE_KEY, + payload: expect.objectContaining({ prompt: 'second' }) + }), + expect.objectContaining({ + paneKey: FUTURE_PANE_KEY, + payload: expect.objectContaining({ prompt: 'third' }) + }), + expect.objectContaining({ + kind: 'providerSession', + paneKey: FUTURE_PANE_KEY, + agent: 'pi', + providerSession: { key: 'session_id', id: 'pi-session' } + }) + ]) + expect(recordAgentProviderSession).not.toHaveBeenCalled() + expect(publicationCount).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + it('keeps a live event enqueued by a synchronous batch subscriber for the next flush', async () => { + vi.useFakeTimers() + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + let reentered = false + const setAgentStatuses = vi.fn((updates: readonly AgentStatusUpdate[]) => { + if (!reentered) { + reentered = true + onSetListenerRef.current?.({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'reentered', + agentType: 'claude', + receivedAt: 1_700_000_000_002, + stateStartedAt: 1_700_000_000_002 + }) + } + return updates.map(() => true) + }) + const storeState = buildStoreState({ + setAgentStatus, + setAgentStatuses, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (callback) => { + onSetListenerRef.current = callback + return () => {} + } + }) + ) + + try { + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + if (!onSetListenerRef.current) { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'leading', + agentType: 'claude', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_700_000_000_000 + }) + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'queued', + agentType: 'claude', + receivedAt: 1_700_000_000_001, + stateStartedAt: 1_700_000_000_001 + }) + + vi.advanceTimersByTime(40) + expect(setAgentStatuses).toHaveBeenCalledTimes(1) + expect(setAgentStatuses.mock.calls[0][0][0].payload.prompt).toBe('queued') + + vi.advanceTimersByTime(40) + expect(setAgentStatuses).toHaveBeenCalledTimes(2) + expect(setAgentStatuses.mock.calls[1][0][0].payload.prompt).toBe('reentered') + } finally { + vi.useRealTimers() + } + }) + + it('projects synthetic pane titles across ordered same-pane batch updates', async () => { + vi.useFakeTimers() + let storeState: StoreLike + const applyStatusUpdate = (update: AgentStatusUpdate): void => { + storeState.agentStatusByPaneKey = { + ...(storeState.agentStatusByPaneKey as Record), + [update.paneKey]: { + ...update.payload, + updatedAt: update.timing?.updatedAt, + providerSession: update.metadata?.providerSession + } + } + } + const setAgentStatus = vi.fn( + ( + paneKey: string, + payload: AgentStatusUpdate['payload'], + terminalTitle?: string, + timing?: AgentStatusUpdate['timing'], + routing?: AgentStatusUpdate['routing'], + metadata?: AgentStatusUpdate['metadata'] + ) => applyStatusUpdate({ paneKey, payload, terminalTitle, timing, routing, metadata }) + ) + const setAgentStatuses = vi.fn((updates: readonly AgentStatusUpdate[]) => { + return updates.map((update) => { + const existing = ( + storeState.agentStatusByPaneKey as Record + )[update.paneKey] + if ( + existing?.updatedAt !== undefined && + update.timing?.updatedAt !== undefined && + update.timing.updatedAt < existing.updatedAt + ) { + return false + } + applyStatusUpdate(update) + return true + }) + }) + const updateTabTitle = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + storeState = buildStoreState({ + setAgentStatus, + setAgentStatuses, + updateTabTitle, + workspaceSessionReady: true, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + const updateTabTitles = vi.mocked(storeState.updateTabTitles as AppState['updateTabTitles']) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (callback) => { + onSetListenerRef.current = callback + return () => {} + } + }) + ) + + try { + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + const onSet = onSetListenerRef.current + if (!onSet) { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + const emit = ( + receivedAt: number, + state: AgentStatusSetData['state'], + agentType?: string + ): void => { + onSet({ + paneKey: FUTURE_PANE_KEY, + state, + prompt: state, + agentType, + receivedAt, + stateStartedAt: receivedAt + }) + } + + emit(1_700_000_000_000, 'working', 'pi') + emit(1_700_000_000_001, 'waiting', 'pi') + emit(1_700_000_000_002, 'waiting', 'pi') + emit(1_700_000_000_003, 'done', 'pi') + emit(1_700_000_000_002, 'waiting', 'pi') + vi.advanceTimersByTime(40) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatuses).toHaveBeenCalledTimes(1) + expect(setAgentStatuses.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ agentType: 'pi', state: 'waiting' }), + terminalTitle: 'Pi - action required' + }), + expect.objectContaining({ + payload: expect.objectContaining({ agentType: 'pi', state: 'waiting' }), + terminalTitle: 'Pi - action required' + }), + expect.objectContaining({ + payload: expect.objectContaining({ agentType: 'pi', state: 'done' }), + terminalTitle: 'Pi ready' + }) + ]) + expect(setAgentStatuses.mock.results[0].value).toEqual([true, true, true]) + expect(updateTabTitle).toHaveBeenCalledOnce() + expect(updateTabTitle).toHaveBeenCalledWith('tab-future', 'Pi ready') + expect(updateTabTitles).toHaveBeenCalledTimes(1) + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(4) } finally { vi.useRealTimers() } @@ -6290,19 +7234,32 @@ describe('useIpcEvents agent status snapshot integration', () => { it('preserves queued set-clear order for working removal and done retention', async () => { vi.useFakeTimers() let storeState: StoreLike + const applyStatusUpdate = ( + paneKey: string, + payload: { state: string }, + timing: { updatedAt?: number } | undefined + ): void => { + storeState.agentStatusByPaneKey = { + ...(storeState.agentStatusByPaneKey as Record), + [paneKey]: { ...payload, updatedAt: timing?.updatedAt } + } + } const setAgentStatus = vi.fn( ( paneKey: string, payload: { state: string }, _title: unknown, - timing: { updatedAt: number } + timing: { updatedAt?: number } | undefined ) => { - storeState.agentStatusByPaneKey = { - ...(storeState.agentStatusByPaneKey as Record), - [paneKey]: { ...payload, updatedAt: timing.updatedAt } - } + applyStatusUpdate(paneKey, payload, timing) } ) + const setAgentStatuses = vi.fn((updates: readonly AgentStatusUpdate[]) => { + for (const update of updates) { + applyStatusUpdate(update.paneKey, update.payload, update.timing) + } + return updates.map(() => true) + }) const removeAgentStatus = vi.fn((paneKey: string) => { const next = { ...(storeState.agentStatusByPaneKey as Record) } delete next[paneKey] @@ -6316,6 +7273,7 @@ describe('useIpcEvents agent status snapshot integration', () => { } storeState = buildStoreState({ setAgentStatus, + setAgentStatuses, removeAgentStatus, workspaceSessionReady: true, tabsByWorktree: { @@ -6385,23 +7343,28 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(storeState.agentStatusByPaneKey).toEqual({}) vi.advanceTimersByTime(40) - expect(setAgentStatus).toHaveBeenCalledTimes(2) + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatuses).toHaveBeenCalledTimes(1) expect(storeState.agentStatusByPaneKey).toEqual({}) emit(1_700_000_000_002, 'task') emit(1_700_000_000_003, 'task', 'done') - expect(setAgentStatus).toHaveBeenCalledTimes(3) + expect(setAgentStatus).toHaveBeenCalledTimes(2) onClearListenerRef.current({ paneKey: FUTURE_PANE_KEY }) - expect(setAgentStatus).toHaveBeenCalledTimes(4) - expect(setAgentStatus.mock.calls[3][1]).toEqual(expect.objectContaining({ state: 'done' })) + expect(setAgentStatus).toHaveBeenCalledTimes(2) + expect(setAgentStatuses).toHaveBeenCalledTimes(2) + expect(setAgentStatuses.mock.calls[1][0][0]).toEqual( + expect.objectContaining({ payload: expect.objectContaining({ state: 'done' }) }) + ) expect(removeAgentStatus).toHaveBeenCalledTimes(1) expect(storeState.agentStatusByPaneKey).toEqual({ [FUTURE_PANE_KEY]: expect.objectContaining({ state: 'done' }) }) vi.advanceTimersByTime(40) - expect(setAgentStatus).toHaveBeenCalledTimes(4) + expect(setAgentStatus).toHaveBeenCalledTimes(2) + expect(setAgentStatuses).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } @@ -6505,6 +7468,93 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatusCalls).toBe(1) }) + // Why: the pending queue is spliced before the fold, so a throwing fold would drop every + // buffered event permanently. Before batching the queue was only replaced after the loop, + // so a throw left it intact — keep that. + it('keeps pending statuses queued when the retry fold throws', async () => { + const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + let shouldThrow = true + const setAgentStatuses = vi.fn((updates: readonly AgentStatusUpdate[]) => { + if (shouldThrow) { + shouldThrow = false + throw new Error('fold blew up') + } + return updates.map(() => true) + }) + const storeState: StoreLike = buildStoreState({ + setAgentStatus: vi.fn(), + setAgentStatuses, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: false } }, + // Pane does not exist yet -> the incoming event is buffered as pending. + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + const notify = (): void => subscribeListenerRef.current?.(storeState, storeState) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn((listener: StoreSubscribeListener) => { + subscribeListenerRef.current = listener + return () => { + subscribeListenerRef.current = null + } + }), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + await Promise.resolve() + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + state: 'working', + prompt: 'buffered', + agentType: 'claude', + receivedAt: 1_700_000_000_100, + stateStartedAt: 1_700_000_000_100 + }) + + // Tab hydrates, so the next store update flushes the pending event — and throws. + storeState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Future Tab' }] + } + storeState.terminalLayoutsByTabId = { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + expect(() => notify()).toThrow('fold blew up') + + // The event must still be queued, so the next flush replays it. + notify() + const replayedAfterThrow = setAgentStatuses.mock.calls + .slice(1) + .flatMap((call) => call[0].map((update) => update.payload.prompt)) + expect(replayedAfterThrow).toContain('buffered') + }) + it('applies ready push events for an unmounted inactive terminal tab', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { @@ -7728,6 +8778,9 @@ describe('useIpcEvents agent status snapshot integration', () => { } } }) + const setAgentStatusBatch = vi.mocked( + storeState.setAgentStatuses as AppState['setAgentStatuses'] + ) stubReactSyncEffect() vi.doMock('../store', () => ({ @@ -7799,6 +8852,8 @@ describe('useIpcEvents agent status snapshot integration', () => { subscribeListenerRef.current(storeState, previousStoreState) expect(setAgentStatus).toHaveBeenCalledTimes(2) + expect(setAgentStatusBatch).toHaveBeenCalledTimes(1) + expect(setAgentStatusBatch.mock.calls[0]?.[0]).toHaveLength(2) expect(setAgentStatus).toHaveBeenNthCalledWith( 1, FUTURE_PANE_KEY, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 22a27cd6da7..f204331ad2e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -153,9 +153,15 @@ import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pan import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { titleHasAgentName } from '../../../shared/agent-detection' +import { isDecorativeAgentTitleFrameChange } from '../../../shared/agent-decorative-title-signature' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' +import type { + AgentStatusBatchTransaction, + AgentStatusBatchUpdate, + AgentStatusUpdate +} from '@/store/slices/agent-status' import { translate } from '@/i18n/i18n' import { redactKagiSessionToken } from '../../../shared/browser-url' import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' @@ -761,6 +767,27 @@ export function useIpcEvents(): void { replay: boolean } type AgentStatusApplyResult = 'applied' | 'pending' | 'dropped' + type ProjectedAgentTabTitles = { + title: string | undefined + identityTitle: string | undefined + } + type AgentStatusBatchContext = { + transaction: AgentStatusBatchTransaction + routingIndex: AgentStatusPaneRoutingIndex + projectedTitlesByTabId: Map + tabTitlesByTabId: Map + notificationEffects: (() => void)[] + } + type AgentStatusBatchEvent = { + data: AgentStatusIpcPayload + replay?: boolean + retry?: boolean + } + type AgentStatusApplyOptions = { + replay?: boolean + retry?: boolean + batch?: AgentStatusBatchContext + } const pendingAgentStatusEvents: PendingAgentStatusEvent[] = [] const transientClearWatermarkByConnectionId = new Map() let agentStatusEffectDisposed = false @@ -3061,18 +3088,25 @@ export function useIpcEvents(): void { isFlushingAgentStatuses = true try { const now = Date.now() - const remaining: PendingAgentStatusEvent[] = [] - for (const event of pendingAgentStatusEvents) { - if (now - event.firstSeenAt > PENDING_AGENT_STATUS_TTL_MS) { - continue - } - const result = applyAgentStatus(event.data, { retry: true, replay: event.replay }) - if (result === 'pending') { - remaining.push(event) + const candidates = pendingAgentStatusEvents + .splice(0) + .filter((event) => now - event.firstSeenAt <= PENDING_AGENT_STATUS_TTL_MS) + let results: AgentStatusApplyResult[] + try { + results = applyAgentStatusBatch( + candidates.map((event) => ({ data: event.data, replay: event.replay, retry: true })) + ) + } catch (err) { + // Why: the queue was already spliced, so a throwing fold would drop the whole + // burst and strand every pane in it. Requeue ahead of newer arrivals and retry. + pendingAgentStatusEvents.unshift(...candidates) + throw err + } + for (let index = 0; index < candidates.length; index += 1) { + if (results[index] === 'pending') { + pendingAgentStatusEvents.push(candidates[index]) } } - pendingAgentStatusEvents.length = 0 - pendingAgentStatusEvents.push(...remaining) if (pendingAgentStatusEvents.length === 0 && pendingAgentStatusRetryTimer !== null) { globalThis.clearTimeout(pendingAgentStatusRetryTimer) pendingAgentStatusRetryTimer = null @@ -3085,9 +3119,9 @@ export function useIpcEvents(): void { const applyAgentStatus = ( data: AgentStatusIpcPayload, - options?: { replay?: boolean; retry?: boolean } + options?: AgentStatusApplyOptions ): AgentStatusApplyResult => { - const store = useAppStore.getState() + const store = options?.batch?.transaction.getState() ?? useAppStore.getState() if (!store.workspaceSessionReady) { return 'dropped' } @@ -3120,12 +3154,25 @@ export function useIpcEvents(): void { identityTitle, repoConnectionId, repoConnectionResolved, - owningWorktreeId - } = resolvePaneKey(store, paneKey) + owningWorktreeId, + titleUsesTabTitle + } = options?.batch + ? resolvePaneKeyFromRoutingIndex(options.batch.routingIndex, paneKey) + : resolvePaneKey(store, paneKey) + const projectedTitles = + titleUsesTabTitle && ownerTabId + ? options?.batch?.projectedTitlesByTabId.get(ownerTabId) + : undefined + if (projectedTitles) { + title = projectedTitles.title + identityTitle = projectedTitles.identityTitle + } if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { // Why: orchestration worker hooks may carry worktree attribution before this renderer has a tab for the pane. // Require runtime identity too — worktreeId-only snapshots can be stale rows from closed/remounted panes. - const fallbackOwnership = resolveWorktreeConnection(store, data.worktreeId) + const fallbackOwnership = options?.batch + ? resolveWorktreeConnectionFromRoutingIndex(options.batch.routingIndex, data.worktreeId) + : resolveWorktreeConnection(store, data.worktreeId) if (fallbackOwnership.worktreeExists) { owningWorktreeId = data.worktreeId repoConnectionId = fallbackOwnership.repoConnectionId @@ -3163,7 +3210,7 @@ export function useIpcEvents(): void { } } } - // Why: drop in-flight events stamped with a dead connection's id after SSH disconnect/reconnect — see docs/design/agent-status-over-ssh.md §5. + // Why: drop in-flight events stamped with a dead connection's id after SSH disconnect/reconnect. // Why: startup snapshot replay can beat SSH repo hydration; accept when worktreeId matches the tab until repo ownership resolves. // Why: WSL relay stamps a `wsl:` connectionId but the pane is a local repo (ownership null); normalize so the strict check below doesn't drop it. const ownershipConnectionId = isWslHookRelayConnectionId(data.connectionId) @@ -3199,18 +3246,30 @@ export function useIpcEvents(): void { if (!data.providerSession || data.agentType !== 'pi') { return 'dropped' } - store.recordAgentProviderSession( + const providerSessionUpdate: AgentStatusBatchUpdate = { + kind: 'providerSession', paneKey, - 'pi', - data.providerSession, - { updatedAt: data.receivedAt }, - { + agent: 'pi', + providerSession: data.providerSession, + timing: { updatedAt: data.receivedAt }, + routing: { tabId: ownerTabId, worktreeId: data.worktreeId ?? owningWorktreeId, // Why: persist the WSL-normalized ownership id, not raw relay provenance; a `wsl:*` connectionId would misroute later resumes. ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) }, - data.launchToken ? { launchToken: data.launchToken } : undefined + metadata: data.launchToken ? { launchToken: data.launchToken } : undefined + } + if (options?.batch) { + return options.batch.transaction.apply(providerSessionUpdate) ? 'applied' : 'dropped' + } + store.recordAgentProviderSession( + providerSessionUpdate.paneKey, + providerSessionUpdate.agent, + providerSessionUpdate.providerSession, + providerSessionUpdate.timing, + providerSessionUpdate.routing, + providerSessionUpdate.metadata ) return 'applied' } @@ -3263,39 +3322,75 @@ export function useIpcEvents(): void { } const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) const statusWorktreeId = data.worktreeId ?? owningWorktreeId - store.setAgentStatus( + const update: AgentStatusUpdate = { paneKey, - statusPayloadWithProvenance, + payload: statusPayloadWithProvenance, terminalTitle, - { + timing: { updatedAt: data.receivedAt, stateStartedAt: data.stateStartedAt }, - { + routing: { tabId: ownerTabId, worktreeId: statusWorktreeId, terminalHandle: data.terminalHandle, ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) }, - data.providerSession || data.launchToken - ? { - ...(data.providerSession ? { providerSession: data.providerSession } : {}), - ...(data.launchToken ? { launchToken: data.launchToken } : {}) + metadata: + data.providerSession || data.launchToken + ? { + ...(data.providerSession ? { providerSession: data.providerSession } : {}), + ...(data.launchToken ? { launchToken: data.launchToken } : {}) + } + : undefined + } + const applyPostCommitNotification = (): void => { + if (options?.replay !== true && statusWorktreeId) { + // Why: local Codex/Claude hooks arrive via this main-process IPC path, not the PTY OSC fallback, so task-complete notifications must observe accepted hook state here too. + const notificationPayload = + typeof data.stateStartedAt === 'number' + ? { ...resolvedPayload, stateStartedAt: data.stateStartedAt } + : resolvedPayload + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: statusWorktreeId, + payload: notificationPayload + }) + } + } + if (options?.batch) { + if (!options.batch.transaction.apply(update)) { + return 'dropped' + } + options.batch.notificationEffects.push(applyPostCommitNotification) + if ( + terminalTitle && + shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) + ) { + const tabId = parsePaneKey(paneKey)?.tabId + if (tabId) { + options.batch.tabTitlesByTabId.set(tabId, terminalTitle) + if (titleUsesTabTitle) { + const titleChanges = + !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle) + options.batch.projectedTitlesByTabId.set(tabId, { + title: titleChanges ? terminalTitle : title, + identityTitle: titleChanges ? terminalTitle : identityTitle + }) } - : undefined - ) - applyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) - if (options?.replay !== true && statusWorktreeId) { - // Why: local Codex/Claude hooks arrive via this main-process IPC path, not the PTY OSC fallback, so task-complete notifications must observe accepted hook state here too. - const notificationPayload = - typeof data.stateStartedAt === 'number' - ? { ...resolvedPayload, stateStartedAt: data.stateStartedAt } - : resolvedPayload - observeAgentHookCompletionForNotification({ - paneKey, - worktreeId: statusWorktreeId, - payload: notificationPayload - }) + } + } + } else { + store.setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle) + applyPostCommitNotification() } return 'applied' } @@ -3326,9 +3421,7 @@ export function useIpcEvents(): void { if (!current.workspaceSessionReady) { return } - for (const entry of entries) { - applyAgentStatus(entry, { replay: true }) - } + applyAgentStatusBatch(entries.map((data) => ({ data, replay: true }))) const getMigrationUnsupportedSnapshot = window.api.agentStatus.getMigrationUnsupportedSnapshot if (typeof getMigrationUnsupportedSnapshot !== 'function') { @@ -3342,8 +3435,12 @@ export function useIpcEvents(): void { if (!unsupportedStore.workspaceSessionReady) { return } + const unsupportedRoutingIndex = createAgentStatusPaneRoutingIndex(unsupportedStore) for (const entry of unsupportedEntries) { - if (entry.paneKey && resolvePaneKey(unsupportedStore, entry.paneKey).exists) { + if ( + entry.paneKey && + resolvePaneKeyFromRoutingIndex(unsupportedRoutingIndex, entry.paneKey).exists + ) { unsupportedStore.setMigrationUnsupportedPty(entry) } } @@ -3355,19 +3452,51 @@ export function useIpcEvents(): void { }) } + function applyAgentStatusBatch( + events: readonly AgentStatusBatchEvent[] + ): AgentStatusApplyResult[] { + if (events.length === 0) { + return [] + } + return useAppStore.getState().transactAgentStatuses((transaction) => { + const batch: AgentStatusBatchContext = { + transaction, + routingIndex: createAgentStatusPaneRoutingIndex(transaction.getState()), + projectedTitlesByTabId: new Map(), + tabTitlesByTabId: new Map(), + notificationEffects: [] + } + const results = events.map(({ data, replay, retry }) => + applyAgentStatus(data, { batch, replay, retry }) + ) + if (batch.tabTitlesByTabId.size > 0) { + transaction.afterCommit(() => { + useAppStore + .getState() + .updateTabTitles( + [...batch.tabTitlesByTabId].map(([tabId, title]) => ({ tabId, title })) + ) + }) + } + for (const effect of batch.notificationEffects) { + transaction.afterCommit(effect) + } + return results + }) + } + + function applyLiveAgentStatusBatch(batch: readonly AgentStatusIpcPayload[]): boolean { + return applyAgentStatusBatch(batch.map((data) => ({ data }))).some( + (result) => result === 'applied' + ) + } + function flushLiveAgentStatusBurst(): void { liveAgentStatusBurstTimer = null lastLiveAgentStatusApplyAt = Date.now() - // Why: splice before applying — applyAgentStatus can synchronously - // re-enter the queue via subscribers, and it must not see this batch. + // Why: splice before publishing — synchronous Zustand subscribers can enqueue the next burst. const batch = liveAgentStatusBurstQueue.splice(0) - let anyApplied = false - for (const data of batch) { - if (applyAgentStatus(data) === 'applied') { - anyApplied = true - } - } - if (!anyApplied) { + if (!applyLiveAgentStatusBatch(batch)) { lastLiveAgentStatusApplyAt = 0 } } @@ -3384,9 +3513,7 @@ export function useIpcEvents(): void { } liveAgentStatusBurstQueue.length = 0 liveAgentStatusBurstQueue.push(...remaining) - for (const queued of queuedForPane) { - applyAgentStatus(queued) - } + applyLiveAgentStatusBatch(queuedForPane) } function enqueueLiveAgentStatus(data: AgentStatusIpcPayload): void { @@ -3705,25 +3832,194 @@ function applyResolvedAgentTerminalTitleToTab( previousTitle: string | undefined, nextTitle: string | undefined ): void { - if (!nextTitle || nextTitle === previousTitle) { + if ( + !nextTitle || + !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle) + ) { return } const parsed = parsePaneKey(paneKey) if (!parsed) { return } - const layout = store.terminalLayoutsByTabId?.[parsed.tabId] - if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) { - return - } // Why: hook completion can arrive while the pane transport is unmounted; keep the tab label synced to the resolved state title. store.updateTabTitle(parsed.tabId, nextTitle) } +function shouldApplyResolvedAgentTerminalTitleToTab( + store: ReturnType, + paneKey: string, + previousTitle: string | undefined, + nextTitle: string | undefined +): boolean { + if (!nextTitle || nextTitle === previousTitle) { + return false + } + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return false + } + const layout = store.terminalLayoutsByTabId?.[parsed.tabId] + if (layout?.root && layout.activeLeafId && layout.activeLeafId !== parsed.leafId) { + return false + } + return true +} + +type AgentStatusPaneResolution = { + exists: boolean + title: string | undefined + identityTitle: string | undefined + repoConnectionId: string | null + repoConnectionResolved: boolean + owningWorktreeId: string | undefined + titleUsesTabTitle: boolean +} + +type AgentStatusWorktreeConnectionResolution = { + worktreeExists: boolean + repoConnectionId: string | null + repoConnectionResolved: boolean +} + +type IndexedAgentStatusTab = { + title: string | undefined + unifiedLabel: string | undefined + owningWorktreeId: string +} + +type AgentStatusPaneRoutingIndex = { + tabsById: Map + layoutsByTabId: AppState['terminalLayoutsByTabId'] + leafIdsByRoot: WeakMap> + worktreesById: ReturnType + reposById: ReturnType +} + +function createUnifiedTerminalLabelIndex( + entries: AppState['unifiedTabsByWorktree'][string] | undefined +): Map { + const labelsByTabId = new Map() + for (const entry of entries ?? []) { + if (entry.contentType !== 'terminal' || labelsByTabId.has(entry.entityId)) { + continue + } + const rawLabel = entry.label?.trim() + labelsByTabId.set(entry.entityId, rawLabel && rawLabel.length > 0 ? rawLabel : undefined) + } + return labelsByTabId +} + +function createAgentStatusPaneRoutingIndex( + store: ReturnType +): AgentStatusPaneRoutingIndex { + const tabsById = new Map() + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { + const unifiedLabelsByTabId = createUnifiedTerminalLabelIndex( + store.unifiedTabsByWorktree?.[worktreeId] + ) + for (const tab of tabs) { + const tabId = tab.id + if (!tabsById.has(tabId)) { + tabsById.set(tabId, { + title: tab.title, + unifiedLabel: unifiedLabelsByTabId.get(tabId), + owningWorktreeId: worktreeId + }) + } + } + } + return { + tabsById, + layoutsByTabId: store.terminalLayoutsByTabId, + leafIdsByRoot: new WeakMap(), + worktreesById: getWorktreeMapFromState(store), + reposById: getRepoMapFromState(store) + } +} + +function resolveWorktreeConnectionFromRoutingIndex( + index: AgentStatusPaneRoutingIndex, + worktreeId: string +): AgentStatusWorktreeConnectionResolution { + const worktree = index.worktreesById.get(worktreeId) + if (!worktree) { + return { worktreeExists: false, repoConnectionId: null, repoConnectionResolved: false } + } + const repo = index.reposById.get(worktree.repoId) + return { + worktreeExists: true, + repoConnectionId: repo?.connectionId ?? null, + repoConnectionResolved: repo !== undefined + } +} + +function resolvePaneKeyFromRoutingIndex( + index: AgentStatusPaneRoutingIndex, + paneKey: string +): AgentStatusPaneResolution { + const parsed = parsePaneKey(paneKey) + if (!parsed) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const { tabId, leafId } = parsed + const tab = index.tabsById.get(tabId) + if (!tab) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: null, + repoConnectionResolved: false, + owningWorktreeId: undefined, + titleUsesTabTitle: false + } + } + const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId) + const layout = index.layoutsByTabId?.[tabId] + if (layout?.root) { + let leafIds = index.leafIdsByRoot.get(layout.root) + if (!leafIds) { + leafIds = new Set(collectLeafIdsInOrder(layout.root)) + index.leafIdsByRoot.set(layout.root, leafIds) + } + if (!leafIds.has(leafId)) { + return { + exists: false, + title: undefined, + identityTitle: undefined, + repoConnectionId: connection.repoConnectionId, + repoConnectionResolved: connection.repoConnectionResolved, + owningWorktreeId: tab.owningWorktreeId, + titleUsesTabTitle: false + } + } + } + const rawPaneTitle = layout?.titlesByLeafId?.[leafId] + const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined + return { + exists: true, + title: paneTitle ?? tab.title, + identityTitle: paneTitle ?? tab.unifiedLabel ?? tab.title, + repoConnectionId: connection.repoConnectionId, + repoConnectionResolved: connection.repoConnectionResolved, + owningWorktreeId: tab.owningWorktreeId, + titleUsesTabTitle: paneTitle === undefined + } +} + /** Resolve a paneKey (tabId:leafId) to liveness, current title, owning worktree, * and the owning repo's connectionId. Used for agent-type inference and to drop - * status updates for torn-down tabs or dead connections - * (see docs/design/agent-status-over-ssh.md §5). */ + * status updates for torn-down tabs or dead connections (an SSH reconnect retires the + * old connectionId, so events still in flight under it must not land). */ function resolvePaneKey( store: ReturnType, paneKey: string @@ -3734,6 +4030,7 @@ function resolvePaneKey( repoConnectionId: string | null repoConnectionResolved: boolean owningWorktreeId: string | undefined + titleUsesTabTitle: boolean } { const parsed = parsePaneKey(paneKey) if (!parsed) { @@ -3743,7 +4040,8 @@ function resolvePaneKey( identityTitle: undefined, repoConnectionId: null, repoConnectionResolved: false, - owningWorktreeId: undefined + owningWorktreeId: undefined, + titleUsesTabTitle: false } } const { tabId, leafId } = parsed @@ -3789,7 +4087,8 @@ function resolvePaneKey( identityTitle: undefined, repoConnectionId, repoConnectionResolved, - owningWorktreeId + owningWorktreeId, + titleUsesTabTitle: false } } // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. @@ -3801,7 +4100,8 @@ function resolvePaneKey( identityTitle: undefined, repoConnectionId, repoConnectionResolved, - owningWorktreeId + owningWorktreeId, + titleUsesTabTitle: false } } // Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there. @@ -3815,7 +4115,8 @@ function resolvePaneKey( identityTitle: paneTitle ?? unifiedTabLabel ?? tabTitle, repoConnectionId, repoConnectionResolved, - owningWorktreeId + owningWorktreeId, + titleUsesTabTitle: paneTitle === undefined } } diff --git a/src/renderer/src/store/slices/agent-status-batch.test.ts b/src/renderer/src/store/slices/agent-status-batch.test.ts new file mode 100644 index 00000000000..5504f4d463e --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-batch.test.ts @@ -0,0 +1,506 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' +import type { AppState } from '../types' +import { + MAX_LIVE_AGENT_STATUSES, + type AgentStatusBatchUpdate, + type RetainedAgentEntry +} from './agent-status' +import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' +import { createTestStore, makeTab } from './store-test-helpers' + +const BASE_TIME = 2_000_000 +const FIRST_PANE = 'tab-1:11111111-1111-4111-8111-111111111111' +const SECOND_PANE = 'tab-1:22222222-2222-4222-8222-222222222222' +const PROVIDER_SESSION = { + key: 'session_id' as const, + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' +} + +function flushMicrotasks(): Promise { + return new Promise((resolve) => queueMicrotask(resolve)) +} + +function seedBatchStore() { + const store = createTestStore() + const tab = makeTab({ id: 'tab-1', worktreeId: 'wt-1', title: 'pi' }) + const retainedEntry: AgentStatusEntry = { + paneKey: FIRST_PANE, + state: 'done', + prompt: 'old turn', + agentType: 'pi', + updatedAt: BASE_TIME - 100, + stateStartedAt: BASE_TIME - 200, + stateHistory: [] + } + const retained: RetainedAgentEntry = { + entry: retainedEntry, + worktreeId: 'wt-1', + tab, + agentType: 'pi', + startedAt: BASE_TIME - 200 + } + store.setState({ + tabsByWorktree: { 'wt-1': [tab] }, + retainedAgentsByPaneKey: { [FIRST_PANE]: retained }, + retentionSuppressedPaneKeys: { [FIRST_PANE]: true }, + refreshGitHubForWorktreeIfStale: vi.fn().mockResolvedValue(undefined) + } as Partial) + return store +} + +function makeUpdates(): AgentStatusBatchUpdate[] { + return [ + { + paneKey: FIRST_PANE, + payload: { state: 'working', prompt: 'first turn', agentType: 'pi' }, + terminalTitle: 'Pi', + timing: { updatedAt: BASE_TIME, stateStartedAt: BASE_TIME }, + routing: { tabId: 'tab-1', worktreeId: 'wt-1', terminalHandle: 'pty-1' }, + metadata: { providerSession: PROVIDER_SESSION } + }, + { + paneKey: FIRST_PANE, + payload: { + state: 'waiting', + prompt: 'first turn', + agentType: 'pi', + toolName: 'AskUserQuestion' + }, + timing: { updatedAt: BASE_TIME + 1, stateStartedAt: BASE_TIME + 1 } + }, + { + paneKey: FIRST_PANE, + payload: { + state: 'done', + prompt: 'first turn', + agentType: 'pi', + lastAssistantMessage: 'Finished.' + }, + timing: { updatedAt: BASE_TIME + 2, stateStartedAt: BASE_TIME + 2 } + }, + { + paneKey: FIRST_PANE, + payload: { state: 'working', prompt: 'second turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME + 3, stateStartedAt: BASE_TIME + 3 } + }, + { + paneKey: FIRST_PANE, + payload: { state: 'done', prompt: 'late first turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME + 2, stateStartedAt: BASE_TIME + 2 } + }, + { + kind: 'providerSession', + paneKey: SECOND_PANE, + agent: 'pi', + providerSession: PROVIDER_SESSION, + timing: { updatedAt: BASE_TIME + 4 }, + routing: { tabId: 'tab-1', worktreeId: 'wt-1' } + }, + { + paneKey: SECOND_PANE, + payload: { state: 'working', prompt: 'provider turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME + 5, stateStartedAt: BASE_TIME + 5 }, + routing: { tabId: 'tab-1', worktreeId: 'wt-1' }, + metadata: { providerSession: PROVIDER_SESSION } + } + ] +} + +function selectBatchState(state: AppState) { + return { + agentStatusByPaneKey: state.agentStatusByPaneKey, + retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, + sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, + agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey, + migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId, + retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys, + agentStatusEpoch: state.agentStatusEpoch, + sortEpoch: state.sortEpoch + } +} + +describe('setAgentStatuses', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('matches sequential updates across repeated panes and provider-session records', async () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME + 10) + const sequentialStore = seedBatchStore() + const batchStore = seedBatchStore() + const sequentialTitleGeneration = vi.fn() + const batchTitleGeneration = vi.fn() + const batchTitleGenerationBulk = vi.fn((updates: readonly GeneratedTabTitleUpdate[]) => { + for (const update of updates) { + if (update.options) { + batchTitleGeneration(update.paneKey, update.prompt, update.options) + } else { + batchTitleGeneration(update.paneKey, update.prompt) + } + } + }) + sequentialStore.setState({ + setGeneratedTabTitleFromAgentPrompt: sequentialTitleGeneration + } as Partial) + batchStore.setState({ + setGeneratedTabTitlesFromAgentPrompts: batchTitleGenerationBulk + } as Partial) + const updates = makeUpdates() + + for (const update of updates) { + if (update.kind === 'providerSession') { + sequentialStore + .getState() + .recordAgentProviderSession( + update.paneKey, + update.agent, + update.providerSession, + update.timing, + update.routing, + update.metadata + ) + } else { + sequentialStore + .getState() + .setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + } + } + const outcomes = batchStore.getState().setAgentStatuses(updates) + + expect(outcomes).toEqual([true, true, true, true, false, true, true]) + expect(selectBatchState(batchStore.getState())).toEqual( + selectBatchState(sequentialStore.getState()) + ) + expect( + batchStore.getState().agentStatusByPaneKey[FIRST_PANE].stateHistory.at(-1) + ).toMatchObject({ state: 'done' }) + // The swallowed `done`'s output survives on the entry, not on the history row. + expect( + batchStore.getState().agentStatusByPaneKey[FIRST_PANE].lastCompletedAssistantMessage + ).toBe('Finished.') + expect(batchTitleGeneration.mock.calls).toEqual(sequentialTitleGeneration.mock.calls) + expect(batchTitleGeneration).toHaveBeenCalledTimes(5) + expect(batchTitleGenerationBulk).toHaveBeenCalledOnce() + await flushMicrotasks() + expect(batchStore.getState().refreshGitHubForWorktreeIfStale).toHaveBeenCalledTimes(1) + expect(sequentialStore.getState().refreshGitHubForWorktreeIfStale).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(AGENT_STATUS_STALE_AFTER_MS + 1) + expect(selectBatchState(batchStore.getState())).toEqual( + selectBatchState(sequentialStore.getState()) + ) + }) + + it('publishes once and reports skipped ordered updates', () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME + 10) + const store = seedBatchStore() + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + const outcomes = store.getState().setAgentStatuses(makeUpdates()) + + expect(outcomes).toEqual([true, true, true, true, false, true, true]) + expect(publications).toBe(1) + expect(store.getState().setAgentStatuses([])).toEqual([]) + expect(publications).toBe(1) + unsubscribe() + }) + + it('coalesces 100 generated tab titles into one post-status publication', () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME + 10) + const store = createTestStore() + const paneCount = 100 + const tabsByWorktree = Object.fromEntries( + Array.from({ length: paneCount }, (_, index) => { + const worktreeId = `wt-${index}` + return [worktreeId, [makeTab({ id: `tab-${index}`, worktreeId })]] + }) + ) + store.setState({ + settings: { ...store.getState().settings, tabAutoGenerateTitle: true }, + tabsByWorktree + } as Partial) + const updates: AgentStatusBatchUpdate[] = Array.from({ length: paneCount }, (_, index) => ({ + paneKey: `tab-${index}:11111111-1111-4111-8111-111111111111`, + payload: { + state: 'working', + prompt: `Refactor remote startup module ${index}`, + agentType: 'pi' + }, + timing: { updatedAt: BASE_TIME + index, stateStartedAt: BASE_TIME + index }, + routing: { tabId: `tab-${index}`, worktreeId: `wt-${index}` } + })) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + const outcomes = store.getState().setAgentStatuses(updates) + + unsubscribe() + expect(outcomes).toEqual(Array(paneCount).fill(true)) + expect(publications).toBe(2) + expect(store.getState().tabsByWorktree['wt-99']?.[0]?.generatedTitle).toBe( + 'Refactor remote startup module 99' + ) + }) + + it('coalesces batch freshness requests while preserving standalone scheduling', async () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME + 10) + const store = seedBatchStore() + const queueMicrotaskSpy = vi.spyOn(globalThis, 'queueMicrotask') + const workingUpdate = (updatedAt: number, prompt: string): AgentStatusBatchUpdate => ({ + paneKey: FIRST_PANE, + payload: { state: 'working', prompt, agentType: 'pi' }, + timing: { updatedAt, stateStartedAt: updatedAt }, + routing: { tabId: 'tab-1', worktreeId: 'wt-1' } + }) + + const outcomes = store + .getState() + .setAgentStatuses([ + workingUpdate(BASE_TIME, 'first'), + workingUpdate(BASE_TIME + 1, 'second'), + workingUpdate(BASE_TIME + 2, 'third'), + workingUpdate(BASE_TIME + 1, 'stale') + ]) + + expect(outcomes).toEqual([true, true, true, false]) + expect(queueMicrotaskSpy).toHaveBeenCalledTimes(1) + + queueMicrotaskSpy.mockClear() + store + .getState() + .setAgentStatus( + FIRST_PANE, + { state: 'working', prompt: 'standalone', agentType: 'pi' }, + undefined, + { updatedAt: BASE_TIME + 3, stateStartedAt: BASE_TIME + 3 }, + { tabId: 'tab-1', worktreeId: 'wt-1' } + ) + store + .getState() + .setAgentStatus( + FIRST_PANE, + { state: 'working', prompt: 'standalone stale', agentType: 'pi' }, + undefined, + { updatedAt: BASE_TIME + 2, stateStartedAt: BASE_TIME + 2 }, + { tabId: 'tab-1', worktreeId: 'wt-1' } + ) + + expect(queueMicrotaskSpy).toHaveBeenCalledTimes(2) + await flushMicrotasks() + }) + + it('shares freshness across nested folds but isolates synchronous reentrant transactions', async () => { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME + 10) + const store = seedBatchStore() + const queueMicrotaskSpy = vi.spyOn(globalThis, 'queueMicrotask') + const update = (paneKey: string, updatedAt: number): AgentStatusBatchUpdate => ({ + paneKey, + payload: { state: 'working', prompt: paneKey, agentType: 'pi' }, + timing: { updatedAt, stateStartedAt: updatedAt }, + routing: { tabId: 'tab-1', worktreeId: 'wt-1' } + }) + let publications = 0 + const unsubscribeNested = store.subscribe(() => { + publications += 1 + }) + + const nestedOutcomes = store + .getState() + .transactAgentStatuses((transaction) => [ + transaction.apply(update(FIRST_PANE, BASE_TIME)), + store + .getState() + .transactAgentStatuses((nestedTransaction) => + nestedTransaction.apply(update(SECOND_PANE, BASE_TIME + 1)) + ) + ]) + + expect(nestedOutcomes).toEqual([true, true]) + expect(publications).toBe(1) + expect(queueMicrotaskSpy).toHaveBeenCalledTimes(1) + unsubscribeNested() + + queueMicrotaskSpy.mockClear() + let reentered = false + const unsubscribeReentrant = store.subscribe(() => { + publications += 1 + if (!reentered) { + reentered = true + store.getState().setAgentStatuses([update(SECOND_PANE, BASE_TIME + 3)]) + } + }) + + store.getState().setAgentStatuses([update(FIRST_PANE, BASE_TIME + 2)]) + + expect(publications).toBe(3) + expect(queueMicrotaskSpy).toHaveBeenCalledTimes(2) + expect(store.getState().agentStatusByPaneKey[FIRST_PANE]?.updatedAt).toBe(BASE_TIME + 2) + expect(store.getState().agentStatusByPaneKey[SECOND_PANE]?.updatedAt).toBe(BASE_TIME + 3) + unsubscribeReentrant() + await flushMicrotasks() + }) + + it('exposes cap evictions to later updates in the same transaction', () => { + const store = seedBatchStore() + const firstPane = 'orphan-tab:orphan-0' + const entries = Object.fromEntries( + Array.from({ length: MAX_LIVE_AGENT_STATUSES }, (_, index) => { + const paneKey = `orphan-tab:orphan-${index}` + return [ + paneKey, + { + paneKey, + state: 'working', + prompt: 'old turn', + agentType: 'pi', + updatedAt: BASE_TIME, + stateStartedAt: BASE_TIME, + stateHistory: [] + } satisfies AgentStatusEntry + ] + }) + ) + store.setState({ agentStatusByPaneKey: entries } as Partial) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + const outcomes = store.getState().transactAgentStatuses((transaction) => { + const added = transaction.apply({ + paneKey: 'orphan-tab:new-pane', + payload: { state: 'working', prompt: 'new turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME + 1, stateStartedAt: BASE_TIME + 1 } + }) + expect(transaction.getState().agentStatusByPaneKey[firstPane]).toBeUndefined() + const completed = transaction.apply({ + paneKey: firstPane, + payload: { state: 'done', prompt: 'different agent', agentType: 'claude' }, + timing: { updatedAt: BASE_TIME + 2, stateStartedAt: BASE_TIME + 2 } + }) + return [added, completed] + }) + + expect(outcomes).toEqual([true, true]) + expect(store.getState().agentStatusByPaneKey[firstPane]).toMatchObject({ + agentType: 'claude', + state: 'done' + }) + expect(publications).toBe(1) + unsubscribe() + }) + + it('preserves a store write that bypassed the transaction instead of reverting it', () => { + const store = seedBatchStore() + + const outcome = store.getState().transactAgentStatuses((transaction) => { + const applied = transaction.apply({ + paneKey: FIRST_PANE, + payload: { state: 'working', prompt: 'first turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME, stateStartedAt: BASE_TIME } + }) + // Any slice action reached through get() writes straight to the real store — a + // REPLACE commit of the fold's snapshot would silently revert it. + store.setState({ worktreesByRepo: { 'repo-late': [] } } as Partial) + return applied + }) + + expect(outcome).toBe(true) + expect(store.getState().worktreesByRepo).toEqual({ 'repo-late': [] }) + expect(store.getState().agentStatusByPaneKey[FIRST_PANE]).toMatchObject({ state: 'working' }) + }) + + it('keeps one completed-turn message per entry instead of one per history row', () => { + const store = seedBatchStore() + const turns: AgentStatusBatchUpdate[] = [] + for (let turn = 0; turn < 5; turn += 1) { + turns.push({ + paneKey: FIRST_PANE, + payload: { state: 'working', prompt: `turn ${turn}`, agentType: 'pi' }, + timing: { updatedAt: BASE_TIME + turn * 2, stateStartedAt: BASE_TIME + turn * 2 } + }) + turns.push({ + paneKey: FIRST_PANE, + payload: { + state: 'done', + prompt: `turn ${turn}`, + agentType: 'pi', + lastAssistantMessage: `output ${turn}` + }, + timing: { updatedAt: BASE_TIME + turn * 2 + 1, stateStartedAt: BASE_TIME + turn * 2 + 1 } + }) + } + store.getState().setAgentStatuses(turns) + + const entry = store.getState().agentStatusByPaneKey[FIRST_PANE] + expect(entry.stateHistory.length).toBeGreaterThan(1) + expect(entry.stateHistory.some((row) => 'lastAssistantMessage' in row)).toBe(false) + // Only the newest done that rolled into history is retained, so memory is O(1) per pane. + expect(entry.lastCompletedAssistantMessage).toBe('output 3') + expect(entry.lastAssistantMessage).toBe('output 4') + }) + + it('clears the completed-turn message when a done carried no assistant output', () => { + const store = seedBatchStore() + store.getState().setAgentStatuses([ + { + paneKey: FIRST_PANE, + payload: { state: 'working', prompt: 'first turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME, stateStartedAt: BASE_TIME } + }, + { + paneKey: FIRST_PANE, + payload: { + state: 'done', + prompt: 'first turn', + agentType: 'pi', + lastAssistantMessage: 'Finished.' + }, + timing: { updatedAt: BASE_TIME + 1, stateStartedAt: BASE_TIME + 1 } + }, + { + paneKey: FIRST_PANE, + payload: { state: 'working', prompt: 'second turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME + 2, stateStartedAt: BASE_TIME + 2 } + }, + { + paneKey: FIRST_PANE, + payload: { state: 'done', prompt: 'second turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME + 3, stateStartedAt: BASE_TIME + 3 } + }, + { + paneKey: FIRST_PANE, + payload: { state: 'working', prompt: 'third turn', agentType: 'pi' }, + timing: { updatedAt: BASE_TIME + 4, stateStartedAt: BASE_TIME + 4 } + } + ]) + + // The second turn finished silently; leaking turn one's output would misreport the run. + expect( + store.getState().agentStatusByPaneKey[FIRST_PANE].lastCompletedAssistantMessage + ).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 6dd2e60df1e..0f1ebf66e08 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -47,6 +47,7 @@ import { transferAgentPaneAuthorityAlias } from './agent-pane-authority' import { createFreshnessScheduler } from './agent-status-freshness-scheduler' +import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' /** Snapshot of a finished/vanished agent status entry, kept so the dashboard and sidebar hover * keep showing the completion until the user clicks the worktree. `worktreeId` is stamped at @@ -107,6 +108,65 @@ type AgentLaunchConfigRegistryEntry = { identity: AgentLaunchConfigRegistrationMetadata } +export type AgentStatusPayload = ParsedAgentStatusPayload & { + orchestration?: AgentStatusOrchestrationContext + promptInteractionKey?: string + restoredUnconfirmed?: boolean +} + +export type AgentStatusTiming = { updatedAt?: number; stateStartedAt?: number } + +export type AgentStatusRouting = { + tabId?: string + worktreeId?: string + terminalHandle?: string + connectionId?: string | null +} + +export type AgentStatusMetadata = { + providerSession?: AgentProviderSessionMetadata + launchConfig?: SleepingAgentLaunchConfig + launchToken?: string +} + +export type AgentStatusUpdate = { + kind?: 'status' + paneKey: string + payload: AgentStatusPayload + terminalTitle?: string + timing?: AgentStatusTiming + routing?: AgentStatusRouting + metadata?: AgentStatusMetadata +} + +export type AgentProviderSessionTiming = { updatedAt?: number } + +export type AgentProviderSessionRouting = { + tabId?: string + worktreeId?: string + connectionId?: string | null +} + +export type AgentProviderSessionRecordMetadata = { launchToken?: string } + +export type AgentProviderSessionUpdate = { + kind: 'providerSession' + paneKey: string + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + timing?: AgentProviderSessionTiming + routing?: AgentProviderSessionRouting + metadata?: AgentProviderSessionRecordMetadata +} + +export type AgentStatusBatchUpdate = AgentStatusUpdate | AgentProviderSessionUpdate + +export type AgentStatusBatchTransaction = { + getState: () => AppState + apply: (update: AgentStatusBatchUpdate) => boolean + afterCommit: (effect: () => void) => void +} + export type AgentStatusSlice = { /** Explicit agent status entries keyed by `${tabId}:${leafId}`; real-time only, not persisted. */ agentStatusByPaneKey: Record @@ -153,34 +213,30 @@ export type AgentStatusSlice = { /** Update or insert an agent status entry from a status payload. */ setAgentStatus: ( paneKey: string, - payload: ParsedAgentStatusPayload & { - orchestration?: AgentStatusOrchestrationContext - promptInteractionKey?: string - restoredUnconfirmed?: boolean - }, + payload: AgentStatusPayload, terminalTitle?: string, - timing?: { updatedAt?: number; stateStartedAt?: number }, - routing?: { - tabId?: string - worktreeId?: string - terminalHandle?: string - connectionId?: string | null - }, - metadata?: { - providerSession?: AgentProviderSessionMetadata - launchConfig?: SleepingAgentLaunchConfig - launchToken?: string - } + timing?: AgentStatusTiming, + routing?: AgentStatusRouting, + metadata?: AgentStatusMetadata ) => void + /** Apply ordered status updates as one status publication (generated titles and tab + * titles still publish after it — three total, not 2N). */ + setAgentStatuses: (updates: readonly AgentStatusBatchUpdate[]) => boolean[] + + /** Fold caller-derived updates against exact staged state, committing one status publication. */ + transactAgentStatuses: ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ) => Result + /** Record resume identity without creating a visible turn-status row. */ recordAgentProviderSession: ( paneKey: string, agent: ResumableTuiAgent, providerSession: AgentProviderSessionMetadata, - timing?: { updatedAt?: number }, - routing?: { tabId?: string; worktreeId?: string; connectionId?: string | null }, - metadata?: { launchToken?: string } + timing?: AgentProviderSessionTiming, + routing?: AgentProviderSessionRouting, + metadata?: AgentProviderSessionRecordMetadata ) => void registerAgentLaunchConfig: ( @@ -1212,10 +1268,143 @@ function collectWorktreeIdsForConnection(state: AppState, connectionId: string): return onConnection } +/** Slices that only the fold touched, so the batch commits as a MERGE. + * Why: the fold runs against a snapshot taken at transaction start. Committing that + * snapshot wholesale (zustand REPLACE) would silently revert any write that landed on + * the real store meanwhile — and it can, because a batched action reaching another + * slice's setter through `get()` bypasses the shadowed `set` entirely + * (setRuntimeAgentOrchestrationByPaneKey → setGeneratedTabTitleFromAgentPrompt does this). + * A merge keeps the single publication while confining conflicts to keys this fold wrote. */ +function buildAgentStatusBatchPatch( + initialState: AppState, + nextState: AppState +): Partial { + const patch: Record = {} + for (const key of Object.keys(nextState) as (keyof AppState)[]) { + if (!Object.is(nextState[key], initialState[key])) { + patch[key as string] = nextState[key] + } + } + return patch as Partial +} + export const createAgentStatusSlice: StateCreator = ( - set, - get + storeSet, + storeGet ) => { + type AgentStatusStateUpdate = + | AppState + | Partial + | ((state: AppState) => AppState | Partial) + + let batchedAgentStatusState: AppState | null = null + let batchedAgentStatusEffects: (() => void)[] | null = null + let batchedGeneratedTabTitleUpdates: GeneratedTabTitleUpdate[] | null = null + let batchedAgentStatusFreshnessRequested = false + const get = (): AppState => batchedAgentStatusState ?? storeGet() + // Deliberately narrower than zustand's `set`: no `replace` parameter, so no call site in + // this slice can compile into a REPLACE the batch commit is unable to express. + const set = (update: AgentStatusStateUpdate): void => { + if (batchedAgentStatusState === null) { + storeSet(update, false) + return + } + const nextState = typeof update === 'function' ? update(batchedAgentStatusState) : update + if (Object.is(nextState, batchedAgentStatusState)) { + return + } + batchedAgentStatusState = Object.assign({}, batchedAgentStatusState, nextState) + } + const runAfterAgentStatusCommit = (effect: () => void): void => { + if (batchedAgentStatusEffects) { + batchedAgentStatusEffects.push(effect) + return + } + effect() + } + const applyGeneratedTabTitleUpdate = (update: GeneratedTabTitleUpdate): void => { + if (batchedGeneratedTabTitleUpdates) { + batchedGeneratedTabTitleUpdates.push(update) + return + } + if (update.options) { + get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt, update.options) + } else { + get().setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt) + } + } + const applyBatchedAgentStatusUpdate = (update: AgentStatusBatchUpdate): boolean => { + const stateBeforeUpdate = batchedAgentStatusState + if (!stateBeforeUpdate) { + return false + } + if (update.kind === 'providerSession') { + get().recordAgentProviderSession( + update.paneKey, + update.agent, + update.providerSession, + update.timing, + update.routing, + update.metadata + ) + } else { + get().setAgentStatus( + update.paneKey, + update.payload, + update.terminalTitle, + update.timing, + update.routing, + update.metadata + ) + } + return batchedAgentStatusState !== stateBeforeUpdate + } + const batchTransaction: AgentStatusBatchTransaction = { + getState: get, + apply: applyBatchedAgentStatusUpdate, + afterCommit: runAfterAgentStatusCommit + } + const transactAgentStatuses = ( + operation: (transaction: AgentStatusBatchTransaction) => Result + ): Result => { + if (batchedAgentStatusState) { + return operation(batchTransaction) + } + const initialState = storeGet() + batchedAgentStatusState = initialState + batchedAgentStatusEffects = [] + batchedGeneratedTabTitleUpdates = [] + try { + const result = operation(batchTransaction) + const nextState = batchedAgentStatusState + const effects = batchedAgentStatusEffects + const generatedTabTitleUpdates = batchedGeneratedTabTitleUpdates + const freshnessRequested = batchedAgentStatusFreshnessRequested + batchedAgentStatusState = null + batchedAgentStatusEffects = null + batchedGeneratedTabTitleUpdates = null + batchedAgentStatusFreshnessRequested = false + if (nextState !== initialState) { + storeSet(buildAgentStatusBatchPatch(initialState, nextState), false) + } + if (generatedTabTitleUpdates.length > 0) { + storeGet().setGeneratedTabTitlesFromAgentPrompts(generatedTabTitleUpdates) + } + if (freshnessRequested) { + queueMicrotask(() => freshness.schedule()) + } + for (const effect of effects) { + effect() + } + return result + } finally { + batchedAgentStatusState = null + batchedAgentStatusEffects = null + batchedGeneratedTabTitleUpdates = null + batchedAgentStatusFreshnessRequested = false + } + } + // Why: scheduler is process-lifetime-scoped (no dispose) because the store is a // module-level singleton with no teardown lifecycle anywhere in the codebase. const freshness = createFreshnessScheduler({ @@ -1229,6 +1418,13 @@ export const createAgentStatusSlice: StateCreator { + if (batchedAgentStatusState !== null) { + batchedAgentStatusFreshnessRequested ||= acceptedInBatch + return + } + queueMicrotask(() => freshness.schedule()) + } const clearSleepingAgentSessionsByPaneKey = (paneKeys: readonly string[]): void => { if (paneKeys.length === 0) { @@ -1707,7 +1903,7 @@ export const createAgentStatusSlice: StateCreator freshness.schedule()) + requestAgentStatusFreshness(true) } }, @@ -1746,6 +1942,10 @@ export const createAgentStatusSlice: StateCreator AGENT_STATE_HISTORY_MAX) { history = history.slice(history.length - AGENT_STATE_HISTORY_MAX) } + if (existing.state === 'done') { + // The push above just moved this completion out of the live entry; a done that + // carried no message must clear the slot, or a stale prior turn leaks forward. + lastCompletedAssistantMessage = existing.lastAssistantMessage + } } const identity = resolveAgentStatusIdentity({ @@ -1934,6 +2139,7 @@ export const createAgentStatusSlice: StateCreator freshness.schedule()) + // Why: batches coalesce accepted updates; standalone calls keep their existing deferred scheduling. + requestAgentStatusFreshness(generatedTitleEntry.current !== null) if (completionRefreshWorktreeId) { const worktreeId = completionRefreshWorktreeId // Why: agents can create a PR via `gh pr create`, bypassing Orca's flow and leaving a stale "no PR" cache entry in place. @@ -2179,6 +2389,13 @@ export const createAgentStatusSlice: StateCreator + updates.length === 0 + ? [] + : transactAgentStatuses((transaction) => updates.map(transaction.apply)), + + transactAgentStatuses, + setMigrationUnsupportedPty: (entry) => { set((s) => { const existing = s.migrationUnsupportedByPtyId[entry.ptyId] diff --git a/src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts b/src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts index b6c10e014ed..4c917c15a27 100644 --- a/src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts +++ b/src/renderer/src/store/slices/runtime-pane-title-sort-epoch.test.ts @@ -226,6 +226,42 @@ describe('runtimePaneTitle → sortEpoch', () => { expect(publications).toBe(tabCount * 2) }) + it('publishes a bulk tab-title update once across worktrees', () => { + const store = createTestStore() + const tabCount = 20 + const worktrees = Array.from({ length: tabCount }, (_, index) => + makeWorktree({ id: `wt-${index}`, repoId: 'repo1', path: `/path/wt-${index}` }) + ) + seedStore(store, { + worktreesByRepo: { repo1: worktrees }, + tabsByWorktree: Object.fromEntries( + worktrees.map((worktree, index) => [ + worktree.id, + [makeTab({ id: `tab-${index}`, worktreeId: worktree.id })] + ]) + ) + }) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + store.getState().updateTabTitles( + worktrees.map((_, index) => ({ + tabId: `tab-${index}`, + title: `Codex ready ${index}` + })) + ) + + unsubscribe() + expect(publications).toBe(1) + for (let index = 0; index < tabCount; index += 1) { + expect(store.getState().tabsByWorktree[`wt-${index}`]?.[0]?.title).toBe( + `Codex ready ${index}` + ) + } + }) + it('does not enumerate terminal tabs when only the spinner frame changes', () => { const store = createTestStore() seedStore(store, { diff --git a/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts b/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts new file mode 100644 index 00000000000..3a7a1b525ae --- /dev/null +++ b/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) +vi.mock('@/components/terminal-pane/pty-transport', () => ({ + registerEagerPtyBuffer: vi.fn(), + ensurePtyDispatcher: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) +vi.mock('@/components/terminal-pane/shutdown-buffer-captures', () => ({ + shutdownBufferCaptures: vi.fn() +})) + +// @ts-expect-error -- minimal preload API stub for the slice's IPC writes +globalThis.window = { api: {} } + +import { + createTestStore, + makeTab, + makeUnifiedTab, + makeWorktree, + seedStore +} from './store-test-helpers' +import type { GeneratedTabTitleUpdate } from './terminal-tab-title-batch' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function makeScaleState(count: number) { + let idReads = 0 + const worktrees = Array.from({ length: count }, (_, index) => + makeWorktree({ id: `wt-${index}`, repoId: 'repo1' }) + ) + const tabsByWorktree = Object.fromEntries( + worktrees.map((worktree, index) => { + const tab = makeTab({ id: `tab-${index}`, worktreeId: worktree.id }) + const id = tab.id + Object.defineProperty(tab, 'id', { + configurable: true, + enumerable: true, + get() { + idReads += 1 + return id + } + }) + return [worktree.id, [tab]] + }) + ) + const unifiedTabsByWorktree = Object.fromEntries( + worktrees.map((worktree, index) => [ + worktree.id, + [makeUnifiedTab({ id: `tab-${index}`, worktreeId: worktree.id, groupId: 'group-1' })] + ]) + ) + return { + getIdReads: () => idReads, + resetIdReads: () => { + idReads = 0 + }, + tabsByWorktree, + unifiedTabsByWorktree, + worktrees + } +} + +describe('terminal tab title batches', () => { + it('updates 100 owners with one publication and a linear owner-index pass', () => { + const store = createTestStore() + const fixture = makeScaleState(101) + seedStore(store, { + worktreesByRepo: { repo1: fixture.worktrees }, + tabsByWorktree: fixture.tabsByWorktree, + unifiedTabsByWorktree: fixture.unifiedTabsByWorktree + }) + const untouchedTabs = fixture.tabsByWorktree['wt-100'] + const baselineSortEpoch = store.getState().sortEpoch + fixture.resetIdReads() + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + store.getState().updateTabTitles( + Array.from({ length: 100 }, (_, index) => ({ + tabId: `tab-${index}`, + title: `Remote agent ${index}` + })) + ) + + unsubscribe() + expect(publications).toBe(1) + expect(fixture.getIdReads()).toBeLessThanOrEqual(305) + expect(store.getState().tabsByWorktree['wt-100']).toBe(untouchedTabs) + expect(store.getState().sortEpoch).toBe(baselineSortEpoch + 100) + for (let index = 0; index < 100; index += 1) { + expect(store.getState().tabsByWorktree[`wt-${index}`]?.[0]?.title).toBe( + `Remote agent ${index}` + ) + } + }) + + it('preserves ordered duplicate updates and last-owner duplicate-id routing', () => { + const store = createTestStore() + seedStore(store, { + tabsByWorktree: { + first: [makeTab({ id: 'duplicate-tab', worktreeId: 'first', title: 'First owner' })], + second: [makeTab({ id: 'duplicate-tab', worktreeId: 'second', title: 'Second owner' })] + } + }) + + store.getState().updateTabTitles([ + { tabId: 'duplicate-tab', title: 'Intermediate' }, + { tabId: 'duplicate-tab', title: 'Final' } + ]) + + expect(store.getState().tabsByWorktree.first?.[0]?.title).toBe('First owner') + expect(store.getState().tabsByWorktree.second?.[0]?.title).toBe('Final') + }) + + it('updates every same-owner backing duplicate but only the first unified row', () => { + const store = createTestStore() + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, + tabsByWorktree: { + owner: [ + makeTab({ id: 'duplicate-tab', worktreeId: 'owner', title: 'First backing' }), + makeTab({ id: 'duplicate-tab', worktreeId: 'owner', title: 'Second backing' }) + ] + }, + unifiedTabsByWorktree: { + owner: [ + makeUnifiedTab({ + id: 'duplicate-tab', + worktreeId: 'owner', + groupId: 'group-1', + label: 'First visible' + }), + makeUnifiedTab({ + id: 'duplicate-tab', + worktreeId: 'owner', + groupId: 'group-2', + label: 'Second visible' + }) + ] + } + }) + + store.getState().updateTabTitles([{ tabId: 'duplicate-tab', title: 'Final live title' }]) + store.getState().setGeneratedTabTitlesFromAgentPrompts([ + { + paneKey: makePaneKey('duplicate-tab', LEAF_ID), + prompt: 'Generated duplicate title for remote startup' + } + ]) + + expect(store.getState().tabsByWorktree.owner?.map((tab) => tab.title)).toEqual([ + 'Final live title', + 'Final live title' + ]) + expect(store.getState().tabsByWorktree.owner?.map((tab) => tab.generatedTitle)).toEqual([ + 'Generated duplicate title for remote', + 'Generated duplicate title for remote' + ]) + expect(store.getState().unifiedTabsByWorktree.owner?.map((tab) => tab.label)).toEqual([ + 'Final live title', + 'Second visible' + ]) + expect(store.getState().unifiedTabsByWorktree.owner?.map((tab) => tab.generatedLabel)).toEqual([ + 'Generated duplicate title for remote', + undefined + ]) + }) + + it('matches sequential live and generated title semantics in event order', () => { + const sequentialStore = createTestStore() + const batchStore = createTestStore() + const fixture = { + settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, + tabsByWorktree: { + owner: [makeTab({ id: 'tab-1', worktreeId: 'owner', title: 'Terminal 1' })] + }, + unifiedTabsByWorktree: { + owner: [makeUnifiedTab({ id: 'tab-1', worktreeId: 'owner', groupId: 'group-1' })] + } + } + seedStore(sequentialStore, structuredClone(fixture)) + seedStore(batchStore, structuredClone(fixture)) + const liveUpdates = [ + { tabId: 'tab-1', title: 'Codex' }, + { tabId: 'tab-1', title: '⠋ Codex is thinking' }, + { tabId: 'tab-1', title: '⠙ Codex is thinking' }, + { tabId: 'tab-1', title: '' }, + { tabId: 'tab-1', title: 'Final stable title' } + ] + const generatedUpdates = [ + { paneKey: makePaneKey('tab-1', LEAF_ID), prompt: 'First generated title wins here' }, + { paneKey: makePaneKey('tab-1', LEAF_ID), prompt: 'Ignored later generated title' }, + { + paneKey: makePaneKey('tab-1', LEAF_ID), + prompt: 'Forced replacement generated title', + options: { replaceExistingGeneratedTitle: true } + } + ] + + for (const update of liveUpdates) { + sequentialStore.getState().updateTabTitle(update.tabId, update.title) + } + for (const update of generatedUpdates) { + sequentialStore + .getState() + .setGeneratedTabTitleFromAgentPrompt(update.paneKey, update.prompt, update.options) + } + batchStore.getState().updateTabTitles(liveUpdates) + batchStore.getState().setGeneratedTabTitlesFromAgentPrompts(generatedUpdates) + + expect(batchStore.getState().tabsByWorktree).toEqual(sequentialStore.getState().tabsByWorktree) + expect(batchStore.getState().unifiedTabsByWorktree).toEqual( + sequentialStore.getState().unifiedTabsByWorktree + ) + expect(batchStore.getState().sortEpoch).toBe(sequentialStore.getState().sortEpoch) + }) + + it('coalesces generated titles while preserving first-write and replacement semantics', () => { + const store = createTestStore() + const fixture = makeScaleState(100) + seedStore(store, { + settings: { ...getDefaultSettings('/tmp'), tabAutoGenerateTitle: true }, + worktreesByRepo: { repo1: fixture.worktrees }, + tabsByWorktree: fixture.tabsByWorktree, + unifiedTabsByWorktree: fixture.unifiedTabsByWorktree + }) + fixture.resetIdReads() + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + const updates: GeneratedTabTitleUpdate[] = Array.from({ length: 100 }, (_, index) => ({ + paneKey: makePaneKey(`tab-${index}`, LEAF_ID), + prompt: `Refactor remote module ${index} for startup performance` + })) + updates.push( + { + paneKey: makePaneKey('tab-0', LEAF_ID), + prompt: 'This later prompt must not replace the first generated title' + }, + { + paneKey: makePaneKey('tab-0', LEAF_ID), + prompt: 'Replacement title for a newer dispatch', + options: { replaceExistingGeneratedTitle: true } + } + ) + + store.getState().setGeneratedTabTitlesFromAgentPrompts(updates) + + unsubscribe() + expect(publications).toBe(1) + expect(fixture.getIdReads()).toBeLessThanOrEqual(305) + expect(store.getState().tabsByWorktree['wt-0']?.[0]?.generatedTitle).toBe( + 'Replacement title for a newer dispatch' + ) + expect(store.getState().tabsByWorktree['wt-99']?.[0]?.generatedTitle).toBe( + 'Refactor remote module 99 for startup' + ) + }) +}) diff --git a/src/renderer/src/store/slices/terminal-tab-title-batch.ts b/src/renderer/src/store/slices/terminal-tab-title-batch.ts new file mode 100644 index 00000000000..21701f3bd9d --- /dev/null +++ b/src/renderer/src/store/slices/terminal-tab-title-batch.ts @@ -0,0 +1,242 @@ +import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title' +import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import type { Tab, TerminalTab } from '../../../../shared/types' +import type { AppState } from '../types' + +export type TerminalTabTitleUpdate = { tabId: string; title: string } + +export type GeneratedTabTitleUpdate = { + paneKey: string + prompt: string + options?: { replaceExistingGeneratedTitle?: boolean } +} + +type TitleState = Pick< + AppState, + 'activeWorktreeId' | 'settings' | 'sortEpoch' | 'tabsByWorktree' | 'unifiedTabsByWorktree' +> + +type TitlePatch = Partial> + +type TitleUpdateResult = { + patch: TitlePatch | null + runtimeGraphChanged: boolean +} + +type OwnerStage = { + tabs: TerminalTab[] + tabsChanged: boolean + tabIndexesById: Map + unifiedTabs: Tab[] + unifiedTabsChanged: boolean + unifiedIndexByTabId: Map +} + +function getFallbackTabTitle(tab: TerminalTab): string { + return ( + tab.customTitle?.trim() || + tab.quickCommandLabel?.trim() || + tab.defaultTitle?.trim() || + tab.title || + 'Terminal 1' + ) +} + +function getTabIdFromPaneKey(paneKey: string): string | null { + return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null +} + +function buildOwnerIndex(tabsByWorktree: TitleState['tabsByWorktree']): Map { + const ownerByTabId = new Map() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + ownerByTabId.set(tab.id, worktreeId) + } + } + return ownerByTabId +} + +function getOwnerStage( + state: TitleState, + stages: Map, + worktreeId: string +): OwnerStage { + const existing = stages.get(worktreeId) + if (existing) { + return existing + } + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const tabIndexesById = new Map() + for (let index = 0; index < tabs.length; index += 1) { + const tabId = tabs[index].id + const indexes = tabIndexesById.get(tabId) + if (indexes) { + indexes.push(index) + } else { + tabIndexesById.set(tabId, [index]) + } + } + const unifiedIndexByTabId = new Map() + for (let index = 0; index < unifiedTabs.length; index += 1) { + const tab = unifiedTabs[index] + if (tab.contentType === 'terminal' && !unifiedIndexByTabId.has(tab.entityId)) { + unifiedIndexByTabId.set(tab.entityId, index) + } + } + const stage: OwnerStage = { + tabs, + tabsChanged: false, + tabIndexesById, + unifiedTabs, + unifiedTabsChanged: false, + unifiedIndexByTabId + } + stages.set(worktreeId, stage) + return stage +} + +function updateStageTabs( + stage: OwnerStage, + indexes: readonly number[], + update: (tab: TerminalTab) => TerminalTab +): void { + if (!stage.tabsChanged) { + stage.tabs = [...stage.tabs] + stage.tabsChanged = true + } + for (const index of indexes) { + stage.tabs[index] = update(stage.tabs[index]) + } +} + +function updateStageUnifiedLabel( + stage: OwnerStage, + tabId: string, + key: 'generatedLabel' | 'label', + value: string +): void { + const index = stage.unifiedIndexByTabId.get(tabId) + if (index === undefined || stage.unifiedTabs[index]?.[key] === value) { + return + } + if (!stage.unifiedTabsChanged) { + stage.unifiedTabs = [...stage.unifiedTabs] + stage.unifiedTabsChanged = true + } + stage.unifiedTabs[index] = { ...stage.unifiedTabs[index], [key]: value } +} + +function finishTitleStages( + state: TitleState, + stages: Map, + sortEpochIncrement = 0 +): TitleUpdateResult { + const tabsChanged = [...stages.values()].some((stage) => stage.tabsChanged) + const unifiedTabsChanged = [...stages.values()].some((stage) => stage.unifiedTabsChanged) + if (!tabsChanged && !unifiedTabsChanged) { + return { patch: null, runtimeGraphChanged: false } + } + const patch: TitlePatch = {} + if (tabsChanged) { + patch.tabsByWorktree = { ...state.tabsByWorktree } + } + if (unifiedTabsChanged) { + patch.unifiedTabsByWorktree = { ...state.unifiedTabsByWorktree } + } + for (const [worktreeId, stage] of stages) { + if (stage.tabsChanged) { + patch.tabsByWorktree![worktreeId] = stage.tabs + } + if (stage.unifiedTabsChanged) { + patch.unifiedTabsByWorktree![worktreeId] = stage.unifiedTabs + } + } + if (sortEpochIncrement > 0) { + patch.sortEpoch = state.sortEpoch + sortEpochIncrement + } + return { patch, runtimeGraphChanged: tabsChanged } +} + +export function applyTerminalTabTitleUpdates( + state: TitleState, + updates: readonly TerminalTabTitleUpdate[], + ownerByTabId = buildOwnerIndex(state.tabsByWorktree) +): TitleUpdateResult { + const stages = new Map() + let sortEpochIncrement = 0 + for (const { tabId, title } of updates) { + const ownerWorktreeId = ownerByTabId.get(tabId) + if (!ownerWorktreeId) { + continue + } + const stage = getOwnerStage(state, stages, ownerWorktreeId) + const tabIndexes = stage.tabIndexesById.get(tabId) + const currentTab = tabIndexes ? stage.tabs[tabIndexes[0]] : undefined + if (!currentTab || !tabIndexes) { + continue + } + const nextTitle = title.trim() || getFallbackTabTitle(currentTab) + if (isDecorativeAgentTitleFrameChange(currentTab.title, nextTitle)) { + updateStageUnifiedLabel(stage, tabId, 'label', currentTab.title) + continue + } + updateStageUnifiedLabel(stage, tabId, 'label', nextTitle) + if (currentTab.title === nextTitle) { + continue + } + updateStageTabs(stage, tabIndexes, (tab) => ({ + ...tab, + title: nextTitle, + defaultTitle: + tab.defaultTitle ?? + (/^Terminal \d+$/.test(tab.title) ? tab.title : undefined) ?? + (/^Terminal \d+$/.test(nextTitle) ? nextTitle : undefined) + })) + if (ownerWorktreeId !== state.activeWorktreeId) { + sortEpochIncrement += 1 + } + } + return finishTitleStages(state, stages, sortEpochIncrement) +} + +export function applyGeneratedTabTitleUpdates( + state: TitleState, + updates: readonly GeneratedTabTitleUpdate[], + ownerByTabId = buildOwnerIndex(state.tabsByWorktree) +): TitleUpdateResult { + if (state.settings?.tabAutoGenerateTitle !== true) { + return { patch: null, runtimeGraphChanged: false } + } + const stages = new Map() + for (const { paneKey, prompt, options } of updates) { + const tabId = getTabIdFromPaneKey(paneKey) + const ownerWorktreeId = tabId ? ownerByTabId.get(tabId) : undefined + if (!tabId || !ownerWorktreeId || prompt.length === 0) { + continue + } + const stage = getOwnerStage(state, stages, ownerWorktreeId) + const tabIndexes = stage.tabIndexesById.get(tabId) + const currentTab = tabIndexes ? stage.tabs[tabIndexes[0]] : undefined + if ( + !currentTab || + !tabIndexes || + currentTab.customTitle?.trim() || + currentTab.quickCommandLabel?.trim() + ) { + continue + } + const existingGeneratedTitle = currentTab.generatedTitle?.trim() + if (existingGeneratedTitle && options?.replaceExistingGeneratedTitle !== true) { + continue + } + const generatedTitle = deriveGeneratedTabTitle(prompt) + if (!generatedTitle || existingGeneratedTitle === generatedTitle) { + continue + } + updateStageTabs(stage, tabIndexes, (tab) => ({ ...tab, generatedTitle })) + updateStageUnifiedLabel(stage, tabId, 'generatedLabel', generatedTitle) + } + return finishTitleStages(state, stages) +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 89fc0657479..c644b6db241 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -28,7 +28,6 @@ import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' -import { deriveGeneratedTabTitle } from '../../../../shared/agent-tab-title' import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature' import { isTerminalLeafId, @@ -72,6 +71,12 @@ import { isClaudeAgent } from '@/lib/agent-status' import { recordTerminalInputActivity } from '@/lib/terminal-input-activity-coalescing' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' +import { + applyGeneratedTabTitleUpdates, + applyTerminalTabTitleUpdates, + type GeneratedTabTitleUpdate, + type TerminalTabTitleUpdate +} from './terminal-tab-title-batch' import { dedupeTabOrder, ensureGroup, @@ -227,16 +232,6 @@ function consumePendingActivationSpawn( return count === 2 ? true : count - 1 } -function getFallbackTabTitle(tab: TerminalTab, index?: number): string { - return ( - tab.customTitle?.trim() || - tab.quickCommandLabel?.trim() || - tab.defaultTitle?.trim() || - tab.title || - `Terminal ${(index ?? 0) + 1}` - ) -} - function getPathDisplayName(path: string, fallback: string): string { const normalized = path.trim().replace(/[\\/]+$/g, '') const basename = normalized.split(/[\\/]/).findLast(Boolean)?.trim() @@ -339,36 +334,6 @@ function getTerminalTabOwnerWorktreeId( return terminalTabOwnerCache.get(tabId) ?? null } -function updateUnifiedTerminalLabel( - unifiedTabs: Tab[], - terminalTabId: string, - label: string -): Tab[] | null { - const unifiedIndex = unifiedTabs.findIndex( - (entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId - ) - if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.label === label) { - return null - } - return unifiedTabs.map((entry, index) => (index === unifiedIndex ? { ...entry, label } : entry)) -} - -function updateUnifiedTerminalGeneratedLabel( - unifiedTabs: Tab[], - terminalTabId: string, - generatedLabel: string -): Tab[] | null { - const unifiedIndex = unifiedTabs.findIndex( - (entry) => entry.contentType === 'terminal' && entry.entityId === terminalTabId - ) - if (unifiedIndex === -1 || unifiedTabs[unifiedIndex]?.generatedLabel === generatedLabel) { - return null - } - return unifiedTabs.map((entry, index) => - index === unifiedIndex ? { ...entry, generatedLabel } : entry - ) -} - function getTabIdFromPaneKey(paneKey: string): string | null { return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null } @@ -690,12 +655,14 @@ export type TerminalSlice = { setActiveTab: (tabId: string) => void setActiveTabForWorktree: (worktreeId: string, tabId: string) => void updateTabTitle: (tabId: string, title: string) => void + updateTabTitles: (updates: readonly TerminalTabTitleUpdate[]) => void setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void setGeneratedTabTitleFromAgentPrompt: ( paneKey: string, prompt: string, options?: { replaceExistingGeneratedTitle?: boolean } ) => void + setGeneratedTabTitlesFromAgentPrompts: (updates: readonly GeneratedTabTitleUpdate[]) => void clearTabLaunchAgent: (tabId: string) => void setRuntimePaneTitle: (tabId: string, paneId: number, title: string) => void clearRuntimePaneTitle: (tabId: string, paneId: number) => void @@ -2002,77 +1969,29 @@ export const createTerminalSlice: StateCreator }, updateTabTitle: (tabId, title) => { - set((s) => { - // Why: update only the owner to preserve selector equality for background worktrees. - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(s.tabsByWorktree, tabId) - if (!ownerWorktreeId) { - return s + set((state) => { + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) + const ownerByTabId = ownerWorktreeId + ? new Map([[tabId, ownerWorktreeId]]) + : new Map() + const result = applyTerminalTabTitleUpdates(state, [{ tabId, title }], ownerByTabId) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() } - const tabs = s.tabsByWorktree[ownerWorktreeId] ?? [] - const tabIndex = tabs.findIndex((t) => t.id === tabId) - const currentTab = tabs[tabIndex] - if (!currentTab) { - return s + return result.patch ?? state + }) + }, + + updateTabTitles: (updates) => { + if (updates.length === 0) { + return + } + set((state) => { + const result = applyTerminalTabTitleUpdates(state, updates) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() } - const nextTitle = title.trim() || getFallbackTabTitle(currentTab) - const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] - if (isDecorativeAgentTitleFrameChange(currentTab.title, nextTitle)) { - const unifiedTabsWithCurrentLabel = updateUnifiedTerminalLabel( - currentUnifiedTabs, - tabId, - currentTab.title - ) - return unifiedTabsWithCurrentLabel - ? { - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [ownerWorktreeId]: unifiedTabsWithCurrentLabel - } - } - : s - } - const unifiedTabsWithUpdatedLabel = updateUnifiedTerminalLabel( - currentUnifiedTabs, - tabId, - nextTitle - ) - if (currentTab.title === nextTitle) { - return unifiedTabsWithUpdatedLabel - ? { - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [ownerWorktreeId]: unifiedTabsWithUpdatedLabel - } - } - : s - } - const ownerTabs = tabs.map((tab) => - tab.id === tabId - ? { - ...tab, - // Why: PTYs can briefly emit an empty title as an agent exits; keep the stable fallback instead of a blank tab. - title: nextTitle, - defaultTitle: - tab.defaultTitle ?? - (/^Terminal \d+$/.test(tab.title) ? tab.title : undefined) ?? - (/^Terminal \d+$/.test(nextTitle) ? nextTitle : undefined) - } - : tab - ) - scheduleRuntimeGraphSync() - const nextTabsByWorktree = { ...s.tabsByWorktree, [ownerWorktreeId]: ownerTabs } - // Why: title changes affect sorting, except active-worktree remount side effects. - const isActive = ownerWorktreeId === s.activeWorktreeId - const nextState: Partial = isActive - ? { tabsByWorktree: nextTabsByWorktree } - : { tabsByWorktree: nextTabsByWorktree, sortEpoch: s.sortEpoch + 1 } - if (unifiedTabsWithUpdatedLabel) { - nextState.unifiedTabsByWorktree = { - ...s.unifiedTabsByWorktree, - [ownerWorktreeId]: unifiedTabsWithUpdatedLabel - } - } - return nextState + return result.patch ?? state }) }, @@ -2109,18 +2028,16 @@ export const createTerminalSlice: StateCreator setGeneratedTabTitleFromAgentPrompt: (paneKey, prompt, options) => { // Why: setAgentStatus is high-frequency; skip derive/set unless the feature is on and this tab still needs a (re)generated title. - if (get().settings?.tabAutoGenerateTitle !== true) { - return - } + const state = get() const tabId = getTabIdFromPaneKey(paneKey) - if (!tabId || prompt.length === 0) { + if (!tabId || prompt.length === 0 || state.settings?.tabAutoGenerateTitle !== true) { return } - const ownerWorktreeId = getTerminalTabOwnerWorktreeId(get().tabsByWorktree, tabId) + const ownerWorktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) if (!ownerWorktreeId) { return } - const tabs = get().tabsByWorktree[ownerWorktreeId] ?? [] + const tabs = state.tabsByWorktree[ownerWorktreeId] ?? [] const currentTab = tabs.find((tab) => tab.id === tabId) if (!currentTab || currentTab.customTitle?.trim() || currentTab.quickCommandLabel?.trim()) { return @@ -2129,57 +2046,29 @@ export const createTerminalSlice: StateCreator if (existingGeneratedTitle && options?.replaceExistingGeneratedTitle !== true) { return } - const generatedTitle = deriveGeneratedTabTitle(prompt) - if (!generatedTitle || existingGeneratedTitle === generatedTitle) { + set((latestState) => { + const result = applyGeneratedTabTitleUpdates( + latestState, + [{ paneKey, prompt, options }], + new Map([[tabId, ownerWorktreeId]]) + ) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() + } + return result.patch ?? latestState + }) + }, + + setGeneratedTabTitlesFromAgentPrompts: (updates) => { + if (updates.length === 0) { return } - set((s) => { - const ownerTabsForWrite = s.tabsByWorktree[ownerWorktreeId] - if (!ownerTabsForWrite) { - return s - } - const tabIndex = ownerTabsForWrite.findIndex((tab) => tab.id === tabId) - const tabForWrite = ownerTabsForWrite[tabIndex] - // Why: re-check inside set so concurrent renames / setting flips win. - if ( - !tabForWrite || - s.settings?.tabAutoGenerateTitle !== true || - tabForWrite.customTitle?.trim() || - tabForWrite.quickCommandLabel?.trim() - ) { - return s - } - const latestGeneratedTitle = tabForWrite.generatedTitle?.trim() - if ( - latestGeneratedTitle && - (latestGeneratedTitle === generatedTitle || options?.replaceExistingGeneratedTitle !== true) - ) { - return s - } - const ownerTabs = ownerTabsForWrite.map((tab) => - tab.id === tabId ? { ...tab, generatedTitle } : tab - ) - const currentUnifiedTabs = s.unifiedTabsByWorktree[ownerWorktreeId] ?? [] - const unifiedTabsWithGeneratedLabel = updateUnifiedTerminalGeneratedLabel( - currentUnifiedTabs, - tabId, - generatedTitle - ) - scheduleRuntimeGraphSync() - return { - tabsByWorktree: { - ...s.tabsByWorktree, - [ownerWorktreeId]: ownerTabs - }, - ...(unifiedTabsWithGeneratedLabel - ? { - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [ownerWorktreeId]: unifiedTabsWithGeneratedLabel - } - } - : {}) + set((state) => { + const result = applyGeneratedTabTitleUpdates(state, updates) + if (result.runtimeGraphChanged) { + scheduleRuntimeGraphSync() } + return result.patch ?? state }) }, diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 9f08791eec3..80ce1a4f823 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -44,7 +44,9 @@ export type AgentType = WellKnownAgentType | (string & {}) /** A snapshot of a previous agent state, used to render activity blocks. * Why: intentionally narrower than AgentStatusEntry — tool/assistant context is - * per-turn, not meaningful on a historical snapshot, and would bloat memory. */ + * per-turn, not meaningful on a historical snapshot, and would bloat memory. + * Coalesced-turn output lives in AgentStatusEntry.lastCompletedAssistantMessage, + * one copy per pane, so it can't multiply by AGENT_STATE_HISTORY_MAX. */ export type AgentStateHistoryEntry = { state: AgentStatusState prompt: string @@ -124,6 +126,10 @@ export type AgentStatusEntry = { interactivePrompt?: string /** Most recent assistant message preview, when the hook carried one. */ lastAssistantMessage?: string + /** Output of the newest completed (non-boundary) turn, kept across the next `working`. + * Why: batched publications can fold a whole done→working turn into one notification, + * so `lastAssistantMessage` is already cleared by the time a subscriber observes it. */ + lastCompletedAssistantMessage?: string /** True when this `done` was reached via interrupt, not normal completion * (agent-reported or Orca's guarded fallback). Undefined otherwise. */ interrupted?: boolean @@ -226,7 +232,7 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & { tabId?: string worktreeId?: string /** Identifies the SSH connection the event arrived on, or null for local. - * Only the remote-ingest path (`ingestRemote`) can stamp it; the HTTP path always sets null. See docs/design/agent-status-over-ssh.md §5. */ + * Only the remote-ingest path (`ingestRemote`) can stamp it from mux identity; the HTTP path has no mux and always sets null. */ connectionId: string | null /** Timestamp (ms) when the hook server received this latest status event. */ receivedAt: number From a2c9f7e52b1c9520b34ea37cd86a14254218f034 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:05:05 -0700 Subject: [PATCH 20/33] fix(terminal): never wipe a painted pane for a snapshot that carries no image (#14099) * fix(terminal): never wipe a painted pane for a snapshot that carries no image A hidden-output restore that comes back as a success frame with an empty image was still applied. The normal-buffer replay opens with \x1b[2J\x1b[3J\x1b[H, so it cleared screen and scrollback and painted nothing in their place -- turning a host that had nothing to say into a blank pane. An empty image is the absence of an answer, not proof the pane is empty, so keep the frame the user can still see. Retry and banner behavior are unchanged: an empty image still counts as a successful, non-bannered recovery. * test(terminal): prove imageless restore preserves painted buffer --- .../terminal-pane/pty-connection.test.ts | 58 +++++++++++++++++++ .../terminal-pane/pty-connection.ts | 13 +++-- 2 files changed, 67 insertions(+), 4 deletions(-) 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 3191bd99a0c..e73521d1ca3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -326,6 +326,7 @@ type MockTransport = { getPtyId: ReturnType getConnectionId: ReturnType serializeBuffer?: ReturnType + serializeBufferOutcome?: ReturnType } const scheduleRuntimeGraphSync = vi.fn() @@ -15169,6 +15170,63 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('preserves the painted normal buffer when a successful hidden restore has no image', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible remote output\r\n' + transport.serializeBuffer = vi.fn() + transport.serializeBufferOutcome = vi.fn().mockResolvedValue({ + availability: { kind: 'snapshot' }, + snapshot: { + data: '', + scrollbackAnsi: '', + cols: 120, + rows: 40, + seq: hidden.length + live.length, + source: 'headless' + } + }) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'remote:env-1@@terminal-1' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + const writeStart = pane.terminal.write.mock.calls.length + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + const restoreWrites = pane.terminal.write.mock.calls + .slice(writeStart) + .map(([data]) => String(data)) + const paintedFrame = Array.from({ length: 8 }, (_, index) => `painted-frame-${index + 1}`).join( + '\r\n' + ) + const actual = await renderHeadlessTerminalState([paintedFrame, ...restoreWrites], 120, 4) + const expected = await renderHeadlessTerminalState( + [paintedFrame, ...restoreWrites.filter((data) => data === live)], + 120, + 4 + ) + expect(actual).toEqual(expected) + expect(transport.serializeBufferOutcome).toHaveBeenCalledTimes(1) + expect(transport.serializeBuffer).not.toHaveBeenCalled() + disposable.dispose() + }) + it('defers inactive split-pane plain hidden output restore until the pane returns', async () => { const { resetHiddenOutputRestoreSchedulerForTests } = await import('./hidden-output-restore-scheduler') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 98a3ce1b66f..907a8af9696 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -7324,10 +7324,15 @@ export function connectPanePty( snapshot.alternateScreen === true && snapshot.frameRestoreAnsi !== undefined && shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane)) - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { - skipAltFrame: skippedAltFrame - })) { - writeReplayData(replayChunk) + // Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback. + const snapshotCarriesNoImage = + snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi + if (!snapshotCarriesNoImage) { + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { + skipAltFrame: skippedAltFrame + })) { + writeReplayData(replayChunk) + } } // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). writeReplayData( From c1e517db5612a490a7dce2164f215db4f0c8fb6b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:14:21 -0700 Subject: [PATCH 21/33] fix(renderer): refresh runtime catalogs on remote repo events (#13787) A runtime host emits one `reposChanged` client event for project-group and folder-workspace mutations as well as repo mutations (createProjectGroup, moveProjectToGroup, createFolderWorkspace, ... all call notifyReposChanged), but the client's handler only refetched that host's repo catalog, worktrees, and lineage. Group and folder-workspace rows for a remote runtime therefore stayed stale for the rest of the session unless an unrelated *local* `repos:changed` happened to fire the all-host sweep. Refetch the environment's project group and folder workspace catalogs in the same scheduled refresh. Groups are fetched first because folder workspaces resolve their owning group from `projectGroups`. Both fetches are host-fenced (`claimHostCatalogFence`), so they cannot clobber local or other runtimes' rows, and the scheduler's existing debounce/min-interval still bounds them. --- src/renderer/src/hooks/useIpcEvents.test.ts | 106 ++++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 5 + 2 files changed, 111 insertions(+) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 2e916dd4f89..25d5cc1046c 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -10175,6 +10175,112 @@ describe('useIpcEvents agent status snapshot integration', () => { }) }) +describe('runtime host catalog refresh on reposChanged', () => { + // Why: the host emits one reposChanged for project-group and folder-workspace edits + // too, so a repos-only refresh leaves those catalogs stale on every paired client. + it('refetches the runtime host project group and folder workspace catalogs', async () => { + vi.resetModules() + vi.useFakeTimers() + try { + const calls: string[] = [] + const fetchRuntimeEnvironmentRepos = vi.fn(() => { + calls.push('repos') + return Promise.resolve([]) + }) + const fetchProjectGroups = vi.fn(() => { + calls.push('project-groups') + return Promise.resolve() + }) + const fetchFolderWorkspaces = vi.fn(() => { + calls.push('folder-workspaces') + return Promise.resolve() + }) + const state = { + settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + runtimeEnvironments: [], + runtimeStatusByEnvironmentId: new Map(), + tabsByWorktree: {}, + ptyIdsByTabId: {}, + remountTerminalTabForRecovery: vi.fn(), + markEnvironmentSshStateStale: vi.fn(), + fetchRepos: vi.fn(() => Promise.resolve()), + fetchRuntimeEnvironmentRepos, + fetchProjectGroups, + fetchFolderWorkspaces, + fetchWorktrees: vi.fn(() => Promise.resolve()), + fetchWorktreeLineage: vi.fn(() => Promise.resolve()) + } + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { ...actual, useEffect: (effect: () => void | (() => void)) => void effect() } + }) + vi.doMock('../store', () => ({ + useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => state } + })) + const noopListener = (): (() => void) => () => {} + const autoStubNamespace = new Proxy( + {}, + { + get: + () => + (...args: unknown[]) => { + if (typeof args[0] === 'function') { + return noopListener() + } + return new Promise(() => {}) + } + } + ) + let runtimeOnResponse: ((response: unknown) => void) | undefined + const api = new Proxy( + { + runtimeEnvironments: { + subscribe: async (_args: unknown, callbacks: { onResponse: (r: unknown) => void }) => { + runtimeOnResponse = callbacks.onResponse + return { unsubscribe: vi.fn(), sendBinary: vi.fn() } + } + } + } as Record, + { get: (target, prop: string) => target[prop] ?? autoStubNamespace } + ) + vi.stubGlobal('window', { api }) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + // Seeded discovery for the connected runtime; drains the scheduler's debounce. + await vi.advanceTimersByTimeAsync(300) + + const runtimeOwner = { runtimeEnvironmentId: 'env-1' } + expect(fetchProjectGroups).toHaveBeenCalledWith(runtimeOwner) + expect(fetchFolderWorkspaces).toHaveBeenCalledWith(runtimeOwner) + // Folder workspaces resolve their owning group from projectGroups, so groups must land first. + expect(calls).toEqual(['repos', 'project-groups', 'folder-workspaces']) + + calls.length = 0 + fetchProjectGroups.mockClear() + fetchFolderWorkspaces.mockClear() + if (!runtimeOnResponse) { + throw new Error('Expected runtime client event callbacks') + } + // Past the scheduler's min interval so the event schedules on the debounce alone. + await vi.advanceTimersByTimeAsync(5_000) + runtimeOnResponse({ ok: true, result: { type: 'reposChanged' } }) + await vi.advanceTimersByTimeAsync(300) + + expect(fetchProjectGroups).toHaveBeenCalledWith(runtimeOwner) + expect(fetchFolderWorkspaces).toHaveBeenCalledWith(runtimeOwner) + expect(calls).toEqual(['repos', 'project-groups', 'folder-workspaces']) + } finally { + vi.useRealTimers() + } + }) +}) + describe('parked terminal recovery on repos:changed', () => { it('remounts a pane that parked on an unresolved host once repos hydrate', async () => { vi.resetModules() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index f204331ad2e..28371d8cd03 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -937,6 +937,11 @@ export function useIpcEvents(): void { // Why: project events can reveal target CRUD, but known target states already arrive by push. void refreshRuntimeEnvironmentSshTargetMetadata(environmentId).catch(() => {}) const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId) + // Why: the host emits one reposChanged for group/folder-workspace edits too, so those + // catalogs go stale without this; groups first because folder workspaces resolve owners from them. + const runtimeOwner = { runtimeEnvironmentId: environmentId } + await useAppStore.getState().fetchProjectGroups(runtimeOwner) + await useAppStore.getState().fetchFolderWorkspaces(runtimeOwner) await refreshRuntimeProjectWorktreesAndLineage( environmentId, repos, From 6ac39b73315231cd74f66e4cfbd2f7527133765f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:19:33 -0700 Subject: [PATCH 22/33] fix(worktrees): make hidden agent worktrees recoverable from the visibility dialog (#13652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktrees): make hidden agent worktrees recoverable from the visibility dialog A discovered agent scratch worktree (.claude/worktrees, .gsd-workspaces) was a one-way door: the non-Orca visibility toggle never reveals scratch by design (#9388), the inbox never announces it, and the dialog listed nothing — so once hidden it was unreachable from the UI while sitting on disk. The per-path import exception has outranked the hidden rule all along; no surface offered it. The dialog now refetches an authoritative list on open (a stale snapshot must not read as 'nothing hidden'), lists hidden importable worktrees, and offers a per-row Show wired to the existing inbox import action, which already merges the import + baseline and rolls back on a failed refresh. When the list cannot be read the dialog says so and offers a retry instead of claiming the repo has nothing. No new settings, schema, or persistence: recovery rides entirely on importedExternalWorktreePaths, which every host already stores and validates. The repo-wide toggle is untouched and still never reveals scratch. Fix #10324 Co-authored-by: Gldywn <14254051+Gldywn@users.noreply.github.com> * fix(worktrees): honest scan states and race-safe row actions in the visibility dialog - row Show stays disabled until the open-time authoritative scan settles; a click mid-scan could join the pre-write refetch and read success off a list computed before the import landed, a silent no-op on slow hosts - checking/failed indicators follow the scan state alone, so a warm older snapshot cannot present stale rows as current with no failure indication - ownership-neutral section copy: non-scratch rows are listed too when the repo-wide switch is off * fix(worktrees): close the retry race window and clear stale failure state - Try again is locked while a row import is in flight; a retry scan started before the import's write lands can absorb the import's own refetch and report success off a pre-import list - a successful row import (which requires a successful authoritative refetch) clears an earlier failed open-time scan instead of leaving a contradictory alert over the refreshed list - zh: 智能体 for agent (代理 reads as network proxy); polite live region on the checking hint * fix(worktrees): serialize visibility dialog actions * fix(worktrees): clarify persistent visibility policy * fix(worktrees): clarify hidden worktree list * Explain hidden worktree defaults * Show agent worktrees with Always show * fix(worktrees): bound visibility dialog state and rendering * fix(worktrees): preserve visibility mutation fences across dismissal * fix(worktrees): scope visibility mutations by host --------- Co-authored-by: Gldywn <14254051+Gldywn@users.noreply.github.com> --- src/main/ipc/repos-remote.test.ts | 22 + src/main/ipc/repos.ts | 9 + src/main/persistence.test.ts | 19 + src/main/persistence.ts | 1 + src/main/runtime/orca-runtime.ts | 1 + .../runtime/rpc/methods/repo-update-schema.ts | 1 + src/main/runtime/rpc/methods/repo.test.ts | 27 + src/preload/api-types.ts | 1 + .../src/components/sidebar/WorktreeList.tsx | 18 +- .../sidebar/WorktreeVisibilityDialog.test.tsx | 540 ++++++++++++++++++ .../sidebar/WorktreeVisibilityDialog.tsx | 383 +++++++++++-- .../WorktreeVisibilityHelpPopover.test.tsx | 123 ++++ .../sidebar/WorktreeVisibilityHelpPopover.tsx | 68 +++ .../worktree-visibility-host-target.ts | 56 ++ .../worktree-visibility-mutation-fence.ts | 92 +++ src/renderer/src/i18n/locales/en.json | 20 +- src/renderer/src/i18n/locales/es.json | 20 +- src/renderer/src/i18n/locales/ja.json | 20 +- src/renderer/src/i18n/locales/ko.json | 20 +- src/renderer/src/i18n/locales/zh.json | 20 +- src/renderer/src/store/slices/repos.ts | 1 + src/shared/external-worktree-inbox.test.ts | 37 ++ src/shared/external-worktree-inbox.ts | 31 +- src/shared/external-worktree-visibility.ts | 7 + src/shared/types.ts | 2 + src/shared/worktree-ownership.test.ts | 21 +- src/shared/worktree-ownership.ts | 6 +- 27 files changed, 1499 insertions(+), 67 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts create mode 100644 src/renderer/src/components/sidebar/worktree-visibility-mutation-fence.ts diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index d3440ed4579..bbd639eac32 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -2167,6 +2167,28 @@ describe('repos:add + repos:clone', () => { expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() }) + it('persists agent worktree visibility through local repos:update', () => { + const updated = { + id: 'repo-agent-visibility', + path: '/tmp/repo-agent-visibility', + displayName: 'repo-agent-visibility', + kind: 'git', + badgeColor: '#22c55e', + agentWorktreeVisibility: 'show' + } + mockStore.updateRepo.mockReturnValue(updated) + + const result = handlers.get('repos:update')!(null, { + repoId: updated.id, + updates: { agentWorktreeVisibility: 'show' } + }) + + expect(result).toBe(updated) + expect(mockStore.updateRepo).toHaveBeenCalledWith(updated.id, { + agentWorktreeVisibility: 'show' + }) + }) + it('prepares and invalidates roots when project host setup update changes worktree base path', () => { const repo = { id: 'repo-setup-update-root', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 9fe387805d3..b1500963f27 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -2126,6 +2126,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' > @@ -2196,6 +2197,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ) { delete updates.externalWorktreeVisibility } + if ( + 'agentWorktreeVisibility' in updates && + updates.agentWorktreeVisibility !== undefined && + updates.agentWorktreeVisibility !== 'hide' && + updates.agentWorktreeVisibility !== 'show' + ) { + delete updates.agentWorktreeVisibility + } if ( 'externalWorktreeVisibilityPromptDismissedAt' in updates && updates.externalWorktreeVisibilityPromptDismissedAt !== undefined && diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 05e6703728d..06ee8a9d6a0 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4738,6 +4738,25 @@ describe('Store', () => { expect(updated!.externalWorktreeVisibilityLegacy).toBe(true) }) + it('persists agent worktree visibility independently from external visibility', async () => { + const store = await createStore() + store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' })) + + const updated = store.updateRepo('r1', { agentWorktreeVisibility: 'show' }) + + expect(updated).toMatchObject({ + externalWorktreeVisibility: 'hide', + agentWorktreeVisibility: 'show' + }) + + store.flush() + const reloaded = await createStore() + expect(reloaded.getRepo('r1')).toMatchObject({ + externalWorktreeVisibility: 'hide', + agentWorktreeVisibility: 'show' + }) + }) + it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => { const store = await createStore() store.addRepo( diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 6d3e4bf04dd..b62b25f7977 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -4919,6 +4919,7 @@ export class Store { | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' | 'projectHostSetupMethod' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bcf6840448d..7cad4ad94bc 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -19149,6 +19149,7 @@ export class OrcaRuntimeService { | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' > diff --git a/src/main/runtime/rpc/methods/repo-update-schema.ts b/src/main/runtime/rpc/methods/repo-update-schema.ts index 6fdb59c3ae9..aaa0d886fb0 100644 --- a/src/main/runtime/rpc/methods/repo-update-schema.ts +++ b/src/main/runtime/rpc/methods/repo-update-schema.ts @@ -54,6 +54,7 @@ export function createRepoUpdateSchema( externalWorktreeVisibilityPromptDismissedAt: z.number().finite().optional(), externalWorktreeInboxBaselinePaths: z.array(z.string()).optional(), importedExternalWorktreePaths: z.array(z.string()).optional(), + agentWorktreeVisibility: z.enum(['hide', 'show']).optional(), externalWorktreeDiscoverySuppressedAt: z.number().finite().nullable().optional(), projectGroupId: OptionalString.nullable().optional(), projectGroupOrder: OptionalFiniteNumber, diff --git a/src/main/runtime/rpc/methods/repo.test.ts b/src/main/runtime/rpc/methods/repo.test.ts index 09991c4103e..7c31660c1c4 100644 --- a/src/main/runtime/rpc/methods/repo.test.ts +++ b/src/main/runtime/rpc/methods/repo.test.ts @@ -303,6 +303,33 @@ describe('repo RPC methods', () => { }) }) + it('persists agent worktree visibility updates', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateRepo: vi.fn().mockResolvedValue({ + id: 'repo-1', + path: '/srv/repo', + agentWorktreeVisibility: 'show' + }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('repo.update', { + repo: 'repo-1', + updates: { agentWorktreeVisibility: 'show' } + }) + ) + + expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', { + agentWorktreeVisibility: 'show' + }) + expect(response).toMatchObject({ + ok: true, + result: { repo: { id: 'repo-1', agentWorktreeVisibility: 'show' } } + }) + }) + it('persists resolved GitHub upstream metadata updates', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 65e47128949..8c901236967 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1196,6 +1196,7 @@ export type PreloadApi = { | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' | 'forkSyncMode' diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 575d50d23d1..25dccde1957 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -251,6 +251,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings' import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' import { ProjectGroupDeleteDialog } from './ProjectGroupDeleteDialog' import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' +import { findRepoForHost } from '@/store/slices/repo-host-identity' import { isGitRepoKind } from '../../../../shared/repo-kind' import { effectiveExternalWorktreeVisibility, @@ -662,7 +663,7 @@ type VirtualizedWorktreeViewportProps = { collapsedGroups: Set handleCreateForRepo: (projectId: string) => void handleOpenRepoSettings: (projectId: string, sectionId?: string) => void - handleOpenWorktreeVisibility: (projectId: string) => void + handleOpenWorktreeVisibility: (repo: Repo) => void handleShowImportedWorktrees: (projectId: string) => void handleKeepImportedWorktreesHidden: (projectId: string) => void importedWorktreeCardActionState: ReadonlyMap @@ -4630,7 +4631,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp { if (row.repo) { - handleOpenWorktreeVisibility(row.repo.id) + handleOpenWorktreeVisibility(row.repo) } }} > @@ -6051,8 +6052,11 @@ const WorktreeList = React.memo(function WorktreeList({ ) const handleOpenWorktreeVisibility = useCallback( - (projectId: string) => { - openModal('worktree-visibility', { repoId: projectId }) + (repo: Repo) => { + openModal('worktree-visibility', { + repoId: repo.id, + hostId: getRepoExecutionHostId(repo) + }) }, [openModal] ) @@ -6819,9 +6823,11 @@ const WorktreeList = React.memo(function WorktreeList({ if (!suppressExternalWorktreeInboxRepoId) { return } - const projectId = suppressExternalWorktreeInboxRepoId + const repo = findRepoForHost(repos, suppressExternalWorktreeInboxRepoId, { settings }) setSuppressExternalWorktreeInboxRepoId(null) - handleOpenWorktreeVisibility(projectId) + if (repo) { + handleOpenWorktreeVisibility(repo) + } }} /> ({ + state: { + activeModal: 'worktree-visibility' as string | null, + modalData: { repoId: 'repo-1' } as Record, + closeModal: vi.fn(), + repos: [] as unknown[], + updateRepo: vi.fn(), + fetchWorktrees: vi.fn(), + detectedWorktreesByRepo: {} as Record + } +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), + { getState: () => mocks.state } + ) +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + values + ? fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values[name] ?? '')) + : fallback +})) + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 56, + getVirtualItems: () => + Array.from({ length: Math.min(count, 10) }, (_, index) => ({ + index, + key: index, + start: index * 56, + size: 56, + end: (index + 1) * 56, + lane: 0 + })), + measureElement: vi.fn() + }) +})) + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo', + displayName: 'orca', + badgeColor: '#000000', + addedAt: Date.UTC(2026, 4, 24), + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityPromptDismissedAt: 1, + // Why: the inbox already stopped announcing this path; recovery must not depend on it. + externalWorktreeInboxBaselinePaths: [SCRATCH_PATH], + ...overrides + } +} + +function makeWorktree(overrides: Partial = {}): DetectedWorktree { + return { + id: `repo-1::${overrides.path ?? SCRATCH_PATH}`, + repoId: 'repo-1', + path: SCRATCH_PATH, + displayName: 'scratch-1', + branch: 'refs/heads/scratch-1', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ownership: 'agent-scratch', + selectedCheckout: false, + visible: false, + ...overrides + } as DetectedWorktree +} + +function makeDetected( + worktrees: DetectedWorktree[] = [makeWorktree()], + overrides: Partial = {} +): DetectedWorktreeListResult { + return { + repoId: 'repo-1', + authoritative: true, + source: 'git', + worktrees, + ...overrides + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + vi.clearAllMocks() + mocks.state.activeModal = 'worktree-visibility' + mocks.state.modalData = { repoId: 'repo-1' } + mocks.state.repos = [makeRepo()] + mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected() } + mocks.state.updateRepo.mockResolvedValue(true) + mocks.state.fetchWorktrees.mockResolvedValue(true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + document.body.innerHTML = '' + vi.resetModules() +}) + +async function renderDialog(): Promise { + const { default: WorktreeVisibilityDialog } = await import('./WorktreeVisibilityDialog') + await act(async () => { + root.render() + }) +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +function buttonWithText(text: string): HTMLButtonElement { + const button = [...document.querySelectorAll('button')].find( + (candidate) => (candidate.textContent ?? '').trim() === text + ) + if (!button) { + throw new Error(`No button with text "${text}"`) + } + return button as HTMLButtonElement +} + +function alwaysShowSwitch(): HTMLButtonElement { + const control = document.querySelector('[role="switch"]') + if (!control) { + throw new Error('No Always show switch') + } + return control as HTMLButtonElement +} + +async function click(element: HTMLElement): Promise { + await act(async () => { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + }) +} + +describe('WorktreeVisibilityDialog', () => { + it('lists a hidden agent worktree with a repo-relative path', async () => { + await renderDialog() + + expect(document.body.textContent).toContain('Hidden worktrees (1)') + expect(document.body.textContent).toContain( + 'Choose which hidden worktrees to show individually.' + ) + expect(document.body.textContent).toContain('scratch-1') + expect(document.body.textContent).toContain('.claude/worktrees/scratch-1') + expect(document.body.textContent).not.toContain('/repo/.claude') + expect( + document.querySelector('button[aria-label="Which worktrees are hidden by default?"]') + ).not.toBeNull() + }) + + it('bounds rendered rows for repositories with many hidden worktrees', async () => { + const worktrees = Array.from({ length: 500 }, (_, index) => + makeWorktree({ + id: `hidden-${index}`, + path: `/repo/.claude/worktrees/scratch-${index}`, + displayName: `scratch-${index}` + }) + ) + mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected(worktrees) } + + await renderDialog() + + expect(document.body.textContent).toContain('Hidden worktrees (500)') + expect(document.querySelectorAll('ul > li').length).toBeLessThan(20) + }) + + it('recovers a hidden worktree per path through the existing import exception', async () => { + // Why: individual recovery remains available while the repo-wide policy is off. + await renderDialog() + + await click(buttonWithText('Show')) + + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + importedExternalWorktreePaths: [SCRATCH_PATH], + externalWorktreeInboxBaselinePaths: [SCRATCH_PATH] + }) + expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1', { + requireAuthoritative: true + }) + expect(mocks.state.closeModal).not.toHaveBeenCalled() + }) + + it('omits the hidden list when nothing is recoverable', async () => { + mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected([]) } + await renderDialog() + + expect(document.body.textContent).not.toContain('Hidden worktrees') + }) + + it('says it is checking instead of claiming nothing is hidden on a fallback snapshot', async () => { + mocks.state.detectedWorktreesByRepo = { + 'repo-1': makeDetected([makeWorktree()], { authoritative: false, source: 'session-fallback' }) + } + mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {})) + await renderDialog() + + expect(document.body.textContent).toContain('Checking…') + expect(document.body.textContent).not.toContain('Hidden worktrees') + expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1', { + requireAuthoritative: true + }) + }) + + it('offers a retry instead of a dead end when the list cannot be read', async () => { + mocks.state.detectedWorktreesByRepo = { + 'repo-1': makeDetected([makeWorktree()], { authoritative: false, source: 'session-fallback' }) + } + mocks.state.fetchWorktrees.mockResolvedValue(false) + await renderDialog() + + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + "Could not list this repo's worktrees." + ) + + // Why: a successful authoritative refetch also writes the trusted snapshot. + mocks.state.fetchWorktrees.mockImplementation(async () => { + mocks.state.detectedWorktreesByRepo = { 'repo-1': makeDetected() } + return true + }) + await click(buttonWithText('Try again')) + + expect(document.querySelector('[role="alert"]')).toBeNull() + expect(document.body.textContent).toContain('Hidden worktrees') + }) + + it('keeps rows visible but not actionable until the open-time scan settles', async () => { + // Why: a Show clicked mid-scan could join the pre-write refetch and read + // success off a list computed before the import landed — a silent no-op. + mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {})) + await renderDialog() + + expect(document.body.textContent).toContain('scratch-1') + expect(buttonWithText('Show').disabled).toBe(true) + expect(document.body.textContent).toContain('Checking…') + }) + + it('locks the repo-wide toggle until the open-time scan settles', async () => { + // Why: its post-write refresh must not coalesce onto a list started before the visibility write. + mocks.state.fetchWorktrees.mockImplementation(() => new Promise(() => {})) + await renderDialog() + + expect(alwaysShowSwitch().disabled).toBe(true) + }) + + it('reports a failed refresh even while an older trusted snapshot is on screen', async () => { + // Why: a warm snapshot must not present stale rows as current with no + // failure indication when the host has since become unreachable. + mocks.state.fetchWorktrees.mockResolvedValue(false) + await renderDialog() + + expect(document.body.textContent).toContain('scratch-1') + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + "Could not list this repo's worktrees." + ) + }) + + it('locks retry while a row import is in flight, so it cannot race the write', async () => { + // Why: a retry scan started before the import's write lands can absorb the + // import's own refetch and report success off a pre-import list. + mocks.state.fetchWorktrees.mockResolvedValue(false) + mocks.state.updateRepo.mockImplementation(() => new Promise(() => {})) + await renderDialog() + + await click(buttonWithText('Show')) + + expect(buttonWithText('Try again').disabled).toBe(true) + }) + + it('locks the repo-wide toggle while a row import is in flight', async () => { + mocks.state.fetchWorktrees.mockResolvedValue(false) + mocks.state.updateRepo.mockImplementation(() => new Promise(() => {})) + await renderDialog() + + await click(buttonWithText('Show')) + + expect(alwaysShowSwitch().disabled).toBe(true) + }) + + it('locks row actions and retry while the repo-wide toggle is in flight', async () => { + mocks.state.fetchWorktrees.mockResolvedValueOnce(false) + mocks.state.updateRepo.mockImplementation(() => new Promise(() => {})) + await renderDialog() + + await click(alwaysShowSwitch()) + + expect(buttonWithText('Show').disabled).toBe(true) + expect(buttonWithText('Try again').disabled).toBe(true) + }) + + it('clears a stale failure once a row import refreshes the list successfully', async () => { + mocks.state.fetchWorktrees.mockResolvedValueOnce(false) + await renderDialog() + expect(document.querySelector('[role="alert"]')).not.toBeNull() + + mocks.state.fetchWorktrees.mockResolvedValue(true) + await click(buttonWithText('Show')) + + expect(document.querySelector('[role="alert"]')).toBeNull() + }) + + it('does not leak an old repo action failure into a newly opened repo', async () => { + const update = deferred() + mocks.state.updateRepo.mockReturnValueOnce(update.promise) + await renderDialog() + await click(buttonWithText('Show')) + + mocks.state.modalData = { repoId: 'repo-2' } + mocks.state.repos = [makeRepo({ id: 'repo-2', path: '/repo-2', displayName: 'other' })] + mocks.state.detectedWorktreesByRepo = { 'repo-2': makeDetected([], { repoId: 'repo-2' }) } + await renderDialog() + + update.resolve(false) + await act(async () => update.promise) + + expect(document.querySelector('[role="alert"]')).toBeNull() + }) + + it('keeps a remounted repo locked until its earlier row action settles', async () => { + const update = deferred() + mocks.state.updateRepo.mockReturnValueOnce(update.promise) + await renderDialog() + mocks.state.fetchWorktrees.mockClear() + await click(buttonWithText('Show')) + + await act(async () => root.render(null)) + await renderDialog() + + expect(document.body.textContent).toContain('Hidden worktrees (1)') + expect(buttonWithText('Showing…').disabled).toBe(true) + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + + await act(async () => { + update.resolve(false) + await update.promise + await Promise.resolve() + }) + expect(buttonWithText('Show').disabled).toBe(false) + }) + + it('keeps a remounted repo locked until its earlier toggle settles', async () => { + const update = deferred() + mocks.state.updateRepo.mockReturnValueOnce(update.promise) + await renderDialog() + mocks.state.fetchWorktrees.mockClear() + await click(alwaysShowSwitch()) + + await act(async () => root.render(null)) + await renderDialog() + + expect(alwaysShowSwitch().disabled).toBe(true) + expect(buttonWithText('Show').disabled).toBe(true) + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + + await act(async () => { + update.resolve(false) + await update.promise + await Promise.resolve() + }) + expect(alwaysShowSwitch().disabled).toBe(false) + }) + + it('does not carry a mutation fence across same-id repos on different hosts', async () => { + const update = deferred() + const localWorktree = makeWorktree({ hostId: 'local' }) + const remotePath = '/srv/repo/.claude/worktrees/remote-scratch' + const remoteWorktree = makeWorktree({ + id: `repo-1::${remotePath}`, + path: remotePath, + displayName: 'remote-scratch', + hostId: 'runtime:env-1' + }) + mocks.state.modalData = { repoId: 'repo-1', hostId: 'local' } + mocks.state.repos = [ + makeRepo(), + makeRepo({ path: '/srv/repo', displayName: 'remote', executionHostId: 'runtime:env-1' }) + ] + mocks.state.detectedWorktreesByRepo = { + 'repo-1': makeDetected([localWorktree, remoteWorktree]) + } + mocks.state.updateRepo.mockReturnValueOnce(update.promise) + await renderDialog() + await click(buttonWithText('Show')) + + await act(async () => root.render(null)) + mocks.state.modalData = { repoId: 'repo-1', hostId: 'runtime:env-1' } + mocks.state.fetchWorktrees.mockClear() + await renderDialog() + + expect(document.body.textContent).toContain('remote-scratch') + expect(document.body.textContent).not.toContain('scratch-1') + expect(buttonWithText('Show').disabled).toBe(false) + expect(mocks.state.fetchWorktrees).toHaveBeenCalledWith('repo-1', { + requireAuthoritative: true, + executionHostId: 'runtime:env-1' + }) + + await click(buttonWithText('Show')) + expect(mocks.state.updateRepo).toHaveBeenLastCalledWith( + 'repo-1', + { + importedExternalWorktreePaths: [remotePath], + externalWorktreeInboxBaselinePaths: [SCRATCH_PATH, remotePath] + }, + { hostId: 'runtime:env-1' } + ) + + await act(async () => { + update.resolve(false) + await update.promise + }) + }) + + it('reports a failed persistent visibility update without starting a refresh', async () => { + mocks.state.updateRepo.mockResolvedValue(false) + await renderDialog() + mocks.state.fetchWorktrees.mockClear() + + await click(alwaysShowSwitch()) + + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + 'Could not update worktree visibility. Try again.' + ) + expect(mocks.state.fetchWorktrees).not.toHaveBeenCalled() + }) + + it('reports a failed authoritative refresh after updating persistent visibility', async () => { + await renderDialog() + mocks.state.fetchWorktrees.mockResolvedValue(false) + + await click(alwaysShowSwitch()) + + expect(mocks.state.fetchWorktrees).toHaveBeenLastCalledWith('repo-1', { + requireAuthoritative: true + }) + expect(document.querySelector('[role="alert"]')?.textContent).toContain( + "Could not list this repo's worktrees." + ) + }) + + it('enables the persistent policy for regular and agent worktrees', async () => { + await renderDialog() + + expect(document.body.textContent).toContain('Always show') + expect(document.body.textContent).toContain('1 worktree currently hidden') + expect(alwaysShowSwitch().getAttribute('aria-checked')).toBe('false') + + await click(alwaysShowSwitch()) + + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + externalWorktreeVisibility: 'show', + agentWorktreeVisibility: 'show', + externalWorktreeDiscoverySuppressedAt: null + }) + expect(mocks.state.closeModal).not.toHaveBeenCalled() + }) + + it('keeps the combined switch off until the agent policy is explicitly enabled', async () => { + mocks.state.repos = [makeRepo({ externalWorktreeVisibility: 'show' })] + await renderDialog() + + expect(alwaysShowSwitch().getAttribute('aria-checked')).toBe('false') + expect(document.body.textContent).toContain('scratch-1') + + await click(alwaysShowSwitch()) + + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + externalWorktreeVisibility: 'show', + agentWorktreeVisibility: 'show', + externalWorktreeDiscoverySuppressedAt: null + }) + }) + + it('turns the persistent policy off without closing the dialog', async () => { + mocks.state.repos = [ + makeRepo({ externalWorktreeVisibility: 'show', agentWorktreeVisibility: 'show' }) + ] + await renderDialog() + + expect(document.body.textContent).toContain('0 worktrees currently shown') + expect(alwaysShowSwitch().getAttribute('aria-checked')).toBe('true') + + await click(alwaysShowSwitch()) + + expect(mocks.state.updateRepo).toHaveBeenCalledWith('repo-1', { + externalWorktreeVisibility: 'hide', + agentWorktreeVisibility: 'hide' + }) + expect(mocks.state.closeModal).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index 5cad24b5ec7..9badad39b41 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -1,5 +1,6 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import { Eye, EyeOff } from 'lucide-react' +import { useVirtualizer } from '@tanstack/react-virtual' import { useAppStore } from '@/store' import { Dialog, @@ -9,16 +10,37 @@ import { DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' import { - getHiddenExternalWorktrees, - getVisibleExternalWorktrees + getHiddenImportableExternalWorktrees, + getVisibleNonOrcaWorktrees } from '../../../../shared/external-worktree-inbox' import { isGitRepoKind } from '../../../../shared/repo-kind' +import { relativePathInsideRoot } from '../../../../shared/cross-platform-path' import { + effectiveAgentWorktreeVisibility, effectiveExternalWorktreeVisibility, isLegacyRepoForExternalWorktreeVisibility } from '../../../../shared/worktree-ownership' import { translate } from '@/i18n/i18n' +import { + importNewExternalWorktreeInboxPaths, + type NewExternalWorktreesInboxActionState +} from './new-external-worktrees-inbox-actions' +import WorktreeVisibilityHelpPopover from './WorktreeVisibilityHelpPopover' +import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { + resolveWorktreeVisibilityHostTarget, + useWorktreeVisibilityHostActions +} from './worktree-visibility-host-target' +import { + finishVisibilityMutation, + getActiveVisibilityMutation, + startVisibilityMutation, + type ActiveVisibilityMutation, + useVisibilityMutationFence +} from './worktree-visibility-mutation-fence' export default function WorktreeVisibilityDialog(): React.JSX.Element | null { const activeModal = useAppStore((s) => s.activeModal) @@ -28,59 +50,216 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { const updateRepo = useAppStore((s) => s.updateRepo) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) + const settings = useAppStore((s) => s.settings) + const [actionState, setActionState] = useState(null) + const [busyPath, setBusyPath] = useState(null) + const [isToggling, setIsToggling] = useState(false) + const [listState, setListState] = useState<'checking' | 'ready' | 'failed'>('checking') + const alwaysShowSwitchId = useId() + const hiddenListHeadingId = useId() + const hiddenListRef = useRef(null) + const isOpen = activeModal === 'worktree-visibility' const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : '' - const repo = repos.find((candidate) => candidate.id === repoId) ?? null - const detected = repoId ? detectedWorktreesByRepo[repoId] : undefined + const { + detected, + repo, + requestedHostId, + scope: mutationScope + } = resolveWorktreeVisibilityHostTarget( + { repos, settings, detectedWorktreesByRepo }, + repoId, + modalData.hostId + ) + const currentMutationScopeRef = useRef(mutationScope) + const activeMutation = getActiveVisibilityMutation(mutationScope) + const effectiveBusyPath = + busyPath ?? (activeMutation?.kind === 'row' ? activeMutation.path : null) + const effectivelyToggling = isToggling || activeMutation?.kind === 'toggle' const showOther = repo ? effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) === 'show' : false - const hiddenCount = getHiddenExternalWorktrees(detected).length - const otherCount = getVisibleExternalWorktrees(detected).length + const showAgentScratch = repo ? effectiveAgentWorktreeVisibility(repo) === 'show' : false + const alwaysShow = showOther && showAgentScratch + const hiddenImportable = getHiddenImportableExternalWorktrees(detected) + const hiddenCount = hiddenImportable.length + const otherCount = getVisibleNonOrcaWorktrees(detected).length const hiddenWorktreeLabel = `${hiddenCount} ${hiddenCount === 1 ? 'worktree' : 'worktrees'}` const shownWorktreeLabel = `${otherCount} ${otherCount === 1 ? 'worktree' : 'worktrees'}` + const hiddenListVirtualizer = useVirtualizer({ + count: hiddenImportable.length, + getScrollElement: () => hiddenListRef.current, + estimateSize: () => 56, + getItemKey: (index) => hiddenImportable[index]?.id ?? index, + overscan: 3, + initialRect: { width: 480, height: 224 } + }) - const handleToggle = useCallback(async () => { + useLayoutEffect(() => { + currentMutationScopeRef.current = mutationScope + }, [mutationScope]) + + const { refreshTargetRepo, updateTargetRepo } = useWorktreeVisibilityHostActions( + fetchWorktrees, + updateRepo, + requestedHostId + ) + + useVisibilityMutationFence({ + scope: mutationScope, + repoId, + currentScopeRef: currentMutationScopeRef, + refresh: refreshTargetRepo, + setActionState, + setBusyPath, + setIsToggling, + setListState + }) + + // Why: recovery must not trust a stale or fallback snapshot — an empty one + // would read as "nothing hidden" for a worktree that is sitting on disk (#10324). + useEffect(() => { + if (!isOpen || !repoId) { + return + } + // Why: reopening mid-write must not start a scan that can absorb the mutation's confirmation refresh. + if (getActiveVisibilityMutation(mutationScope)) { + return + } + let cancelled = false + setListState('checking') + void refreshTargetRepo(repoId, { requireAuthoritative: true }).then((refreshed) => { + if (!cancelled) { + setListState(refreshed ? 'ready' : 'failed') + } + }) + return () => { + cancelled = true + } + }, [isOpen, mutationScope, refreshTargetRepo, repoId]) + + const handleRetryList = useCallback(async () => { if (!repoId) { return } - await updateRepo(repoId, { - externalWorktreeVisibility: showOther ? 'hide' : 'show', - // Why: showing hidden externals again should re-enable the inbox if the - // user previously opted out of discovery prompts for this repo. - // Why: null is the transport sentinel for clearing on remote runtime paths - // where `undefined` is stripped before persistence. - ...(!showOther ? { externalWorktreeDiscoverySuppressedAt: null } : {}) - }) - await fetchWorktrees(repoId) - closeModal() - }, [closeModal, fetchWorktrees, repoId, showOther, updateRepo]) + setListState('checking') + const refreshed = await refreshTargetRepo(repoId, { requireAuthoritative: true }) + if (currentMutationScopeRef.current === mutationScope) { + setListState(refreshed ? 'ready' : 'failed') + } + }, [mutationScope, refreshTargetRepo, repoId]) - if (activeModal !== 'worktree-visibility' || !repo || !isGitRepoKind(repo)) { + const handleShowWorktree = useCallback( + async (worktreePath: string) => { + if (!repo) { + return + } + const mutation: ActiveVisibilityMutation = { kind: 'row', path: worktreePath } + const targetMutationScope = getRepoHostIdentity(repo) + startVisibilityMutation(targetMutationScope, mutation) + setBusyPath(worktreePath) + try { + await importNewExternalWorktreeInboxPaths({ + projectId: repo.id, + repo, + worktreePaths: [worktreePath], + updateRepo: updateTargetRepo, + fetchWorktrees: refreshTargetRepo, + setInboxState: (_projectId, state) => { + if (currentMutationScopeRef.current !== targetMutationScope) { + return + } + setActionState(state) + // Why: a null state is only reachable after a successful authoritative + // refetch, which supersedes an earlier failed open-time scan. + if (state === null) { + setListState('ready') + } + } + }) + } finally { + finishVisibilityMutation(targetMutationScope, mutation) + if (currentMutationScopeRef.current === targetMutationScope) { + setBusyPath(null) + } + } + }, + [refreshTargetRepo, repo, updateTargetRepo] + ) + + const handleAlwaysShowChange = useCallback( + async (checked: boolean) => { + if (!repoId || checked === alwaysShow) { + return + } + const mutation: ActiveVisibilityMutation = { kind: 'toggle' } + startVisibilityMutation(mutationScope, mutation) + setActionState(null) + setIsToggling(true) + try { + const updated = await updateTargetRepo(repoId, { + externalWorktreeVisibility: checked ? 'show' : 'hide', + agentWorktreeVisibility: checked ? 'show' : 'hide', + // Why: showing hidden externals again should re-enable the inbox if the + // user previously opted out of discovery prompts for this repo. + // Why: null is the transport sentinel for clearing on remote runtime paths + // where `undefined` is stripped before persistence. + ...(checked ? { externalWorktreeDiscoverySuppressedAt: null } : {}) + }) + if (!updated) { + if (currentMutationScopeRef.current === mutationScope) { + setActionState({ + pending: false, + error: translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.d40d436fc2', + 'Could not update worktree visibility. Try again.' + ) + }) + } + return + } + const refreshed = await refreshTargetRepo(repoId, { requireAuthoritative: true }) + if (currentMutationScopeRef.current === mutationScope) { + setListState(refreshed ? 'ready' : 'failed') + } + } finally { + finishVisibilityMutation(mutationScope, mutation) + if (currentMutationScopeRef.current === mutationScope) { + setIsToggling(false) + } + } + }, + [alwaysShow, mutationScope, refreshTargetRepo, repoId, updateTargetRepo] + ) + + if (!isOpen || !repo || !isGitRepoKind(repo)) { return null } return ( !open && closeModal()}> - + - - {translate( - 'auto.components.sidebar.WorktreeVisibilityDialog.83a5ba8dd1', - 'Non-Orca worktrees' - )} - +
+ + {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.83a5ba8dd1', + 'Non-Orca worktrees' + )} + + +
{repo.displayName}
- {showOther ? : } + {alwaysShow ? : }
- {showOther + {alwaysShow ? translate( 'auto.components.sidebar.WorktreeVisibilityDialog.3e045d4cb8', 'Shown in sidebar' @@ -91,7 +270,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { )}
- {showOther + {alwaysShow ? translate( 'auto.components.sidebar.WorktreeVisibilityDialog.8372e4bbd9', '{{value0}} currently shown', @@ -99,21 +278,145 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { ) : translate( 'auto.components.sidebar.WorktreeVisibilityDialog.25ddf19920', - '{{value0}} available to import', + '{{value0}} currently hidden', { value0: hiddenWorktreeLabel } )}
- +
+ + {listState === 'checking' ? ( +

+ {translate('auto.components.sidebar.WorktreeVisibilityDialog.a3f19c07d2', 'Checking…')} +

+ ) : null} + + {listState === 'failed' ? ( +
+

+ {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.b8d24e61f5', + "Could not list this repo's worktrees." + )} +

+ +
+ ) : null} + + {hiddenImportable.length > 0 ? ( +
+
+

+ {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.7d21c5e848', + 'Hidden worktrees ({{value0}})', + { value0: hiddenImportable.length } + )} +

+

+ {translate( + 'auto.components.sidebar.WorktreeVisibilityDialog.9b53f7a160', + 'Choose which hidden worktrees to show individually.' + )} +

+
+
+
    + {hiddenListVirtualizer.getVirtualItems().map((virtualRow) => { + const worktree = hiddenImportable[virtualRow.index] + if (!worktree) { + return null + } + const displayPath = + relativePathInsideRoot(repo.path, worktree.path) || worktree.path + return ( +
  • +
    +
    +
    {worktree.displayName}
    +
    + {displayPath} +
    +
    + +
    +
  • + ) + })} +
+
+
+ ) : null} + + {actionState?.error ? ( +

+ {actionState.error} +

+ ) : null}
) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx new file mode 100644 index 00000000000..3822753ad2c --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.innerHTML = '' +}) + +async function renderPopover(): Promise { + const { default: WorktreeVisibilityHelpPopover } = await import('./WorktreeVisibilityHelpPopover') + await act(async () => root.render()) + const trigger = document.querySelector( + 'button[aria-label="Which worktrees are hidden by default?"]' + ) + if (!trigger) { + throw new Error('Missing worktree visibility help trigger') + } + return trigger +} + +function content(): HTMLElement | null { + return document.querySelector('[data-slot="popover-content"]') +} + +async function dispatch(target: EventTarget, event: Event): Promise { + await act(async () => target.dispatchEvent(event)) +} + +describe('WorktreeVisibilityHelpPopover', () => { + it('stays closed when the trigger receives initial dialog focus', async () => { + const trigger = await renderPopover() + + await act(async () => trigger.focus()) + + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(content()).toBeNull() + }) + + it('opens on pointer hover and closes when the pointer leaves', async () => { + const trigger = await renderPopover() + const hoverTarget = trigger.parentElement + expect(hoverTarget).not.toBeNull() + + await dispatch( + hoverTarget!, + new PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' }) + ) + + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(content()?.getAttribute('aria-label')).toBe('Which worktrees are hidden by default?') + expect(content()?.textContent).toContain('current and future non-Orca worktrees') + expect(content()?.textContent).toContain('.claude/worktrees/*') + expect(content()?.textContent).toContain('.gsd-workspaces/*') + + await dispatch( + hoverTarget!, + new PointerEvent('pointerout', { + bubbles: true, + pointerType: 'mouse', + relatedTarget: document.body + }) + ) + + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(content()).toBeNull() + }) + + it('opens without moving focus and dismisses with Escape', async () => { + const trigger = await renderPopover() + trigger.focus() + + await dispatch(trigger, new MouseEvent('click', { bubbles: true, cancelable: true })) + + expect(trigger.getAttribute('aria-expanded')).toBe('true') + expect(document.activeElement).toBe(trigger) + + await dispatch( + document, + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(trigger) + }) + + it('dismisses when the user clicks outside', async () => { + const trigger = await renderPopover() + await dispatch(trigger, new MouseEvent('click', { bubbles: true, cancelable: true })) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))) + + await act(async () => { + document.body.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }) + ) + document.body.dispatchEvent( + new PointerEvent('pointerup', { bubbles: true, pointerType: 'mouse' }) + ) + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx new file mode 100644 index 00000000000..bcb96c79e84 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityHelpPopover.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react' +import { CircleHelp } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' + +export default function WorktreeVisibilityHelpPopover(): React.JSX.Element { + const [open, setOpen] = useState(false) + const title = translate( + 'auto.components.sidebar.WorktreeVisibilityHelpPopover.c41f2d7e90', + 'Which worktrees are hidden by default?' + ) + + return ( +
setOpen(true)} + onPointerLeave={() => setOpen(false)} + > + + + + + event.preventDefault()} + > +
{title}
+
    +
  • + {translate( + 'auto.components.sidebar.WorktreeVisibilityHelpPopover.8db4e19a26', + 'This setting never hides worktrees created through Orca.' + )} +
  • +
  • + {translate( + 'auto.components.sidebar.WorktreeVisibilityHelpPopover.54a7c2f183', + 'For newly added repositories, worktrees created manually or by another tool are hidden by default to avoid unexpected sidebar clutter.' + )} +
  • +
  • + {translate( + 'auto.components.sidebar.WorktreeVisibilityHelpPopover.b9e0d43675', + 'Always show includes current and future non-Orca worktrees, including agent worktrees under .claude/worktrees/* and .gsd-workspaces/*. When it is off, use Show below to add one individually.' + )} +
  • +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-visibility-host-target.ts b/src/renderer/src/components/sidebar/worktree-visibility-host-target.ts new file mode 100644 index 00000000000..b507241b847 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-host-target.ts @@ -0,0 +1,56 @@ +import { useCallback } from 'react' +import type { AppState } from '@/store/types' +import { findRepoForHost, getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { + getWorktreeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' + +export function resolveWorktreeVisibilityHostTarget( + state: Pick, + repoId: string, + modalHostId: unknown +) { + const requestedHostId = + typeof modalHostId === 'string' ? parseExecutionHostId(modalHostId)?.id : undefined + const repo = findRepoForHost(state.repos, repoId, { + hostId: requestedHostId, + settings: state.settings + }) + const detectedForRepo = repoId ? state.detectedWorktreesByRepo[repoId] : undefined + const detected = + detectedForRepo && repo && requestedHostId + ? { + ...detectedForRepo, + worktrees: detectedForRepo.worktrees.filter( + (worktree) => getWorktreeExecutionHostId(worktree, repo) === requestedHostId + ) + } + : detectedForRepo + const scope = repo ? getRepoHostIdentity(repo) : `${requestedHostId ?? ''}\0${repoId}` + return { detected, repo, requestedHostId, scope } +} + +export function useWorktreeVisibilityHostActions( + fetchWorktrees: AppState['fetchWorktrees'], + updateRepo: AppState['updateRepo'], + requestedHostId: ExecutionHostId | undefined +) { + const refreshTargetRepo = useCallback( + (repoId: string, options?: { requireAuthoritative?: boolean }) => + fetchWorktrees(repoId, { + ...options, + ...(requestedHostId ? { executionHostId: requestedHostId } : {}) + }), + [fetchWorktrees, requestedHostId] + ) + const updateTargetRepo = useCallback( + (repoId: string, updates: Parameters[1]) => + requestedHostId + ? updateRepo(repoId, updates, { hostId: requestedHostId }) + : updateRepo(repoId, updates), + [requestedHostId, updateRepo] + ) + return { refreshTargetRepo, updateTargetRepo } +} diff --git a/src/renderer/src/components/sidebar/worktree-visibility-mutation-fence.ts b/src/renderer/src/components/sidebar/worktree-visibility-mutation-fence.ts new file mode 100644 index 00000000000..ef18ac08f17 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-visibility-mutation-fence.ts @@ -0,0 +1,92 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' + +export type ActiveVisibilityMutation = { kind: 'row'; path: string } | { kind: 'toggle' } +type VisibilityListState = 'checking' | 'ready' | 'failed' + +// Why: the modal unmounts on close, but its persistence request survives dismissal. +const activeMutations = new Map() +const mutationListeners = new Map void>>() + +export function getActiveVisibilityMutation(scope: string): ActiveVisibilityMutation | undefined { + return activeMutations.get(scope) +} + +export function startVisibilityMutation(scope: string, mutation: ActiveVisibilityMutation): void { + activeMutations.set(scope, mutation) +} + +export function subscribeToVisibilityMutation(scope: string, listener: () => void): () => void { + const listeners = mutationListeners.get(scope) ?? new Set() + listeners.add(listener) + mutationListeners.set(scope, listeners) + return () => { + listeners.delete(listener) + if (listeners.size === 0) { + mutationListeners.delete(scope) + } + } +} + +export function finishVisibilityMutation(scope: string, mutation: ActiveVisibilityMutation): void { + if (activeMutations.get(scope) !== mutation) { + return + } + activeMutations.delete(scope) + mutationListeners.get(scope)?.forEach((listener) => listener()) +} + +export function useVisibilityMutationFence(args: { + scope: string + repoId: string + currentScopeRef: MutableRefObject + refresh: (repoId: string, options: { requireAuthoritative: true }) => Promise + setActionState: Dispatch> + setBusyPath: Dispatch> + setIsToggling: Dispatch> + setListState: Dispatch> +}): void { + const { + currentScopeRef, + refresh, + repoId, + scope, + setActionState, + setBusyPath, + setIsToggling, + setListState + } = args + useEffect(() => { + const activeMutation = getActiveVisibilityMutation(scope) + setActionState(null) + setBusyPath(activeMutation?.kind === 'row' ? activeMutation.path : null) + setIsToggling(activeMutation?.kind === 'toggle') + if (!activeMutation) { + return + } + let cancelled = false + let unsubscribe = (): void => undefined + unsubscribe = subscribeToVisibilityMutation(scope, () => { + unsubscribe() + void refresh(repoId, { requireAuthoritative: true }).then((refreshed) => { + if (!cancelled && currentScopeRef.current === scope) { + setListState(refreshed ? 'ready' : 'failed') + setBusyPath(null) + setIsToggling(false) + } + }) + }) + return () => { + cancelled = true + unsubscribe() + } + }, [ + currentScopeRef, + refresh, + repoId, + scope, + setActionState, + setBusyPath, + setIsToggling, + setListState + ]) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2c98b1b7ca0..bcf81f68835 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4900,14 +4900,28 @@ "bff3bdd00c": "Rename workspace", "8df295a78d": "Failed to rename workspace." }, + "WorktreeVisibilityHelpPopover": { + "c41f2d7e90": "Which worktrees are hidden by default?", + "8db4e19a26": "This setting never hides worktrees created through Orca.", + "54a7c2f183": "For newly added repositories, worktrees created manually or by another tool are hidden by default to avoid unexpected sidebar clutter.", + "b9e0d43675": "Always show includes current and future non-Orca worktrees, including agent worktrees under .claude/worktrees/* and .gsd-workspaces/*. When it is off, use Show below to add one individually." + }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Non-Orca worktrees", - "f1f71b9f02": "Import", + "f1f71b9f02": "Always show", "759371df43": "Hide", - "25ddf19920": "{{value0}} available to import", + "25ddf19920": "{{value0}} currently hidden", "8372e4bbd9": "{{value0}} currently shown", "5d02a5647f": "Hidden from sidebar", - "3e045d4cb8": "Shown in sidebar" + "3e045d4cb8": "Shown in sidebar", + "a3f19c07d2": "Checking…", + "b8d24e61f5": "Could not list this repo's worktrees.", + "c5e70a93b1": "Try again", + "7d21c5e848": "Hidden worktrees ({{value0}})", + "9b53f7a160": "Choose which hidden worktrees to show individually.", + "2f80cd4b97": "Showing…", + "e64b81d3a9": "Show", + "d40d436fc2": "Could not update worktree visibility. Try again." }, "add": { "repo": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7d189faeb25..e4b9b41f6c1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4697,14 +4697,28 @@ "bff3bdd00c": "Cambiar nombre del espacio de trabajo", "8df295a78d": "No se pudo cambiar el nombre del espacio de trabajo." }, + "WorktreeVisibilityHelpPopover": { + "c41f2d7e90": "¿Qué worktrees se ocultan de forma predeterminada?", + "8db4e19a26": "Este ajuste nunca oculta los worktrees creados mediante Orca.", + "54a7c2f183": "En los repositorios recién añadidos, los worktrees creados manualmente o con otra herramienta se ocultan de forma predeterminada para evitar elementos inesperados en la barra lateral.", + "b9e0d43675": "«Mostrar siempre» incluye los worktrees ajenos a Orca actuales y futuros, incluidos los worktrees de agentes en .claude/worktrees/* y .gsd-workspaces/*. Cuando está desactivado, usa «Mostrar» abajo para añadir uno individualmente." + }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Worktrees que no son de Orca", - "f1f71b9f02": "Importar", + "f1f71b9f02": "Mostrar siempre", "759371df43": "Ocultar", - "25ddf19920": "{{value0}} disponible para importar", + "25ddf19920": "{{value0}} oculto actualmente", "8372e4bbd9": "{{value0}} mostrado actualmente", "5d02a5647f": "Oculto de la barra lateral", - "3e045d4cb8": "Mostrado en la barra lateral" + "3e045d4cb8": "Mostrado en la barra lateral", + "a3f19c07d2": "Comprobando…", + "b8d24e61f5": "No se pudieron listar los worktrees de este repositorio.", + "c5e70a93b1": "Reintentar", + "7d21c5e848": "Worktrees ocultos ({{value0}})", + "9b53f7a160": "Elige qué worktrees ocultos mostrar de forma individual.", + "2f80cd4b97": "Mostrando…", + "e64b81d3a9": "Mostrar", + "d40d436fc2": "No se pudo actualizar la visibilidad de los worktrees. Inténtalo de nuevo." }, "add": { "repo": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2b9f42e51b1..bc358d627fd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4678,14 +4678,28 @@ "bff3bdd00c": "ワークスペースの名前を変更する", "8df295a78d": "ワークスペースの名前を変更できませんでした。" }, + "WorktreeVisibilityHelpPopover": { + "c41f2d7e90": "デフォルトで非表示になるワークツリー", + "8db4e19a26": "この設定では、Orca で作成されたワークツリーが非表示になることはありません。", + "54a7c2f183": "新しく追加したリポジトリでは、手動または別のツールで作成したワークツリーは、意図しない項目でサイドバーが埋まらないよう、デフォルトでは非表示です。", + "b9e0d43675": "「常に表示」では、.claude/worktrees/* および .gsd-workspaces/* にある Agent ワークツリーを含む、Orca 以外で作成された現在および今後のワークツリーが表示されます。オフの場合は、下の「表示」を使用して個別に追加できます。" + }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Orca 以外のワークツリー", - "f1f71b9f02": "インポート", + "f1f71b9f02": "常に表示", "759371df43": "非表示", - "25ddf19920": "{{value0}} をインポートできます", + "25ddf19920": "{{value0}} は現在非表示", "8372e4bbd9": "現在表示されている{{value0}}", "5d02a5647f": "サイドバーから非表示", - "3e045d4cb8": "サイドバーに表示" + "3e045d4cb8": "サイドバーに表示", + "a3f19c07d2": "確認中…", + "b8d24e61f5": "このリポジトリのワークツリーを一覧できませんでした。", + "c5e70a93b1": "再試行", + "7d21c5e848": "非表示のワークツリー({{value0}})", + "9b53f7a160": "個別にサイドバーへ表示する非表示のワークツリーを選択します。", + "2f80cd4b97": "表示中…", + "e64b81d3a9": "表示", + "d40d436fc2": "ワークツリーの表示設定を更新できませんでした。もう一度お試しください。" }, "add": { "repo": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4a34c5639b0..697effbfd2b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4678,14 +4678,28 @@ "bff3bdd00c": "워크스페이스 이름 바꾸기", "8df295a78d": "워크스페이스 이름을 바꾸지 못했습니다." }, + "WorktreeVisibilityHelpPopover": { + "c41f2d7e90": "기본적으로 숨겨지는 워크트리", + "8db4e19a26": "이 설정은 Orca에서 만든 워크트리를 숨기지 않습니다.", + "54a7c2f183": "새로 추가한 저장소에서는 직접 만들거나 다른 도구에서 만든 워크트리가 예기치 않은 항목으로 사이드바를 복잡하게 하지 않도록 기본적으로 숨겨집니다.", + "b9e0d43675": "'항상 표시'는 .claude/worktrees/* 및 .gsd-workspaces/* 아래의 에이전트 워크트리를 포함하여 현재와 앞으로 생성되는 Orca 외부 워크트리를 표시합니다. 꺼져 있을 때는 아래의 '표시'를 사용해 하나씩 추가할 수 있습니다." + }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "Orca 외부 워크트리", - "f1f71b9f02": "가져오기", + "f1f71b9f02": "항상 표시", "759371df43": "숨기기", - "25ddf19920": "{{value0}} 가져오기 가능", + "25ddf19920": "{{value0}} 현재 숨겨짐", "8372e4bbd9": "{{value0}} 현재 표시됨", "5d02a5647f": "사이드바에서 숨겨짐", - "3e045d4cb8": "사이드바에 표시됨" + "3e045d4cb8": "사이드바에 표시됨", + "a3f19c07d2": "확인 중…", + "b8d24e61f5": "이 저장소의 워크트리를 나열할 수 없습니다.", + "c5e70a93b1": "다시 시도", + "7d21c5e848": "숨겨진 워크트리({{value0}})", + "9b53f7a160": "개별적으로 사이드바에 표시할 숨겨진 워크트리를 선택하세요.", + "2f80cd4b97": "표시 중…", + "e64b81d3a9": "표시", + "d40d436fc2": "워크트리 표시 설정을 업데이트하지 못했습니다. 다시 시도하세요." }, "add": { "repo": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8fa0cf2b3e1..7e604f6e7b1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4690,14 +4690,28 @@ "bff3bdd00c": "重命名工作区", "8df295a78d": "无法重命名工作区。" }, + "WorktreeVisibilityHelpPopover": { + "c41f2d7e90": "哪些工作树默认隐藏?", + "8db4e19a26": "此设置不会隐藏通过 Orca 创建的工作树。", + "54a7c2f183": "对于新添加的仓库,手动或通过其他工具创建的工作树默认隐藏,以免侧边栏出现意外内容。", + "b9e0d43675": "“始终显示”会显示当前及以后创建的非 Orca 工作树,包括 .claude/worktrees/* 和 .gsd-workspaces/* 下的智能体工作树。关闭时,可使用下方的“显示”逐个添加。" + }, "WorktreeVisibilityDialog": { "83a5ba8dd1": "非 Orca 工作树", - "f1f71b9f02": "导入", + "f1f71b9f02": "始终显示", "759371df43": "隐藏", - "25ddf19920": "{{value0}} 可导入", + "25ddf19920": "当前隐藏 {{value0}}", "8372e4bbd9": "当前显示 {{value0}}", "5d02a5647f": "从侧边栏隐藏", - "3e045d4cb8": "显示在侧边栏中" + "3e045d4cb8": "显示在侧边栏中", + "a3f19c07d2": "检查中…", + "b8d24e61f5": "无法列出此仓库的工作树。", + "c5e70a93b1": "重试", + "7d21c5e848": "已隐藏的工作树({{value0}})", + "9b53f7a160": "选择要单独显示的隐藏工作树。", + "2f80cd4b97": "显示中…", + "e64b81d3a9": "显示", + "d40d436fc2": "无法更新工作树可见性。请重试。" }, "add": { "repo": { diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 5c09152e9c5..6353eaff25f 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -132,6 +132,7 @@ export type RepoUpdate = Partial< | 'externalWorktreeVisibilityPromptDismissedAt' | 'externalWorktreeInboxBaselinePaths' | 'importedExternalWorktreePaths' + | 'agentWorktreeVisibility' | 'projectGroupId' | 'projectGroupOrder' > diff --git a/src/shared/external-worktree-inbox.test.ts b/src/shared/external-worktree-inbox.test.ts index 34bfadd7f66..724866cc15e 100644 --- a/src/shared/external-worktree-inbox.test.ts +++ b/src/shared/external-worktree-inbox.test.ts @@ -9,8 +9,10 @@ import type { } from './types' import { getHiddenExternalWorktrees, + getHiddenImportableExternalWorktrees, getNewExternalWorktreeInboxWorktrees, getVisibleExternalWorktrees, + getVisibleNonOrcaWorktrees, mergeExternalWorktreeInboxPaths, shouldOfferNewExternalWorktreeInbox } from './external-worktree-inbox' @@ -172,6 +174,41 @@ describe('external worktree inbox', () => { }) expect(getVisibleExternalWorktrees(detectedResult([visible, scratch]))).toEqual([visible]) + expect(getVisibleNonOrcaWorktrees(detectedResult([visible, scratch]))).toEqual([ + visible, + scratch + ]) + }) + + it('lists hidden agent scratch among importable worktrees so recovery stays reachable', () => { + // Why: the visibility toggle can never reveal scratch (#9388), so if no list + // offers it for per-path import, a hidden scratch worktree is lost for good (#10324). + const hiddenScratch = detectedWorktree({ + id: 'hidden-scratch', + path: '/repo/.claude/worktrees/scratch-1', + ownership: 'agent-scratch' + }) + const hiddenExternal = detectedWorktree({ id: 'hidden-external' }) + const detected = detectedResult([ + hiddenScratch, + hiddenExternal, + detectedWorktree({ id: 'visible-scratch', ownership: 'agent-scratch', visible: true }), + detectedWorktree({ id: 'orca-managed', ownership: 'orca-managed' }), + detectedWorktree({ id: 'checked-out', selectedCheckout: true }) + ]) + + expect(getHiddenImportableExternalWorktrees(detected)).toEqual([hiddenScratch, hiddenExternal]) + // Why: scratch stays out of discovery counts even though the dialog controls it. + expect(getHiddenExternalWorktrees(detected)).toEqual([hiddenExternal]) + }) + + it('offers no importable worktrees from a non-authoritative snapshot', () => { + const scratch = detectedWorktree({ id: 'scratch', ownership: 'agent-scratch' }) + + expect( + getHiddenImportableExternalWorktrees({ ...detectedResult([scratch]), authoritative: false }) + ).toEqual([]) + expect(getHiddenImportableExternalWorktrees(undefined)).toEqual([]) }) it('offers metadata-free nested Orca workspace worktrees through the inbox', () => { diff --git a/src/shared/external-worktree-inbox.ts b/src/shared/external-worktree-inbox.ts index 56aea01ff8b..c42a1249bd7 100644 --- a/src/shared/external-worktree-inbox.ts +++ b/src/shared/external-worktree-inbox.ts @@ -55,8 +55,7 @@ export function getVisibleExternalWorktrees( } function isUserFacingExternalWorktree(worktree: DetectedWorktree): boolean { - // Why: an explicit scratch import may be visible, but agent plumbing must - // stay outside repo-wide discovery and visibility controls (#9388). + // Why: agent plumbing stays outside the discovery inbox even when its separate visibility policy shows it. return ( !worktree.selectedCheckout && worktree.ownership !== 'orca-managed' && @@ -64,6 +63,34 @@ function isUserFacingExternalWorktree(worktree: DetectedWorktree): boolean { ) } +// Why: per-path recovery remains available while either repo visibility policy is off. +function isImportableExternalWorktree(worktree: DetectedWorktree): boolean { + return !worktree.selectedCheckout && worktree.ownership !== 'orca-managed' +} + +export function getHiddenImportableExternalWorktrees( + detected: DetectedWorktreeListResult | undefined +): DetectedWorktree[] { + if (detected?.authoritative !== true) { + return [] + } + return detected.worktrees.filter( + (worktree) => !worktree.visible && isImportableExternalWorktree(worktree) + ) +} + +export function getVisibleNonOrcaWorktrees( + detected: DetectedWorktreeListResult | undefined +): DetectedWorktree[] { + if (detected?.authoritative !== true) { + return [] + } + return detected.worktrees.filter( + (worktree) => + worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed' + ) +} + export function isExternalWorktreeDiscoverySuppressed( repo: Pick ): boolean { diff --git a/src/shared/external-worktree-visibility.ts b/src/shared/external-worktree-visibility.ts index 3907d133a30..fc4af8c1681 100644 --- a/src/shared/external-worktree-visibility.ts +++ b/src/shared/external-worktree-visibility.ts @@ -67,3 +67,10 @@ export function effectiveExternalWorktreeVisibility( } return isLegacyRepoForVisibility ? 'show' : 'hide' } + +export function effectiveAgentWorktreeVisibility( + repo: Pick +): ExternalWorktreeVisibility { + // Why: scratch worktrees have always defaulted hidden, including for legacy repos. + return repo.agentWorktreeVisibility === 'show' ? 'show' : 'hide' +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 5fcd3938133..ab929644fbf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -289,6 +289,8 @@ export type Repo = { externalWorktreeInboxBaselinePaths?: string[] /** External worktree paths explicitly imported while global visibility stays hide. */ importedExternalWorktreePaths?: string[] + /** Opt-in repo policy for coding-agent scratch worktrees; absent means hide. */ + agentWorktreeVisibility?: ExternalWorktreeVisibility /** User permanently opted out of the new-external-worktree inbox for this repo. */ externalWorktreeDiscoverySuppressedAt?: number /** Paths (relative to the primary checkout) that should be APFS clone-copied diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index 1697b654d5a..c0bdbfea483 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -477,7 +477,7 @@ describe('agent scratch worktrees', () => { ).toBe('orca-managed') }) - it('hides agent scratch even when the repo shows non-Orca worktrees', () => { + it('keeps agent scratch hidden by default for new and legacy repos', () => { for (const repo of [ makeRepo({ externalWorktreeVisibility: 'show' }), makeRepo({ addedAt: EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT - 1 }) @@ -494,6 +494,25 @@ describe('agent scratch worktrees', () => { } }) + it('shows agent scratch when its repo policy is enabled', () => { + const repo = makeRepo({ + externalWorktreeVisibility: 'hide', + agentWorktreeVisibility: 'show' + }) + const settings = makeSettings() + + for (const path of [scratchPath, '/repos/app/.gsd-workspaces/phase-1']) { + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ path, isMainWorktree: false }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected).toMatchObject({ ownership: 'agent-scratch', visible: true }) + } + }) + it('still shows agent scratch for the selected checkout or an explicit import', () => { const repo = makeRepo({ externalWorktreeVisibility: 'hide', diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index a14523c4d8f..a5260b4adc5 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -13,6 +13,7 @@ import { } from './agent-scratch-worktrees' import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox' import { + effectiveAgentWorktreeVisibility, effectiveExternalWorktreeVisibility, isLegacyRepoForExternalWorktreeVisibility } from './external-worktree-visibility' @@ -27,6 +28,7 @@ import type { } from './types' export { + effectiveAgentWorktreeVisibility, effectiveExternalWorktreeVisibility, EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, isLegacyRepoForExternalWorktreeVisibility @@ -221,10 +223,8 @@ export function shouldShowWorktree(args: { ) { return true } - // Why: agent scratch stays hidden even when the repo shows non-Orca - // worktrees; only an explicit import or selected checkout reveals it. if (args.ownership === 'agent-scratch') { - return false + return effectiveAgentWorktreeVisibility(args.repo) === 'show' } if (args.ownership === 'unknown-legacy' && args.isLegacyRepoForVisibility) { return true From d9b390f4fd45fccfe1740dc4f29224b268abdfeb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:28:35 -0700 Subject: [PATCH 23/33] fix(daemon): let the connect prove a retired daemon, not the token read (#14128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(daemon): let the connect prove a retired daemon, not the token read The daemon unlinks its token on exit but leaves its socket, and the client read that token as the first statement of doConnect. So a retired endpoint failed at the open, producing an errno no recovery predicate could classify — every one of them keys on syscall 'connect' — and the pane showed raw errno text. Read the token totally instead. A dead endpoint now fails at the connect, where isDaemonGoneError already authorizes a respawn and isDaemonEndpointGoneError already explains itself; a live daemon whose token was rotated or removed rejects the empty token as 'Invalid token' rather than being declared dead by a missing file. checkDaemonHealth has always read the token this way. * fix(daemon): require endpoint failure for respawn --- src/main/daemon/client.test.ts | 24 ++++++++++ src/main/daemon/client.ts | 14 +++++- src/main/daemon/daemon-pty-adapter.test.ts | 36 ++++++++++++++ src/main/daemon/daemon-pty-adapter.ts | 27 ++++------- .../daemon-self-retirement-respawn.test.ts | 47 ++++++++++++++++--- 5 files changed, 122 insertions(+), 26 deletions(-) diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 47b77cfb78e..266cd0fe255 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -335,6 +335,30 @@ describe('DaemonClient', () => { client = new DaemonClient({ socketPath, tokenPath }) await expect(client.ensureConnected()).rejects.toThrow() }) + + it('fails a retired endpoint at the connect, not at the token read', async () => { + rmSync(tokenPath) + client = new DaemonClient({ socketPath, tokenPath }) + + const error = (await client + .ensureConnected() + .catch((err: unknown) => err)) as NodeJS.ErrnoException + + expect(error.syscall).toBe('connect') + expect(['ENOENT', 'ECONNREFUSED']).toContain(error.code) + }) + + it('still attempts the handshake when the token is gone but a daemon is listening', async () => { + const hellos: HelloMessage[] = [] + await startMockDaemon({ onHello: (msg) => hellos.push(msg) }) + rmSync(tokenPath) + + client = new DaemonClient({ socketPath, tokenPath }) + await client.ensureConnected() + + expect(hellos.length).toBeGreaterThan(0) + expect(hellos[0]?.token).toBe('') + }) }) describe('RPC', () => { diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index d137ee84ea5..147277c8450 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -114,12 +114,24 @@ export class DaemonClient { } } + // Why: a missing token must not preempt the connect that proves whether the endpoint is gone. + private readToken(): string { + try { + return readFileSync(this.tokenPath, 'utf-8').trim() + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return '' + } + throw error + } + } + private async doConnect( timeoutMs: number, attemptGeneration: number, sharedBudget: boolean ): Promise { - const token = readFileSync(this.tokenPath, 'utf-8').trim() + const token = this.readToken() const deadlineMs = Date.now() + timeoutMs const remainingMs = (): number => sharedBudget ? Math.max(1, deadlineMs - Date.now()) : timeoutMs diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index b138fbe3b8d..1e41e9de9ee 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -595,6 +595,42 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('respawns after a retired daemon regardless of how the connection ended', async () => { + // Why this shape: the daemon retires when its last authenticated client drops, and that drop + // is often our own disconnect() — which observes no socket close. Once the token read stops + // preempting the connect, the retired endpoint fails as a connect and isDaemonGoneError + // classifies it, so recovery no longer depends on having witnessed the drop. + let respawnServer: DaemonServer | undefined + const respawn = vi.fn(async () => { + respawnServer = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await respawnServer.start() + }) + const healingAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { id } = await healingAdapter.spawn({ cols: 80, rows: 24 }) + const client = (healingAdapter as unknown as { client: DaemonClient }).client + + client.disconnect() + await server.shutdown() + expect(existsSync(tokenPath)).toBe(false) + expect(client.hasObservedAuthenticatedDisconnect()).toBe(false) + + await expect( + healingAdapter.spawn({ sessionId: id, cols: 80, rows: 24 }) + ).resolves.toMatchObject({ id }) + expect(respawn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + healingAdapter.dispose() + await respawnServer?.shutdown() + } + }) + it('does not spawn a daemon per keystroke after respawn fails', async () => { const respawn = vi.fn(async () => { throw new Error('daemon unavailable') diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 9e8142922ac..a66ec323848 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -1473,8 +1473,7 @@ export class DaemonPtyAdapter implements IPtyProvider { ) return processes } catch (error) { - const missingAuthenticatedToken = - isMissingTokenFileError(error) && this.client.hasObservedAuthenticatedDisconnect() + const missingAuthenticatedToken = this.isRetiredEndpointTokenMissing() const missingNamedPipe = isMissingWindowsNamedPipeError(error) this.observeAuditFailure( missingAuthenticatedToken @@ -2120,14 +2119,16 @@ export class DaemonPtyAdapter implements IPtyProvider { return 'unavailable' } - // Why: on daemon-death errors, respawn a fresh daemon and retry once rather than leaving terminals broken until app restart. + // Why: the token read no longer throws, so audit its absence directly after an authenticated drop. + private isRetiredEndpointTokenMissing(): boolean { + return this.client.hasObservedAuthenticatedDisconnect() && !existsSync(this.tokenPath) + } + private async withDaemonRetry(fn: () => Promise): Promise { try { return await fn() } catch (err) { - // Why: the token is removed only after an authenticated drop; an initial missing token may still hide a live daemon. - const missingRetiredEndpointToken = - isMissingTokenFileError(err) && this.client.hasObservedAuthenticatedDisconnect() + const missingRetiredEndpointToken = this.isRetiredEndpointTokenMissing() if (missingRetiredEndpointToken) { this.observeAuditFailure( 'token_missing_after_authenticated_disconnect', @@ -2135,11 +2136,7 @@ export class DaemonPtyAdapter implements IPtyProvider { ['token_file'] ) } - if ( - this.respawnAdoptionClosed || - !this.respawnFn || - (!isDaemonGoneError(err) && !missingRetiredEndpointToken) - ) { + if (this.respawnAdoptionClosed || !this.respawnFn || !isDaemonGoneError(err)) { throw err } if (!this.respawnPromise) { @@ -2533,14 +2530,6 @@ export function isDaemonGoneError(err: unknown): boolean { ) } -function isMissingTokenFileError(err: unknown): boolean { - if (!(err instanceof Error)) { - return false - } - const errno = err as NodeJS.ErrnoException - return errno.code === 'ENOENT' && errno.syscall === 'open' -} - function isMissingWindowsNamedPipeError(err: unknown): boolean { if (process.platform !== 'win32' || !(err instanceof Error)) { return false diff --git a/src/main/daemon/daemon-self-retirement-respawn.test.ts b/src/main/daemon/daemon-self-retirement-respawn.test.ts index a5df5453fe6..c588b5a6918 100644 --- a/src/main/daemon/daemon-self-retirement-respawn.test.ts +++ b/src/main/daemon/daemon-self-retirement-respawn.test.ts @@ -213,17 +213,52 @@ describe('daemon self-retirement respawn', () => { expect(respawn).not.toHaveBeenCalled() }) - it('does not treat an initial missing token as respawn authority', async () => { + it('does not respawn a listening daemon whose token is absent', async () => { + await startServer() + rmSync(tokenPath) const respawn = vi.fn(async () => {}) const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) - await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toMatchObject( - { - code: 'ENOENT' - } - ) + await expect( + adapter.spawn({ sessionId: 'startup-window', cols: 80, rows: 24 }) + ).rejects.toThrow(/Invalid token/i) expect(respawn).not.toHaveBeenCalled() adapter.dispose() }) + + it('does not let stale disconnect evidence authorize respawning a listening daemon', async () => { + const original = await startServer() + const respawn = vi.fn(async () => {}) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + await adapter.listProcesses() + const client = ( + adapter as unknown as { + client: { hasObservedAuthenticatedDisconnect(): boolean } + } + ).client + + await original.shutdown() + await waitFor(() => client.hasObservedAuthenticatedDisconnect()) + await startServer() + rmSync(tokenPath) + + await expect( + adapter.spawn({ sessionId: 'replacement-startup-window', cols: 80, rows: 24 }) + ).rejects.toThrow(/Invalid token/i) + + expect(respawn).not.toHaveBeenCalled() + adapter.dispose() + }) + + it('respawns when nothing is accepting on the endpoint', async () => { + const respawn = vi.fn(async () => {}) + const adapter = new DaemonPtyAdapter({ socketPath, tokenPath, respawn }) + + // Fails after the respawn attempt because the mock respawn starts no replacement. + await expect(adapter.spawn({ sessionId: 'missing', cols: 80, rows: 24 })).rejects.toThrow() + + expect(respawn).toHaveBeenCalledTimes(1) + adapter.dispose() + }) }) From f1ee3088a5cd529d608e25f6db9c597ad06ad57f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:47:03 -0700 Subject: [PATCH 24/33] perf: avoid polling remote runtimes on local repo changes (#13632) * perf: keep local repo events off remote runtimes * test(renderer): assert parked-pane remount on the runtime-active repos:changed path The local-slice refresh 13632 introduces still has to release panes parked on an unhydrated host; the prior test stubbed the remount but never asserted it. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/renderer/src/hooks/useIpcEvents.test.ts | 77 ++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 7 +- .../slices/repos-all-hosts-generation.test.ts | 80 +++++++++++++++++++ src/renderer/src/store/slices/repos.ts | 6 +- 4 files changed, 163 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 25d5cc1046c..c19fe942461 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -10281,6 +10281,83 @@ describe('runtime host catalog refresh on reposChanged', () => { }) }) +describe('repo catalog refresh on repos:changed', () => { + it('skips remote catalog RPCs when refreshing local rows under a runtime', async () => { + vi.resetModules() + let reposChangedListener: (() => void) | undefined + const fetchRepos = vi.fn(() => Promise.resolve()) + const fetchProjectGroups = vi.fn(() => Promise.resolve()) + const fetchFolderWorkspaces = vi.fn(() => Promise.resolve()) + const remountTerminalTabForRecovery = vi.fn(() => true) + const state = { + settings: { activeRuntimeEnvironmentId: null as string | null }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + worktreesByRepo: { repo1: [{ id: 'wt-1', repoId: 'repo1' }] }, + folderWorkspaces: [], + projectGroups: [], + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: {}, + remountTerminalTabForRecovery, + fetchRepos, + fetchProjectGroups, + fetchFolderWorkspaces + } + + vi.doMock('react', async () => { + const actual = await vi.importActual('react') + return { ...actual, useEffect: (effect: () => void | (() => void)) => void effect() } + }) + vi.doMock('../store', () => ({ + useAppStore: { subscribe: vi.fn(() => () => {}), getState: () => state } + })) + const noopListener = (): (() => void) => () => {} + const autoStubNamespace = new Proxy( + {}, + { + get: + () => + (...args: unknown[]) => { + if (typeof args[0] === 'function') { + return noopListener() + } + return new Promise(() => {}) + } + } + ) + const api = new Proxy( + { + repos: { + onChanged: (listener: () => void) => { + reposChangedListener = listener + return () => {} + } + } + } as Record, + { get: (target, prop: string) => target[prop] ?? autoStubNamespace } + ) + vi.stubGlobal('window', { api }) + + const { recordTerminalTabParkedOnUnresolvedHost, clearTerminalTabsParkedOnUnresolvedHost } = + await import('@/lib/parked-terminal-host-hydration') + clearTerminalTabsParkedOnUnresolvedHost() + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + state.settings.activeRuntimeEnvironmentId = 'env-1' + // Why: the local-slice refresh still has to release panes that parked on an unhydrated host. + recordTerminalTabParkedOnUnresolvedHost('wt-1', 'tab-1') + reposChangedListener?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + + const localOwner = { runtimeEnvironmentId: null } + expect(fetchRepos).toHaveBeenCalledWith(localOwner) + expect(fetchProjectGroups).toHaveBeenCalledWith(localOwner) + expect(fetchFolderWorkspaces).toHaveBeenCalledWith(localOwner) + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1') + clearTerminalTabsParkedOnUnresolvedHost() + }) +}) + describe('parked terminal recovery on repos:changed', () => { it('remounts a pane that parked on an unresolved host once repos hydrate', async () => { vi.resetModules() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 28371d8cd03..85fd5757722 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1101,9 +1101,10 @@ export function useIpcEvents(): void { if (isRuntimeEnvironmentActive()) { // Why: the all-host sidebar shows local repos even under a runtime; refresh the local slice, keep runtime slices. void (async () => { - await state.fetchReposForAllHosts() - await state.fetchProjectGroupsForAllHosts() - await state.fetchFolderWorkspacesForAllHosts() + const localOwner = { runtimeEnvironmentId: null } + await state.fetchRepos(localOwner) + await state.fetchProjectGroups(localOwner) + await state.fetchFolderWorkspaces(localOwner) remountTerminalTabsAwaitingHostHydration() })() return diff --git a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts index 24e2c770dce..1aa9ed76072 100644 --- a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts @@ -147,6 +147,86 @@ describe('fetchReposForAllHosts generation', () => { expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo']) }) + it('keeps an in-flight all-host remote result across a newer local refresh', async () => { + let resolveRemote!: (value: unknown) => void + let markRemoteStarted!: () => void + const remote = new Promise((resolve) => { + resolveRemote = resolve + }) + const remoteStarted = new Promise((resolve) => { + markRemoteStarted = resolve + }) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRemoteStarted() + return remote + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + const allHosts = store.getState().fetchReposForAllHosts() + await remoteStarted + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) + resolveRemote({ + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await allHosts + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo']) + }) + + it('starts all-host remote loads after a newer local refresh replaces its local result', async () => { + let resolveOlderLocal!: (repos: Repo[]) => void + let resolveRemote!: (value: unknown) => void + let markRemoteStarted!: () => void + const olderLocal = new Promise((resolve) => { + resolveOlderLocal = resolve + }) + const remote = new Promise((resolve) => { + resolveRemote = resolve + }) + const remoteStarted = new Promise((resolve) => { + markRemoteStarted = resolve + }) + reposList.mockReturnValueOnce(olderLocal) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRemoteStarted() + return remote + } + return { + id: 'rpc-other', + ok: true, + result: { projects: [], setups: [] }, + _meta: { runtimeId: 'runtime-remote' } + } + }) + const store = createTestStore() + + const allHosts = store.getState().fetchReposForAllHosts() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) + resolveOlderLocal([localRepo]) + await remoteStarted + resolveRemote({ + id: 'rpc-repo-list', + ok: true, + result: { repos: [remoteRepo] }, + _meta: { runtimeId: 'runtime-remote' } + }) + await allHosts + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo']) + }) + it('keeps a newer Connect-flow catalog when an older all-host response resolves last', async () => { let resolveOlder!: (value: unknown) => void let markOlderStarted!: () => void diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 6353eaff25f..49c53d12a7b 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -2194,10 +2194,8 @@ export const createRepoSlice: StateCreator = (set, } // Why: startup hydration needs the newest local catalog, not unreachable remote hosts. settleLocalCatalog(localCatalogOutcome) - if ( - get().reposFetchGeneration !== generation && - !isLatestRepoCatalogGeneration(get, LOCAL_EXECUTION_HOST_ID, generation) - ) { + // Why: a newer local-only refresh must not cancel this load's unrelated remote catalogs. + if (latestAllHostRepoCatalogGenerationByStore.get(get) !== generation) { return } if (options?.remoteHosts === 'skip') { From c86418eaad20126430dcb3128a4a61d438feda61 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:53:17 -0700 Subject: [PATCH 25/33] rm git shim (#14141) * rm git shim Drops the terminal git/gh PATH wrapper and its settings toggle. Renames the no-marker shell-ready launch config after what it does. Co-authored-by: Orca * rm git shim: clear stale state from older installs Deletes the orphaned wrapper dir and scrubs inherited env/PATH, so a daemon that outlives the upgrade cannot keep seeding it. Drops a now-unread spawn option. Co-authored-by: Orca * rm git shim: cover the daemon and headless paths Scrub after the PATH prepends (they re-read process.env on the sparse daemon env) and run the cleanup above the serve branch so remote hosts get it too. Retry a locked removal; match PATH case-insensitively. Co-authored-by: Orca * rm git shim: keep the scrub final Refuse to re-prepend a legacy entry during agent-teams PATH promotion, which runs after the scrub. Cover the removal guard. Co-authored-by: Orca --------- Co-authored-by: Orca --- config/max-lines-baseline.txt | 1 - config/scripts/locale-ko-key-overrides.json | 9 - config/scripts/locale-ko-value-overrides.mjs | 4 +- .../locale-search-keyword-overrides.mjs | 1 - .../locale-translation-policy.es-pr.test.mjs | 6 +- ...cale-translation-policy.zh-round5.test.mjs | 14 +- .../scripts/locale-zh-phrase-fixes-round5.mjs | 1 - config/scripts/locale-zh-value-overrides.mjs | 4 +- ...rminal-attribution-path-env-casing.test.ts | 169 --- ...al-attribution-win32-dash-argument.test.ts | 100 -- .../attribution/terminal-attribution.test.ts | 824 ------------- src/main/attribution/terminal-attribution.ts | 1058 ----------------- .../runtime-home-service.test.ts | 1 - src/main/codex-accounts/service.test.ts | 1 - src/main/daemon/pty-subprocess.test.ts | 8 +- src/main/daemon/pty-subprocess.ts | 7 +- src/main/daemon/shell-ready.test.ts | 6 +- src/main/daemon/shell-ready.ts | 28 +- src/main/index.ts | 3 + src/main/ipc/pty.test.ts | 203 ++-- src/main/ipc/pty.ts | 43 +- src/main/providers/local-pty-provider.test.ts | 12 +- src/main/providers/local-pty-provider.ts | 11 +- .../providers/local-pty-shell-ready.test.ts | 10 +- src/main/providers/local-pty-shell-ready.ts | 28 +- src/main/providers/ssh-pty-provider.test.ts | 6 +- src/main/pty/legacy-terminal-shim-dir.test.ts | 124 ++ src/main/pty/legacy-terminal-shim-dir.ts | 71 ++ .../claude-agent-teams-shim-env.test.ts | 2 +- .../runtime/claude-agent-teams-shim-env.ts | 2 +- .../claude-agent-teams-tmux-dispatcher.ts | 4 +- src/main/runtime/orca-runtime-git.test.ts | 6 +- src/main/runtime/orca-runtime-git.ts | 5 +- src/main/runtime/rpc/methods/git-params.ts | 1 - src/main/runtime/rpc/methods/git.test.ts | 2 - src/main/runtime/rpc/methods/git.ts | 6 - .../commit-message-text-generation.test.ts | 1 - src/relay/pty-handler.test.ts | 40 +- .../src/components/settings/GitPane.tsx | 49 - .../src/components/settings/Settings.tsx | 2 +- .../src/components/settings/git-search.ts | 19 +- .../hooks/useSettingsNavigationMetadata.ts | 2 +- src/renderer/src/i18n/locales/en.json | 20 +- src/renderer/src/i18n/locales/es.json | 20 +- src/renderer/src/i18n/locales/ja.json | 20 +- src/renderer/src/i18n/locales/ko.json | 20 +- src/renderer/src/i18n/locales/zh.json | 20 +- .../src/runtime/runtime-git-client.test.ts | 4 +- .../src/runtime/runtime-git-client.ts | 17 +- src/shared/commit-message-prompt.ts | 2 +- src/shared/constants.ts | 1 - src/shared/external-worktree-inbox.test.ts | 1 - src/shared/orca-attribution.ts | 6 - src/shared/types.ts | 1 - src/shared/worktree-ownership.test.ts | 1 - 55 files changed, 366 insertions(+), 2661 deletions(-) delete mode 100644 src/main/attribution/terminal-attribution-path-env-casing.test.ts delete mode 100644 src/main/attribution/terminal-attribution-win32-dash-argument.test.ts delete mode 100644 src/main/attribution/terminal-attribution.test.ts delete mode 100644 src/main/attribution/terminal-attribution.ts create mode 100644 src/main/pty/legacy-terminal-shim-dir.test.ts create mode 100644 src/main/pty/legacy-terminal-shim-dir.ts delete mode 100644 src/shared/orca-attribution.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 59368273624..a9c03d1b9d6 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -10,7 +10,6 @@ inline src/main/agent-hooks/server.test.ts inline src/main/agent-hooks/server.ts inline src/main/amp/hook-service.ts inline src/main/antigravity/hook-service.ts -inline src/main/attribution/terminal-attribution.ts inline src/main/automations/external-manager.ts inline src/main/automations/hermes-cron-output.ts inline src/main/browser/agent-browser-bridge.test.ts diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index bd0c3c15aee..aacf053926e 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -2273,12 +2273,6 @@ "auto.components.settings.GitPane.976afc6b3e": { "ko": "워크스페이스를 만들면 Orca가 원격 기준 브랜치를 새로고침하고, 일치하는 로컬 브랜치를 안전하게 fast-forward합니다. 예:" }, - "auto.components.settings.GitPane.b5f534717a": { - "ko": "공동 작성" - }, - "auto.components.settings.GitPane.b9b5771bb1": { - "ko": "표기" - }, "auto.components.settings.GitPane.b9c011fbc2": { "ko": "속도 제한" }, @@ -2288,9 +2282,6 @@ "auto.components.settings.GitPane.db3a127eb1": { "ko": "이렇게 하면 다음과 같은 명령이 유지됩니다" }, - "auto.components.settings.GitPane.e02ea23a32": { - "ko": "Orca 표기" - }, "auto.components.settings.GitPane.f35007e6e8": { "ko": "git-username" }, diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index 19db7c753ec..5dac8b29a0d 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -44,7 +44,6 @@ export const KO_VALUE_OVERRIDES = { 'Issue #{{value0}}': '이슈 #{{value0}}', 'Linked issue #{{value0}}': '연결된 이슈 #{{value0}}', 'Issue # or GitHub URL': '이슈 # 또는 GitHub URL', - 'Add Orca attribution to commits, PRs, and issues.': '커밋, PR 및 이슈에 Orca 속성을 추가합니다.', 'Paste an issue URL, or enter a number. Leave blank to remove the link.': '이슈 URL을 붙여넣거나 숫자를 입력하세요. 링크를 제거하려면 비워두세요.', 'Connect Linear in Settings to search issues.': '이슈를 검색하려면 설정에서 Linear를 연결하세요.', @@ -157,8 +156,7 @@ export const KO_VALUE_OVERRIDES = { '저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.', 'Discard unsaved Git AI Author changes?': '저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?', - 'Branch naming, base refs, attribution, and Git AI Author.': - '브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.', + 'Branch naming, base refs, and Git AI Author.': '브랜치 이름 지정, 기본 참조 및 Git AI Author.', 'Used by repositories that inherit global hosted-review defaults.': '전역 호스팅 PR 기본값을 상속하는 저장소에서 사용됩니다.', 'Defaults used when the hosted-review composer opens.': diff --git a/config/scripts/locale-search-keyword-overrides.mjs b/config/scripts/locale-search-keyword-overrides.mjs index 2a9d6266f5d..352199c1f7e 100644 --- a/config/scripts/locale-search-keyword-overrides.mjs +++ b/config/scripts/locale-search-keyword-overrides.mjs @@ -164,7 +164,6 @@ export const SEARCH_KEYWORD_OVERRIDES = { gitlab: 'GitLab', gitea: 'Gitea', oauth: 'OAuth', - attribution: '归因', skill: '技能', skills: '技能', phone: '手机', diff --git a/config/scripts/locale-translation-policy.es-pr.test.mjs b/config/scripts/locale-translation-policy.es-pr.test.mjs index a3c046ce895..389f914c86b 100644 --- a/config/scripts/locale-translation-policy.es-pr.test.mjs +++ b/config/scripts/locale-translation-policy.es-pr.test.mjs @@ -25,10 +25,10 @@ describe('locale-translation-policy es PR glossary', () => { it('rewrites PR inside longer sentences', () => { expect( repairEs( - 'Add Orca attribution to commits, PRs, and issues.', - 'Agregue la atribución de Orca a commits, relaciones públicas y problemas.' + 'Split a large change into smaller PRs', + 'Dividir un cambio grande en relaciones públicas más pequeños' ) - ).toBe('Agregue la atribución de Orca a commits, PR y problemas.') + ).toBe('Dividir un cambio grande en PR más pequeños') expect( repairEs( 'Open the PR details to view current reviewers.', diff --git a/config/scripts/locale-translation-policy.zh-round5.test.mjs b/config/scripts/locale-translation-policy.zh-round5.test.mjs index 28901fba8d4..622b3c5c31c 100644 --- a/config/scripts/locale-translation-policy.zh-round5.test.mjs +++ b/config/scripts/locale-translation-policy.zh-round5.test.mjs @@ -44,22 +44,14 @@ describe('locale-translation-policy zh round 5', () => { locale: 'zh' }) ).toBe('显示 Orca Mobile 按钮') - expect( - repairTranslatedValue({ - key: 'auto.components.settings.GitPane.e02ea23a32', - enValue: 'Orca Attribution', - localeValue: 'Orca归属', - locale: 'zh' - }) - ).toBe('Orca 归因') expect( repairTranslatedValue({ key: 'auto.hooks.useSettingsNavigationMetadata.ab4b21b58e', - enValue: 'Branch naming, base refs, attribution, and Git AI Author.', - localeValue: '分支命名、基本引用、归属和 Git AI 作者。', + enValue: 'Branch naming, base refs, and Git AI Author.', + localeValue: '分支命名、基本引用和 Git AI 作者。', locale: 'zh' }) - ).toBe('分支命名、基础引用、归因和 Git AI Author。') + ).toBe('分支命名、基础引用和 Git AI Author。') expect( repairTranslatedValue({ key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569', diff --git a/config/scripts/locale-zh-phrase-fixes-round5.mjs b/config/scripts/locale-zh-phrase-fixes-round5.mjs index 1aa117395e8..23fcc6d32a5 100644 --- a/config/scripts/locale-zh-phrase-fixes-round5.mjs +++ b/config/scripts/locale-zh-phrase-fixes-round5.mjs @@ -3,7 +3,6 @@ export const ZH_PHRASE_FIXES_ROUND5 = [ { pattern: /Orca集成开发环境/g, replacement: 'Orca IDE', whenEnIncludes: 'Orca IDE' }, { pattern: /Orca第一/g, replacement: 'Orca 优先', whenEnIncludes: 'Orca first' }, { pattern: /Orca移动/g, replacement: 'Orca Mobile', whenEnIncludes: 'Orca Mobile' }, - { pattern: /Orca归属/g, replacement: 'Orca 归因', whenEnIncludes: 'Orca Attribution' }, { pattern: /Orca标志/g, replacement: 'Orca 标志', whenEnIncludes: 'Orca logo' }, { pattern: /喜欢Orca/g, replacement: '喜欢 Orca', whenEnIncludes: 'Enjoying Orca' }, { pattern: /认识Orca/g, replacement: '了解 Orca', whenEnIncludes: 'Get to know Orca' }, diff --git a/config/scripts/locale-zh-value-overrides.mjs b/config/scripts/locale-zh-value-overrides.mjs index 087091a3f3e..b53fb1d2248 100644 --- a/config/scripts/locale-zh-value-overrides.mjs +++ b/config/scripts/locale-zh-value-overrides.mjs @@ -144,7 +144,6 @@ export const ZH_VALUE_OVERRIDES = { 'Expand Orca': '展开 Orca', 'Support Orca': '支持 Orca', 'Restarting Orca…': '正在重启 Orca…', - 'Orca Attribution': 'Orca 归因', 'Show Orca Mobile Button': '显示 Orca Mobile 按钮', 'Codex Accounts': 'Codex 账户', 'Codex Account': 'Codex 账户', @@ -186,8 +185,7 @@ export const ZH_VALUE_OVERRIDES = { "Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.": '让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。', 'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”', - 'Branch naming, base refs, attribution, and Git AI Author.': - '分支命名、基础引用、归因和 Git AI Author。', + 'Branch naming, base refs, and Git AI Author.': '分支命名、基础引用和 Git AI Author。', 'You have unsaved Git AI Author changes. Leaving will discard them.': '您有未保存的 Git AI Author 更改。离开将丢弃它们。', 'Discard unsaved Git AI Author changes?': '放弃未保存的 Git AI Author 更改?', diff --git a/src/main/attribution/terminal-attribution-path-env-casing.test.ts b/src/main/attribution/terminal-attribution-path-env-casing.test.ts deleted file mode 100644 index 95ff6fdc2e3..00000000000 --- a/src/main/attribution/terminal-attribution-path-env-casing.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { applyTerminalAttributionEnv } from './terminal-attribution' - -describe('applyTerminalAttributionEnv PATH key casing', () => { - let tmpRoot: string | null = null - - afterEach(() => { - if (tmpRoot) { - rmSync(tmpRoot, { force: true, recursive: true }) - tmpRoot = null - } - }) - - function makeTmpRoot(): string { - tmpRoot = mkdtempSync(join(tmpdir(), 'orca-attribution-path-casing-')) - return tmpRoot - } - - // Why: a Windows child that inherits both spellings crashes the packaged orca.exe launcher - // with "Item has already been added. Key in dictionary: 'PATH'" (stablyai/orca#12046). - function pathEnvKeys(env: Record): string[] { - return Object.keys(env).filter((key) => /^path$/i.test(key)) - } - - it('writes back the inherited Windows `Path` spelling instead of adding `PATH`', () => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const baseEnv: Record = { - Path: 'C:\\Windows\\system32;C:\\Program Files\\Git\\cmd', - SystemRoot: 'C:\\Windows' - } - - applyTerminalAttributionEnv(baseEnv, { - enabled: true, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') - const pathEntries = baseEnv.Path.split(';') - - expect(pathEnvKeys(baseEnv)).toEqual(['Path']) - expect(pathEntries[0]).toBe(win32Dir) - expect(pathEntries).toContain('C:\\Program Files\\Git\\cmd') - }) - - // Why: Win32 resolves a duplicated variable by taking the first match in the block, so the - // shim must land on the first-listed spelling and the shadowed one must keep its value — - // dropping it would promote it the moment anything downstream removes the live key. - it.each([ - { first: 'PATH', second: 'Path' }, - { first: 'Path', second: 'PATH' } - ])( - 'prepends the shim onto the first-listed `$first` and leaves `$second` untouched', - ({ first, second }) => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const baseEnv: Record = { - [first]: 'C:\\tools\\bin;C:\\Windows\\system32', - [second]: 'C:\\Windows\\system32' - } - - applyTerminalAttributionEnv(baseEnv, { - enabled: true, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - const win32Dir = join(userDataPath, 'orca-terminal-attribution', 'win32') - expect(baseEnv[first].split(';')).toEqual([ - win32Dir, - 'C:\\tools\\bin', - 'C:\\Windows\\system32' - ]) - expect(baseEnv[second]).toBe('C:\\Windows\\system32') - } - ) - - it('keeps a shadowed duplicate when the live spelling strips down to nothing', () => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32') - const baseEnv: Record = { Path: shimDir, PATH: 'C:\\Windows\\system32' } - - applyTerminalAttributionEnv(baseEnv, { - enabled: false, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - expect(pathEnvKeys(baseEnv)).toEqual(['PATH']) - expect(baseEnv.PATH).toBe('C:\\Windows\\system32') - }) - - it('strips shim entries from the inherited `Path` when attribution is disabled', () => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const shimDir = join(userDataPath, 'orca-terminal-attribution', 'win32') - const baseEnv: Record = { Path: `${shimDir};C:\\Windows\\system32` } - - applyTerminalAttributionEnv(baseEnv, { - enabled: false, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - expect(pathEnvKeys(baseEnv)).toEqual(['Path']) - expect(baseEnv.Path).toBe('C:\\Windows\\system32') - }) - - // Why: the daemon receives a sparse env patch with no path key and re-merges its own block - // underneath it, so guessing the spelling here hands the child both. - it.each([ - { hostKey: 'PATH', otherKey: 'Path' }, - { hostKey: 'Path', otherKey: 'PATH' } - ])('adopts a host block spelt `$hostKey` on a path-less env', ({ hostKey, otherKey }) => { - const root = makeTmpRoot() - const userDataPath = join(root, 'user-data') - const inherited = { PATH: process.env.PATH, Path: process.env.Path } - delete process.env.PATH - delete process.env.Path - process.env[hostKey] = 'C:\\Windows\\system32' - - try { - const baseEnv: Record = { SystemRoot: 'C:\\Windows' } - - applyTerminalAttributionEnv(baseEnv, { - enabled: true, - platform: 'win32', - shellFamily: 'native-windows', - userDataPath - }) - - expect(pathEnvKeys(baseEnv)).toEqual([hostKey]) - expect(baseEnv[otherKey]).toBeUndefined() - } finally { - delete process.env[hostKey] - for (const [key, value] of Object.entries(inherited)) { - if (value !== undefined) { - process.env[key] = value - } - } - } - }) - - it('leaves a case-sensitive POSIX `Path` variable alone', () => { - const root = makeTmpRoot() - const baseEnv: Record = { - PATH: '/usr/bin:/bin', - Path: '/decoy' - } - - applyTerminalAttributionEnv(baseEnv, { - enabled: true, - platform: 'linux', - userDataPath: join(root, 'user-data') - }) - - expect(baseEnv.Path).toBe('/decoy') - expect(baseEnv.PATH.split(':')).toContain('/usr/bin') - }) -}) diff --git a/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts b/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts deleted file mode 100644 index 810322a53c9..00000000000 --- a/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { applyTerminalAttributionEnv } from './terminal-attribution' - -// Why (#12046 follow-up): `powershell.exe -File