From 1fd740f6dd4ff11fca1f022d5b7a6302813a5f4a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:09:59 -0700 Subject: [PATCH 1/3] fix(agent-launch): a paired device's launch leaves the desktop window where it is A launch from the phone (or any paired client) revealed the workspace on the host desktop: the early tab's viewer rule said `reveal-owner` for callers whose navigation is their own, and with no early tab the spawn surfaced the owner by default. One predicate, `agentLaunchMovesHostWindow`, now decides both: paired callers get `none` for the shown tab and `surfaceOwner: false` at spawn. The device still selects the tab for itself; the CLI and the desktop are unchanged. --- .../agent-launch-floating-workspace.test.ts | 2 + .../methods/agent-launch-instant-tab.test.ts | 38 ++++++++++++++++++- .../rpc/methods/agent-launch-surfaces.ts | 8 +++- .../methods/agent-launch-tab-publication.ts | 20 +++++++--- .../runtime/rpc/methods/agent-launch.test.ts | 3 ++ 5 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 3331c8091816..f51e53369eae 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -132,6 +132,8 @@ describe('agent.launch with the real floating workspace resolver', () => { startupAgent: 'codex', // Derived on the host by the window's rule, from the same chat-view setting. viewMode: 'terminal', + // A paired device's launch never moves the desktop window. + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) } ) diff --git a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts index 7d75ebcc56b7..9fae70b55dbd 100644 --- a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts @@ -74,6 +74,15 @@ const PHONE: Partial = { ] } +const PAIRED_DESKTOP: Partial = { + clientKind: 'runtime', + pairedDeviceId: 'device-2', + clientCapabilities: [ + AGENT_LAUNCH_RUNTIME_CAPABILITY, + AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY + ] +} + const PHONE_READING_TAB_CLOSED: Partial = { ...OLD_PHONE, clientCapabilities: [ @@ -722,8 +731,9 @@ describe('whose view moves', () => { undefined, 'focus-in-workspace' ], - ['a phone reveals the workspace on the desktop, as before', PHONE, 'focused', 'reveal-owner'], - ['a phone by default reveals it too', PHONE, undefined, 'reveal-owner'], + ['a phone asking for focus leaves the desktop where it is', PHONE, 'focused', 'none'], + ['a phone by default leaves it too', PHONE, undefined, 'none'], + ['a paired desktop leaves the host window too', PAIRED_DESKTOP, 'focused', 'none'], ['background moves nobody', CLI, 'background', 'none'] ] as const)('%s', async (_name, context, presentation, viewer) => { const runtime = hostWithWindow() @@ -733,6 +743,30 @@ describe('whose view moves', () => { expect(runtime.published[0]?.viewer).toBe(viewer) }) + it.each([ + ['a phone that reads no early tab', OLD_PHONE, replayLaunch], + ['a phone launch with no operation id', PHONE, plainLaunch] + ] as const)( + 'leaves the desktop where it is when the agent spawns with no tab shown first: %s', + async (_name, context, run) => { + const runtime = hostWithWindow() + + await run(runtime, {}, context) + + expect(runtime.published).toHaveLength(0) + expect(terminalOptions(runtime)).toMatchObject({ surfaceOwner: false }) + } + ) + + it('still reveals a local launch that spawns with no tab shown first', async () => { + const runtime = hostWithWindow() + + await plainLaunch(runtime, {}, CLI) + + expect(runtime.published).toHaveLength(0) + expect(terminalOptions(runtime)).not.toHaveProperty('surfaceOwner') + }) + it("moves the phone's own selection unless it asked for the background", async () => { const focused = hostWithWindow({ terminalPaneKey: PANE_KEY }) await replayLaunch(focused, { paneKey: PANE_KEY }, PHONE) diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index d25fa7acd28d..0727f31bef0b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -37,7 +37,10 @@ import { type TerminalSpawnDispatch } from '../../../agent-launch/agent-launch-not-started' import { AgentLaunchTabClosedError } from '../../../../shared/agent-launch-tab-closed' -import type { EarlyAgentLaunchTab } from './agent-launch-tab-publication' +import { + agentLaunchMovesHostWindow, + type EarlyAgentLaunchTab +} from './agent-launch-tab-publication' /** Replay-safe launches keep the nested attach in the same stable caller namespace as the launch. */ export function agentLaunchSurfaceFactory( @@ -51,6 +54,7 @@ export function agentLaunchSurfaceFactory( // close can both land after admission. earlyTab: Pick | null = null ): AgentLaunchSurfaceFactory { + const movesHostWindow = agentLaunchMovesHostWindow(context) return { createStructuredSession: async ({ worktreeId, @@ -154,7 +158,7 @@ export function agentLaunchSurfaceFactory( ...(paneKey ? { ...paneIdentity(paneKey), requireFreshPane: true } : {}), ...(launchSource ? { launchSource } : {}), ...(viewMode ? { viewMode } : {}), - ...(earlyTab?.windowShowsTab() ? { surfaceOwner: false as const } : {}), + ...(earlyTab?.windowShowsTab() || !movesHostWindow ? { surfaceOwner: false as const } : {}), onPtySpawnDispatched: terminalSpawn.onPtySpawnDispatched }) const terminal = await created.catch(terminalSpawn.rethrow) diff --git a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts index c27e2edf8539..fd4d29bd5476 100644 --- a/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts +++ b/src/main/runtime/rpc/methods/agent-launch-tab-publication.ts @@ -48,25 +48,33 @@ import type { RpcContext } from '../core' import type { AgentLaunchParams } from './agent-launch-schemas' import { agentLaunchOperationCallerKey } from './agent-launch-replay' +/** Whether a launch may move the host window at all: a paired device's launch moves only its own + * view, as its browser tabs do. The tab shown first and the spawn's own reveal both ask this. */ +export function agentLaunchMovesHostWindow( + context: Pick +): boolean { + return ( + context.caller?.kind === 'desktop' || + navigationTargetsHost(resolveRuntimeNavigationTarget({ clientKind: context.clientKind })) + ) +} + /** * Whose screen moves. `presentation` is `terminal.create`'s field with its meaning (focused: the * host selects the tab; background: nothing moves). The desktop's own view is the host window, - * which follows only while it still shows that workspace. A caller whose navigation is elsewhere (a - * paired device) gets the window's reveal it always got, as well as its own selection. + * which follows only while it still shows that workspace. A paired device's launch leaves the host + * window where it is; the device selects the tab for itself. */ export function agentLaunchTabViewerRule( context: Pick, presentation: AgentLaunchParams['presentation'] ): AgentLaunchTabViewerRule { - if (presentation === 'background') { + if (presentation === 'background' || !agentLaunchMovesHostWindow(context)) { return 'none' } if (context.caller?.kind === 'desktop') { return 'focus-in-workspace' } - if (!navigationTargetsHost(resolveRuntimeNavigationTarget({ clientKind: context.clientKind }))) { - return 'reveal-owner' - } return presentation === 'focused' ? 'focus-window' : 'reveal-owner' } diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index 910aeadc1966..f884b54addf1 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -482,6 +482,8 @@ describe('the terminal factory', () => { startupAgent: 'claude', // The host derives the tab's first view by the window's rule; chat view is on by default here. viewMode: 'chat', + // A paired device's launch never moves the desktop window. + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) }) expect(createStructuredSession).not.toHaveBeenCalled() @@ -506,6 +508,7 @@ describe('the terminal factory', () => { expect(runtime.createTerminal).toHaveBeenCalledWith('id:wt-7', { startupAgent: 'grok', viewMode: 'chat', + surfaceOwner: false, onPtySpawnDispatched: expect.any(Function) }) expect(result.worktreeId).toBe('wt-7') From f63ef7834cf673ef36dbe7f4a1bd19eab8b4a277 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:31:26 -0700 Subject: [PATCH 2/3] fix(agent-launch): a paired device's new-workspace launch leaves the desktop window where it is An agent.launch that creates its workspace never asked agentLaunchMovesHostWindow: the create payload's activate/runHooks passed straight through, so a paired caller that set either would switch the desktop to the new workspace and reveal the startup terminal there. No shipped client sends them through agent.launch yet, so nothing changes today; it closes the gap before a phone's work-item creates move here. For a caller whose launch does not move the host window, the create now runs unactivated, which provisions setup and default tabs in the background. runHooks keeps its setup half as setupDecision 'run'. CLI and desktop creates are unchanged. Also makes the CLI "still reveals" test assert that a terminal was actually created, so it cannot pass with none. --- .../methods/agent-launch-instant-tab.test.ts | 1 + .../methods/agent-launch-worktree-creation.ts | 18 +++++- .../runtime/rpc/methods/agent-launch.test.ts | 61 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts index 9fae70b55dbd..75a801d75983 100644 --- a/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-instant-tab.test.ts @@ -764,6 +764,7 @@ describe('whose view moves', () => { await plainLaunch(runtime, {}, CLI) expect(runtime.published).toHaveLength(0) + expect(runtime.createTerminal).toHaveBeenCalledOnce() expect(terminalOptions(runtime)).not.toHaveProperty('surfaceOwner') }) diff --git a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts index 72cc48c3bc3e..ff79e44b7642 100644 --- a/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts +++ b/src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts @@ -23,6 +23,7 @@ import { resolveRpcWorkspaceCreatorProvenance } from '../workspace-creator-conte import { buildManagedWorktreeCreateArgs } from './worktree-create-args' import { toAgentLaunchPreferences } from '../../../../shared/agent-launch-preferences' import type { AgentLaunchParams } from './agent-launch-schemas' +import { agentLaunchMovesHostWindow } from './agent-launch-tab-publication' type WorktreeCreateParams = Extract< AgentLaunchParams['target'], @@ -63,7 +64,7 @@ export function agentLaunchWorkspaceFactory( const result = await runtime.createManagedWorktree({ ...buildManagedWorktreeCreateArgs( { - ...params, + ...(agentLaunchMovesHostWindow(context) ? params : withoutHostActivation(params)), ...(startupAgent ? { startupAgent } : {}), // Only ever set alongside `startupAgent`, which is what the create requires; the // executor offers it only to an agent that takes its prompt on argv, and it rides only @@ -125,6 +126,21 @@ export function agentLaunchWorkspaceFactory( } } +/** + * A paired device's create leaves the host window where it is, as the rest of its launch does: + * activating would switch the desktop to the new workspace and reveal the startup terminal there. + * Without it, setup and default tabs are provisioned in the background instead. `runHooks` means + * "run setup, and activate", so only its setup half is kept. + */ +function withoutHostActivation(params: WorktreeCreateParams): WorktreeCreateParams { + return { + ...params, + activate: false, + runHooks: false, + ...(params.runHooks === true ? { setupDecision: 'run' as const } : {}) + } +} + async function waitForStructuredSetup( runtime: Pick, receipt: Awaited>['setupReceipt'] diff --git a/src/main/runtime/rpc/methods/agent-launch.test.ts b/src/main/runtime/rpc/methods/agent-launch.test.ts index f884b54addf1..66cd25a7352e 100644 --- a/src/main/runtime/rpc/methods/agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch.test.ts @@ -378,6 +378,67 @@ describe('the worktree factory', () => { }) }) +describe('whose window a new workspace moves', () => { + const ACTIVATING_CREATE_LAUNCH = { + agent: 'claude', + target: { + kind: 'create-worktree', + create: { ...CREATE_LAUNCH.target.create, activate: true, runHooks: true } + } + } + const PAIRED_DESKTOP: Partial = { ...CAPABLE_CLIENT, clientKind: 'runtime' } + + it.each([ + ['a phone', 'chat', CAPABLE_CLIENT], + ['a phone', 'terminal', CAPABLE_CLIENT], + ['a paired desktop', 'chat', PAIRED_DESKTOP], + ['a paired desktop', 'terminal', PAIRED_DESKTOP] + ] as const)( + '%s creates without activating the host, and its setup still runs (%s)', + async (_name, mode, context) => { + const runtime = runtimeStub(mode === 'terminal' ? { settings: {} } : {}) + await launch(ACTIVATING_CREATE_LAUNCH, runtime, context) + + // Unactivated, the create provisions setup and default tabs in the background. + expect(createArgs(runtime)).toMatchObject({ + activate: false, + runHooks: false, + setupDecision: 'run', + awaitTerminalProvisioning: true + }) + } + ) + + it("keeps a paired device's own setup decision when it asked only to activate", async () => { + const runtime = runtimeStub() + await launch( + { + agent: 'claude', + target: { + kind: 'create-worktree', + create: { ...CREATE_LAUNCH.target.create, activate: true, setupDecision: 'skip' } + } + }, + runtime + ) + + expect(createArgs(runtime)).toMatchObject({ + activate: false, + runHooks: false, + setupDecision: 'skip' + }) + }) + + it('still activates a create the CLI asked to activate', async () => { + const runtime = runtimeStub({ settings: {} }) + await launch(ACTIVATING_CREATE_LAUNCH, runtime, {}) + + const args = createArgs(runtime) + expect(args).toMatchObject({ activate: true, runHooks: true }) + expect(args.setupDecision).toBeUndefined() + }) +}) + describe('the structured session factory', () => { it('creates the session for the worktree the launch just made, and activates it', async () => { const runtime = runtimeStub() From b173ea15eae88e0f6010307423bb8a32ec761010 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:39:00 -0700 Subject: [PATCH 3/3] docs(agent-launch): a paired client's create no longer activates the host to run setup The caller-selection header said a launch that creates its workspace relies on worktree.create's host activation to run setup. Since the previous commit that holds only for in-process callers: a paired client's create never activates the host window, and its setup and default tabs are provisioned in the background. Comment only. --- .../runtime/rpc/methods/agent-launch-caller-selection.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts index 2260c1af2f2c..04480ac091c4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.ts @@ -3,9 +3,9 @@ * * A paired client (a phone, or a desktop client of a remote server) that launches into an existing * workspace gets the new tab as its own selection, recorded the way `session.tabs.createTerminal` - * selects the tab it creates for its caller. In-process callers keep today's behaviour, and a - * launch that creates its workspace keeps `worktree.create`'s navigation, whose host activation is - * what runs the new workspace's setup. + * selects the tab it creates for its caller. In-process callers keep today's behaviour, and their + * launch that creates its workspace keeps `worktree.create`'s navigation. A paired client's create + * never activates the host window: its setup and default tabs are provisioned in the background. */ import type { AgentLaunchTarget } from '../../../../shared/agent-launch-intent'