Wake slept agents when opening a worktree on mobile (#7906)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-09 13:05:32 -07:00 committed by GitHub
parent 41edc493b8
commit 1b0febc4cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 532 additions and 15 deletions

View File

@ -14772,6 +14772,90 @@ describe('OrcaRuntimeService', () => {
expect(activateWorktree).not.toHaveBeenCalled()
})
it('wakes slept agents on the host renderer when a phone activates a worktree', async () => {
// Seed isUnread:false so the unread-clear branch stays quiet and the
// assertions isolate the mobile slept-agent wake.
const metaById: Record<string, WorktreeMeta> = {
[TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false })
}
const activateWorktree = vi.fn()
const resumeSleepingAgents = vi.fn()
const runtime = new OrcaRuntimeService({
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId]
} as never)
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree,
createTerminal: vi.fn(),
revealTerminalSession: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
resumeSleepingAgents,
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
// A renderer must be attached to receive the wake (headless serve is a
// deliberate non-goal — no renderer, no wake).
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
notifyClients: false,
clientKind: 'mobile'
})
// INV-2: mobile wake never navigates the desktop (no activateWorktree); it
// routes exclusively through the renderer's own navigation-free wake.
expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID)
expect(activateWorktree).not.toHaveBeenCalled()
})
it('does not wake slept agents for non-mobile session-only activation', async () => {
const metaById: Record<string, WorktreeMeta> = {
[TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false })
}
const resumeSleepingAgents = vi.fn()
const runtime = new OrcaRuntimeService({
...store,
getAllWorktreeMeta: () => metaById,
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId]
} as never)
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn(),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
resumeSleepingAgents,
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never)
runtime.attachWindow(TEST_WINDOW_ID)
runtime.markGraphReady(TEST_WINDOW_ID)
// INV-3: web/desktop runtime clients keep their existing wake-on-activation
// paths untouched — the renderer notifier wake is mobile-scoped.
await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, {
notifyClients: false,
clientKind: 'runtime'
})
expect(resumeSleepingAgents).not.toHaveBeenCalled()
})
it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => {
// Why: seed instanceId so worktree resolution does not emit its own
// metadata-stamp write, isolating the assertion to the unread clear.

View File

@ -1293,6 +1293,11 @@ type RuntimeNotifier = {
): Promise<RuntimeMarkdownSaveTabResult>
closeTerminal(tabId: string, paneRuntimeId?: number): void
sleepWorktree(worktreeId: string): void
// Why: a phone opening a worktree wakes its slept agents by asking the host
// renderer to run its own navigation-free wake (experimental agent sleep);
// the runtime has no in-memory sleeping records or wake authority. Optional to
// match the many renderer-backed notifier methods only the real bridge wires.
resumeSleepingAgents?(worktreeId: string): void
terminalFitOverrideChanged(
ptyId: string,
mode: 'mobile-fit' | 'desktop-fit',
@ -12587,7 +12592,7 @@ export class OrcaRuntimeService {
async activateManagedWorktree(
worktreeSelector: string,
opts: { notifyClients?: boolean } = {}
opts: { notifyClients?: boolean; clientKind?: 'mobile' | 'runtime' } = {}
): Promise<{
repoId: string
worktreeId: string
@ -12619,6 +12624,13 @@ export class OrcaRuntimeService {
})
await this.refreshMobileSessionPtyRecords()
this.notifyMobileSessionTabsChanged(worktree.id)
// Why: a phone open must also wake the worktree's slept agents (experimental
// agent sleep). Only the host renderer holds the sleeping records + wake
// authority, so fire-and-forget ask it — mobile-scoped so web/desktop are
// unaffected, and only when a renderer is attached to receive it.
if (opts.clientKind === 'mobile' && this.getAvailableAuthoritativeWindow()) {
this.notifier?.resumeSleepingAgents?.(worktree.id)
}
}
return { repoId: repo.id, worktreeId: worktree.id, activated: true }
}

View File

@ -38,7 +38,32 @@ describe('worktree RPC methods', () => {
expect(response).toMatchObject({ ok: true })
expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', {
notifyClients: false
notifyClients: false,
clientKind: undefined
})
})
it('forwards the mobile clientKind to the runtime on session-only activation', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
activateManagedWorktree: vi
.fn()
.mockResolvedValue({ repoId: 'repo-1', worktreeId: 'wt-1', activated: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
// The mobile WebSocket path always uses dispatchStreaming, which threads the
// authenticated device scope as clientKind even for non-streaming methods.
const replies: string[] = []
await dispatcher.dispatchStreaming(
makeRequest('worktree.activate', { worktree: 'id:wt-1', notifyClients: false }),
(response) => replies.push(response),
{ clientKind: 'mobile' }
)
expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', {
notifyClients: false,
clientKind: 'mobile'
})
})

View File

@ -59,9 +59,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [
defineMethod({
name: 'worktree.activate',
params: WorktreeActivate,
handler: async (params, { runtime }) =>
handler: async (params, { runtime, clientKind }) =>
// Why: clientKind ('mobile'|'runtime') scopes the host-renderer slept-agent
// wake to phones so web/desktop activation behavior is unchanged.
runtime.activateManagedWorktree(params.worktree, {
notifyClients: params.notifyClients !== false
notifyClients: params.notifyClients !== false,
clientKind
})
}),
defineMethod({

View File

@ -389,6 +389,7 @@ function registerRuntimeWindowLifecycle(
}) as Promise<RuntimeMarkdownSaveTabResult>,
closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }),
sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }),
resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }),
terminalFitOverrideChanged: (ptyId, mode, cols, rows) =>
send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }),
terminalDriverChanged: (ptyId, driver) =>

View File

@ -2750,6 +2750,7 @@ export type PreloadApi = {
callback: (data: { tabId: string; paneRuntimeId?: number }) => void
) => () => void
onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void
onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
onSystemResumed: (callback: () => void) => () => void
readClipboardText: (options?: ReadClipboardTextOptions) => Promise<string>

View File

@ -3478,6 +3478,12 @@ const api = {
ipcRenderer.on('ui:sleepWorktree', listener)
return () => ipcRenderer.removeListener('ui:sleepWorktree', listener)
},
onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) =>
callback(data)
ipcRenderer.on('ui:resumeSleepingAgents', listener)
return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener)
},
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') =>
callback(direction)

View File

@ -1872,6 +1872,64 @@ describe('connectPanePty', () => {
expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake)
})
it('resumes a hibernated agent from a navigation-free wake without a visibility reveal', async () => {
// Mobile wake fanout drives wakeHibernatedAgentIfArmed on a still-hidden pane
// (no isVisible flip): the armed cold-restore --resume must fire exactly once
// even when the wake is delivered twice (INV-1 idempotency).
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-pane-2')
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps({
consumeSuppressedPtyExit: vi.fn(() => true),
isVisibleRef: { current: false }
})
const pane = createPane(2)
const paneKey = `tab-1:${leafIdForPane(2)}`
mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: 'sess-hibernated-bg' },
prompt: 'test prompt',
state: 'done',
capturedAt: 1,
updatedAt: 1,
origin: 'worktree-sleep'
}
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
wakeHibernatedAgentIfArmed: () => void
dispose: () => void
}
await flushAsyncTicks()
const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty')
const connectCallsBeforeExit = transport.connect.mock.calls.length
onPtyExit?.('tab-pty')
await flushAsyncTicks()
// Still hidden: no reveal happened, so nothing respawned on exit.
expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit)
binding.wakeHibernatedAgentIfArmed()
await flushAsyncTicks()
expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit)
const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as
| { command?: string }
| undefined
expect(resumeConnectOptions?.command).toContain('--resume')
expect(resumeConnectOptions?.command).toContain('sess-hibernated-bg')
// A second navigation-free wake must not spawn again (one-pane/one-PTY).
const connectCallsAfterWake = transport.connect.mock.calls.length
binding.wakeHibernatedAgentIfArmed()
await flushAsyncTicks()
expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake)
})
it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => {
// Race: the user reveals the background tab in the window between the
// coordinator confirming the candidate and the kill's exit arriving. The

View File

@ -673,6 +673,11 @@ let inactiveForegroundImmediateBudgetWindowStart = 0
type PanePtyBinding = IDisposable & {
syncProcessTracking: () => void
noteVisibilityResume: () => void
/** Navigation-free hibernation wake: fires the armed cold-restore --resume
* without the size-reassert/foreground-sample side effects of a real reveal.
* Used by the mobile wake fanout so a hidden hibernated pane resumes with no
* desktop hiddenvisible transition. */
wakeHibernatedAgentIfArmed: () => void
/** Re-sample process identity when the pane gains intra-tab focus: the tab
* icon follows the active leaf, and a shell-marked entry on a still-running
* agent pane has no OSC boundary left to correct it. */
@ -6244,6 +6249,11 @@ export function connectPanePty(
consumeHibernatedAgentWake()
sampleVisiblePaneForegroundAgent()
},
// Why: mobile wake reaches this pane while it stays hidden on the desktop, so
// it must consume only the armed hibernation wake — no size/foreground reads.
wakeHibernatedAgentIfArmed() {
consumeHibernatedAgentWake()
},
sampleForegroundAgentOnFocus() {
sampleVisiblePaneForegroundAgent()
},

View File

@ -113,8 +113,10 @@ import {
import {
SPLIT_TERMINAL_PANE_EVENT,
CLOSE_TERMINAL_PANE_EVENT,
WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT,
type SplitTerminalPaneDetail,
type CloseTerminalPaneDetail
type CloseTerminalPaneDetail,
type WakeHibernatedAgentsWorktreeDetail
} from '@/constants/terminal'
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
@ -881,11 +883,12 @@ export function useTerminalPaneLifecycle({
imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder)
pane.terminal.attachCustomKeyEventHandler((e) => {
const now = Date.now()
const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease(
e,
pendingTerminalImeCandidateKeyReleases,
now
)
const pendingCandidateReleaseGuardActive =
shouldApplyTerminalImePendingCandidateKeyRelease(
e,
pendingTerminalImeCandidateKeyReleases,
now
)
const imeKeyboardOptions = {
compositionActive: imeCompositionTracker.isActive(),
candidateKeyGuardActive:
@ -1746,6 +1749,28 @@ export function useTerminalPaneLifecycle({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId, cwd])
// Why: mobile wake fanout — this pane self-selects by worktreeId and fires its
// own armed hibernation --resume while staying hidden on the desktop (no
// reveal, no focus/navigation change). Not-yet-mounted panes are covered by
// the background-mount fresh-connect cold-restore path instead.
useEffect(() => {
const onWakeHibernatedAgents = (event: Event): void => {
const detail = (event as CustomEvent<WakeHibernatedAgentsWorktreeDetail>).detail
if (!detail || detail.worktreeId !== worktreeId) {
return
}
for (const panePtyBinding of panePtyBindingsRef.current.values()) {
;(
panePtyBinding as IDisposable & { wakeHibernatedAgentIfArmed?: () => void }
).wakeHibernatedAgentIfArmed?.()
}
}
window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents)
return () => {
window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents)
}
}, [worktreeId, panePtyBindingsRef])
useEffect(() => {
const previousIsVisible = getPreviousVisibleForTerminalPane({
previous: previousVisibleForReconcileRef.current,

View File

@ -8,6 +8,13 @@ export const REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT = 'orca-request-active-ter
export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane'
export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree'
// Why: mobile wake (experimental agent sleep) must fire the cold-restore
// --resume of a worktree's mounted hidden hibernated panes without a desktop
// hidden→visible reveal. Each mounted TerminalPane self-selects on this event
// by worktreeId and invokes its own armed hibernation wake — a fanout, since
// pane bindings are per-instance with no global registry.
export const WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT = 'orca-wake-hibernated-agents-worktree'
// Why: sidebar open/close is an instantaneous width change. If we wait for
// the ResizeObserver rAF (and the 150ms debounced global fit) to catch up,
// the user sees the terminal in a wrongly-fit state for ~16ms+ then a snap
@ -68,3 +75,7 @@ export type CloseTerminalPaneDetail = {
export type BackgroundMountTerminalWorktreeDetail = {
worktreeId: string
}
export type WakeHibernatedAgentsWorktreeDetail = {
worktreeId: string
}

View File

@ -978,6 +978,7 @@ describe('useIpcEvents browser tab create routing', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: (
@ -1198,6 +1199,7 @@ describe('useIpcEvents updater integration', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -1441,6 +1443,7 @@ describe('useIpcEvents updater integration', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -1928,6 +1931,7 @@ describe('useIpcEvents updater integration', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -2779,6 +2783,7 @@ describe('useIpcEvents browser tab close routing', () => {
return () => {}
},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -3257,6 +3262,7 @@ describe('useIpcEvents browser tab close routing', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -3473,6 +3479,7 @@ describe('useIpcEvents browser tab close routing', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -3684,6 +3691,7 @@ describe('useIpcEvents browser tab close routing', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -3922,6 +3930,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -4168,6 +4177,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},
@ -4400,6 +4410,7 @@ describe('useIpcEvents agent status snapshot integration', () => {
onOpenDiffFromMobile: () => () => {},
onCloseTerminal: () => () => {},
onSleepWorktree: () => () => {},
onResumeSleepingAgents: () => () => {},
onNewBrowserTab: () => () => {},
onNewMarkdownTab: () => () => {},
onRequestTabCreate: () => () => {},

View File

@ -9,6 +9,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow'
import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
import { wakeSleepingAgentsForWorktreeInBackground } from '@/lib/wake-sleeping-agents-in-background'
import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel'
import {
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
@ -1942,6 +1943,14 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.ui.onResumeSleepingAgents(({ worktreeId }) => {
// Why: a phone opened this worktree; wake its slept agents on the host
// renderer navigation-free (no desktop worktree/tab/view change).
wakeSleepingAgentsForWorktreeInBackground(worktreeId)
})
)
// Hydrate initial update status then subscribe to changes
window.api.updater.getStatus().then((status) => {
useAppStore.getState().setUpdateStatus(status as UpdateStatus)

View File

@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
const initialAppStoreState = useAppStore.getState()
afterEach(() => {
vi.unstubAllGlobals()
useAppStore.setState(initialAppStoreState, true)
})
function makeRecord(
overrides: Partial<SleepingAgentSessionRecord> = {}
): SleepingAgentSessionRecord {
return {
paneKey: 'tab-1:leaf-1',
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: 'sess-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1,
...overrides
}
}
function makeTerminalTab(id: string, worktreeId: string): Record<string, unknown> {
return {
id,
ptyId: null,
worktreeId,
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
describe('resumeSleepingAgentSessionsForWorktree navigation suppression', () => {
it('resumes without navigating the desktop when navigation is suppressed', () => {
// Mobile-scoped wake: the desktop sits on a different worktree/view. The
// resume must spawn the recovery tab without changing the active surface.
const record = makeRecord({ origin: 'quit' })
useAppStore.setState({
activeWorktreeId: 'wt-other',
activeTabId: 'other-tab',
activeTabType: 'browser',
activeTabIdByWorktree: { 'wt-other': 'other-tab' },
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1', { suppressNavigation: true })
expect(launched).toBe(1)
const state = useAppStore.getState()
const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1')
// A resume tab is created for the slept worktree...
expect(resumedTab?.launchAgent).toBe('claude')
// ...but the desktop's active worktree/tab/view are untouched (INV-2).
expect(state.activeWorktreeId).toBe('wt-other')
expect(state.activeTabId).toBe('other-tab')
expect(state.activeTabType).toBe('browser')
})
it('still navigates to the resumed tab for default (desktop) callers', () => {
// Regression guard: the suppress-navigation flag must be opt-in — desktop
// resume keeps flipping the active view to the recovered terminal.
const record = makeRecord({ origin: 'quit' })
useAppStore.setState({
activeWorktreeId: 'wt-1',
activeTabId: 'tab-1',
activeTabType: 'browser',
activeTabIdByWorktree: { 'wt-1': 'tab-1' },
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
resumeSleepingAgentSessionsForWorktree('wt-1')
expect(useAppStore.getState().activeTabType).toBe('terminal')
})
})

View File

@ -57,7 +57,12 @@ function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void {
state.setTabBarOrder(worktreeId, order)
}
function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean {
// Why: mobile-driven wake runs on the desktop host renderer, so it must create
// the resume tab without stealing the desktop's active worktree/tab/view.
function launchSleepingAgentSession(
record: SleepingAgentSessionRecord,
options?: { suppressNavigation?: boolean }
): boolean {
const state = useAppStore.getState()
const launchConfig = record.launchConfig
const startupPlan = buildAgentResumeStartupPlan({
@ -86,7 +91,8 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean
}
const tab = state.createTab(record.worktreeId, undefined, undefined, {
launchAgent: record.agent
launchAgent: record.agent,
...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {})
})
state.queueTabStartupCommand(tab.id, {
command: startupPlan.launchCommand,
@ -110,7 +116,9 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean
providerSession: record.providerSession
})
state.clearSleepingAgentSession(record.paneKey)
state.setActiveTabType('terminal')
if (!options?.suppressNavigation) {
state.setActiveTabType('terminal')
}
appendTabToWorktreeOrder(record.worktreeId, tab.id)
return true
}
@ -239,7 +247,10 @@ function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord):
)
}
export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number {
export function resumeSleepingAgentSessionsForWorktree(
worktreeId: string,
options?: { suppressNavigation?: boolean }
): number {
const state = useAppStore.getState()
const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey)
.filter((record) => record.worktreeId === worktreeId)
@ -298,7 +309,7 @@ export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): numb
if (isPaneOwned) {
continue
}
if (launchSleepingAgentSession(record)) {
if (launchSleepingAgentSession(record, options)) {
launched += 1
freshlyLaunchedClaimKeys.add(claimKey)
clearPassiveCompletedRecordsForClaimKey(worktreeRecords, claimKey, record.paneKey)

View File

@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT
} from '@/constants/terminal'
const resumeSpy = vi.fn()
vi.mock('./resume-sleeping-agent-session', () => ({
resumeSleepingAgentSessionsForWorktree: (worktreeId: string, options?: unknown) =>
resumeSpy(worktreeId, options)
}))
// Why: control passive-vs-non-passive classification directly so the test asserts
// the gating, not the predicate internals.
const isPassiveSpy = vi.fn()
vi.mock('./sleeping-agent-pane-ownership', () => ({
isPassiveCompletedHibernationEvidence: (record: unknown) => isPassiveSpy(record)
}))
let sleepingRecords: Record<string, { worktreeId: string }> = {}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({ sleepingAgentSessionsByPaneKey: sleepingRecords })
}
}))
import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background'
function recordEvents(): { events: string[]; stop: () => void } {
const events: string[] = []
const onWake = (event: Event): void => {
events.push(`wake:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`)
}
const onMount = (event: Event): void => {
events.push(`mount:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`)
}
window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake)
window.addEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount)
return {
events,
stop: () => {
window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake)
window.removeEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount)
}
}
}
beforeEach(() => {
sleepingRecords = {}
isPassiveSpy.mockReset()
resumeSpy.mockReset()
})
afterEach(() => {
resumeSpy.mockReset()
})
describe('wakeSleepingAgentsForWorktreeInBackground', () => {
it('fires wake, background-mount, then resume when a passive record exists', () => {
sleepingRecords = { k1: { worktreeId: 'wt-1' } }
isPassiveSpy.mockReturnValue(true)
const rec = recordEvents()
wakeSleepingAgentsForWorktreeInBackground('wt-1')
rec.stop()
// (a) pane-level wake of mounted hidden panes fires before (b) background-mount
// of not-yet-mounted panes.
expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1'])
// (c) non-passive records resume with navigation suppressed (INV-2).
expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true })
})
it('skips background-mount when only non-passive records exist', () => {
sleepingRecords = { k1: { worktreeId: 'wt-1' } }
isPassiveSpy.mockReturnValue(false)
const rec = recordEvents()
wakeSleepingAgentsForWorktreeInBackground('wt-1')
rec.stop()
// Why: no passive record → no not-yet-mounted pane to fresh-connect, so
// background-mount must not run (it would strand a plain shell / mount work).
expect(rec.events).toEqual(['wake:wt-1'])
expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true })
})
it('does nothing when the worktree has no sleeping records', () => {
sleepingRecords = { k1: { worktreeId: 'other-wt' } }
const rec = recordEvents()
wakeSleepingAgentsForWorktreeInBackground('wt-1')
rec.stop()
// Why: mobile browsing a worktree with nothing slept must not mount it (and
// its PTYs) on the desktop host.
expect(rec.events).toEqual([])
expect(resumeSpy).not.toHaveBeenCalled()
expect(isPassiveSpy).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,57 @@
import {
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT,
type BackgroundMountTerminalWorktreeDetail,
type WakeHibernatedAgentsWorktreeDetail
} from '@/constants/terminal'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
import { isPassiveCompletedHibernationEvidence } from './sleeping-agent-pane-ownership'
/**
* Wakes a worktree's slept agents on the desktop host renderer with NO desktop
* navigation used when a phone (`clientKind: 'mobile'`) opens the worktree.
* Runs up to three steps, in order:
* (a) fire the armed cold-restore `--resume` of the worktree's mounted hidden
* hibernated panes (the experimental agent-sleep records; the primary
* wake mechanism, since those records are passive for path C);
* (b) background-mount so a hibernated pane that is NOT currently mounted
* (post-restart / evicted) mounts offscreen and takes the fresh-connect
* cold-restore path;
* (c) resume the non-passive record classes (manual sleep of a still-working
* agent, `origin: 'quit'`) with navigation suppressed.
* Woken PTYs auto-publish to mobile via the renderer graph republish, so no
* spawn is awaited.
*/
export function wakeSleepingAgentsForWorktreeInBackground(worktreeId: string): void {
const worktreeRecords = Object.values(
useAppStore.getState().sleepingAgentSessionsByPaneKey
).filter((record) => record.worktreeId === worktreeId)
// Why: nothing is slept here, so there is no wake work. Skipping is what keeps
// a phone browsing many worktrees from permanently background-mounting each one
// (and reattaching its PTYs) on the desktop host it is paired to.
if (worktreeRecords.length === 0) {
return
}
window.dispatchEvent(
new CustomEvent<WakeHibernatedAgentsWorktreeDetail>(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, {
detail: { worktreeId }
})
)
// Why: only a passive completed-hibernation record has a not-yet-mounted pane
// that needs a fresh-connect cold-restore (step b). Gating on it avoids mounting
// the worktree for non-passive records — which step (c) recovers into a fresh
// tab — so background-mount can't strand a plain shell in the stale tab.
if (worktreeRecords.some(isPassiveCompletedHibernationEvidence)) {
window.dispatchEvent(
new CustomEvent<BackgroundMountTerminalWorktreeDetail>(
BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT,
{
detail: { worktreeId }
}
)
)
}
resumeSleepingAgentSessionsForWorktree(worktreeId, { suppressNavigation: true })
}

View File

@ -2344,6 +2344,9 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
respondMobileMarkdownRequest: () => {},
onCloseTerminal: () => noopUnsubscribe,
onSleepWorktree: () => noopUnsubscribe,
// Why: paired web is a full renderer that wakes on activation; mobile wake is
// desktop-host-scoped, so the web client never receives this signal.
onResumeSleepingAgents: () => noopUnsubscribe,
onTerminalZoom: () => noopUnsubscribe,
// Why: a paired web client has no OS sleep signal; occlusion-driven
// visibilitychange already covers its wake recovery.