diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 49f3c583a..5c65779d7 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -688,6 +688,372 @@ describe("registerRuntimeBridge", () => { ); }); + describe("runtime event subscriptions", () => { + type RecordedSubscription = { + rootPath: string; + request: Record; + emit: (event: unknown, eventEpoch?: string | null) => void; + end: () => void; + cleanup: ReturnType; + }; + + function recordingLocalRuntimePool() { + const subscriptions: RecordedSubscription[] = []; + const pool = { + subscribeEventsForRoot: vi.fn( + async ( + rootPath: string, + request: Record, + onEvent: (event: unknown, eventEpoch?: string | null) => void, + onEnded: () => void, + onSubscribed?: (result: Record) => void, + ) => { + const cleanup = vi.fn(); + onSubscribed?.({ nextCursor: request.cursor ?? 0, hasMore: false }); + subscriptions.push({ + rootPath, + request, + emit: onEvent, + end: onEnded, + cleanup, + }); + return cleanup; + }, + ), + streamEventsForRoot: vi.fn(), + }; + return { subscriptions, pool }; + } + + function destroyableSender(id: number) { + const destroyedHandlers: Array<() => void> = []; + const webContents = { + id, + isDestroyed: vi.fn(() => false), + once: vi.fn((channel: string, handler: () => void) => { + if (channel === "destroyed") destroyedHandlers.push(handler); + }), + send: vi.fn(), + } as any; + return { + webContents, + destroy: () => { + webContents.isDestroyed.mockReturnValue(true); + for (const handler of [...destroyedHandlers]) handler(); + }, + }; + } + + function ptyEvent(id: number) { + return { + id, + timestamp: "2026-08-01T00:00:00.000Z", + category: "pty" as const, + payload: { + type: "pty_data", + event: { ptyId: "pty-1", sessionId: "session-1", data: "out" }, + }, + }; + } + + function registerWithPool(pool: unknown) { + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: pool as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo", baseRef: "main" }, + binding: localBinding("/repo"), + openProjectTabs: [ + { rootPath: "/repo", displayName: "Repo", baseRef: "main" }, + ], + }), + }); + return ipcHandlers.get(IPC.localRuntimeStreamEvents)!; + } + + const activePumpRequest = { cursor: 0, limit: 100 }; + const pinnedPtyPumpRequest = { cursor: 0, limit: 200, category: "pty" }; + + it("holds the active and pinned pumps as independent subscriptions on one window", async () => { + const { subscriptions, pool } = recordingLocalRuntimePool(); + const stream = registerWithPool(pool); + const active = destroyableSender(210); + const poll = (request: Record) => + stream(eventForSender(active.webContents), { + rootPath: "/repo", + request, + }); + + await poll(activePumpRequest); + await poll(pinnedPtyPumpRequest); + + // Distinct request keys must not evict one another. + expect(subscriptions).toHaveLength(2); + expect(subscriptions[0].request).toMatchObject({ category: undefined }); + expect(subscriptions[1].request).toMatchObject({ category: "pty" }); + expect(subscriptions[0].cleanup).not.toHaveBeenCalled(); + expect(subscriptions[1].cleanup).not.toHaveBeenCalled(); + + subscriptions[0].emit(ptyEvent(1), "epoch-1"); + subscriptions[1].emit(ptyEvent(2), "epoch-1"); + expect(active.webContents.send).toHaveBeenCalledTimes(2); + + // Re-polling either pump reuses its subscription instead of resubscribing. + await poll(activePumpRequest); + await poll(pinnedPtyPumpRequest); + expect(subscriptions).toHaveLength(2); + expect(pool.subscribeEventsForRoot).toHaveBeenCalledTimes(2); + + subscriptions[1].emit(ptyEvent(3), "epoch-1"); + expect(active.webContents.send).toHaveBeenCalledTimes(3); + }); + + it("releases every subscription a window holds when its sender is destroyed", async () => { + const { subscriptions, pool } = recordingLocalRuntimePool(); + const stream = registerWithPool(pool); + const active = destroyableSender(211); + const other = destroyableSender(212); + + await stream(eventForSender(active.webContents), { + rootPath: "/repo", + request: activePumpRequest, + }); + await stream(eventForSender(active.webContents), { + rootPath: "/repo", + request: pinnedPtyPumpRequest, + }); + await stream(eventForSender(other.webContents), { + rootPath: "/repo", + request: activePumpRequest, + }); + expect(subscriptions).toHaveLength(3); + + active.destroy(); + + expect(subscriptions[0].cleanup).toHaveBeenCalledTimes(1); + expect(subscriptions[1].cleanup).toHaveBeenCalledTimes(1); + expect(subscriptions[2].cleanup).not.toHaveBeenCalled(); + + subscriptions[1].emit(ptyEvent(4), "epoch-1"); + expect(active.webContents.send).not.toHaveBeenCalled(); + subscriptions[2].emit(ptyEvent(5), "epoch-1"); + expect(other.webContents.send).toHaveBeenCalledTimes(1); + }); + + it("expires subscriptions whose pump stopped polling so they cannot accumulate", async () => { + const previous = process.env.ADE_DISABLE_REMOTE_AUTOCONNECT; + process.env.ADE_DISABLE_REMOTE_AUTOCONNECT = "1"; + vi.useFakeTimers(); + try { + const { subscriptions, pool } = recordingLocalRuntimePool(); + const stream = registerWithPool(pool); + const active = destroyableSender(213); + const poll = (request: Record) => + stream(eventForSender(active.webContents), { + rootPath: "/repo", + request, + }); + + await poll(activePumpRequest); + await poll(pinnedPtyPumpRequest); + expect(subscriptions).toHaveLength(2); + + // The active pump keeps polling; the pinned PTY pump goes away silently. + for (let tick = 0; tick < 12; tick += 1) { + await vi.advanceTimersByTimeAsync(10_000); + await poll(activePumpRequest); + } + + expect(subscriptions[1].cleanup).toHaveBeenCalledTimes(1); + expect(subscriptions[0].cleanup).not.toHaveBeenCalled(); + // The surviving pump never resubscribed, so nothing churned either. + expect(pool.subscribeEventsForRoot).toHaveBeenCalledTimes(2); + + active.webContents.send.mockClear(); + subscriptions[1].emit(ptyEvent(6), "epoch-1"); + expect(active.webContents.send).not.toHaveBeenCalled(); + subscriptions[0].emit(ptyEvent(7), "epoch-1"); + expect(active.webContents.send).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + if (previous === undefined) { + delete process.env.ADE_DISABLE_REMOTE_AUTOCONNECT; + } else { + process.env.ADE_DISABLE_REMOTE_AUTOCONNECT = previous; + } + } + }); + + it("drops every subscription a window holds against a disconnected remote target", async () => { + remoteRegistryGetMock.mockReturnValue(target); + const cleanups: Array> = []; + remoteSubscribeEventsForTargetMock.mockImplementation( + async ( + _target: unknown, + _projectId: string, + _request: unknown, + _onEvent: unknown, + _onEnded: unknown, + onSubscribed?: (result: Record) => void, + ) => { + const cleanup = vi.fn(); + onSubscribed?.({ nextCursor: 0, hasMore: false }); + cleanups.push(cleanup); + return cleanup; + }, + ); + const { pool } = recordingLocalRuntimePool(); + registerWithPool(pool); + const streamRemote = ipcHandlers.get(IPC.remoteRuntimeStreamEvents)!; + const disconnect = ipcHandlers.get(IPC.remoteRuntimeDisconnect)!; + const active = destroyableSender(214); + + await streamRemote(eventForSender(active.webContents), { + id: target.id, + projectId: "project-1", + request: { cursor: 0, limit: 100, replay: false }, + }); + await streamRemote(eventForSender(active.webContents), { + id: target.id, + projectId: "project-1", + request: { cursor: 0, limit: 200, category: "pty", replay: false }, + }); + expect(cleanups).toHaveLength(2); + + await disconnect(eventForSender(active.webContents), { id: target.id }); + + expect(cleanups[0]).toHaveBeenCalledTimes(1); + expect(cleanups[1]).toHaveBeenCalledTimes(1); + }); + + it("releases a local pump's subscription as soon as the renderer says it stopped reading", async () => { + const { subscriptions, pool } = recordingLocalRuntimePool(); + const stream = registerWithPool(pool); + const release = ipcHandlers.get(IPC.runtimeEventsRelease)!; + const active = destroyableSender(215); + const poll = (request: Record) => + stream(eventForSender(active.webContents), { + rootPath: "/repo", + request, + }); + + await poll(activePumpRequest); + await poll(pinnedPtyPumpRequest); + expect(subscriptions).toHaveLength(2); + + await expect( + release(eventForSender(active.webContents), { + rootPath: "/repo", + category: "pty", + }), + ).resolves.toEqual({ released: 1 }); + + // Only the pinned PTY pump's subscription went; the active pump keeps its + // own, and the released one stops reaching the renderer immediately — + // without waiting out the 60s idle expiry. + expect(subscriptions[1].cleanup).toHaveBeenCalledTimes(1); + expect(subscriptions[0].cleanup).not.toHaveBeenCalled(); + subscriptions[1].emit(ptyEvent(8), "epoch-1"); + expect(active.webContents.send).not.toHaveBeenCalled(); + subscriptions[0].emit(ptyEvent(9), "epoch-1"); + expect(active.webContents.send).toHaveBeenCalledTimes(1); + + await expect( + release(eventForSender(active.webContents), { rootPath: "/repo" }), + ).resolves.toEqual({ released: 1 }); + expect(subscriptions[0].cleanup).toHaveBeenCalledTimes(1); + }); + + it("releases both replay variants a remote pinned pump accumulated", async () => { + remoteRegistryGetMock.mockReturnValue(target); + const cleanups: Array> = []; + remoteSubscribeEventsForTargetMock.mockImplementation( + async ( + _target: unknown, + _projectId: string, + _request: unknown, + _onEvent: unknown, + _onEnded: unknown, + onSubscribed?: (result: Record) => void, + ) => { + const cleanup = vi.fn(); + onSubscribed?.({ nextCursor: 0, hasMore: false }); + cleanups.push(cleanup); + return cleanup; + }, + ); + const { pool } = recordingLocalRuntimePool(); + registerWithPool(pool); + const streamRemote = ipcHandlers.get(IPC.remoteRuntimeStreamEvents)!; + const release = ipcHandlers.get(IPC.runtimeEventsRelease)!; + const active = destroyableSender(216); + + // A pinned pump suppresses replay on its first poll and stops suppressing + // once caught up, so it owns both request-key variants over its life. + await streamRemote(eventForSender(active.webContents), { + id: target.id, + projectId: "project-1", + request: { cursor: 0, limit: 200, category: "pty", replay: false }, + }); + remoteStreamEventsForTargetMock.mockResolvedValue({ + events: [], + nextCursor: 5, + hasMore: false, + }); + await streamRemote(eventForSender(active.webContents), { + id: target.id, + projectId: "project-1", + request: { cursor: 5, limit: 200, category: "pty" }, + }); + // The replay-bearing path subscribes fire-and-forget alongside its poll. + await new Promise((resolve) => setImmediate(resolve)); + expect(cleanups).toHaveLength(2); + + await expect( + release(eventForSender(active.webContents), { + id: target.id, + projectId: "project-1", + category: "pty", + }), + ).resolves.toEqual({ released: 2 }); + expect(cleanups[0]).toHaveBeenCalledTimes(1); + expect(cleanups[1]).toHaveBeenCalledTimes(1); + }); + + it("stops the idle sweeper when an ended subscription empties the registry", async () => { + const previous = process.env.ADE_DISABLE_REMOTE_AUTOCONNECT; + process.env.ADE_DISABLE_REMOTE_AUTOCONNECT = "1"; + vi.useFakeTimers(); + try { + const { subscriptions, pool } = recordingLocalRuntimePool(); + const stream = registerWithPool(pool); + const active = destroyableSender(217); + await stream(eventForSender(active.webContents), { + rootPath: "/repo", + request: activePumpRequest, + }); + expect(subscriptions).toHaveLength(1); + expect(vi.getTimerCount()).toBe(1); + + // The runtime connection dropped, so the subscription ends itself. That + // path used to leave the sweeper running over an empty registry. + subscriptions[0].end(); + + expect(subscriptions[0].cleanup).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + if (previous === undefined) { + delete process.env.ADE_DISABLE_REMOTE_AUTOCONNECT; + } else { + process.env.ADE_DISABLE_REMOTE_AUTOCONNECT = previous; + } + } + }); + }); + it("forwards remote project runtime actions through the selected target and project", async () => { remoteRegistryGetMock.mockReturnValue(target); remoteConnectMock.mockResolvedValue({ diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 112bd8894..6e4092a76 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -45,6 +45,8 @@ import type { RemoteRuntimeTarget, RemoteRuntimeTargetInput, RemoteRuntimeTrustSshHostKeyResult, + RuntimeEventsReleaseRequest, + RuntimeEventsReleaseResult, SyncWebPairingInfo, } from "../../../shared/types"; import type { LocalRuntimeConnectionPool } from "../localRuntime/localRuntimeConnectionPool"; @@ -63,6 +65,10 @@ import { shouldSendPtyDataToWebContents } from "../pty/ptyDataSubscriptions"; import { getSharedAccountAuthService } from "../../../../../ade-cli/src/services/account/sharedAccountAuthService"; import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; import { createSyncCloudRelayStore } from "../../../../../ade-cli/src/services/sync/syncCloudRelayStore"; +import { + createRuntimeEventSubscriptionRegistry, + type RuntimeEventWindowSubscription, +} from "./runtimeEventSubscriptionRegistry"; // Lane attach/adopt performed through the runtime action path never touches the // in-process IPC.lanesAttach handler, so the project-path inspection cache must @@ -128,12 +134,6 @@ const REMOTE_RUNTIME_SYNC_METHODS = new Set([ "modelPicker.pushRecent", ]); -type RuntimeEventWindowSubscription = { - bindingKey: string; - requestKey: string; - cleanup: (() => void) | null; -}; - type RuntimeEventSubscriptionInit = Pick< RemoteRuntimeStreamEventsResult, "nextCursor" | "hasMore" | "eventEpoch" | "gap" | "oldestCursor" @@ -272,6 +272,18 @@ function resolveAuthorizedLocalRuntimeRootPath( : null; } +// A window bound to the same local project shares that binding's key; any other +// authorized root gets a root-scoped key of its own. +function localRuntimeEventBindingKey( + binding: WindowRuntimeSession["binding"] | null | undefined, + rootPath: string, +): string { + return binding?.kind === "local" && + localRuntimeRootKey(binding.rootPath) === localRuntimeRootKey(rootPath) + ? binding.key + : `local:${rootPath}`; +} + function canBindRemoteProjectToSender( windowId: number | null, sender: WebContents, @@ -396,11 +408,14 @@ export function registerRuntimeBridge({ { appVersion, getAccountRelayProof, getAuthorizedAccountOwnerId }, pairedMachineStore, ); - const runtimeEventSubscriptions = new Map< - number, - RuntimeEventWindowSubscription - >(); - const runtimeEventWatchedSenders = new Set(); + const { + addRuntimeEventSubscription, + attachRuntimeEventSubscriptionCleanup, + cleanupRuntimeEventSubscriptions, + getRuntimeEventSubscription, + refreshRuntimeEventSubscription, + removeRuntimeEventSubscription, + } = createRuntimeEventSubscriptionRegistry(); const remoteOpenProjectGenerations = new Map(); let remoteOpenProjectGeneration = 0; let lastDiscoveredMachines: RemoteRuntimeDiscoveryResult["machines"] = []; @@ -495,24 +510,20 @@ export function registerRuntimeBridge({ }; } - const cleanupRuntimeEventSubscription = (senderId: number): void => { - const existing = runtimeEventSubscriptions.get(senderId); - runtimeEventSubscriptions.delete(senderId); - try { - existing?.cleanup?.(); - } catch { - // Best-effort subscription cleanup. - } - }; + // One shape for the (binding, category, replay) subscription key, so the + // release handler can rebuild exactly the key the subscribe path registered. + const runtimeEventRequestKeyPrefix = ( + bindingKey: string, + category: RemoteRuntimeEventCategory | undefined, + ): string => `${bindingKey}:${category ?? "*"}:`; - const watchRuntimeEventSender = (sender: WebContents): void => { - if (runtimeEventWatchedSenders.has(sender.id)) return; - runtimeEventWatchedSenders.add(sender.id); - sender.once("destroyed", () => { - runtimeEventWatchedSenders.delete(sender.id); - cleanupRuntimeEventSubscription(sender.id); - }); - }; + const runtimeEventRequestKey = ( + bindingKey: string, + request: RemoteRuntimeStreamEventsRequest, + ): string => + `${runtimeEventRequestKeyPrefix(bindingKey, request.category)}${ + request.replay === false ? "live" : "replay" + }`; const shouldForwardRuntimeEvent = ( sender: WebContents, @@ -530,17 +541,13 @@ export function registerRuntimeBridge({ }; const sendRuntimeEvent = ( - sender: WebContents, - bindingKey: string, - requestKey: string, + subscription: RuntimeEventWindowSubscription, event: RemoteRuntimeBufferedEvent, eventEpoch?: string | null, ): void => { - const existing = runtimeEventSubscriptions.get(sender.id); + const { sender, bindingKey, requestKey } = subscription; if ( - !existing || - existing.bindingKey !== bindingKey || - existing.requestKey !== requestKey || + getRuntimeEventSubscription(sender.id, requestKey) !== subscription || sender.isDestroyed() ) return; @@ -563,43 +570,44 @@ export function registerRuntimeBridge({ requestKey: string, subscribe: RuntimeEventSubscribe, ): Promise => { - const existing = runtimeEventSubscriptions.get(sender.id); - if (existing?.requestKey === requestKey) return null; - cleanupRuntimeEventSubscription(sender.id); - watchRuntimeEventSender(sender); - runtimeEventSubscriptions.set(sender.id, { bindingKey, requestKey, cleanup: null }); + // `requestKey` is prefixed with `bindingKey`, so an identical request key is + // an identical binding: this pump already owns a live subscription and only + // needs its idle-expiry clock refreshed. + const existing = refreshRuntimeEventSubscription(sender.id, requestKey); + if (existing) return null; + const subscription = addRuntimeEventSubscription({ + sender, + bindingKey, + requestKey, + cleanup: null, + }); const onEnded = () => { - const current = runtimeEventSubscriptions.get(sender.id); - if (current?.requestKey === requestKey && current.bindingKey === bindingKey) { - runtimeEventSubscriptions.delete(sender.id); - } + removeRuntimeEventSubscription(sender.id, requestKey, subscription); }; let subscriptionInit: RuntimeEventSubscriptionInit | null = null; try { const cleanup = await subscribe( - (event, eventEpoch) => - sendRuntimeEvent(sender, bindingKey, requestKey, event, eventEpoch), + (event, eventEpoch) => sendRuntimeEvent(subscription, event, eventEpoch), onEnded, (result) => { subscriptionInit = result; }, ); - const current = runtimeEventSubscriptions.get(sender.id); if ( - !current || - current.requestKey !== requestKey || - current.bindingKey !== bindingKey || - sender.isDestroyed() + !attachRuntimeEventSubscriptionCleanup( + sender.id, + requestKey, + subscription, + cleanup, + ) ) { cleanup(); return subscriptionInit; } - current.cleanup = cleanup; return subscriptionInit; } catch (error) { - const current = runtimeEventSubscriptions.get(sender.id); - if (current?.requestKey === requestKey && current.bindingKey === bindingKey && !current.cleanup) { - runtimeEventSubscriptions.delete(sender.id); + if (!subscription.cleanup) { + removeRuntimeEventSubscription(sender.id, requestKey, subscription); } console.warn("Runtime event subscription failed", error); throw error; @@ -1262,11 +1270,7 @@ export function registerRuntimeBridge({ const request = normalizeRuntimeStreamEventsRequest(arg?.request); const requestedRootPath = normalizeLocalRuntimeRootPath(arg?.rootPath); if (binding?.kind === "local" || requestedRootPath) { - const bindingKey = - binding?.kind === "local" && - localRuntimeRootKey(binding.rootPath) === localRuntimeRootKey(rootPath) - ? binding.key - : `local:${rootPath}`; + const bindingKey = localRuntimeEventBindingKey(binding, rootPath); const subscribe = ( onEvent: (event: RemoteRuntimeBufferedEvent, eventEpoch?: string | null) => void, onEnded: () => void, @@ -1284,7 +1288,7 @@ export function registerRuntimeBridge({ onEnded, onSubscribed, ); - const requestKey = `${bindingKey}:${request.category ?? "*"}:${request.replay === false ? "live" : "replay"}`; + const requestKey = runtimeEventRequestKey(bindingKey, request); const subscriptionInit = await ensureRuntimeEventSubscription( event.sender, bindingKey, @@ -1326,7 +1330,7 @@ export function registerRuntimeBridge({ if (!target) throw new Error("Remote target was not found."); const request = normalizeRuntimeStreamEventsRequest(arg?.request); const bindingKey = remoteProjectBindingKey(target.id, projectId); - const requestKey = `${bindingKey}:${request.category ?? "*"}:${request.replay === false ? "live" : "replay"}`; + const requestKey = runtimeEventRequestKey(bindingKey, request); const subscribe = ( onEvent: (event: RemoteRuntimeBufferedEvent, eventEpoch?: string | null) => void, onEnded: () => void, @@ -1376,6 +1380,61 @@ export function registerRuntimeBridge({ }, ); + // Idle expiry alone leaves a switched-away binding streaming for up to a + // minute per switch, into a renderer that discards every event. The pump that + // stops reading says so explicitly; expiry stays as the backstop for renderers + // that die without one. The argument mirrors the subscribe call so main can + // re-derive the same request key rather than trust one the renderer guessed. + ipcMain.handle( + IPC.runtimeEventsRelease, + async ( + event, + arg: RuntimeEventsReleaseRequest, + ): Promise => { + // Enforce the contract's exclusive union at runtime: exactly one complete + // binding shape — remote `{id, projectId}` XOR local `{rootPath}`. A + // mixed or partial payload is rejected outright instead of being + // ambiguously interpreted (e.g. `{id, rootPath}` silently going local). + const id = typeof arg?.id === "string" ? arg.id.trim() : ""; + const projectId = + typeof arg?.projectId === "string" ? arg.projectId.trim() : ""; + const rawRootPath = + typeof arg?.rootPath === "string" ? arg.rootPath.trim() : ""; + const remoteShape = Boolean(id && projectId); + const localShape = Boolean(rawRootPath); + if (remoteShape === localShape || Boolean(id) !== Boolean(projectId)) { + return { released: 0 }; + } + let bindingKey: string | null = null; + if (remoteShape) { + bindingKey = remoteProjectBindingKey(id, projectId); + } else { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; + const session = getWindowSession ? getWindowSession(windowId) : null; + const rootPath = resolveAuthorizedLocalRuntimeRootPath( + session, + rawRootPath, + authorizeLocalRuntimeRoot, + ); + if (rootPath) { + bindingKey = localRuntimeEventBindingKey(session?.binding, rootPath); + } + } + if (!bindingKey) return { released: 0 }; + // A pump's replay flag flips from `live` to `replay` once it is caught up, + // so it can own both key variants. Release the whole (binding, category). + const prefix = runtimeEventRequestKeyPrefix( + bindingKey, + isRemoteRuntimeEventCategory(arg?.category) ? arg.category : undefined, + ); + const released = cleanupRuntimeEventSubscriptions( + event.sender.id, + (subscription) => subscription.requestKey.startsWith(prefix), + ); + return { released }; + }, + ); + ipcMain.handle( IPC.remoteRuntimeDisconnect, async ( @@ -1384,10 +1443,12 @@ export function registerRuntimeBridge({ ): Promise<{ disconnected: boolean }> => { const id = typeof arg?.id === "string" ? arg.id.trim() : ""; if (!id) return { disconnected: false }; - const currentSubscription = runtimeEventSubscriptions.get(event.sender.id); - if (currentSubscription?.bindingKey.startsWith(`remote:${id}:`)) { - cleanupRuntimeEventSubscription(event.sender.id); - } + // Drop every subscription this window holds against the target, not just + // the most recent one: a window can hold the active pump plus one or more + // pinned pumps on the same remote runtime. + cleanupRuntimeEventSubscriptions(event.sender.id, (subscription) => + subscription.bindingKey.startsWith(`remote:${id}:`), + ); remoteConnectionService.disconnect(id, { manual: arg.manual !== false }); return { disconnected: true }; }, diff --git a/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts b/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts new file mode 100644 index 000000000..b9e0ac8bd --- /dev/null +++ b/apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts @@ -0,0 +1,180 @@ +import type { WebContents } from "electron"; + +export type RuntimeEventWindowSubscription = { + sender: WebContents; + bindingKey: string; + requestKey: string; + /** Owned by the registry: attached only via `attachRuntimeEventSubscriptionCleanup`. */ + readonly cleanup: (() => void) | null; + lastRequestedAtMs: number; +}; + +type RuntimeEventWindowSubscriptionInput = Omit< + RuntimeEventWindowSubscription, + "lastRequestedAtMs" +>; + +// A renderer runs several independent event pumps at once (the active binding +// pump, one pinned PTY pump per foreign lane, one pinned chat pump), each with +// its own category/replay shape. They must each hold their own subscription, so +// subscriptions are keyed by (sender, requestKey) rather than by sender alone. +// +// Keying by sender alone used to double as garbage collection: a pump with a +// different requestKey tore the previous one down. Independent subscriptions +// remove that, so a stale (sender, requestKey) is reclaimed by idle expiry. +// Every live pump refreshes its subscription on each poll (750ms..5s normally, +// 30s at the slowest failure backoff). +const RUNTIME_EVENT_SUBSCRIPTION_IDLE_MS = 60_000; +const RUNTIME_EVENT_SUBSCRIPTION_SWEEP_MS = 20_000; + +export function createRuntimeEventSubscriptionRegistry() { + const subscriptions = new Map< + number, + Map + >(); + const watchedSenders = new Set(); + let sweepTimer: ReturnType | null = null; + + function getRuntimeEventSubscription( + senderId: number, + requestKey: string, + ): RuntimeEventWindowSubscription | null { + return subscriptions.get(senderId)?.get(requestKey) ?? null; + } + + function disposeRuntimeEventSubscription( + subscription: RuntimeEventWindowSubscription, + ): void { + try { + subscription.cleanup?.(); + } catch { + // Best-effort subscription cleanup. + } + } + + function stopRuntimeEventSubscriptionSweeper(): void { + if (!sweepTimer) return; + clearInterval(sweepTimer); + sweepTimer = null; + } + + // The one way a subscription leaves the registry. Every caller — renderer + // release, ended callback, remote disconnect, sender death, and idle sweep — + // goes through it so disposal and registry pruning cannot drift apart. + function removeRuntimeEventSubscription( + senderId: number, + requestKey: string, + expected?: RuntimeEventWindowSubscription, + ): boolean { + const bySender = subscriptions.get(senderId); + const subscription = bySender?.get(requestKey); + if (!bySender || !subscription) return false; + if (expected && subscription !== expected) return false; + bySender.delete(requestKey); + if (bySender.size === 0) subscriptions.delete(senderId); + if (subscriptions.size === 0) stopRuntimeEventSubscriptionSweeper(); + disposeRuntimeEventSubscription(subscription); + return true; + } + + function cleanupRuntimeEventSubscriptions( + senderId: number, + predicate?: (subscription: RuntimeEventWindowSubscription) => boolean, + ): number { + const bySender = subscriptions.get(senderId); + if (!bySender) return 0; + let released = 0; + for (const [requestKey, subscription] of [...bySender]) { + if (predicate && !predicate(subscription)) continue; + if (removeRuntimeEventSubscription(senderId, requestKey, subscription)) { + released += 1; + } + } + return released; + } + + function sweepRuntimeEventSubscriptions(): void { + const now = Date.now(); + for (const [senderId, bySender] of [...subscriptions]) { + for (const [requestKey, subscription] of [...bySender]) { + const expired = + subscription.sender.isDestroyed() || + now - subscription.lastRequestedAtMs >= + RUNTIME_EVENT_SUBSCRIPTION_IDLE_MS; + if (expired) { + removeRuntimeEventSubscription(senderId, requestKey, subscription); + } + } + } + } + + function startRuntimeEventSubscriptionSweeper(): void { + if (sweepTimer) return; + sweepTimer = setInterval( + sweepRuntimeEventSubscriptions, + RUNTIME_EVENT_SUBSCRIPTION_SWEEP_MS, + ); + sweepTimer.unref?.(); + } + + function watchRuntimeEventSender(sender: WebContents): void { + if (watchedSenders.has(sender.id)) return; + watchedSenders.add(sender.id); + sender.once("destroyed", () => { + watchedSenders.delete(sender.id); + cleanupRuntimeEventSubscriptions(sender.id); + }); + } + + function addRuntimeEventSubscription( + input: RuntimeEventWindowSubscriptionInput, + ): RuntimeEventWindowSubscription { + watchRuntimeEventSender(input.sender); + const subscription: RuntimeEventWindowSubscription = { + ...input, + lastRequestedAtMs: Date.now(), + }; + const bySender = subscriptions.get(input.sender.id) ?? + new Map(); + bySender.set(input.requestKey, subscription); + subscriptions.set(input.sender.id, bySender); + startRuntimeEventSubscriptionSweeper(); + return subscription; + } + + function refreshRuntimeEventSubscription( + senderId: number, + requestKey: string, + ): RuntimeEventWindowSubscription | null { + const subscription = getRuntimeEventSubscription(senderId, requestKey); + if (!subscription) return null; + subscription.lastRequestedAtMs = Date.now(); + return subscription; + } + + // Cleanup ownership stays inside the registry. The subscribe flow attaches + // its disposer through this atomic check, so a subscription that was + // replaced mid-flight or whose sender died never adopts a cleanup the + // registry would then be unable to run; the caller keeps responsibility for + // disposing the orphaned cleanup when this returns false. + function attachRuntimeEventSubscriptionCleanup( + senderId: number, + requestKey: string, + expected: RuntimeEventWindowSubscription, + cleanup: () => void, + ): boolean { + const current = getRuntimeEventSubscription(senderId, requestKey); + if (current !== expected || expected.sender.isDestroyed()) return false; + (expected as { cleanup: (() => void) | null }).cleanup = cleanup; + return true; + } + + return { + getRuntimeEventSubscription, + addRuntimeEventSubscription, + attachRuntimeEventSubscriptionCleanup, + refreshRuntimeEventSubscription, + removeRuntimeEventSubscription, + cleanupRuntimeEventSubscriptions, + }; +} diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 5d68669a5..de35cd0a4 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -407,6 +407,13 @@ function createHarness(overrides: { return session; }), get: vi.fn((id: string) => sessionStore.get(id) ?? null), + list: vi.fn((args: { laneId?: string; status?: string; limit?: number | null; toolTypes?: string[] } = {}) => { + const sessions = Array.from(sessionStore.values()) + .filter((session) => !args.laneId || session.laneId === args.laneId) + .filter((session) => !args.status || session.status === args.status) + .filter((session) => !args.toolTypes?.length || args.toolTypes.includes(session.toolType)); + return args.limit === null ? sessions : sessions.slice(0, args.limit ?? 200); + }), setSummary: vi.fn(), setLastOutputPreview: vi.fn((sessionId: string, preview: string, opts?: { clearSettled?: boolean }) => { const session = sessionStore.get(sessionId); @@ -518,6 +525,127 @@ function createHarness(overrides: { }; } +function seedCodexRollout({ + id, + cwd, + startedAt, + mtime, + records = [], + originator, + parentThreadId, +}: { + id: string; + cwd: string; + startedAt: string; + mtime: number | string | Date; + records?: unknown[]; + /** Mirrors `session_meta.payload.originator` — ADE's per-launch nonce lands here. */ + originator?: string; + /** Set on subagent rollouts, which fork from a parent thread. */ + parentThreadId?: string; +}): { filePath: string; body: string } { + const date = new Date(startedAt); + const sessionsBase = path.join(os.homedir(), ".codex", "sessions"); + const dirPath = path.join( + sessionsBase, + String(date.getFullYear()), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + ); + const filePath = path.join(dirPath, `rollout-${startedAt.replace(/[:.]/g, "-")}-${id}.jsonl`); + const body = [ + JSON.stringify({ + timestamp: startedAt, + type: "session_meta", + payload: { + id, + timestamp: startedAt, + cwd, + ...(originator ? { originator } : {}), + ...(parentThreadId ? { parent_thread_id: parentThreadId } : {}), + }, + }), + ...records.map((record) => JSON.stringify(record)), + ].join("\n") + "\n"; + const mtimeMs = mtime instanceof Date + ? mtime.getTime() + : typeof mtime === "number" + ? mtime + : Date.parse(mtime); + + mocks.existsSyncResults.set(sessionsBase, true); + mocks.existsSyncResults.set(dirPath, true); + mocks.dirEntries.set(dirPath, Array.from(new Set([ + ...(mocks.dirEntries.get(dirPath) ?? []), + path.basename(filePath), + ]))); + mocks.fileContents.set(filePath, body); + mocks.fileStats.set(filePath, { + size: Buffer.byteLength(body, "utf8"), + mtimeMs, + isDirectory: false, + }); + return { filePath, body }; +} + +/** Mirrors `workTabCliPrompt`: a fixed ADE preamble, then the user's prompt. */ +function adeCodexPrompt(userPrompt: string): string { + return [ + "ADE session guidance. Treat this as operating guidance for the CLI session", + "and keep it in mind while handling the user prompt below.", + "", + "User prompt:", + userPrompt, + ].join("\n"); +} + +const OWNED_PROMPT = "extract the pty pump helpers and keep the transcript tests green"; + +/** A user turn as Codex records it in the rollout — the text lands JSON-escaped. */ +function codexUserMessageRecord(text: string): unknown { + return { + type: "response_item", + payload: { type: "message", role: "user", content: [{ type: "input_text", text }] }, + }; +} + +function createDetachedResumableSession( + sessionService: ReturnType["sessionService"], + { + sessionId, + startedAt = "2026-04-09T12:00:00.000Z", + endedAt = "2026-04-09T12:30:00.000Z", + }: { + sessionId: string; + startedAt?: string; + endedAt?: string; + }, +): void { + sessionService.create({ + sessionId, + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Claude CLI", + startedAt, + transcriptPath: `/tmp/transcripts/${sessionId}.log`, + toolType: "claude", + resumeCommand: "claude --resume claude-session-123", + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: "claude-session-123", + launch: { permissionMode: "default" }, + }, + }); + sessionService.end({ + sessionId, + endedAt, + exitCode: null, + status: "detached", + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1307,98 +1435,897 @@ describe("ptyService", () => { expect(mockPty.write).toHaveBeenCalledWith("\x1b[200~ADE session guidance\nUser prompt:\nhello\x1b[201~\r"); }); - it("injects the validated bundled plugin into tracked Claude CLI launches", async () => { - const pluginRoot = "/Applications/ADE.app/Contents/Resources/agent-skills"; - const repositoryPluginRoot = "/tmp/lane/apps/desktop/resources/agent-skills"; - mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); - mocks.fileStats.set(path.join(repositoryPluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); - const { service, loadPty } = createHarness({ - getAdeCliAgentEnv: (env) => ({ - ...env, - ADE_AGENT_SKILLS_DIRS: [repositoryPluginRoot, pluginRoot].join(path.delimiter), - ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, - }), - }); + it("injects the validated bundled plugin into tracked Claude CLI launches", async () => { + const pluginRoot = "/Applications/ADE.app/Contents/Resources/agent-skills"; + const repositoryPluginRoot = "/tmp/lane/apps/desktop/resources/agent-skills"; + mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + mocks.fileStats.set(path.join(repositoryPluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + const { service, loadPty } = createHarness({ + getAdeCliAgentEnv: (env) => ({ + ...env, + ADE_AGENT_SKILLS_DIRS: [repositoryPluginRoot, pluginRoot].join(path.delimiter), + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }), + }); + + await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + command: "claude", + args: ["--plugin-dir=/tmp/custom-plugin", "--permission-mode", "default"], + startupCommand: "claude --plugin-dir=/tmp/custom-plugin --permission-mode default", + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + expect(ptyLib.spawn).toHaveBeenCalledWith( + "claude", + expect.arrayContaining(["--plugin-dir", pluginRoot]), + expect.any(Object), + ); + expect(ptyLib.spawn.mock.calls.at(-1)?.[1]).toEqual(expect.arrayContaining([ + "--plugin-dir=/tmp/custom-plugin", + ])); + expect(ptyLib.spawn.mock.calls.at(-1)?.[1]).not.toEqual(expect.arrayContaining([ + repositoryPluginRoot, + ])); + }); + + it("injects the bundled Claude plugin into env-prefixed shell fallback commands", async () => { + const pluginRoot = "/Applications/ADE Preview.app/Contents/Resources/agent-skills"; + mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + const { service, mockPty, loadPty } = createHarness({ + getAdeCliAgentEnv: (env) => ({ + ...env, + ADE_AGENT_SKILLS_DIRS: pluginRoot, + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }), + }); + const spawn = vi.fn((command: string) => { + if (command === "claude") throw new Error("ENOENT"); + return mockPty; + }); + loadPty.mockImplementationOnce(() => ({ spawn: spawn as any })); + + await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + command: "claude", + args: ["--plugin-dir=/tmp/custom-plugin", "--permission-mode", "default"], + startupCommand: "ADE_RUN_ID='run 1' ADE_DEFAULT_ROLE=agent claude --plugin-dir=/tmp/custom-plugin --permission-mode default", + }); + + expect(mockPty.write).toHaveBeenCalledWith( + "ADE_RUN_ID='run 1' ADE_DEFAULT_ROLE=agent claude --plugin-dir \"/Applications/ADE Preview.app/Contents/Resources/agent-skills\" --plugin-dir=/tmp/custom-plugin --permission-mode default\r", + ); + }); + + it("does not duplicate the bundled Claude plugin in env-prefixed startup commands", async () => { + const pluginRoot = "/Applications/ADE Preview.app/Contents/Resources/agent-skills"; + mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + const startupCommand = `ADE_RUN_ID=run-1 claude --plugin-dir "${pluginRoot}" --plugin-dir=/tmp/custom-plugin`; + const { service, mockPty } = createHarness({ + getAdeCliAgentEnv: (env) => ({ + ...env, + ADE_AGENT_SKILLS_DIRS: pluginRoot, + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }), + }); + + await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand, + }); + + expect(mockPty.write).toHaveBeenCalledWith(`${startupCommand}\r`); + }); + + it("routes the bundled Claude plugin into the -lc command line of shell-wrapped resume launches", async () => { + // Regression: resume/reattach launches spawn `/bin/bash --noprofile + // --norc -lc "claude …"`. Prepending --plugin-dir to that argv makes + // bash itself die with "invalid option" (exit 2), which used to kill + // every Claude resume. + const pluginRoot = "/Applications/ADE.app/Contents/Resources/agent-skills"; + mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); + const { service, loadPty } = createHarness({ + getAdeCliAgentEnv: (env) => ({ + ...env, + ADE_AGENT_SKILLS_DIRS: pluginRoot, + ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, + }), + }); + + await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + command: "/bin/bash", + args: ["--noprofile", "--norc", "-lc", "claude --resume claude-session-123"], + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const [spawnedCommand, spawnedArgs] = ptyLib.spawn.mock.calls.at(-1) as [string, string[]]; + expect(spawnedCommand).toBe("/bin/bash"); + // bash's own argv must be untouched apart from the rewritten -lc payload. + expect(spawnedArgs.slice(0, 3)).toEqual(["--noprofile", "--norc", "-lc"]); + expect(spawnedArgs).toHaveLength(4); + expect(spawnedArgs[3]).toBe(`claude --plugin-dir "${pluginRoot}" --resume claude-session-123`); + }); + + it("keeps a resumable session's prior status when the resume launch dies immediately", async () => { + // Regression: a resume whose spawned shell exits nonzero right away used + // to stamp the reused row failed/exit-2, making a still-resumable + // detached session look permanently dead. + const { service, sessionService, mockPty } = createHarness(); + createDetachedResumableSession(sessionService, { sessionId: "session-resume-status" }); + + await service.create({ + sessionId: "session-resume-status", + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand: "claude --resume claude-session-123", + }); + + mockPty._emitter.emit("exit", { exitCode: 2 }); + + const session = sessionService.get("session-resume-status"); + expect(session.status).toBe("detached"); + expect(session.exitCode).toBeNull(); + expect(session.endedAt).toBe("2026-04-09T12:30:00.000Z"); + }); + + it("does not restore a stale running status when a resume launch dies immediately", async () => { + // A brain restart can leave a running row without a live PTY. That row + // is eligible for relaunch, but it is not a valid prior end state. + const { service, sessionService, mockPty } = createHarness(); + sessionService.create({ + sessionId: "session-stale-running", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Claude CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-stale-running.log", + toolType: "claude", + resumeCommand: "claude --resume claude-session-123", + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: "claude-session-123", + launch: { permissionMode: "default" }, + }, + }); + + await service.create({ + sessionId: "session-stale-running", + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand: "claude --resume claude-session-123", + }); + + mockPty._emitter.emit("exit", { exitCode: 2 }); + + const session = sessionService.get("session-stale-running"); + expect(session.status).toBe("failed"); + expect(session.exitCode).toBe(2); + expect(session.endedAt).not.toBeNull(); + expect(session.ptyId).toBeNull(); + }); + + it("captures a live Codex thread id from a rollout without the ADE guidance marker", async () => { + // Regression: live capture used to require the string "ADE session + // guidance" in the rollout, which only the Work-tab CLI preamble emits. + // Goal-launched sessions never wrote it, so thread ids were essentially + // never captured live. Identification is cwd + timestamp proximity. + vi.useFakeTimers(); + try { + const fakeNow = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(fakeNow); + // A goal launch: no "ADE session guidance" anywhere in the rollout. + seedCodexRollout({ + id: "thread-goal", + cwd: "/tmp/test-worktree", + startedAt: fakeNow.toISOString(), + mtime: fakeNow, + records: [{ type: "event_msg", payload: { message: "" } }], + }); + + const { service, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + }); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(sessionId, "codex resume thread-goal"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not adopt a same-cwd Codex rollout that predates the session", async () => { + // The existing not-before floor must continue rejecting rollouts that + // were already present before this launch. + vi.useFakeTimers(); + try { + const fakeNow = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(fakeNow); + const otherStartedAt = "2026-04-15T21:00:00.000Z"; + seedCodexRollout({ + id: "thread-other", + cwd: "/tmp/test-worktree", + startedAt: otherStartedAt, + mtime: fakeNow, + }); + + const { service, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + }); + + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith(sessionId, "codex resume thread-other"); + } finally { + vi.useRealTimers(); + } + }); + + it("excludes Codex rollout ids already adopted by other terminal sessions", async () => { + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + const { service, sessionService } = createHarness(); + + sessionService.create({ + sessionId: "session-owned-metadata", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: launchAt.toISOString(), + transcriptPath: "/tmp/transcripts/session-owned-metadata.log", + toolType: "codex", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-owned-metadata", + launch: { permissionMode: "default" }, + }, + }); + sessionService.create({ + sessionId: "session-owned-command", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: launchAt.toISOString(), + transcriptPath: "/tmp/transcripts/session-owned-command.log", + toolType: "codex", + resumeCommand: "codex resume thread-owned-command", + }); + + const atOffset = (offsetMs: number) => new Date(launchAt.getTime() + offsetMs).toISOString(); + seedCodexRollout({ + id: "thread-owned-metadata", + cwd: "/tmp/test-worktree", + startedAt: atOffset(400), + mtime: launchAt.getTime() + 400, + }); + seedCodexRollout({ + id: "thread-owned-command", + cwd: "/tmp/test-worktree", + startedAt: atOffset(500), + mtime: launchAt.getTime() + 500, + }); + seedCodexRollout({ + id: "thread-this-session", + cwd: "/tmp/test-worktree", + startedAt: atOffset(800), + mtime: launchAt.getTime() + 800, + }); + + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + }); + + expect(sessionService.list).toHaveBeenCalledWith({ limit: null }); + expect(sessionService.setResumeCommand).toHaveBeenCalledWith( + sessionId, + "codex resume thread-this-session", + ); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringMatching(/thread-owned-(metadata|command)/), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("uses a 90-second window for live Codex rollout capture", async () => { + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + const atOffset = (offsetMs: number) => new Date(launchAt.getTime() + offsetMs).toISOString(); + seedCodexRollout({ + id: "thread-too-late", + cwd: "/tmp/test-worktree", + startedAt: atOffset(90_001), + mtime: launchAt.getTime() + 90_001, + }); + + const { service, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + }); + + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + "codex resume thread-too-late", + ); + + seedCodexRollout({ + id: "thread-in-window", + cwd: "/tmp/test-worktree", + startedAt: atOffset(90_000), + mtime: launchAt.getTime() + 90_000, + }); + await vi.advanceTimersByTimeAsync(500); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith( + sessionId, + "codex resume thread-in-window", + ); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + "codex resume thread-too-late", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("does not adopt a same-cwd in-window rollout that lacks this launch's delivered text", async () => { + // The window plus the already-adopted exclusion cannot tell a concurrent + // unrelated Codex process in the same worktree from this one. When this + // launch delivered text of its own, the rollout has to contain it. + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + seedCodexRollout({ + id: "thread-foreign", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 300).toISOString(), + mtime: launchAt.getTime() + 300, + records: [codexUserMessageRecord("someone else's unrelated codex prompt in this worktree")], + }); + + const { service, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + initialInput: adeCodexPrompt(OWNED_PROMPT), + }); + await vi.advanceTimersByTimeAsync(60_000); + + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringContaining("thread-foreign"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("adopts the rollout carrying this launch's delivered text over a closer unrelated one", async () => { + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + // Deliberately closer to the launch instant than ours, so plain + // timestamp proximity would pick it. + seedCodexRollout({ + id: "thread-foreign", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 300).toISOString(), + mtime: launchAt.getTime() + 300, + records: [codexUserMessageRecord("someone else's unrelated codex prompt in this worktree")], + }); + seedCodexRollout({ + id: "thread-ours", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 800).toISOString(), + mtime: launchAt.getTime() + 800, + records: [codexUserMessageRecord(adeCodexPrompt(OWNED_PROMPT))], + }); + + const { service, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + initialInput: adeCodexPrompt(OWNED_PROMPT), + }); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(sessionId, "codex resume thread-ours"); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringContaining("thread-foreign"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("matches delivered text whose quotes and newlines are JSON-escaped in the rollout", async () => { + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + const prompt = "fix the \"adopted id\" guard\nand add a regression test for it"; + const { body } = seedCodexRollout({ + id: "thread-escaped", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 400).toISOString(), + mtime: launchAt.getTime() + 400, + records: [codexUserMessageRecord(adeCodexPrompt(prompt))], + }); + // The rollout stores the prompt inside a JSON string, so the raw text is + // not literally present — matching has to go through the escaped form. + expect(body).not.toContain(prompt); + + const { service, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + initialInput: adeCodexPrompt(prompt), + }); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(sessionId, "codex resume thread-escaped"); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps an already-adopted rollout excluded even when it carries this launch's text", async () => { + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + const { service, sessionService } = createHarness(); + + sessionService.create({ + sessionId: "session-owner", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: launchAt.toISOString(), + transcriptPath: "/tmp/transcripts/session-owner.log", + toolType: "codex", + resumeCommand: "codex resume thread-owned-needle", + }); + seedCodexRollout({ + id: "thread-owned-needle", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 400).toISOString(), + mtime: launchAt.getTime() + 400, + records: [codexUserMessageRecord(adeCodexPrompt(OWNED_PROMPT))], + }); + + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + initialInput: adeCodexPrompt(OWNED_PROMPT), + }); + await vi.advanceTimersByTimeAsync(60_000); + + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringContaining("thread-owned-needle"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("tells apart prompts that share a long head and diverge later", async () => { + // Regression: the needle used to be a 200-character head slice, so two + // launches whose prompts opened identically were indistinguishable. The + // needle now spans the whole delivered prompt. + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + const sharedHead = `${"re-run the pty ownership audit and keep the transcript tests green; ".repeat(4)}then`; + expect(sharedHead.length).toBeGreaterThan(200); + const ourPrompt = `${sharedHead} extract the codex rollout helpers`; + const theirPrompt = `${sharedHead} rewrite the claude storage backfill`; + + // Closer to the launch instant than ours, so timestamp proximity alone + // would pick it, and identical for the first 200 characters. + seedCodexRollout({ + id: "thread-shared-head", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 300).toISOString(), + mtime: launchAt.getTime() + 300, + records: [codexUserMessageRecord(adeCodexPrompt(theirPrompt))], + }); + seedCodexRollout({ + id: "thread-full-text", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 800).toISOString(), + mtime: launchAt.getTime() + 800, + records: [codexUserMessageRecord(adeCodexPrompt(ourPrompt))], + }); + + const { service, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + initialInput: adeCodexPrompt(ourPrompt), + }); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(sessionId, "codex resume thread-full-text"); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringContaining("thread-shared-head"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("stamps a distinct ownership nonce on every Codex launch environment", async () => { + const { service, loadPty } = createHarness(); + const launchCodex = async (): Promise => { + await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + }); + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const env = (ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env; + return String(env?.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? ""); + }; + + const first = await launchCodex(); + const second = await launchCodex(); + // The `ade` prefix keeps usage attribution counting these as ADE-launched. + expect(first).toMatch(/^ade_pty_.+/); + expect(second).toMatch(/^ade_pty_.+/); + expect(second).not.toBe(first); + }); + + it("leaves an explicitly configured Codex originator override alone", async () => { + const { service, loadPty } = createHarness(); + await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + env: { CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "my_own_client" }, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const env = (ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env; + expect(env?.CODEX_INTERNAL_ORIGINATOR_OVERRIDE).toBe("my_own_client"); + }); + + it("adopts by launch nonce when two same-worktree launches share the same prompt", async () => { + // The case the needle cannot decide: same worktree, same launch window, + // byte-identical prompts. Only the nonce ADE stamped on this launch, which + // Codex writes back as the rollout originator, separates them. + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + const prompt = adeCodexPrompt(OWNED_PROMPT); + + const { service, sessionService, loadPty } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + initialInput: prompt, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const env = (ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env; + const nonce = String(env?.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? ""); + expect(nonce).not.toBe(""); + + // The twin launch: same cwd, same text, closer to the launch instant. + seedCodexRollout({ + id: "thread-twin", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 200).toISOString(), + mtime: launchAt.getTime() + 200, + originator: "ade_pty_ffffffffffffffffffffffffffffffff", + records: [codexUserMessageRecord(prompt)], + }); + seedCodexRollout({ + id: "thread-nonce", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 900).toISOString(), + mtime: launchAt.getTime() + 900, + originator: nonce, + records: [codexUserMessageRecord(prompt)], + }); + await vi.advanceTimersByTimeAsync(500); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(sessionId, "codex resume thread-nonce"); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringContaining("thread-twin"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("adopts by launch nonce when the prompt is too short to be a needle", async () => { + // Prompts under the needle minimum carry no ownership signal of their own, + // which used to leave short-prompt launches racing on timestamps. + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + const prompt = adeCodexPrompt("ship it"); + + const { service, sessionService, loadPty } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + initialInput: prompt, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const env = (ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env; + const nonce = String(env?.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? ""); + + seedCodexRollout({ + id: "thread-short-twin", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 200).toISOString(), + mtime: launchAt.getTime() + 200, + records: [codexUserMessageRecord(prompt)], + }); + seedCodexRollout({ + id: "thread-short-ours", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 900).toISOString(), + mtime: launchAt.getTime() + 900, + originator: nonce, + records: [codexUserMessageRecord(prompt)], + }); + await vi.advanceTimersByTimeAsync(500); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(sessionId, "codex resume thread-short-ours"); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringContaining("thread-short-twin"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("does not adopt a subagent rollout that inherited this launch's nonce", async () => { + // Codex subagents fork from the launched thread and inherit its + // environment, so they carry the same originator. The thread ADE launched + // is the root one. + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); - await service.create({ - laneId: "lane-1", - title: "Claude CLI", - cols: 80, - rows: 24, - toolType: "claude", - command: "claude", - args: ["--plugin-dir=/tmp/custom-plugin", "--permission-mode", "default"], - startupCommand: "claude --plugin-dir=/tmp/custom-plugin --permission-mode default", - }); + const { service, sessionService, loadPty } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + }); - const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; - expect(ptyLib.spawn).toHaveBeenCalledWith( - "claude", - expect.arrayContaining(["--plugin-dir", pluginRoot]), - expect.any(Object), - ); - expect(ptyLib.spawn.mock.calls.at(-1)?.[1]).toEqual(expect.arrayContaining([ - "--plugin-dir=/tmp/custom-plugin", - ])); - expect(ptyLib.spawn.mock.calls.at(-1)?.[1]).not.toEqual(expect.arrayContaining([ - repositoryPluginRoot, - ])); + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + const env = (ptyLib.spawn.mock.calls.at(-1)?.[2] as { env?: NodeJS.ProcessEnv } | undefined)?.env; + const nonce = String(env?.CODEX_INTERNAL_ORIGINATOR_OVERRIDE ?? ""); + + // The subagent rollout lands first and is the only candidate in the + // window: it still must not be adopted, nonce or no nonce. + seedCodexRollout({ + id: "thread-subagent", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 400).toISOString(), + mtime: launchAt.getTime() + 400, + originator: nonce, + parentThreadId: "thread-root", + }); + await vi.advanceTimersByTimeAsync(500); + + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringContaining("thread-subagent"), + ); + + seedCodexRollout({ + id: "thread-root", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 900).toISOString(), + mtime: launchAt.getTime() + 900, + originator: nonce, + }); + await vi.advanceTimersByTimeAsync(2_000); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(sessionId, "codex resume thread-root"); + } finally { + vi.useRealTimers(); + } }); - it("injects the bundled Claude plugin into env-prefixed shell fallback commands", async () => { - const pluginRoot = "/Applications/ADE Preview.app/Contents/Resources/agent-skills"; - mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); - const { service, mockPty, loadPty } = createHarness({ - getAdeCliAgentEnv: (env) => ({ - ...env, - ADE_AGENT_SKILLS_DIRS: pluginRoot, - ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, - }), - }); - const spawn = vi.fn((command: string) => { - if (command === "claude") throw new Error("ENOENT"); - return mockPty; - }); - loadPty.mockImplementationOnce(() => ({ spawn: spawn as any })); + it("still adopts by window and exclusion when the launch delivered no text", async () => { + // A bare interactive `codex` types nothing, so there is no ownership + // signal to demand and capture keeps its pre-needle behavior. + vi.useFakeTimers(); + try { + const launchAt = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(launchAt); + const { service, sessionService } = createHarness(); - await service.create({ - laneId: "lane-1", - title: "Claude CLI", - cols: 80, - rows: 24, - toolType: "claude", - command: "claude", - args: ["--plugin-dir=/tmp/custom-plugin", "--permission-mode", "default"], - startupCommand: "ADE_RUN_ID='run 1' ADE_DEFAULT_ROLE=agent claude --plugin-dir=/tmp/custom-plugin --permission-mode default", - }); + sessionService.create({ + sessionId: "session-owner", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: launchAt.toISOString(), + transcriptPath: "/tmp/transcripts/session-owner.log", + toolType: "codex", + resumeCommand: "codex resume thread-already-owned", + }); + seedCodexRollout({ + id: "thread-already-owned", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 300).toISOString(), + mtime: launchAt.getTime() + 300, + }); + seedCodexRollout({ + id: "thread-bare-launch", + cwd: "/tmp/test-worktree", + startedAt: new Date(launchAt.getTime() + 700).toISOString(), + mtime: launchAt.getTime() + 700, + }); - expect(mockPty.write).toHaveBeenCalledWith( - "ADE_RUN_ID='run 1' ADE_DEFAULT_ROLE=agent claude --plugin-dir \"/Applications/ADE Preview.app/Contents/Resources/agent-skills\" --plugin-dir=/tmp/custom-plugin --permission-mode default\r", - ); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen"], + }); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(sessionId, "codex resume thread-bare-launch"); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + expect.stringContaining("thread-already-owned"), + ); + } finally { + vi.useRealTimers(); + } }); - it("does not duplicate the bundled Claude plugin in env-prefixed startup commands", async () => { - const pluginRoot = "/Applications/ADE Preview.app/Contents/Resources/agent-skills"; - mocks.fileStats.set(path.join(pluginRoot, ".claude-plugin", "plugin.json"), { isDirectory: false }); - const startupCommand = `ADE_RUN_ID=run-1 claude --plugin-dir "${pluginRoot}" --plugin-dir=/tmp/custom-plugin`; - const { service, mockPty } = createHarness({ - getAdeCliAgentEnv: (env) => ({ - ...env, - ADE_AGENT_SKILLS_DIRS: pluginRoot, - ADE_BUNDLED_AGENT_SKILLS_DIR: pluginRoot, - }), - }); + it("marks a resumed session failed when it exits nonzero after actually running", async () => { + vi.useFakeTimers(); + try { + const { service, sessionService, mockPty } = createHarness(); + createDetachedResumableSession(sessionService, { sessionId: "session-resume-real-exit" }); - await service.create({ - laneId: "lane-1", - title: "Claude CLI", - cols: 80, - rows: 24, - toolType: "claude", - startupCommand, - }); + await service.create({ + sessionId: "session-resume-real-exit", + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand: "claude --resume claude-session-123", + }); - expect(mockPty.write).toHaveBeenCalledWith(`${startupCommand}\r`); + await vi.advanceTimersByTimeAsync(60_000); + mockPty._emitter.emit("exit", { exitCode: 2 }); + + const session = sessionService.get("session-resume-real-exit"); + expect(session.status).toBe("failed"); + expect(session.exitCode).toBe(2); + } finally { + vi.useRealTimers(); + } }); it("waits for agent CLI readiness before sending initialInput", async () => { @@ -3030,28 +3957,14 @@ describe("ptyService", () => { try { const fakeNow = new Date("2026-04-15T22:00:00.000Z"); vi.setSystemTime(fakeNow); - - const homedir = os.homedir(); - const sessionsBase = path.join(homedir, ".codex", "sessions"); - const dirPath = path.join(sessionsBase, "2026", "04", "15"); - const filePath = path.join(dirPath, "rollout-2026-04-15T21-30-00-thread-storage.jsonl"); const startedAt = "2026-04-15T21:30:00.000Z"; - const firstLine = JSON.stringify({ - timestamp: startedAt, - type: "session_meta", - payload: { - id: "thread-storage", - timestamp: startedAt, - cwd: "/tmp/worktree", - }, + seedCodexRollout({ + id: "thread-storage", + cwd: "/tmp/test-worktree", + startedAt, + mtime: fakeNow.getTime() - 30_000, }); - mocks.existsSyncResults.set(sessionsBase, true); - mocks.existsSyncResults.set(dirPath, true); - mocks.dirEntries.set(dirPath, [path.basename(filePath)]); - mocks.fileContents.set(filePath, `${firstLine}\n`); - mocks.fileStats.set(filePath, { size: firstLine.length, mtimeMs: fakeNow.getTime() - 30_000, isDirectory: false }); - const { service, sessionService, loadPty } = createHarness(); sessionService.readTranscriptTail.mockResolvedValue("OpenAI Codex\nmodel: gpt-5\n› "); sessionService.create({ @@ -3061,7 +3974,7 @@ describe("ptyService", () => { tracked: true, title: "Codex CLI", startedAt, - transcriptPath: "/tmp/worktree/.ade/transcripts/session-codex-storage-send.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-codex-storage-send.log", toolType: "codex", resumeCommand: "codex --no-alt-screen resume", }); @@ -5961,6 +6874,87 @@ describe("ptyService", () => { }); describe("ensureResumeTargets", () => { + it("resolves the backfill cwd from the lane worktree, not the transcript path", async () => { + // Transcripts live under the project root even for lane sessions, so a + // transcript-derived cwd searched the wrong directory and never matched + // the rollout Codex wrote in the lane worktree. + vi.useFakeTimers(); + try { + const fakeNow = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(fakeNow); + const startedAt = "2026-04-15T21:30:00.000Z"; + seedCodexRollout({ + id: "thread-lane", + cwd: "/tmp/test-worktree", + startedAt, + mtime: fakeNow.getTime() - 30_000, + }); + + const { service, sessionService, laneService } = createHarness(); + sessionService.readTranscriptTail.mockResolvedValueOnce("OpenAI Codex\nmodel: gpt-5\n› "); + sessionService.create({ + sessionId: "session-lane-cwd", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt, + // Project-root transcript, deliberately NOT under the lane worktree. + transcriptPath: "/tmp/test-project/.ade/transcripts/session-lane-cwd.log", + toolType: "codex", + }); + + await service.ensureResumeTargets(["session-lane-cwd"]); + await vi.advanceTimersByTimeAsync(0); + + expect(laneService.getLaneBaseAndBranch).toHaveBeenCalledWith("lane-1"); + expect(sessionService.setResumeCommand).toHaveBeenCalledWith("session-lane-cwd", "codex resume thread-lane"); + } finally { + vi.useRealTimers(); + } + }); + + it("falls back to the transcript-derived cwd when the lane lookup fails", async () => { + vi.useFakeTimers(); + try { + const fakeNow = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(fakeNow); + const startedAt = "2026-04-15T21:30:00.000Z"; + seedCodexRollout({ + id: "thread-fallback", + cwd: "/tmp/deleted-lane", + startedAt, + mtime: fakeNow.getTime() - 30_000, + }); + + const { service, sessionService, laneService } = createHarness(); + laneService.getLaneBaseAndBranch.mockImplementation(() => { + throw new Error("lane 'lane-1' no longer exists"); + }); + sessionService.readTranscriptTail.mockResolvedValueOnce("OpenAI Codex\nmodel: gpt-5\n› "); + sessionService.create({ + sessionId: "session-fallback-cwd", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt, + transcriptPath: "/tmp/deleted-lane/.ade/transcripts/session-fallback-cwd.log", + toolType: "codex", + }); + + await service.ensureResumeTargets(["session-fallback-cwd"]); + await vi.advanceTimersByTimeAsync(0); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith( + "session-fallback-cwd", + "codex resume thread-fallback", + ); + } finally { + vi.useRealTimers(); + } + }); + it("backfills Codex storage resume targets during session-list hydration", async () => { // The session-list path is how older sessions (whose transcripts no // longer contain an explicit resume command) get their resume target @@ -5970,28 +6964,14 @@ describe("ptyService", () => { try { const fakeNow = new Date("2026-04-15T22:00:00.000Z"); vi.setSystemTime(fakeNow); - - const homedir = os.homedir(); - const sessionsBase = path.join(homedir, ".codex", "sessions"); - const dirPath = path.join(sessionsBase, "2026", "04", "15"); - const filePath = path.join(dirPath, "rollout-2026-04-15T21-30-00-thread-abc.jsonl"); const startedAt = "2026-04-15T21:30:00.000Z"; - const firstLine = JSON.stringify({ - timestamp: startedAt, - type: "session_meta", - payload: { - id: "thread-abc", - timestamp: startedAt, - cwd: "/tmp/worktree", - }, + seedCodexRollout({ + id: "thread-abc", + cwd: "/tmp/test-worktree", + startedAt, + mtime: fakeNow.getTime() - 30_000, }); - mocks.existsSyncResults.set(sessionsBase, true); - mocks.existsSyncResults.set(dirPath, true); - mocks.dirEntries.set(dirPath, [path.basename(filePath)]); - mocks.fileContents.set(filePath, `${firstLine}\n`); - mocks.fileStats.set(filePath, { size: firstLine.length, mtimeMs: fakeNow.getTime() - 30_000, isDirectory: false }); - const { service, sessionService } = createHarness(); sessionService.readTranscriptTail.mockResolvedValueOnce("OpenAI Codex\nmodel: gpt-5\n› "); sessionService.create({ @@ -6001,7 +6981,7 @@ describe("ptyService", () => { tracked: true, title: "Codex CLI", startedAt, - transcriptPath: "/tmp/worktree/.ade/transcripts/session-1.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-1.log", toolType: "codex", }); @@ -6020,28 +7000,14 @@ describe("ptyService", () => { try { const fakeNow = new Date("2026-04-15T22:00:00.000Z"); vi.setSystemTime(fakeNow); - - const homedir = os.homedir(); - const sessionsBase = path.join(homedir, ".codex", "sessions"); - const dirPath = path.join(sessionsBase, "2026", "04", "15"); - const filePath = path.join(dirPath, "rollout-2026-04-15T21-30-00-thread-live.jsonl"); const startedAt = "2026-04-15T21:30:00.000Z"; - const firstLine = JSON.stringify({ - timestamp: startedAt, - type: "session_meta", - payload: { - id: "thread-live", - timestamp: startedAt, - cwd: "/tmp/worktree", - }, + seedCodexRollout({ + id: "thread-live", + cwd: "/tmp/test-worktree", + startedAt, + mtime: fakeNow.getTime() - 30_000, }); - mocks.existsSyncResults.set(sessionsBase, true); - mocks.existsSyncResults.set(dirPath, true); - mocks.dirEntries.set(dirPath, [path.basename(filePath)]); - mocks.fileContents.set(filePath, `${firstLine}\n`); - mocks.fileStats.set(filePath, { size: firstLine.length, mtimeMs: fakeNow.getTime() - 30_000, isDirectory: false }); - const { service, sessionService, logger } = createHarness(); sessionService.readTranscriptTail.mockResolvedValueOnce([ "Update available! 0.130.0 -> 0.134.0\n", @@ -6055,7 +7021,7 @@ describe("ptyService", () => { tracked: true, title: "Codex CLI", startedAt, - transcriptPath: "/tmp/worktree/.ade/transcripts/session-update-only.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-update-only.log", toolType: "codex", }); @@ -6095,7 +7061,7 @@ describe("ptyService", () => { tracked: true, title: "Claude CLI", startedAt: "2026-04-15T21:30:00.000Z", - transcriptPath: "/tmp/worktree/.ade/transcripts/session-claude-with-codex-words.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-claude-with-codex-words.log", toolType: "claude", }); @@ -6123,20 +7089,20 @@ describe("ptyService", () => { const matchedId = "11111111-1111-1111-1111-111111111111"; const newerDifferentId = "22222222-2222-2222-2222-222222222222"; - const claudeProjectDir = path.join(os.homedir(), ".claude", "projects", "-tmp-worktree"); + const claudeProjectDir = path.join(os.homedir(), ".claude", "projects", "-tmp-test-worktree"); const matchedPath = path.join(claudeProjectDir, `${matchedId}.jsonl`); const newerDifferentPath = path.join(claudeProjectDir, `${newerDifferentId}.jsonl`); const matchedFirstLine = JSON.stringify({ timestamp: "2026-04-15T21:30:00.000Z", type: "user", sessionId: matchedId, - cwd: "/tmp/worktree", + cwd: "/tmp/test-worktree", }); const newerDifferentFirstLine = JSON.stringify({ timestamp: "2026-04-15T22:00:00.000Z", type: "user", sessionId: newerDifferentId, - cwd: "/tmp/worktree", + cwd: "/tmp/test-worktree", }); mocks.existsSyncResults.set(claudeProjectDir, true); @@ -6158,7 +7124,7 @@ describe("ptyService", () => { tracked: true, title: "Claude CLI", startedAt: "2026-04-15T21:30:00.000Z", - transcriptPath: "/tmp/worktree/.ade/transcripts/session-claude-storage.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-claude-storage.log", toolType: "claude", }); @@ -6185,13 +7151,13 @@ describe("ptyService", () => { vi.setSystemTime(fakeNow); const otherId = "33333333-3333-3333-3333-333333333333"; - const claudeProjectDir = path.join(os.homedir(), ".claude", "projects", "-tmp-worktree"); + const claudeProjectDir = path.join(os.homedir(), ".claude", "projects", "-tmp-test-worktree"); const otherPath = path.join(claudeProjectDir, `${otherId}.jsonl`); const otherFirstLine = JSON.stringify({ timestamp: "2026-04-15T21:31:00.000Z", type: "user", sessionId: otherId, - cwd: "/tmp/worktree", + cwd: "/tmp/test-worktree", }); mocks.existsSyncResults.set(claudeProjectDir, true); @@ -6208,7 +7174,7 @@ describe("ptyService", () => { tracked: true, title: "Claude CLI", startedAt: "2026-04-15T21:30:00.000Z", - transcriptPath: "/tmp/worktree/.ade/transcripts/session-claude-targetless.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-claude-targetless.log", toolType: "claude", }); sessionService.end({ @@ -6238,20 +7204,20 @@ describe("ptyService", () => { const firstId = "44444444-4444-4444-4444-444444444444"; const secondId = "55555555-5555-5555-5555-555555555555"; - const claudeProjectDir = path.join(os.homedir(), ".claude", "projects", "-tmp-worktree"); + const claudeProjectDir = path.join(os.homedir(), ".claude", "projects", "-tmp-test-worktree"); const firstPath = path.join(claudeProjectDir, `${firstId}.jsonl`); const secondPath = path.join(claudeProjectDir, `${secondId}.jsonl`); const firstLine = JSON.stringify({ timestamp: "2026-04-15T21:30:00.500Z", type: "user", sessionId: firstId, - cwd: "/tmp/worktree", + cwd: "/tmp/test-worktree", }); const secondLine = JSON.stringify({ timestamp: "2026-04-15T21:30:01.000Z", type: "user", sessionId: secondId, - cwd: "/tmp/worktree", + cwd: "/tmp/test-worktree", }); mocks.existsSyncResults.set(claudeProjectDir, true); @@ -6270,7 +7236,7 @@ describe("ptyService", () => { tracked: true, title: "Claude CLI", startedAt: "2026-04-15T21:30:00.000Z", - transcriptPath: "/tmp/worktree/.ade/transcripts/session-claude-ambiguous.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-claude-ambiguous.log", toolType: "claude", }); sessionService.end({ @@ -6305,7 +7271,7 @@ describe("ptyService", () => { tracked: true, title: "Codex CLI", startedAt: "2026-04-15T21:30:00.000Z", - transcriptPath: "/tmp/worktree/.ade/transcripts/session-missing.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-missing.log", toolType: "codex", }); @@ -6339,7 +7305,7 @@ describe("ptyService", () => { stdout: JSON.stringify([ { id: "ses_abc", - directory: "/tmp/worktree", + directory: "/tmp/test-worktree", created: Date.parse(startedAt), updated: Date.parse(startedAt) + 1000, }, @@ -6356,7 +7322,7 @@ describe("ptyService", () => { tracked: true, title: "OpenCode CLI", startedAt, - transcriptPath: "/tmp/worktree/.ade/transcripts/session-opencode.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-opencode.log", toolType: "opencode", }); @@ -6366,7 +7332,7 @@ describe("ptyService", () => { bundledOpenCode, ["session", "list", "--format", "json", "--max-count", "80"], expect.objectContaining({ - cwd: "/tmp/worktree", + cwd: "/tmp/test-worktree", encoding: "utf8", }), ); @@ -6380,7 +7346,7 @@ describe("ptyService", () => { stdout: JSON.stringify([ { id: "ses_false_match", - directory: "/tmp/worktree", + directory: "/tmp/test-worktree", created: Date.parse(startedAt), updated: Date.parse(startedAt) + 1000, }, @@ -6397,7 +7363,7 @@ describe("ptyService", () => { tracked: true, title: "OpenCode CLI", startedAt, - transcriptPath: "/tmp/worktree/.ade/transcripts/session-opencode-false-match.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-opencode-false-match.log", toolType: "opencode", }); @@ -6417,12 +7383,12 @@ describe("ptyService", () => { vi.setSystemTime(fakeNow); const startedAt = "2026-04-15T21:30:00.000Z"; const droidSessionsDir = path.join(os.homedir(), ".factory", "sessions"); - const projectDir = path.join(droidSessionsDir, "-tmp-worktree"); + const projectDir = path.join(droidSessionsDir, "-tmp-test-worktree"); const filePath = path.join(projectDir, "droid-session.jsonl"); const firstLine = JSON.stringify({ type: "session_start", id: "droid_false_match", - cwd: "/tmp/worktree", + cwd: "/tmp/test-worktree", }); mocks.dirEntries.set(projectDir, [path.basename(filePath)]); mocks.fileContents.set(filePath, `${firstLine}\n`); @@ -6437,7 +7403,7 @@ describe("ptyService", () => { tracked: true, title: "Droid CLI", startedAt, - transcriptPath: "/tmp/worktree/.ade/transcripts/session-droid-false-match.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-droid-false-match.log", toolType: "droid", }); @@ -6461,7 +7427,7 @@ describe("ptyService", () => { os.homedir(), ".claude", "projects", - "-tmp-worktree", + "-tmp-test-worktree", `${claudeSessionId}.jsonl`, ); mocks.fileContents.set(filePath, `${JSON.stringify({ @@ -6478,7 +7444,7 @@ describe("ptyService", () => { tracked: true, title: "Say exactly: patched exit works", startedAt: "2026-04-15T21:30:00.000Z", - transcriptPath: "/tmp/worktree/.ade/transcripts/session-claude-existing.log", + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-claude-existing.log", toolType: "claude", resumeMetadata: { provider: "claude", diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index ffd3af6f9..bbad8c1af 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -68,11 +68,13 @@ import { } from "../../../shared/types"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import { + isClaudeBinaryCommand, sanitizeTrackedCliPromptSeed, + shellCommandLineArgIndex, trackedCliTitleFromPromptSeed, + withClaudePluginInCommandLine, withCodexNoAltScreen, } from "../../../shared/cliLaunch"; -import { commandArrayToLine, parseCommandLine } from "../../../shared/shell"; import { claudeAgentSkillPluginRoots } from "../skills/agentSkillRuntimeService"; import { stripAnsi } from "../../utils/ansiStrip"; import { summarizeTerminalSession } from "../../utils/sessionSummary"; @@ -129,10 +131,39 @@ function shouldScheduleOutputSnippetTitle(tool: TerminalToolType | null): boolea } const CLI_USER_TITLE_SEED_MIN_LEN = 3; -const CODEX_ADE_GUIDANCE_SCAN_BYTES = 160 * 1024; const CODEX_THREAD_NAME_SCAN_BYTES = 512 * 1024; const CLAUDE_TITLE_SCAN_BYTES = 512 * 1024; const CLAUDE_STORAGE_MATCH_START_SKEW_MS = 1_000; +/** A resumed PTY that exits nonzero within this window never actually launched. */ +const RESUME_LAUNCH_FAILURE_WINDOW_MS = 5_000; +// Live capture itself stops after 60 seconds. Ninety seconds covers Codex CLI +// startup plus modest write/timestamp skew without admitting unrelated launches +// several minutes later; storage backfill keeps its wider historical window. +const CODEX_LIVE_CAPTURE_MAX_START_DELTA_MS = 90_000; +// ADE's delivered text lands after session_meta plus any restored context, so +// the ownership scan reaches well past the first few KB while staying bounded. +const CODEX_OWNERSHIP_NEEDLE_SCAN_BYTES = 160 * 1024; +/** Shorter slices are not distinctive enough to prove a rollout is ours. */ +const CODEX_OWNERSHIP_NEEDLE_MIN_LEN = 24; +// The needle is the whole delivered prompt, not a short head slice: two +// launches whose prompts share a long prefix and diverge later are only told +// apart by a needle that reaches past the shared part. The cap keeps the +// `includes()` scan bounded for pasted-in prompts of arbitrary length. +const CODEX_OWNERSHIP_NEEDLE_MAX_LEN = 2_000; +/** + * Codex copies this env var verbatim into `session_meta.payload.originator` + * (checked against codex-cli 0.146.0 for both the `source: "cli"` TUI launches + * ADE makes and `codex exec`). It is the one per-launch channel ADE controls + * that reaches the rollout without adding a single token to the conversation + * the model sees, so it — not the prompt text — is the primary ownership proof. + */ +const CODEX_ORIGINATOR_OVERRIDE_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; +/** + * Prefixed `ade` so usage attribution keeps counting these launches as + * ADE-originated, and deliberately not the bare `ade_desktop` the app-server + * client reports, which other services match exactly. + */ +const CODEX_LAUNCH_ORIGINATOR_PREFIX = "ade_pty_"; const CLAUDE_STORAGE_MATCH_END_SKEW_MS = 5_000; const PTY_DATA_BATCH_INTERVAL_MS = 50; // Echo latency is dominated by the data batch window. After a user keystroke @@ -315,6 +346,8 @@ type CodexStorageSessionMatch = { id: string; filePath: string; threadName: string | null; + /** Which ownership layer proved this rollout belongs to the caller's launch. */ + ownership: "launch-nonce" | "launch-text" | "window"; }; type ClaudeStorageSessionMatch = { @@ -625,6 +658,18 @@ type PtyEntry = { initialInputTimer: ReturnType | null; cliUserTitleLineBuffer: string; cliUserTitleCommitted: boolean; + /** + * For a resume/reattach launch only: the terminal end state this launch took + * over. If the new process dies immediately (a launch failure — a bad flag, + * a missing binary, a shell usage error), closeEntry restores this instead + * of stamping the row `failed`, so a still-resumable session does not become + * permanently dead-looking. `running` is deliberately unrepresentable here. + */ + priorEndState: { + status: Exclude; + exitCode: number | null; + endedAt: string | null; + } | null; }; function isHighSurrogateCodeUnit(codeUnit: number): boolean { @@ -654,6 +699,75 @@ function replaceUnpairedSurrogates(value: string): string { return segmentStart === 0 ? value : `${normalized}${value.slice(segmentStart)}`; } +/** + * A per-launch ownership nonce, carried to Codex through + * `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` and written back out as the rollout's + * `session_meta.payload.originator`. Unlike the text needle below it is unique + * per launch by construction, so it separates two launches that share a + * worktree, a launch window, and the exact same prompt. + */ +function newCodexLaunchOriginator(): string { + return `${CODEX_LAUNCH_ORIGINATOR_PREFIX}${randomUUID().replace(/-/g, "")}`; +} + +/** + * A per-launch ownership marker: a bounded, contiguous slice of the text ADE + * itself delivers to this Codex process. Codex writes that text into its + * rollout, so finding the slice there proves the rollout belongs to this + * launch — not to some other Codex process that happens to share the worktree + * and the launch window. + * + * The slice is taken from the user's own prompt when there is one: everything + * ahead of it is ADE's fixed session-guidance preamble, which every ADE Codex + * launch emits and therefore cannot distinguish two of them from each other. + */ +function codexOwnershipNeedleFromDeliveredText(raw: string | null | undefined): string | null { + const text = String(raw ?? "").replace(/\r\n?/g, "\n"); + if (!text.trim().length) return null; + const promptMarker = /\bUser prompt:[ \t]*\n?/iu.exec(text); + const distinctive = promptMarker + ? text.slice(promptMarker.index + promptMarker[0].length) + : text; + const trimmed = distinctive.trim(); + if (!trimmed.length) return null; + let needle = trimmed.slice(0, CODEX_OWNERSHIP_NEEDLE_MAX_LEN); + // A slice that ends mid-surrogate would JSON-escape differently than the + // whole pair Codex wrote, so drop the dangling half. + if (needle.length > 0 && isHighSurrogateCodeUnit(needle.charCodeAt(needle.length - 1))) { + needle = needle.slice(0, -1); + } + needle = needle.trimEnd(); + return needle.length >= CODEX_OWNERSHIP_NEEDLE_MIN_LEN ? needle : null; +} + +/** + * Codex launches carry ADE's prompt one of two ways: typed into the PTY as + * initial input, or pushed onto argv as the trailing positional prompt (see + * `usePromptArg` in shared/cliLaunch). Either is text we know this process + * received; nothing else on the command line is ours to claim. + */ +function codexLaunchOwnershipNeedle(args: { + initialInput: string; + args: readonly string[]; +}): string | null { + const fromInitialInput = codexOwnershipNeedleFromDeliveredText(args.initialInput); + if (fromInitialInput) return fromInitialInput; + const trailingArg = args.args.length ? args.args[args.args.length - 1] ?? "" : ""; + if (trailingArg.startsWith("-")) return null; + return codexOwnershipNeedleFromDeliveredText(trailingArg); +} + +/** + * Rollout JSONL holds the delivered text inside JSON strings, so newlines and + * quotes arrive escaped. Match the raw slice (it may sit in a plain-text field) + * and its JSON-escaped form (the usual case). + */ +function rolloutTextContainsOwnershipNeedle(rolloutText: string, needle: string): boolean { + if (rolloutText.includes(needle)) return true; + const escaped = JSON.stringify(needle).slice(1, -1); + return escaped !== needle && rolloutText.includes(escaped); +} + function takeCanonicalPtyOutput(entry: PtyEntry, data: string, final = false): string { let value = entry.pendingOutputHighSurrogate ? `${entry.pendingOutputHighSurrogate}${data}` @@ -1073,40 +1187,17 @@ function hasClaudePluginRoot(args: string[], pluginRoot: string): boolean { ); } -function shellWordSpans(command: string): Array<{ start: number; end: number }> { - const spans: Array<{ start: number; end: number }> = []; - let index = 0; - while (index < command.length) { - while (index < command.length && /\s/.test(command[index]!)) index += 1; - if (index >= command.length) break; - - const start = index; - let quote: "'" | "\"" | null = null; - let escaped = false; - while (index < command.length) { - const char = command[index]!; - if (escaped) { - escaped = false; - } else if (quote === "'") { - if (char === "'") quote = null; - } else if (quote === "\"") { - if (char === "\"") quote = null; - else if (char === "\\") escaped = true; - } else if (char === "\\") { - escaped = true; - } else if (char === "'" || char === "\"") { - quote = char; - } else if (/\s/.test(char)) { - break; - } - index += 1; - } - spans.push({ start, end: index }); - } - return spans; -} - +/** + * `args` are the argv of whatever `command` is actually spawned — which is the + * Claude binary for ordinary launches but `/bin/bash ... -lc ""` + * for resume and reattach launches. Prepending Claude flags to a shell's argv + * makes bash die with "invalid option", so the flag goes wherever the `claude` + * token really lives: the argv for a direct Claude spawn, the -lc command line + * for a shell wrapper, and the startup command written into an interactive + * shell. + */ function withBundledClaudePlugin( + command: string | null, args: string[], startupCommand: string, toolType: TerminalToolType | null, @@ -1118,32 +1209,24 @@ function withBundledClaudePlugin( const pluginRoot = claudeAgentSkillPluginRoots(env)[0]; if (!pluginRoot) return { args, startupCommand }; - const normalizedArgs = hasClaudePluginRoot(args, pluginRoot) - ? args - : ["--plugin-dir", pluginRoot, ...args]; - let normalizedStartupCommand = startupCommand; - if (startupCommand?.trim()) { - let commandArgs: string[] = []; - try { - commandArgs = parseCommandLine(startupCommand); - } catch { - // Keep malformed or unsupported shell input intact. + let normalizedArgs = args; + if (isClaudeBinaryCommand(command)) { + if (!hasClaudePluginRoot(args, pluginRoot)) { + normalizedArgs = ["--plugin-dir", pluginRoot, ...args]; } - const claudeIndex = commandArgs.findIndex((arg, index) => - arg === "claude" - && commandArgs.slice(0, index).every((prefix) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(prefix)), - ); - if (claudeIndex >= 0 && !hasClaudePluginRoot(commandArgs.slice(claudeIndex + 1), pluginRoot)) { - const claudeSpan = shellWordSpans(startupCommand)[claudeIndex]; - if (claudeSpan) { - const pluginArgs = commandArrayToLine(["--plugin-dir", pluginRoot]); - normalizedStartupCommand = `${startupCommand.slice(0, claudeSpan.end)} ${pluginArgs}${startupCommand.slice(claudeSpan.end)}`; + } else if (command?.trim()) { + const commandLineIndex = shellCommandLineArgIndex(args); + if (commandLineIndex >= 0) { + const rewritten = withClaudePluginInCommandLine(args[commandLineIndex]!, pluginRoot); + if (rewritten !== args[commandLineIndex]) { + normalizedArgs = args.slice(); + normalizedArgs[commandLineIndex] = rewritten; } } } return { args: normalizedArgs, - startupCommand: normalizedStartupCommand, + startupCommand: withClaudePluginInCommandLine(startupCommand, pluginRoot), }; } @@ -1550,6 +1633,21 @@ function getPtyHostReadyPromise(pty: IPty): Promise | null { return null; } +function resumeTargetIdForProvider( + session: TerminalSessionSummary, + provider: TerminalResumeProvider, +): string | null { + const metadataTargetId = session.resumeMetadata?.provider === provider + ? sanitizeResumeTargetId(session.resumeMetadata.targetId ?? null) + : null; + if (metadataTargetId) return metadataTargetId; + + const parsedResumeCommand = parseTrackedCliResumeCommand(session.resumeCommand, session.toolType); + return parsedResumeCommand?.provider === provider + ? sanitizeResumeTargetId(parsedResumeCommand.targetId ?? null) + : null; +} + export function createPtyService({ projectRoot, transcriptsDir, @@ -2598,13 +2696,19 @@ export function createPtyService({ * Codex stores sessions at ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl. * Each JSONL starts with a session_meta event containing `payload.id` and `payload.cwd`. * We score recent candidates by cwd match and closeness to ADE's session startedAt. + * `ownershipOriginator`, when the caller stamped one on the launch, wins + * outright: it is a per-launch nonce Codex echoes into `session_meta`. + * `ownershipNeedle` is the fallback — it requires the rollout to contain text + * this launch delivered, see `codexLaunchOwnershipNeedle`. */ const resolveCodexSessionFromStorage = (args: { cwd: string; startedAt?: string | null; maxStartDeltaMs?: number; notBeforeMs?: number; - requiredText?: string; + excludedIds?: ReadonlySet; + ownershipNeedle?: string | null; + ownershipOriginator?: string | null; }): CodexStorageSessionMatch | null => { try { const sessionsBase = path.join(os.homedir(), ".codex", "sessions"); @@ -2633,7 +2737,15 @@ export function createPtyService({ if (!candidates.length) return null; candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); - let bestMatch: { id: string; filePath: string; score: number; mtimeMs: number } | null = null; + type CodexRolloutCandidate = { + id: string; + filePath: string; + mtimeMs: number; + /** Session start per the rollout itself, falling back to file mtime. */ + referenceMs: number; + originator: string; + }; + const parsed: CodexRolloutCandidate[] = []; for (const candidate of candidates.slice(0, 80)) { const firstLine = readJsonlFirstLine(candidate.filePath); if (!firstLine) continue; @@ -2648,39 +2760,71 @@ export function createPtyService({ const id = typeof payload?.id === "string" ? payload.id.trim() : ""; const cwd = typeof payload?.cwd === "string" ? payload.cwd.trim() : ""; if (type !== "session_meta" || !id || cwd !== args.cwd) continue; - if (args.requiredText) { - // ADE's injected session guidance can land after a large session_meta - // line plus restored context, so scan beyond the first few KB while - // still keeping the live poll bounded. - const prefix = readFilePrefix(candidate.filePath, CODEX_ADE_GUIDANCE_SCAN_BYTES); - if (!prefix?.includes(args.requiredText)) continue; - } - - if (!hasStartedAt) { - return { - id, - filePath: candidate.filePath, - threadName: readCodexRuntimeThreadName(candidate.filePath, id), - }; - } - + if (args.excludedIds?.has(id)) continue; + // Codex subagents fork from the thread ADE launched, share its cwd, and + // inherit its environment (nonce included). Resuming one would resume + // the wrong thread, so they are never a candidate. + const parentThreadId = typeof payload?.parent_thread_id === "string" ? payload.parent_thread_id.trim() : ""; + if (parentThreadId) continue; const payloadTimestamp = typeof payload?.timestamp === "string" ? payload.timestamp : ""; const payloadTimestampMs = Date.parse(payloadTimestamp); - const referenceMs = Number.isFinite(payloadTimestampMs) ? payloadTimestampMs : candidate.mtimeMs; - if (typeof args.notBeforeMs === "number" && referenceMs < args.notBeforeMs) continue; - const score = Math.abs(referenceMs - requestedStartedAtMs); - if (typeof args.maxStartDeltaMs === "number" && score > args.maxStartDeltaMs) continue; - if (!bestMatch || score < bestMatch.score || (score === bestMatch.score && candidate.mtimeMs > bestMatch.mtimeMs)) { - bestMatch = { id, filePath: candidate.filePath, score, mtimeMs: candidate.mtimeMs }; - } + parsed.push({ + id, + filePath: candidate.filePath, + mtimeMs: candidate.mtimeMs, + referenceMs: Number.isFinite(payloadTimestampMs) ? payloadTimestampMs : candidate.mtimeMs, + originator: typeof payload?.originator === "string" ? payload.originator.trim() : "", + }); } - return bestMatch - ? { - id: bestMatch.id, - filePath: bestMatch.filePath, - threadName: readCodexRuntimeThreadName(bestMatch.filePath, bestMatch.id), + + const toMatch = ( + candidate: CodexRolloutCandidate, + ownership: CodexStorageSessionMatch["ownership"], + ): CodexStorageSessionMatch => ({ + id: candidate.id, + filePath: candidate.filePath, + threadName: readCodexRuntimeThreadName(candidate.filePath, candidate.id), + ownership, + }); + + const pickBest = ( + ownership: CodexStorageSessionMatch["ownership"], + accept: (candidate: CodexRolloutCandidate) => boolean, + ): CodexStorageSessionMatch | null => { + let bestMatch: { candidate: CodexRolloutCandidate; score: number } | null = null; + for (const candidate of parsed) { + if (!accept(candidate)) continue; + if (!hasStartedAt) return toMatch(candidate, ownership); + if (typeof args.notBeforeMs === "number" && candidate.referenceMs < args.notBeforeMs) continue; + const score = Math.abs(candidate.referenceMs - requestedStartedAtMs); + if (typeof args.maxStartDeltaMs === "number" && score > args.maxStartDeltaMs) continue; + if ( + !bestMatch + || score < bestMatch.score + || (score === bestMatch.score && candidate.mtimeMs > bestMatch.candidate.mtimeMs) + ) { + bestMatch = { candidate, score }; } - : null; + } + return bestMatch ? toMatch(bestMatch.candidate, ownership) : null; + }; + + // The nonce is unique per launch, so it outranks the prompt-text needle, + // which two launches can legitimately share. + const ownershipOriginator = args.ownershipOriginator?.trim() ?? ""; + if (ownershipOriginator) { + const nonceMatch = pickBest("launch-nonce", (candidate) => candidate.originator === ownershipOriginator); + if (nonceMatch) return nonceMatch; + } + + // Older Codex builds may not honour the originator override, so ownership + // falls back to the delivered text rather than refusing to capture. + const ownershipNeedle = args.ownershipNeedle ?? ""; + return pickBest(ownershipNeedle ? "launch-text" : "window", (candidate) => { + if (!ownershipNeedle) return true; + const prefix = readFilePrefix(candidate.filePath, CODEX_OWNERSHIP_NEEDLE_SCAN_BYTES); + return prefix != null && rolloutTextContainsOwnershipNeedle(prefix, ownershipNeedle); + }); } catch { return null; } @@ -2788,6 +2932,23 @@ export function createPtyService({ } }; + /** + * The directory a session's agent actually ran in. Transcripts live under the + * project root even for lane sessions, so the transcript path is only a + * fallback: every storage backfill below matches on an exact cwd, and a lane + * session's rollout records the lane worktree, not the project root. + */ + const resolveSessionRunCwd = (session: TerminalSessionSummary): string | null => { + let worktreePath = ""; + try { + worktreePath = (laneService.getLaneBaseAndBranch(session.laneId).worktreePath ?? "").trim(); + } catch { + // Deleted lane: fall back to the transcript-derived directory. + } + if (worktreePath) return worktreePath; + return inferSessionCwdFromTranscriptPath(session.transcriptPath); + }; + const tryBackfillResumeTarget = async ( sessionId: string, preferredToolType: TerminalToolType | null, @@ -2800,7 +2961,7 @@ export function createPtyService({ if (!isTrackedAgentCliToolType(effectiveToolType)) return false; const existingTargetId = sanitizeResumeTargetId(session.resumeMetadata?.targetId ?? null); if (existingTargetId) { - const cwd = sessionCwd ?? inferSessionCwdFromTranscriptPath(session.transcriptPath); + const cwd = sessionCwd ?? resolveSessionRunCwd(session); if (isClaudeTrackedCliToolType(effectiveToolType) && cwd) { scheduleClaudeRuntimeTitleCaptureBestEffort(sessionId, existingTargetId, cwd); } @@ -2838,7 +2999,7 @@ export function createPtyService({ } // Strategy 2: Read the session/thread ID from the CLI's local storage - const cwd = sessionCwd ?? inferSessionCwdFromTranscriptPath(session.transcriptPath); + const cwd = sessionCwd ?? resolveSessionRunCwd(session); const effectiveProvider = providerFromTool(effectiveToolType); const hasStorageBackfillEvidence = effectiveProvider ? hasProviderStorageBackfillEvidence(effectiveProvider, transcript) @@ -3057,6 +3218,16 @@ export function createPtyService({ } }; + const listOtherAdoptedCodexTargetIds = (sessionId: string): Set => { + const adoptedIds = new Set(); + for (const candidate of sessionService.list({ limit: null })) { + if (candidate.id === sessionId) continue; + const targetId = resumeTargetIdForProvider(candidate, "codex"); + if (targetId) adoptedIds.add(targetId); + } + return adoptedIds; + }; + // Codex CLI has no pre-assigned session ID flag (unlike Claude's --session-id), so the // rollout JSONL is the only handle on the session's UUID. We watch the day directory for // the file's appearance, then store the UUID directly for resume and separately adopt any @@ -3066,6 +3237,8 @@ export function createPtyService({ sessionId: string, cwd: string, startedAt: string, + ownershipNeedle: string | null = null, + ownershipOriginator: string | null = null, ): void => { const startedAtMs = Date.parse(startedAt); const startedAtFinite = Number.isFinite(startedAtMs) ? startedAtMs : null; @@ -3106,12 +3279,53 @@ export function createPtyService({ cleanup(); return true; } + let excludedIds: Set; + try { + excludedIds = listOtherAdoptedCodexTargetIds(sessionId); + } catch (err) { + // Capturing no target is safer than assigning a thread when the + // cross-session ownership check could not run. + logger.warn("pty.codex_session_id_exclusion_query_failed", { + sessionId, + source, + attempt, + err: String(err), + }); + return false; + } + // There is deliberately no FIXED text gate here. ADE used to require the + // "ADE session guidance" preamble marker in the rollout, but only the + // Work-tab CLI preamble ever emits it — goal launches send + // `` instead — so the gate was + // closed for nearly every real session and thread ids were essentially + // never captured live. + // + // Mis-adoption safety for concurrent Codex runs in the SAME worktree has + // four layers: + // 1. the per-launch ownership nonce ADE stamped on this launch's + // environment, which Codex writes back as the rollout's originator: + // unique by construction, so it separates even two launches with + // byte-identical prompts. It is absent only if the Codex build + // ignores the override, in which case ownership degrades to layer 2; + // 2. the ownership needle, when this launch delivered text of its own: + // only a rollout containing that text can be adopted, which rules out + // an unrelated Codex process that merely shares the cwd and window; + // 3. a narrow launch window (including the existing not-before floor); + // 4. exclusion of thread ids already owned by every other terminal row — + // an adopted id stays excluded even when nonce or needle matches. + // Timestamp proximity still breaks ties among the remaining candidates, + // but does not claim that rollout write order uniquely proves which PTY + // launched a thread. A bare interactive `codex` with nothing typed has no + // text to demand, so on a Codex build without the originator override it + // falls back to layers 3 and 4 alone and keeps that residual window. const codexSession = resolveCodexSessionFromStorage({ cwd, startedAt, - maxStartDeltaMs: 5 * 60_000, + maxStartDeltaMs: CODEX_LIVE_CAPTURE_MAX_START_DELTA_MS, ...(startedAtFinite !== null ? { notBeforeMs: startedAtFinite - 1_000 } : {}), - requiredText: "ADE session guidance", + excludedIds, + ownershipNeedle, + ownershipOriginator, }); if (!codexSession) return false; @@ -3125,6 +3339,7 @@ export function createPtyService({ codexSessionId: codexSession.id, source, attempt, + ownership: codexSession.ownership, }); cleanup(); return true; @@ -3217,8 +3432,31 @@ export function createPtyService({ entry.recentOutputTail = ""; const endedAt = new Date().toISOString(); - const status = statusFromExit(exitCode); - sessionService.end({ sessionId: entry.sessionId, endedAt, exitCode, status }); + let status = statusFromExit(exitCode); + let endExitCode = exitCode; + let endEndedAt = endedAt; + // A resume that dies on launch must not clobber the row it took over: the + // prior session is still resumable, and stamping it failed/exit-2 makes it + // look permanently dead. Only an *immediate* nonzero exit counts as a + // launch failure (a bad flag, a missing binary, a shell usage error) — + // once the CLI has actually been running, a real nonzero exit is its own. + const priorEndState = entry.priorEndState; + if ( + priorEndState + && status === "failed" + && Date.now() - entry.createdAt <= RESUME_LAUNCH_FAILURE_WINDOW_MS + ) { + logger.warn("pty.resume_launch_failed_status_preserved", { + sessionId: entry.sessionId, + ptyId, + exitCode, + priorStatus: priorEndState.status, + }); + status = priorEndState.status; + endExitCode = priorEndState.exitCode; + endEndedAt = priorEndState.endedAt ?? endedAt; + } + sessionService.end({ sessionId: entry.sessionId, endedAt: endEndedAt, exitCode: endExitCode, status }); flushTerminalSnapshot(entry); scheduleTranscriptDependentWork(entry, "close"); clearIdleTimer(entry.sessionId); @@ -4052,23 +4290,15 @@ export function createPtyService({ `${displayName} exited before ADE could capture a concrete resume target. Start a new ${displayName} session.`, ); }; - const resumeTargetIdFor = (candidate: TerminalSessionSummary): string | null => { - const parsedResumeCommand = parseTrackedCliResumeCommand(candidate.resumeCommand, candidate.toolType); - return sanitizeResumeTargetId(candidate.resumeMetadata?.targetId ?? null) - ?? (parsedResumeCommand?.provider === provider - ? sanitizeResumeTargetId(parsedResumeCommand.targetId ?? null) - : null); - }; - let resolvedSession = session; - let storedResumeTargetId = resumeTargetIdFor(resolvedSession); + let storedResumeTargetId = resumeTargetIdForProvider(resolvedSession, provider); if (!storedResumeTargetId && provider !== "cursor" && isTrackedAgentCliToolType(resolvedSession.toolType)) { - const cwd = inferSessionCwdFromTranscriptPath(resolvedSession.transcriptPath); + const cwd = resolveSessionRunCwd(resolvedSession); const backfilled = await tryBackfillResumeTarget(sessionId, resolvedSession.toolType, "resume-launch", cwd); const updatedSession = backfilled ? sessionService.get(sessionId) : null; if (updatedSession) { resolvedSession = updatedSession; - storedResumeTargetId = resumeTargetIdFor(resolvedSession); + storedResumeTargetId = resumeTargetIdForProvider(resolvedSession, provider); } } if ( @@ -4279,6 +4509,18 @@ export function createPtyService({ if (existingSession && !existingSession.tracked) { throw ptySendPreDeliveryError(`Terminal session '${requestedSessionId}' is not tracked and cannot be resumed.`); } + // Snapshot only a real terminal end state before reattach/backfill can + // overwrite it. A row may still say `running` after its owning brain + // died; capturing that stale state would make closeEntry restore a dead + // relaunch to `running`. Leaving it null makes closeEntry persist the new + // failure, while detached/completed/failed restore byte-for-byte. + const priorEndState = existingSession && existingSession.status !== "running" + ? { + status: existingSession.status, + exitCode: existingSession.exitCode ?? null, + endedAt: existingSession.endedAt ?? null, + } + : null; const liveAttachedEntry = existingSession ? Array.from(ptys.entries()).find(([, entry]) => entry.sessionId === existingSession.id && !entry.disposed) : null; @@ -4533,6 +4775,7 @@ export function createPtyService({ } } const claudePluginLaunch = withBundledClaudePlugin( + directCommand, directArgs, startupCommand, toolTypeHint, @@ -4545,6 +4788,19 @@ export function createPtyService({ directCommand, startupCommand, }); + // Stamp a per-launch ownership nonce Codex echoes into its rollout, so + // thread capture never has to infer ownership from prompt text alone. An + // explicitly configured override wins: the caller's identity choice is + // not ADE's to overwrite, capture just falls back to the text needle. + let codexLaunchOriginator: string | null = null; + if ( + (toolTypeHint === "codex" || toolTypeHint === "codex-orchestrated") + && !hasEnvKey(args.env ?? {}, CODEX_ORIGINATOR_OVERRIDE_ENV) + && !hasEnvKey(laneRuntimeEnv, CODEX_ORIGINATOR_OVERRIDE_ENV) + ) { + codexLaunchOriginator = newCodexLaunchOriginator(); + launchEnv[CODEX_ORIGINATOR_OVERRIDE_ENV] = codexLaunchOriginator; + } let pty: IPty; let selectedShell: ShellSpec | null = null; @@ -4737,6 +4993,7 @@ export function createPtyService({ initialInputTimer: null, cliUserTitleLineBuffer: "", cliUserTitleCommitted: false, + priorEndState, }; ptys.set(ptyId, entry); if (chatSessionId) { @@ -5010,7 +5267,16 @@ export function createPtyService({ && (toolTypeHint === "codex" || toolTypeHint === "codex-orchestrated") && cwd ) { - scheduleCodexSessionIdCaptureBestEffort(sessionId, cwd, startedAt); + // Derived from what this launch delivers, not from what it succeeds in + // delivering: if the write never lands the rollout never carries the + // needle, and capture is skipped rather than guessed at. + scheduleCodexSessionIdCaptureBestEffort( + sessionId, + cwd, + startedAt, + codexLaunchOwnershipNeedle({ initialInput: requestedInitialInput, args: directArgs }), + codexLaunchOriginator, + ); } if (isClaudeTrackedCliToolType(toolTypeHint) && cwd) { scheduleClaudeRuntimeTitleCaptureBestEffort( diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 3b5a48248..1e411f14d 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -2045,6 +2045,7 @@ declare global { read: (args?: ChatTerminalReadArgs) => Promise; preview: ( args?: ChatTerminalPreviewArgs, + pin?: OpenProjectBinding | null, ) => Promise; write: (args: ChatTerminalWriteArgs) => Promise<{ ok: true }>; signal: (args: ChatTerminalSignalArgs) => Promise<{ ok: true }>; @@ -2071,6 +2072,7 @@ declare global { create: (args: PtyCreateArgs, pin?: OpenProjectBinding | null) => Promise; resumeSession: ( args: PtyResumeSessionArgs, + pin?: OpenProjectBinding | null, ) => Promise; sendToSession: ( args: PtySendToSessionArgs, @@ -2089,9 +2091,18 @@ declare global { pin?: OpenProjectBinding | null, ) => Promise; dispose: (args: { ptyId: string; sessionId?: string }, pin?: OpenProjectBinding | null) => Promise; - setDataSubscriptions: (args: { ptyIds: string[] }) => Promise; - onData: (cb: (ev: PtyDataEvent) => void) => () => void; - onExit: (cb: (ev: PtyExitEvent) => void) => () => void; + setDataSubscriptions: ( + args: { ptyIds: string[] }, + pin?: OpenProjectBinding | null, + ) => Promise; + onData: ( + cb: (ev: PtyDataEvent) => void, + pin?: OpenProjectBinding | null, + ) => () => void; + onExit: ( + cb: (ev: PtyExitEvent) => void, + pin?: OpenProjectBinding | null, + ) => () => void; }; diff: { getChanges: (args: GetDiffChangesArgs) => Promise; diff --git a/apps/desktop/src/preload/pinnedRuntimeEvents.ts b/apps/desktop/src/preload/pinnedRuntimeEvents.ts new file mode 100644 index 000000000..83f6f8368 --- /dev/null +++ b/apps/desktop/src/preload/pinnedRuntimeEvents.ts @@ -0,0 +1,581 @@ +import { IPC } from "../shared/ipc"; +import type { OpenProjectBinding } from "../shared/types/core"; +import type { PtyDataEvent, PtyExitEvent } from "../shared/types/sessions"; +import type { + RemoteRuntimeBufferedEvent, + RemoteRuntimeEventCategory, + RemoteRuntimeStreamEventsRequest, + RemoteRuntimeStreamEventsResult, + RuntimeEventsReleaseRequest, + RuntimeEventsReleaseResult, +} from "../shared/types/remoteRuntime"; + +type IpcRendererLike = { + invoke: (channel: string, ...args: unknown[]) => Promise; +}; + +export const REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS = 750; +export const REMOTE_RUNTIME_EVENT_IDLE_POLL_MS = 5_000; +export const REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS = 50; +const PINNED_RUNTIME_EVENT_FAILURE_BASE_POLL_MS = 2_000; +const PINNED_RUNTIME_EVENT_FAILURE_MAX_POLL_MS = 30_000; + +export function normalizePinnedRuntimeEventEpoch(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function pinnedRuntimeBatchDelayMs( + batch: RemoteRuntimeStreamEventsResult, +): number { + if (batch.hasMore) return REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS; + return batch.events?.length + ? REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS + : REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; +} + +function pinnedRuntimeFailureDelayMs(consecutiveFailures: number): number { + return Math.min( + PINNED_RUNTIME_EVENT_FAILURE_BASE_POLL_MS * 2 ** (consecutiveFailures - 1), + PINNED_RUNTIME_EVENT_FAILURE_MAX_POLL_MS, + ); +} + +// A pinned pump that starts mid-stream must not replay history the view has +// already rendered locally; drop anything stamped before the pump existed. +export function isPinnedRuntimeEventStale( + startedAtMs: number, + timestamp: string, +): boolean { + if (startedAtMs <= 0) return false; + const eventTime = Date.parse(timestamp); + return Number.isFinite(eventTime) && eventTime < startedAtMs - 1_000; +} + +// Returns false when the id was already delivered. The ring is capped so a +// long-lived pump cannot grow the set without bound. +export function rememberPinnedRuntimeEventId( + seenEventIds: Set, + id: number, +): boolean { + if (seenEventIds.has(id)) return false; + seenEventIds.add(id); + while (seenEventIds.size > 1_000) { + const oldest = seenEventIds.values().next().value; + if (typeof oldest !== "number") break; + seenEventIds.delete(oldest); + } + return true; +} + +type PinnedPtyEventState = { + pin: OpenProjectBinding; + dataCallbacks: Set<(payload: PtyDataEvent) => void>; + exitCallbacks: Set<(payload: PtyExitEvent) => void>; + dataSubscriptionsConfigured: boolean; + subscribedPtyDataIds: Set; + timer: ReturnType | null; + inFlight: boolean; + cursor: number; + eventEpoch: string | null; + replaySuppressed: boolean; + startedAtMs: number; + consecutiveFailures: number; + seenEventIds: Set; + // Bumped every time an epoch change rewinds the cursor. A poll that was in + // flight across the rewind must discard its result instead of restoring the + // pre-rewind cursor, which would silently cancel the replay-from-zero. + epochGeneration: number; + cancelled: boolean; +}; + +type PinnedRuntimeEventPumpOptions = { + pin: OpenProjectBinding; + /** Used only in the polling-failure warning. */ + label: string; + /** Initial value, and the value restored when an epoch change rewinds. */ + suppressReplay: boolean; + dispatch: (event: RemoteRuntimeBufferedEvent) => void; +}; + +type PinnedRuntimeEventsDeps = { + ipcRenderer: IpcRendererLike; + /** Unwraps a `{ type, event }` runtime payload; shared with the active pump. */ + toWrappedEvent: (payload: unknown, type: string) => T | null; + /** + * Pushes the union of the active binding's PTY id filter and every pinned + * pump's filter to main. Preload owns the active half, so it owns the call. + */ + syncPtyDataSubscriptions: () => Promise; + /** True once the active path opted into main-side PTY id filtering. */ + isPtyDataFilteringConfigured: () => boolean; +}; + +/** + * The pinned runtime event subsystem: every event pump that reads a binding the + * window is *not* bound to. Three pumps live here — the shared per-binding PTY + * pump, the per-listener generic pump, and the helpers both share with the + * active-binding pump that stays in preload.ts. + */ +export function createPinnedRuntimeEvents(deps: PinnedRuntimeEventsDeps) { + const { ipcRenderer, toWrappedEvent } = deps; + + // The active-binding event pump intentionally has one mutable cursor. Foreign + // PTYs cannot share it: switching the window binding would reset their cursor, + // while polling two machines through it would cross-contaminate epoch/dedup + // state. Each explicit binding therefore owns one lazy, shared PTY pump. + const pinnedPtyEventStates = new Map(); + + // Main keys one subscription per (sender, binding, category), so several pumps + // on the same binding and category share one. Release it only when the last of + // them goes away, otherwise one teardown would silence its siblings. + const pinnedSubscriptionRefs = new Map(); + + const subscriptionRefKey = ( + pin: OpenProjectBinding, + category?: RemoteRuntimeEventCategory, + ): string => `${pin.key}:${category ?? "*"}`; + + // Main derives the same request key from this descriptor that it derived when + // the pump subscribed, so the renderer never has to guess the key itself. + const sendRuntimeEventRelease = ( + pin: OpenProjectBinding, + category?: RemoteRuntimeEventCategory, + ): void => { + const request: RuntimeEventsReleaseRequest = pin.kind === "remote" + ? { id: pin.targetId, projectId: pin.projectId, ...(category ? { category } : {}) } + : { rootPath: pin.rootPath, ...(category ? { category } : {}) }; + const release = ipcRenderer.invoke( + IPC.runtimeEventsRelease, + request, + ) as Promise; + void release.catch(() => { + // Idle expiry is the backstop when the release cannot be delivered. + }); + }; + + const retainRuntimeEventSubscription = ( + pin: OpenProjectBinding, + category?: RemoteRuntimeEventCategory, + ): void => { + const key = subscriptionRefKey(pin, category); + pinnedSubscriptionRefs.set(key, (pinnedSubscriptionRefs.get(key) ?? 0) + 1); + }; + + const releaseRuntimeEventSubscription = ( + pin: OpenProjectBinding, + category?: RemoteRuntimeEventCategory, + ): void => { + const key = subscriptionRefKey(pin, category); + const remaining = (pinnedSubscriptionRefs.get(key) ?? 0) - 1; + if (remaining > 0) { + pinnedSubscriptionRefs.set(key, remaining); + return; + } + pinnedSubscriptionRefs.delete(key); + sendRuntimeEventRelease(pin, category); + }; + + // The active pump never retains a subscription, so it must not release one a + // pinned pump on the same binding is still reading. + const releaseRuntimeEventSubscriptionIfUnpinned = ( + binding: OpenProjectBinding, + category?: RemoteRuntimeEventCategory, + ): void => { + if (pinnedSubscriptionRefs.has(subscriptionRefKey(binding, category))) return; + sendRuntimeEventRelease(binding, category); + }; + + // Every pinned pump reaches its runtime the same way: the channel and argument + // shape follow from the binding kind alone, never from what is being polled. + const invokePinnedRuntimeStreamEvents = ( + pin: OpenProjectBinding, + request: RemoteRuntimeStreamEventsRequest, + ): Promise => + ipcRenderer.invoke( + pin.kind === "remote" + ? IPC.remoteRuntimeStreamEvents + : IPC.localRuntimeStreamEvents, + pin.kind === "remote" + ? { id: pin.targetId, projectId: pin.projectId, request } + : { rootPath: pin.rootPath, request }, + ) as Promise; + + // Cursor/epoch/replay/backoff state machine for the per-listener pinned pumps. + // The PTY pump deliberately does not use this: it is shared across listeners of + // one binding, fed by push notifications as well as polling, and therefore + // carries in-flight and epoch-generation state this loop has no use for. + const startPinnedRuntimeEventPump = ({ + pin, + label, + suppressReplay, + dispatch, + }: PinnedRuntimeEventPumpOptions): (() => void) => { + let cancelled = false; + let timer: ReturnType | null = null; + let cursor = 0; + let eventEpoch: string | null = null; + let replaySuppressed = suppressReplay; + let consecutiveFailures = 0; + retainRuntimeEventSubscription(pin); + + const poll = async (): Promise => { + let delay = REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; + try { + const request = { + cursor, + limit: 200, + ...(replaySuppressed && cursor === 0 ? { replay: false } : {}), + } satisfies RemoteRuntimeStreamEventsRequest; + const batch = await invokePinnedRuntimeStreamEvents(pin, request); + if (cancelled) return; + consecutiveFailures = 0; + const batchEpoch = normalizePinnedRuntimeEventEpoch(batch.eventEpoch); + const epochChanged = batchEpoch + ? eventEpoch + ? batchEpoch !== eventEpoch + : cursor > 0 + : false; + if (batchEpoch) eventEpoch = batchEpoch; + if (epochChanged) { + // The runtime restarted its buffer. What happens next is driven by + // the caller's `suppressReplay` option, restored here: `false` (the + // chat pump on a local pin) replays the new epoch from cursor 0; + // `true` (remote chat pins, and the generic pump on every pin) sends + // `{ cursor: 0, replay: false }`, which re-anchors to the live head + // without replaying the pre-restart transcript. + cursor = 0; + replaySuppressed = suppressReplay; + delay = 0; + } else { + cursor = Number.isFinite(batch.nextCursor) + ? Math.max(0, Math.floor(batch.nextCursor)) + : cursor; + if (request.replay === false) replaySuppressed = false; + for (const event of batch.events ?? []) dispatch(event); + delay = pinnedRuntimeBatchDelayMs(batch); + } + } catch (error) { + if (!cancelled) { + console.warn(`ADE pinned ${label} event polling failed`, error); + } + consecutiveFailures = Math.min(consecutiveFailures + 1, 5); + delay = pinnedRuntimeFailureDelayMs(consecutiveFailures); + } + if (!cancelled) timer = setTimeout(() => void poll(), delay); + }; + + void poll(); + return () => { + if (cancelled) return; + cancelled = true; + if (timer) clearTimeout(timer); + timer = null; + // This uncategorized release can also tear down the active pump's shared + // main-side subscription after a tab switch. Its next poll re-subscribes + // from the active cursor, so only push latency (750ms–5s) is lost; buffered + // events are not. + releaseRuntimeEventSubscription(pin); + }; + }; + + const hasPinnedPtyEventListeners = (state: PinnedPtyEventState): boolean => + state.dataCallbacks.size > 0 || state.exitCallbacks.size > 0; + + const canPollPinnedPtyEvents = (state: PinnedPtyEventState): boolean => + hasPinnedPtyEventListeners(state) && + (state.dataCallbacks.size === 0 || + state.dataSubscriptionsConfigured || + !deps.isPtyDataFilteringConfigured()); + + const createPinnedPtyEventState = ( + pin: OpenProjectBinding, + ): PinnedPtyEventState => ({ + pin, + dataCallbacks: new Set(), + exitCallbacks: new Set(), + dataSubscriptionsConfigured: false, + subscribedPtyDataIds: new Set(), + timer: null, + inFlight: false, + cursor: 0, + eventEpoch: null, + replaySuppressed: pin.kind === "remote", + startedAtMs: pin.kind === "local" ? Date.now() : 0, + consecutiveFailures: 0, + seenEventIds: new Set(), + epochGeneration: 0, + cancelled: false, + }); + + const getOrCreatePinnedPtyEventState = ( + pin: OpenProjectBinding, + ): PinnedPtyEventState => { + const existing = pinnedPtyEventStates.get(pin.key); + if (existing) return existing; + const state = createPinnedPtyEventState(pin); + pinnedPtyEventStates.set(pin.key, state); + retainRuntimeEventSubscription(pin, "pty"); + return state; + }; + + const collectPinnedPtyDataSubscriptionIds = (): string[] => { + const ids: string[] = []; + for (const state of pinnedPtyEventStates.values()) { + if (!state.dataSubscriptionsConfigured) continue; + for (const ptyId of state.subscribedPtyDataIds) ids.push(ptyId); + } + return ids; + }; + + const setPinnedPtyDataSubscriptions = async ( + pin: OpenProjectBinding, + ptyIds: Set, + ): Promise => { + const state = getOrCreatePinnedPtyEventState(pin); + // Update before any await so a view that subscribes first cannot lose the + // first live chunk to the previous filter while the IPC call is in flight. + state.dataSubscriptionsConfigured = true; + state.subscribedPtyDataIds = ptyIds; + await deps.syncPtyDataSubscriptions(); + ensurePinnedPtyEventPump(state); + releasePinnedPtyEventStateIfUnused(state); + }; + + const resetPinnedPtyEventDedup = (state: PinnedPtyEventState): void => { + state.seenEventIds.clear(); + }; + + const updatePinnedPtyEventEpoch = ( + state: PinnedPtyEventState, + value: unknown, + ): boolean => { + const eventEpoch = normalizePinnedRuntimeEventEpoch(value); + if (!eventEpoch) return false; + const epochChanged = state.eventEpoch + ? eventEpoch !== state.eventEpoch + : state.cursor > 0 || state.seenEventIds.size > 0; + state.eventEpoch = eventEpoch; + if (!epochChanged) return false; + state.cursor = 0; + state.epochGeneration += 1; + state.replaySuppressed = state.pin.kind === "remote"; + resetPinnedPtyEventDedup(state); + return true; + }; + + const dispatchPinnedPtyRuntimeEvent = ( + state: PinnedPtyEventState, + event: RemoteRuntimeBufferedEvent, + ): void => { + if (state.cancelled) return; + const ptyDataEvent = toWrappedEvent(event.payload, "pty_data"); + const ptyExitEvent = toWrappedEvent(event.payload, "pty_exit"); + if (!ptyDataEvent && !ptyExitEvent) return; + if (isPinnedRuntimeEventStale(state.startedAtMs, event.timestamp)) return; + if (!rememberPinnedRuntimeEventId(state.seenEventIds, event.id)) return; + state.cursor = Math.max(state.cursor, event.id); + + if ( + ptyDataEvent && + (!state.dataSubscriptionsConfigured || + state.subscribedPtyDataIds.has(ptyDataEvent.ptyId)) + ) { + for (const cb of [...state.dataCallbacks]) { + try { + cb(ptyDataEvent); + } catch (error) { + console.error("preload pinned pty data listener failed", error); + } + } + } + + if (ptyExitEvent) { + for (const cb of [...state.exitCallbacks]) { + try { + cb(ptyExitEvent); + } catch (error) { + console.error("preload pinned pty exit listener failed", error); + } + } + } + }; + + function ensurePinnedPtyEventPump(state: PinnedPtyEventState): void { + if (state.cancelled || !canPollPinnedPtyEvents(state)) return; + if (state.timer || state.inFlight) return; + state.timer = setTimeout(() => { + state.timer = null; + void pollPinnedPtyEvents(state); + }, 0); + } + + function schedulePinnedPtyEventPoll( + state: PinnedPtyEventState, + delayMs: number, + ): void { + if (state.cancelled || !canPollPinnedPtyEvents(state)) return; + if (state.timer || state.inFlight) return; + state.timer = setTimeout(() => { + state.timer = null; + void pollPinnedPtyEvents(state); + }, delayMs); + } + + // Preempts whatever idle delay is pending. An in-flight poll needs no help: it + // already re-polls at once when it notices the epoch generation moved. + const repollPinnedPtyEventsNow = (state: PinnedPtyEventState): void => { + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + schedulePinnedPtyEventPoll(state, 0); + }; + + async function pollPinnedPtyEvents(state: PinnedPtyEventState): Promise { + if ( + state.cancelled || + state.inFlight || + !canPollPinnedPtyEvents(state) + ) { + return; + } + state.inFlight = true; + let nextDelayMs: number | null = null; + const pollEpochGeneration = state.epochGeneration; + try { + const request = { + cursor: state.cursor, + limit: 200, + category: "pty", + ...(state.replaySuppressed && state.cursor === 0 + ? { replay: false } + : {}), + } satisfies RemoteRuntimeStreamEventsRequest; + const pin = state.pin; + const batch = await invokePinnedRuntimeStreamEvents(pin, request); + if (state.cancelled || pinnedPtyEventStates.get(pin.key) !== state) return; + state.consecutiveFailures = 0; + if (state.epochGeneration !== pollEpochGeneration) { + // A push notification rewound the cursor while this poll was in flight. + // The batch (and its epoch stamp) predates the rewind, so applying either + // would restore the stale cursor and drop the replay. Re-poll from zero. + nextDelayMs = 0; + return; + } + const resetForEpochChange = updatePinnedPtyEventEpoch( + state, + batch.eventEpoch, + ); + if (resetForEpochChange) { + nextDelayMs = 0; + } else { + state.cursor = Number.isFinite(batch.nextCursor) + ? Math.max(state.cursor, 0, Math.floor(batch.nextCursor)) + : state.cursor; + if (request.replay === false) state.replaySuppressed = false; + if (batch.gap === true) resetPinnedPtyEventDedup(state); + for (const event of batch.events ?? []) { + dispatchPinnedPtyRuntimeEvent(state, event); + } + nextDelayMs = pinnedRuntimeBatchDelayMs(batch); + } + } catch (error) { + if (!state.cancelled) { + console.warn("ADE pinned PTY event polling failed", error); + state.consecutiveFailures = Math.min(state.consecutiveFailures + 1, 5); + nextDelayMs = pinnedRuntimeFailureDelayMs(state.consecutiveFailures); + } + } finally { + state.inFlight = false; + if (nextDelayMs != null) { + schedulePinnedPtyEventPoll(state, nextDelayMs); + } + } + } + + const releasePinnedPtyEventStateIfUnused = ( + state: PinnedPtyEventState, + ): void => { + if (state.cancelled || hasPinnedPtyEventListeners(state)) return; + state.cancelled = true; + if (state.timer) clearTimeout(state.timer); + state.timer = null; + state.seenEventIds.clear(); + state.subscribedPtyDataIds.clear(); + pinnedPtyEventStates.delete(state.pin.key); + // Main keeps streaming this binding until idle expiry otherwise, and the + // events would only be discarded by a preload that no longer has listeners. + releaseRuntimeEventSubscription(state.pin, "pty"); + if (state.dataSubscriptionsConfigured) { + // Main owns one sender-wide PTY filter, so remove this pin's ids from the + // union as soon as its final listener leaves. Teardown stays synchronous. + void deps.syncPtyDataSubscriptions().catch((error) => { + console.warn("ADE pinned PTY subscription cleanup failed", error); + }); + } + }; + + const subscribePinnedPtyDataEvents = ( + pin: OpenProjectBinding, + cb: (payload: PtyDataEvent) => void, + ): (() => void) => { + const state = getOrCreatePinnedPtyEventState(pin); + if (!hasPinnedPtyEventListeners(state)) { + state.startedAtMs = pin.kind === "local" ? Date.now() : 0; + } + state.dataCallbacks.add(cb); + ensurePinnedPtyEventPump(state); + return () => { + state.dataCallbacks.delete(cb); + releasePinnedPtyEventStateIfUnused(state); + }; + }; + + const subscribePinnedPtyExitEvents = ( + pin: OpenProjectBinding, + cb: (payload: PtyExitEvent) => void, + ): (() => void) => { + const state = getOrCreatePinnedPtyEventState(pin); + if (!hasPinnedPtyEventListeners(state)) { + state.startedAtMs = pin.kind === "local" ? Date.now() : 0; + } + state.exitCallbacks.add(cb); + ensurePinnedPtyEventPump(state); + return () => { + state.exitCallbacks.delete(cb); + releasePinnedPtyEventStateIfUnused(state); + }; + }; + + /** + * Push delivery for the shared PTY pump. A pushed event that carries a *new* + * epoch must not be dispatched: `updatePinnedPtyEventEpoch` rewinds the cursor + * to 0. Local pins then replay the restarted buffer; remote pins re-issue + * `{ cursor: 0, replay: false }` so the server re-anchors them to the live head. + * Dispatching the announcing push would advance the cursor before either path + * can establish its intended post-restart position. + */ + const handlePinnedPtyRuntimeEventNotification = ( + bindingKey: string, + eventEpoch: unknown, + event: RemoteRuntimeBufferedEvent, + ): void => { + const state = pinnedPtyEventStates.get(bindingKey); + if (!state || !hasPinnedPtyEventListeners(state)) return; + if (updatePinnedPtyEventEpoch(state, eventEpoch)) { + repollPinnedPtyEventsNow(state); + return; + } + dispatchPinnedPtyRuntimeEvent(state, event); + }; + + return { + startPinnedRuntimeEventPump, + collectPinnedPtyDataSubscriptionIds, + setPinnedPtyDataSubscriptions, + subscribePinnedPtyDataEvents, + subscribePinnedPtyExitEvents, + handlePinnedPtyRuntimeEventNotification, + releaseRuntimeEventSubscriptionIfUnpinned, + }; +} diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 5d65fd51d..a8e0f6c61 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -7304,6 +7304,731 @@ describe("per-chat runtime routing", () => { ); }); + it("routes every terminal session read through an explicit runtime pin", async () => { + const { bridge, invoke } = await mountBridge(machineA); + + await bridge.terminal.preview( + { terminalId: "session-b", maxBytes: 4_096 }, + machineB, + ); + await bridge.sessions.get("session-b", machineB); + await bridge.sessions.readTranscriptTail( + { sessionId: "session-b", maxBytes: 8_192, raw: true }, + machineB, + ); + await bridge.pty.resumeSession( + { sessionId: "session-b", cols: 120, rows: 40 }, + machineB, + ); + + const requests = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) + .map(([, arg]) => (arg as { request: unknown }).request); + expect(requests).toEqual([ + { + domain: "terminal", + action: "preview", + args: { terminalId: "session-b", maxBytes: 4_096 }, + }, + { domain: "session", action: "get", arg: "session-b" }, + { + domain: "session", + action: "readTranscriptTail", + args: { sessionId: "session-b", maxBytes: 8_192, raw: true }, + }, + { + domain: "pty", + action: "resumeSession", + args: { sessionId: "session-b", cols: 120, rows: 40 }, + }, + ]); + expect(invoke).not.toHaveBeenCalledWith( + IPC.localRuntimeCallAction, + expect.anything(), + ); + }); + + it("delivers pinned PTY data and exit events without rebinding the active project", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T12:00:00.500Z")); + try { + const { bridge, invoke, on } = await mountBridge(machineB); + await bridge.app.getWindowSession(); + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.localRuntimeStreamEvents) { + return { + events: [], + nextCursor: 10, + hasMore: false, + eventEpoch: "machine-a-epoch", + }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + const onData = vi.fn(); + const onExit = vi.fn(); + const removeData = bridge.pty.onData(onData, machineA); + const removeExit = bridge.pty.onExit(onExit, machineA); + await vi.advanceTimersByTimeAsync(0); + + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeStreamEvents, { + rootPath: "/repo-a", + request: { + cursor: 0, + limit: 200, + category: "pty", + }, + }); + expect(invoke).not.toHaveBeenCalledWith( + IPC.remoteRuntimeStreamEvents, + expect.anything(), + ); + + const runtimeListener = on.mock.calls.find( + ([channel]) => channel === IPC.runtimeEvent, + )?.[1]; + expect(runtimeListener).toBeTypeOf("function"); + const dataEvent = { + ptyId: "pty-a", + sessionId: "session-a", + data: "foreign output", + }; + const exitEvent = { + ptyId: "pty-a", + sessionId: "session-a", + exitCode: 0, + }; + runtimeListener({}, { + bindingKey: machineA.key, + eventEpoch: "machine-a-epoch", + event: { + id: 11, + timestamp: "2026-07-28T12:00:01.000Z", + category: "pty", + payload: { type: "pty_data", event: dataEvent }, + }, + }); + runtimeListener({}, { + bindingKey: machineA.key, + eventEpoch: "machine-a-epoch", + event: { + id: 12, + timestamp: "2026-07-28T12:00:02.000Z", + category: "pty", + payload: { type: "pty_exit", event: exitEvent }, + }, + }); + + expect(onData).toHaveBeenCalledWith(dataEvent); + expect(onExit).toHaveBeenCalledWith(exitEvent); + removeData(); + removeExit(); + } finally { + vi.useRealTimers(); + } + }); + + it("drops the duplicate copy when a binding is covered by two runtime event subscriptions", async () => { + // Main holds one subscription per (sender, requestKey), so a window running + // the active `*` pump and a pinned `pty` pump over the same binding receives + // each event twice. Every consumer dedups on event.id, so it stays harmless. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-28T12:00:00.500Z")); + try { + const { bridge, invoke, on } = await mountBridge(machineA); + await bridge.app.getWindowSession(); + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.localRuntimeStreamEvents) { + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "shared-epoch", + }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + const onActive = vi.fn(); + const onPinned = vi.fn(); + const removeActive = bridge.pty.onData(onActive); + const removePinned = bridge.pty.onData(onPinned, machineA); + await vi.advanceTimersByTimeAsync(0); + + const runtimeListener = on.mock.calls.find( + ([channel]) => channel === IPC.runtimeEvent, + )?.[1]; + const notification = { + bindingKey: machineA.key, + eventEpoch: "shared-epoch", + event: { + id: 21, + timestamp: "2026-07-28T12:00:01.000Z", + category: "pty", + payload: { + type: "pty_data", + event: { ptyId: "pty-a", sessionId: "session-a", data: "once" }, + }, + }, + }; + runtimeListener({}, notification); + runtimeListener({}, notification); + + expect(onPinned).toHaveBeenCalledTimes(1); + expect(onActive).toHaveBeenCalledTimes(1); + + removeActive(); + removePinned(); + } finally { + vi.useRealTimers(); + } + }); + + it("isolates PTY data filters per pin and updates the sender filter before polling", async () => { + vi.useFakeTimers(); + try { + const machineC = { + kind: "remote" as const, + key: "remote:target-c:project-c", + targetId: "target-c", + runtimeName: "machine-c", + projectId: "project-c", + rootPath: "/repo-c", + displayName: "Machine C", + }; + const { bridge, invoke, on } = await mountBridge(machineA); + await bridge.app.getWindowSession(); + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.ptyDataSubscriptions) return undefined; + if (channel === IPC.remoteRuntimeStreamEvents) { + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "pinned-epoch", + }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + await bridge.pty.setDataSubscriptions({ ptyIds: ["pty-a"] }); + const onDataB = vi.fn(); + const onDataC = vi.fn(); + const removeB = bridge.pty.onData(onDataB, machineB); + const removeC = bridge.pty.onData(onDataC, machineC); + + // The active sender filter is already narrow, so merely subscribing must + // not start a pump that would discard the first foreign PTY chunks. + await vi.advanceTimersByTimeAsync(0); + expect(invoke).not.toHaveBeenCalledWith( + IPC.remoteRuntimeStreamEvents, + expect.anything(), + ); + + await bridge.pty.setDataSubscriptions({ ptyIds: ["pty-b"] }, machineB); + await bridge.pty.setDataSubscriptions({ ptyIds: ["pty-c"] }, machineC); + expect( + invoke.mock.calls + .filter(([channel]) => channel === IPC.ptyDataSubscriptions) + .map(([, arg]) => arg), + ).toEqual([ + { ptyIds: ["pty-a"] }, + { ptyIds: ["pty-a", "pty-b"] }, + { ptyIds: ["pty-a", "pty-b", "pty-c"] }, + ]); + + await vi.advanceTimersByTimeAsync(0); + const streamTargets = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeStreamEvents) + .map(([, arg]) => (arg as { id: string }).id) + .sort(); + expect(streamTargets).toEqual(["target-b", "target-c"]); + + const runtimeListener = on.mock.calls.find( + ([channel]) => channel === IPC.runtimeEvent, + )?.[1]; + expect(runtimeListener).toBeTypeOf("function"); + const emitData = ( + bindingKey: string, + id: number, + ptyId: string, + data: string, + ) => runtimeListener({}, { + bindingKey, + eventEpoch: "pinned-epoch", + event: { + id, + timestamp: "2026-07-28T12:00:01.000Z", + category: "pty", + payload: { + type: "pty_data", + event: { ptyId, sessionId: `session-${ptyId}`, data }, + }, + }, + }); + // Reusing ids across runtimes also proves their dedup state is isolated. + emitData(machineB.key, 1, "pty-c", "wrong for B"); + emitData(machineB.key, 2, "pty-b", "right for B"); + emitData(machineC.key, 1, "pty-b", "wrong for C"); + emitData(machineC.key, 2, "pty-c", "right for C"); + + expect(onDataB).toHaveBeenCalledTimes(1); + expect(onDataB).toHaveBeenCalledWith( + expect.objectContaining({ ptyId: "pty-b", data: "right for B" }), + ); + expect(onDataC).toHaveBeenCalledTimes(1); + expect(onDataC).toHaveBeenCalledWith( + expect.objectContaining({ ptyId: "pty-c", data: "right for C" }), + ); + + removeB(); + removeC(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the epoch rewind when a pinned PTY poll is in flight across it", async () => { + vi.useFakeTimers(); + try { + const { bridge, invoke, on } = await mountBridge(machineA); + await bridge.app.getWindowSession(); + const streamRequests: Array> = []; + const pendingStreamPolls: Array<(batch: unknown) => void> = []; + const releaseStream = (batch: unknown): void => { + const resolve = pendingStreamPolls.shift(); + expect(resolve).toBeTypeOf("function"); + resolve?.(batch); + }; + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.remoteRuntimeStreamEvents) { + streamRequests.push( + (arg as { request: Record }).request, + ); + return await new Promise((resolve) => { + pendingStreamPolls.push(resolve as (batch: unknown) => void); + }); + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + const onData = vi.fn(); + const removeData = bridge.pty.onData(onData, machineB); + await vi.advanceTimersByTimeAsync(0); + expect(streamRequests).toHaveLength(1); + releaseStream({ + events: [], + nextCursor: 10, + hasMore: false, + eventEpoch: "epoch-1", + }); + await vi.advanceTimersByTimeAsync(0); + + // Second poll leaves with the epoch-1 cursor and is still in flight when + // the runtime restarts. + await vi.advanceTimersByTimeAsync(5_000); + expect(streamRequests).toHaveLength(2); + expect(streamRequests[1]).toMatchObject({ cursor: 10 }); + + const runtimeListener = on.mock.calls.find( + ([channel]) => channel === IPC.runtimeEvent, + )?.[1]; + runtimeListener({}, { + bindingKey: machineB.key, + eventEpoch: "epoch-2", + event: { + id: 3, + timestamp: "2026-07-28T12:00:01.000Z", + category: "pty", + payload: { + type: "pty_data", + event: { ptyId: "pty-b", sessionId: "session-b", data: "reborn" }, + }, + }, + }); + // The push announced the new epoch, so it is replayed by the pump rather + // than dispatched here. + expect(onData).not.toHaveBeenCalled(); + + // The in-flight batch predates the rewind: applying its cursor would skip + // events 4..10 of the new epoch. + releaseStream({ + events: [], + nextCursor: 10, + hasMore: false, + eventEpoch: "epoch-2", + }); + // The fixed pump re-polls immediately; without the guard it waits out the + // idle delay and re-polls from the restored cursor 10. + await vi.advanceTimersByTimeAsync(5_000); + + expect(streamRequests).toHaveLength(3); + expect(streamRequests[2]).toMatchObject({ cursor: 0, replay: false }); + + removeData(); + } finally { + vi.useRealTimers(); + } + }); + + it("replays a restarted pinned PTY epoch instead of dispatching the push that announced it", async () => { + vi.useFakeTimers(); + try { + const { bridge, invoke, on } = await mountBridge(machineA); + await bridge.app.getWindowSession(); + const ptyEvent = (id: number, data: string) => ({ + id, + timestamp: `2026-07-28T12:00:0${id}.000Z`, + category: "pty" as const, + payload: { + type: "pty_data", + event: { ptyId: "pty-b", sessionId: "session-b", data }, + }, + }); + const streamRequests: Array> = []; + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.runtimeEventsRelease) return { released: 0 }; + if (channel === IPC.remoteRuntimeStreamEvents) { + streamRequests.push( + (arg as { request: Record }).request, + ); + if (streamRequests.length === 1) { + return { + events: [], + nextCursor: 4, + hasMore: false, + eventEpoch: "epoch-1", + }; + } + return { + events: [ptyEvent(1, "first of new epoch"), ptyEvent(2, "reborn")], + nextCursor: 2, + hasMore: false, + eventEpoch: "epoch-2", + }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + const onData = vi.fn(); + const removeData = bridge.pty.onData(onData, machineB); + await vi.advanceTimersByTimeAsync(0); + expect(streamRequests).toHaveLength(1); + + const runtimeListener = on.mock.calls.find( + ([channel]) => channel === IPC.runtimeEvent, + )?.[1]; + runtimeListener({}, { + bindingKey: machineB.key, + eventEpoch: "epoch-2", + event: ptyEvent(2, "reborn"), + }); + + // Dispatching the pushed event would drag the cursor to 2 and strand + // event 1 of the restarted buffer, so the pump replays the epoch instead. + expect(onData).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(0); + expect(streamRequests).toHaveLength(2); + expect(streamRequests[1]).toMatchObject({ cursor: 0, replay: false }); + expect( + onData.mock.calls.map(([payload]) => (payload as { data: string }).data), + ).toEqual(["first of new epoch", "reborn"]); + + // A push on the epoch the pump already tracks still dispatches directly. + runtimeListener({}, { + bindingKey: machineB.key, + eventEpoch: "epoch-2", + event: ptyEvent(3, "live"), + }); + expect(onData).toHaveBeenCalledTimes(3); + expect(onData.mock.calls.at(-1)?.[0]).toMatchObject({ data: "live" }); + + removeData(); + } finally { + vi.useRealTimers(); + } + }); + + it("releases a pinned pump's main-side subscription on teardown instead of waiting for idle expiry", async () => { + vi.useFakeTimers(); + try { + const { bridge, invoke } = await mountBridge(machineA); + await bridge.app.getWindowSession(); + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.runtimeEventsRelease) return { released: 1 }; + if (channel === IPC.remoteRuntimeStreamEvents) { + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "machine-b-epoch", + }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + const removeData = bridge.pty.onData(vi.fn(), machineB); + const removeExit = bridge.pty.onExit(vi.fn(), machineB); + const removeChat = bridge.agentChat.onEvent(vi.fn(), machineB); + await vi.advanceTimersByTimeAsync(0); + + const releaseCalls = () => + invoke.mock.calls + .filter(([channel]) => channel === IPC.runtimeEventsRelease) + .map(([, payload]) => payload); + expect(releaseCalls()).toEqual([]); + + // Both PTY listeners share one pump, so only the last one releases it. + removeData(); + expect(releaseCalls()).toEqual([]); + removeExit(); + await Promise.resolve(); + expect(releaseCalls()).toEqual([ + { id: "target-b", projectId: "project-b", category: "pty" }, + ]); + + removeChat(); + expect(releaseCalls()).toEqual([ + { id: "target-b", projectId: "project-b", category: "pty" }, + { id: "target-b", projectId: "project-b" }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("does not retain a pinned PTY subscription when its filter is set before any listener subscribes", async () => { + vi.useFakeTimers(); + try { + const { bridge, invoke } = await mountBridge(machineA); + await bridge.app.getWindowSession(); + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.ptyDataSubscriptions) return undefined; + if (channel === IPC.runtimeEventsRelease) return { released: 1 }; + if (channel === IPC.remoteRuntimeStreamEvents) { + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "machine-b-epoch", + }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + const releaseCalls = () => + invoke.mock.calls + .filter(([channel]) => channel === IPC.runtimeEventsRelease) + .map(([, payload]) => payload); + + await bridge.pty.setDataSubscriptions({ ptyIds: ["pty-b"] }, machineB); + await Promise.resolve(); + expect(releaseCalls()).toEqual([ + { id: "target-b", projectId: "project-b", category: "pty" }, + ]); + + const removeData = bridge.pty.onData(vi.fn(), machineB); + await vi.advanceTimersByTimeAsync(0); + removeData(); + await Promise.resolve(); + + // The filter-only state released its retain, so this real listener owns a + // fresh ref and its teardown reaches main immediately as well. + expect(releaseCalls()).toEqual([ + { id: "target-b", projectId: "project-b", category: "pty" }, + { id: "target-b", projectId: "project-b", category: "pty" }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("releases the previous binding's subscription when the active pump switches machines", async () => { + vi.useFakeTimers(); + try { + let activeBinding: typeof machineA | typeof machineB = machineA; + const { bridge, invoke } = await mountBridge(machineA); + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { + windowId: 1, + project: { + rootPath: activeBinding.rootPath, + displayName: activeBinding.displayName, + }, + binding: activeBinding, + }; + } + if (channel === IPC.runtimeEventsRelease) return { released: 1 }; + if ( + channel === IPC.localRuntimeStreamEvents || + channel === IPC.remoteRuntimeStreamEvents + ) { + return { events: [], nextCursor: 0, hasMore: false }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + const unsubscribe = bridge.orchestration.subscribe( + { runId: "run-1" }, + vi.fn(), + ); + await vi.advanceTimersByTimeAsync(0); + const releaseCalls = () => + invoke.mock.calls + .filter(([channel]) => channel === IPC.runtimeEventsRelease) + .map(([, payload]) => payload); + expect(releaseCalls()).toEqual([]); + + activeBinding = machineB; + await bridge.app.getWindowSession(); + await vi.advanceTimersByTimeAsync(750); + + expect(releaseCalls()).toEqual([{ rootPath: "/repo-a" }]); + + unsubscribe(); + } finally { + vi.useRealTimers(); + } + }); + + it("tears down a pinned PTY pump and frees its sender-filter state after the last listener", async () => { + vi.useFakeTimers(); + try { + const { bridge, invoke, on } = await mountBridge(machineA); + await bridge.app.getWindowSession(); + let streamCalls = 0; + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.ptyDataSubscriptions) return undefined; + if (channel === IPC.remoteRuntimeStreamEvents) { + streamCalls += 1; + return { + events: [], + nextCursor: 0, + hasMore: false, + eventEpoch: "machine-b-epoch", + }; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + await bridge.pty.setDataSubscriptions({ ptyIds: ["pty-a"] }); + const onData = vi.fn(); + const onExit = vi.fn(); + const removeData = bridge.pty.onData(onData, machineB); + const removeExit = bridge.pty.onExit(onExit, machineB); + await bridge.pty.setDataSubscriptions({ ptyIds: ["pty-b"] }, machineB); + await vi.advanceTimersByTimeAsync(0); + expect(streamCalls).toBe(1); + + removeData(); + expect(vi.getTimerCount()).toBe(1); + removeExit(); + await Promise.resolve(); + expect(vi.getTimerCount()).toBe(0); + expect( + invoke.mock.calls + .filter(([channel]) => channel === IPC.ptyDataSubscriptions) + .at(-1)?.[1], + ).toEqual({ ptyIds: ["pty-a"] }); + + const runtimeListener = on.mock.calls.find( + ([channel]) => channel === IPC.runtimeEvent, + )?.[1]; + runtimeListener({}, { + bindingKey: machineB.key, + eventEpoch: "machine-b-epoch", + event: { + id: 1, + timestamp: "2026-07-28T12:00:01.000Z", + category: "pty", + payload: { + type: "pty_data", + event: { ptyId: "pty-b", sessionId: "session-b", data: "late" }, + }, + }, + }); + await vi.advanceTimersByTimeAsync(30_000); + expect(onData).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(streamCalls).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the unpinned PTY fanout and subscription hot path unchanged", async () => { + vi.useFakeTimers(); + try { + const { bridge, invoke, on, removeListener } = await mountBridge(machineA); + await bridge.app.getWindowSession(); + invoke.mockImplementation(async (channel: string, arg?: unknown) => { + if (channel === IPC.ptyDataSubscriptions) return undefined; + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); + }); + + await bridge.pty.setDataSubscriptions({ ptyIds: ["pty-a"] }); + const onData = vi.fn(); + const onExit = vi.fn(); + const removeData = bridge.pty.onData(onData); + const removeExit = bridge.pty.onExit(onExit); + + expect( + invoke.mock.calls.filter( + ([channel]) => channel === IPC.ptyDataSubscriptions, + ), + ).toEqual([[IPC.ptyDataSubscriptions, { ptyIds: ["pty-a"] }]]); + expect( + invoke.mock.calls.some( + ([channel]) => + channel === IPC.localRuntimeStreamEvents || + channel === IPC.remoteRuntimeStreamEvents, + ), + ).toBe(false); + + const localDataListener = on.mock.calls.find( + ([channel]) => channel === IPC.ptyData, + )?.[1]; + const localExitListener = on.mock.calls.find( + ([channel]) => channel === IPC.ptyExit, + )?.[1]; + expect(localDataListener).toBeTypeOf("function"); + expect(localExitListener).toBeTypeOf("function"); + localDataListener({}, { + ptyId: "pty-hidden", + sessionId: "session-hidden", + data: "hidden", + }); + localDataListener({}, { + ptyId: "pty-a", + sessionId: "session-a", + data: "visible", + }); + localExitListener({}, { + ptyId: "pty-a", + sessionId: "session-a", + exitCode: 0, + }); + + expect(onData).toHaveBeenCalledTimes(1); + expect(onData).toHaveBeenCalledWith( + expect.objectContaining({ ptyId: "pty-a", data: "visible" }), + ); + expect(onExit).toHaveBeenCalledWith( + expect.objectContaining({ ptyId: "pty-a", exitCode: 0 }), + ); + + removeData(); + removeExit(); + expect(removeListener).toHaveBeenCalledWith(IPC.ptyData, localDataListener); + expect(removeListener).toHaveBeenCalledWith(IPC.ptyExit, localExitListener); + } finally { + vi.useRealTimers(); + } + }); + it("resets a pinned chat event cursor when the remote runtime epoch changes", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fe9006dd0..9b62f5bd1 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -13,6 +13,15 @@ import { import { deriveSmartLinkPreview, type SmartLinkPreview } from "../shared/smartLinks"; import { sessionLifecycleApplied } from "../shared/sessionLifecycleResult"; import { createOrchestrationBridge } from "./orchestrationBridge"; +import { + createPinnedRuntimeEvents, + isPinnedRuntimeEventStale, + normalizePinnedRuntimeEventEpoch, + rememberPinnedRuntimeEventId, + REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS, + REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS, + REMOTE_RUNTIME_EVENT_IDLE_POLL_MS, +} from "./pinnedRuntimeEvents"; import type { OrchestrationEventPayload } from "../shared/types/orchestration"; import type { ProjectRecoveryDiagnosis, ProjectRepairReport } from "../shared/types/recovery"; import type { @@ -1848,6 +1857,9 @@ let remoteRuntimeEventTimer: ReturnType | null = null; let remoteRuntimeEventInFlight = false; let remoteRuntimeEventCursor = 0; let remoteRuntimeEventBindingKey: string | null = null; +// The binding the pump is currently subscribed to, kept alongside its key so a +// switch can tell main which subscription to drop. +let remoteRuntimeEventBinding: OpenProjectBinding | null = null; let remoteRuntimeEventGeneration = -1; let remoteRuntimeEventEpoch: string | null = null; let remoteRuntimeEventStartedAtMs = 0; @@ -1856,12 +1868,18 @@ let remoteRuntimeEmptyPollCount = 0; let remoteRuntimeSeenEventBindingKey: string | null = null; const remoteRuntimeSeenEventIds = new Set(); const LOCAL_RUNTIME_EVENT_IDLE_POLL_MS = 750; -const REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS = 750; const REMOTE_RUNTIME_EVENT_INITIAL_IDLE_POLL_MS = 2_500; -const REMOTE_RUNTIME_EVENT_IDLE_POLL_MS = 5_000; -const REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS = 50; -const PINNED_CHAT_EVENT_FAILURE_BASE_POLL_MS = 2_000; -const PINNED_CHAT_EVENT_FAILURE_MAX_POLL_MS = 30_000; + +// Every pump that reads a binding the window is not bound to lives in this +// subsystem; the active-binding pump below stays here and shares its helpers. +const pinnedRuntimeEvents = createPinnedRuntimeEvents({ + ipcRenderer, + toWrappedEvent, + syncPtyDataSubscriptions: () => syncPtyDataSubscriptions(), + isPtyDataFilteringConfigured: () => ptyDataSubscriptionsConfigured, +}); +const startPinnedRuntimeEventPump = + pinnedRuntimeEvents.startPinnedRuntimeEventPump; function clearPendingRemoteRuntimeEventPoll(): void { if (!remoteRuntimeEventTimer) return; @@ -1885,12 +1903,8 @@ function shouldDispatchRemoteRuntimeEvent( if (remoteRuntimeSeenEventBindingKey !== bindingKey) { resetRemoteRuntimeEventDedup(bindingKey); } - if (remoteRuntimeSeenEventIds.has(event.id)) return false; - remoteRuntimeSeenEventIds.add(event.id); - while (remoteRuntimeSeenEventIds.size > 1_000) { - const oldest = remoteRuntimeSeenEventIds.values().next().value; - if (typeof oldest !== "number") break; - remoteRuntimeSeenEventIds.delete(oldest); + if (!rememberPinnedRuntimeEventId(remoteRuntimeSeenEventIds, event.id)) { + return false; } remoteRuntimeEventCursor = Math.max(remoteRuntimeEventCursor, event.id); return true; @@ -1958,13 +1972,37 @@ function shouldDispatchPtyDataEvent(payload: PtyDataEvent): boolean { return subscribedPtyDataIds.has(payload.ptyId); } -async function setPtyDataSubscriptions(args: { ptyIds?: string[] }): Promise { +function collectPtyDataSubscriptionIds(): string[] { + const ids = new Set(subscribedPtyDataIds); + for (const ptyId of pinnedRuntimeEvents.collectPinnedPtyDataSubscriptionIds()) { + ids.add(ptyId); + } + return [...ids]; +} + +function syncPtyDataSubscriptions(): Promise { + // Until the active path opts into filtering, main-process delivery is + // intentionally unrestricted. A pinned view must not narrow that legacy + // stream merely by mounting; its own filter still applies in preload. + if (!ptyDataSubscriptionsConfigured) return Promise.resolve(); + return ipcRenderer.invoke(IPC.ptyDataSubscriptions, { + ptyIds: collectPtyDataSubscriptionIds(), + }); +} + +async function setPtyDataSubscriptions( + args: { ptyIds?: string[] }, + pin?: OpenProjectBinding | null, +): Promise { + const ptyIds = normalizePtyDataSubscriptionIds(args?.ptyIds); + if (pin) { + await pinnedRuntimeEvents.setPinnedPtyDataSubscriptions(pin, ptyIds); + return; + } ptyDataSubscriptionsConfigured = true; - subscribedPtyDataIds = normalizePtyDataSubscriptionIds(args?.ptyIds); + subscribedPtyDataIds = ptyIds; ensureRemoteRuntimeEventPump(); - await ipcRenderer.invoke(IPC.ptyDataSubscriptions, { - ptyIds: [...subscribedPtyDataIds], - }); + await syncPtyDataSubscriptions(); } function ensureRemoteRuntimeEventPump(): void { @@ -1976,6 +2014,19 @@ function ensureRemoteRuntimeEventPump(): void { }, 0); } +// The active pump owns exactly one main-side subscription at a time. Without an +// explicit release, a binding the window switched away from keeps streaming +// orchestrator/dag_mutation/runtime events into a preload that discards them all, +// for up to the idle-expiry window, once per switch. +function releaseRuntimeEventSubscriptionForPreviousBinding( + nextBinding: OpenProjectBinding | null, +): void { + const previous = remoteRuntimeEventBinding; + remoteRuntimeEventBinding = nextBinding; + if (!previous || previous.key === nextBinding?.key) return; + pinnedRuntimeEvents.releaseRuntimeEventSubscriptionIfUnpinned(previous); +} + function scheduleRemoteRuntimeEventPoll(delayMs: number): void { if (!hasRemoteRuntimeEventSubscribers()) return; if (remoteRuntimeEventTimer || remoteRuntimeEventInFlight) return; @@ -1994,6 +2045,7 @@ async function pollRemoteRuntimeEvents(): Promise { try { const binding = await getProjectRuntimeBinding(); if (!binding) { + releaseRuntimeEventSubscriptionForPreviousBinding(null); remoteRuntimeEventCursor = 0; remoteRuntimeEventBindingKey = null; remoteRuntimeEventGeneration = projectBindingGeneration; @@ -2009,6 +2061,7 @@ async function pollRemoteRuntimeEvents(): Promise { remoteRuntimeEventBindingKey !== binding.key || remoteRuntimeEventGeneration !== projectBindingGeneration ) { + releaseRuntimeEventSubscriptionForPreviousBinding(binding); remoteRuntimeEventCursor = 0; remoteRuntimeEventBindingKey = binding.key; remoteRuntimeEventGeneration = projectBindingGeneration; @@ -2051,10 +2104,7 @@ async function pollRemoteRuntimeEvents(): Promise { return; } - const batchEpoch = - typeof batch.eventEpoch === "string" && batch.eventEpoch.trim() - ? batch.eventEpoch.trim() - : null; + const batchEpoch = normalizePinnedRuntimeEventEpoch(batch.eventEpoch); if (batchEpoch) { const epochChanged = remoteRuntimeEventEpoch ? batchEpoch !== remoteRuntimeEventEpoch @@ -2082,13 +2132,9 @@ async function pollRemoteRuntimeEvents(): Promise { } for (const event of batch.events) { - const eventTime = Date.parse(event.timestamp); - if ( - binding.kind === "local" && - remoteRuntimeEventStartedAtMs > 0 && - Number.isFinite(eventTime) && - eventTime < remoteRuntimeEventStartedAtMs - 1_000 - ) { + // `remoteRuntimeEventStartedAtMs` is 0 for remote bindings, so the shared + // helper's zero guard already restricts this to local ones. + if (isPinnedRuntimeEventStale(remoteRuntimeEventStartedAtMs, event.timestamp)) { continue; } if (!shouldDispatchRemoteRuntimeEvent(binding.key, event)) continue; @@ -2140,6 +2186,11 @@ async function pollRemoteRuntimeEvents(): Promise { function handleRemoteRuntimeEventNotification(value: unknown): void { const payload = toRemoteRuntimeEventNotificationPayload(value); if (!payload) return; + pinnedRuntimeEvents.handlePinnedPtyRuntimeEventNotification( + payload.bindingKey, + payload.eventEpoch, + payload.event, + ); const pinnedLocalCallbacks = pinnedLocalAgentChatEventCallbacks.get( payload.bindingKey, ); @@ -2155,10 +2206,7 @@ function handleRemoteRuntimeEventNotification(value: unknown): void { const binding = currentProjectBinding; if (!binding || payload.bindingKey !== binding.key) return; resetRemoteRuntimeEmptyPolls(); - const notificationEpoch = - typeof payload.eventEpoch === "string" && payload.eventEpoch.trim() - ? payload.eventEpoch.trim() - : null; + const notificationEpoch = normalizePinnedRuntimeEventEpoch(payload.eventEpoch); if (notificationEpoch) { const epochChanged = remoteRuntimeEventEpoch ? notificationEpoch !== remoteRuntimeEventEpoch @@ -2170,13 +2218,7 @@ function handleRemoteRuntimeEventNotification(value: unknown): void { resetRemoteRuntimeEventDedup(binding.key); } } - const eventTime = Date.parse(payload.event.timestamp); - if ( - binding.kind === "local" && - remoteRuntimeEventStartedAtMs > 0 && - Number.isFinite(eventTime) && - eventTime < remoteRuntimeEventStartedAtMs - 1_000 - ) { + if (isPinnedRuntimeEventStale(remoteRuntimeEventStartedAtMs, payload.event.timestamp)) { return; } if (!shouldDispatchRemoteRuntimeEvent(payload.bindingKey, payload.event)) @@ -2964,30 +3006,11 @@ function subscribeAgentChatEvents( const forcePinned = Boolean(pin && options?.forcePinned === true); const removeLocal = forcePinned ? () => undefined : agentChatEventFanout(cb); if (pin && (forcePinned || pin.key !== currentProjectBinding?.key)) { - let cancelled = false; - let timer: ReturnType | null = null; - let cursor = 0; - let eventEpoch: string | null = null; - let replaySuppressed = pin.kind === "remote"; const startedAtMs = pin.kind === "local" ? Date.now() : 0; - let consecutiveFailures = 0; const seenLocalEventIds = new Set(); const dispatchPinnedLocalEvent = (event: RemoteRuntimeBufferedEvent): void => { - const eventTime = Date.parse(event.timestamp); - if ( - startedAtMs > 0 - && Number.isFinite(eventTime) - && eventTime < startedAtMs - 1_000 - ) { - return; - } - if (seenLocalEventIds.has(event.id)) return; - seenLocalEventIds.add(event.id); - while (seenLocalEventIds.size > 1_000) { - const oldest = seenLocalEventIds.values().next().value; - if (typeof oldest !== "number") break; - seenLocalEventIds.delete(oldest); - } + if (isPinnedRuntimeEventStale(startedAtMs, event.timestamp)) return; + if (!rememberPinnedRuntimeEventId(seenLocalEventIds, event.id)) return; const envelope = toAgentChatEventEnvelope(event.payload); if (!envelope) return; agentChatSummaryCache.clear(); @@ -3002,73 +3025,22 @@ function subscribeAgentChatEvents( pinnedLocalCallbacks.add(dispatchPinnedLocalEvent); pinnedLocalAgentChatEventCallbacks.set(pin.key, pinnedLocalCallbacks); } - const poll = async (): Promise => { - let delay = REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; - try { - const request = { - cursor, - limit: 200, - ...(replaySuppressed && cursor === 0 ? { replay: false } : {}), - } satisfies RemoteRuntimeStreamEventsRequest; - const batch = await ipcRenderer.invoke( - pin.kind === "remote" - ? IPC.remoteRuntimeStreamEvents - : IPC.localRuntimeStreamEvents, - pin.kind === "remote" - ? { id: pin.targetId, projectId: pin.projectId, request } - : { rootPath: pin.rootPath, request }, - ) as RemoteRuntimeStreamEventsResult; - if (cancelled) return; - consecutiveFailures = 0; - let resetForEpochChange = false; - const batchEpoch = - typeof batch.eventEpoch === "string" && batch.eventEpoch.trim() - ? batch.eventEpoch.trim() - : null; - if (batchEpoch) { - const epochChanged = eventEpoch - ? batchEpoch !== eventEpoch - : cursor > 0; - eventEpoch = batchEpoch; - if (epochChanged) { - cursor = 0; - replaySuppressed = pin.kind === "remote"; - delay = 0; - resetForEpochChange = true; - } - } - if (!resetForEpochChange) { - cursor = Number.isFinite(batch.nextCursor) ? Math.max(0, Math.floor(batch.nextCursor)) : cursor; - if (request.replay === false) replaySuppressed = false; - for (const event of batch.events ?? []) { - if (pin.kind === "local") { - dispatchPinnedLocalEvent(event); - } else { - const envelope = toAgentChatEventEnvelope(event.payload); - if (envelope) cb(envelope); - } - } - delay = batch.hasMore - ? REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS - : batch.events?.length - ? REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS - : REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; + const stopPump = startPinnedRuntimeEventPump({ + pin, + label: "chat", + suppressReplay: pin.kind === "remote", + dispatch: (event) => { + if (pin.kind === "local") { + // Shared with the push-notification path, so it owns dedup itself. + dispatchPinnedLocalEvent(event); + return; } - } catch (error) { - if (!cancelled) console.warn("ADE pinned chat event polling failed", error); - consecutiveFailures = Math.min(consecutiveFailures + 1, 5); - delay = Math.min( - PINNED_CHAT_EVENT_FAILURE_BASE_POLL_MS * - 2 ** (consecutiveFailures - 1), - PINNED_CHAT_EVENT_FAILURE_MAX_POLL_MS, - ); - } - if (!cancelled) timer = setTimeout(() => void poll(), delay); - }; - void poll(); + const envelope = toAgentChatEventEnvelope(event.payload); + if (envelope) cb(envelope); + }, + }); return () => { - cancelled = true; - if (timer) clearTimeout(timer); + stopPump(); if (pinnedLocalCallbacks) { pinnedLocalCallbacks.delete(dispatchPinnedLocalEvent); if (pinnedLocalCallbacks.size === 0) { @@ -3087,7 +3059,9 @@ function subscribeAgentChatEvents( function subscribePtyDataEvents( cb: (payload: PtyDataEvent) => void, + pin?: OpenProjectBinding | null, ): () => void { + if (pin) return pinnedRuntimeEvents.subscribePinnedPtyDataEvents(pin, cb); const filteredCb = (payload: PtyDataEvent) => { if (shouldDispatchPtyDataEvent(payload)) cb(payload); }; @@ -3101,7 +3075,9 @@ function subscribePtyDataEvents( function subscribePtyExitEvents( cb: (payload: PtyExitEvent) => void, + pin?: OpenProjectBinding | null, ): () => void { + if (pin) return pinnedRuntimeEvents.subscribePinnedPtyExitEvents(pin, cb); const removeLocal = ptyExitEventFanout(cb); const removeRemote = subscribeRemotePtyExitEvents(cb); return () => { @@ -3176,84 +3152,17 @@ function subscribePinnedProjectRuntimeEvents( onPayload?: () => void, ): (() => void) | null { if (!pin || pin.key === currentProjectBinding?.key) return null; - let cancelled = false; - let timer: ReturnType | null = null; - let cursor = 0; - let eventEpoch: string | null = null; - let replaySuppressed = true; - let consecutiveFailures = 0; - - const poll = async (): Promise => { - let delay = REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; - try { - const request = { - cursor, - limit: 200, - ...(replaySuppressed && cursor === 0 ? { replay: false } : {}), - } satisfies RemoteRuntimeStreamEventsRequest; - const batch = await ipcRenderer.invoke( - pin.kind === "remote" - ? IPC.remoteRuntimeStreamEvents - : IPC.localRuntimeStreamEvents, - pin.kind === "remote" - ? { id: pin.targetId, projectId: pin.projectId, request } - : { rootPath: pin.rootPath, request }, - ) as RemoteRuntimeStreamEventsResult; - if (cancelled) return; - consecutiveFailures = 0; - let resetForEpochChange = false; - const batchEpoch = - typeof batch.eventEpoch === "string" && batch.eventEpoch.trim() - ? batch.eventEpoch.trim() - : null; - if (batchEpoch) { - const epochChanged = eventEpoch - ? batchEpoch !== eventEpoch - : cursor > 0; - eventEpoch = batchEpoch; - if (epochChanged) { - cursor = 0; - replaySuppressed = true; - delay = 0; - resetForEpochChange = true; - } - } - if (!resetForEpochChange) { - cursor = Number.isFinite(batch.nextCursor) - ? Math.max(0, Math.floor(batch.nextCursor)) - : cursor; - if (request.replay === false) replaySuppressed = false; - for (const event of batch.events ?? []) { - const payload = decode(event.payload); - if (!payload) continue; - onPayload?.(); - cb(payload); - } - delay = batch.hasMore - ? REMOTE_RUNTIME_EVENT_CATCH_UP_POLL_MS - : batch.events?.length - ? REMOTE_RUNTIME_EVENT_ACTIVE_POLL_MS - : REMOTE_RUNTIME_EVENT_IDLE_POLL_MS; - } - } catch (error) { - if (!cancelled) { - console.warn(`ADE pinned ${label} event polling failed`, error); - } - consecutiveFailures = Math.min(consecutiveFailures + 1, 5); - delay = Math.min( - PINNED_CHAT_EVENT_FAILURE_BASE_POLL_MS * - 2 ** (consecutiveFailures - 1), - PINNED_CHAT_EVENT_FAILURE_MAX_POLL_MS, - ); - } - if (!cancelled) timer = setTimeout(() => void poll(), delay); - }; - - void poll(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; + return startPinnedRuntimeEventPump({ + pin, + label, + suppressReplay: true, + dispatch: (event) => { + const payload = decode(event.payload); + if (!payload) return; + onPayload?.(); + cb(payload); + }, + }); } function subscribeComputerUseEvents( @@ -7430,17 +7339,15 @@ contextBridge.exposeInMainWorld("ade", { }, preview: async ( args: ChatTerminalPreviewArgs = {}, - ): Promise => { - const runtime = - await callProjectRuntimeActionIfBound( - "terminal", - "preview", - { args }, - ); - return runtime.handled - ? runtime.result - : ipcRenderer.invoke(IPC.terminalPreview, args); - }, + pin?: OpenProjectBinding | null, + ): Promise => + callPinnedOrBoundRuntimeActionOr( + pin, + "terminal", + "preview", + { args }, + () => ipcRenderer.invoke(IPC.terminalPreview, args), + ), write: async (args: ChatTerminalWriteArgs): Promise<{ ok: true }> => { const runtime = await callProjectRuntimeActionIfBound<{ ok: true }>( "terminal", @@ -7549,17 +7456,15 @@ contextBridge.exposeInMainWorld("ade", { }, resumeSession: async ( args: PtyResumeSessionArgs, - ): Promise => { - const runtime = - await callProjectRuntimeActionIfBound( - "pty", - "resumeSession", - { args }, - ); - return runtime.handled - ? runtime.result - : ipcRenderer.invoke(IPC.ptyResumeSession, args); - }, + pin?: OpenProjectBinding | null, + ): Promise => + callPinnedOrBoundRuntimeActionOr( + pin, + "pty", + "resumeSession", + { args }, + () => ipcRenderer.invoke(IPC.ptyResumeSession, args), + ), sendToSession: async ( args: PtySendToSessionArgs, pin?: OpenProjectBinding | null, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 2885be5f7..5e9ca024f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -203,7 +203,8 @@ import { setLaneNaming } from "../../state/laneNamingStore"; import { buildChatAppearanceRootStyle, resolveChatContentWidthPx } from "./chatAppearance"; import { copyLaunchPromptToClipboard } from "../../lib/launchPromptClipboard"; import { - buildLaneBindingIndex, + buildChatMachineRoutingState, + collectOpenProjectBindings, createChatMachineRouter, isLivePinnedBinding, type LaneBindingSource, @@ -3320,39 +3321,21 @@ export function AgentChatPane({ const projectInfoByRoot = useAppStore((s) => s.projectInfoByRoot); const laneCacheByProject = useAppStore((s) => s.laneCacheByProject); const crossMachineLanesByMachineId = useRootAppStore((s) => s.crossMachineLanesByMachineId); - const openProjectBindings = useMemo(() => { - const bindings: OpenProjectBinding[] = []; - const seen = new Set(); - const push = (binding: OpenProjectBinding | null | undefined) => { - if (!binding || seen.has(binding.key)) return; - seen.add(binding.key); - bindings.push(binding); - }; - push(projectBinding); - for (const remote of openRemoteProjectTabs) push(remote); - for (const root of openProjectTabRoots) { - push({ - kind: "local", - key: `local:${root}`, - rootPath: root, - displayName: projectInfoByRoot[root]?.displayName ?? root, - }); - } - for (const machine of Object.values(crossMachineLanesByMachineId)) { - push(machine.binding); - } - return bindings; - }, [crossMachineLanesByMachineId, openProjectTabRoots, openRemoteProjectTabs, projectBinding, projectInfoByRoot]); + const openProjectBindings = useMemo(() => collectOpenProjectBindings({ + activeBinding: projectBinding ?? null, + remoteBindings: openRemoteProjectTabs, + localProjects: openProjectTabRoots.map((rootPath) => ({ + rootPath, + displayName: projectInfoByRoot[rootPath]?.displayName ?? rootPath, + })), + additionalBindings: Object.values(crossMachineLanesByMachineId).map((machine) => machine.binding), + }), [crossMachineLanesByMachineId, openProjectTabRoots, openRemoteProjectTabs, projectBinding, projectInfoByRoot]); const openProjectBindingsRef = useRef(openProjectBindings); openProjectBindingsRef.current = openProjectBindings; const chatMachineRouter = useMemo(() => { const stateKeyFor = (binding: OpenProjectBinding): string => binding.kind === "remote" ? binding.key : binding.rootPath; const sources: LaneBindingSource[] = []; - // The active binding's live lane list wins over any cached copy. - if (projectBinding) { - sources.push({ bindingKey: projectBinding.key, laneIds: lanes.map((lane) => lane.id) }); - } for (const binding of openProjectBindings) { const cached = laneCacheByProject[stateKeyFor(binding)]; if (!cached) continue; @@ -3363,11 +3346,12 @@ export function AgentChatPane({ if (!binding) continue; sources.push({ bindingKey: binding.key, laneIds: machine.lanes.map((lane) => lane.id) }); } - return createChatMachineRouter({ + return createChatMachineRouter(buildChatMachineRoutingState({ activeBinding: projectBinding ?? null, openBindings: openProjectBindings, - laneBindingIndex: buildLaneBindingIndex(sources), - }); + activeLaneIds: lanes.map((lane) => lane.id), + additionalLaneSources: sources, + })); }, [crossMachineLanesByMachineId, laneCacheByProject, lanes, openProjectBindings, projectBinding]); const navigate = useNavigate(); const openAiProvidersSettings = useCallback(() => { diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts index f98ad3858..d27d80414 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.test.ts @@ -597,6 +597,51 @@ describe("useLaneWorkSessions — refresh-before-focus ordering", () => { }, pin); }); + it("collapses a remembered PTY pin that matches the active project binding", async () => { + const activeBinding = { + kind: "local", + key: "local:/active/project", + rootPath: "/active/project", + displayName: "Active", + } as const; + fakeProjectBinding = activeBinding; + const { result } = renderHook(() => useLaneWorkSessions("lane-1")); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + await act(async () => { + await result.current.launchPtySession({ + laneId: "lane-1", + profile: "codex", + pin: activeBinding, + }); + }); + + await act(async () => { + await result.current.continueCliSession({ + ...makeSession("new-pty-session", "lane-1", "Active Codex"), + ptyId: "pty-1", + toolType: "codex", + } as any, "keep going locally"); + }); + expect((window as any).ade.pty.sendToSession).toHaveBeenLastCalledWith({ + sessionId: "new-pty-session", + text: "keep going locally", + cols: 100, + rows: 30, + }); + + await act(async () => { + await result.current.closePtySession("pty-1"); + await new Promise((r) => setTimeout(r, 0)); + }); + expect((window as any).ade.pty.dispose).toHaveBeenLastCalledWith({ + ptyId: "pty-1", + sessionId: "new-pty-session", + }); + }); + it("launchPtySession skips lane UI mutations when a pinned launch resolves after project switch", async () => { const pin = { kind: "local", diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 6a6442f2e..11ccec5cf 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -143,6 +143,11 @@ export function useLaneWorkSessions(laneId: string | null) { const canMutatePinnedProjectUi = useCallback((pin: WorkPtyLaunchArgs["pin"] | undefined) => ( !pin || appStore.getState().projectBinding?.key === pin.key ), [appStore]); + const collapseActiveBindingPtyPin = useCallback((pin: WorkPtyLaunchArgs["pin"]) => { + // Match useWorkMachineRouter.pinForSession: a remembered pin that became + // the active binding must stay on the unpinned path with its local fallback. + return pin?.key === appStore.getState().projectBinding?.key ? null : pin ?? null; + }, [appStore]); const currentLane = useMemo( () => (laneId ? lanes.find((lane) => lane.id === laneId) ?? null : null), @@ -776,7 +781,7 @@ export function useLaneWorkSessions(laneId: string | null) { rows: 30, ...buildPtyContinuationLaunchFields(launch), }; - const pin = workPtyLaunchPinFor(session); + const pin = collapseActiveBindingPtyPin(workPtyLaunchPinFor(session)); const result = pin ? await window.ade.pty.sendToSession(sendArgs, pin) : await window.ade.pty.sendToSession(sendArgs); @@ -789,12 +794,14 @@ export function useLaneWorkSessions(laneId: string | null) { selectLane(session.laneId); focusSession(result.sessionId); openSessionTab(result.sessionId); - }, [focusSession, openSessionTab, refresh, selectLane, upsertSessionSnapshot]); + }, [collapseActiveBindingPtyPin, focusSession, openSessionTab, refresh, selectLane, upsertSessionSnapshot]); const closePtySession = useCallback(async (ptyId: string) => { const matchedSession = sessionsRef.current.find((session) => session.ptyId === ptyId) ?? null; const sessionId = matchedSession?.id ?? null; - const pin = workPtyLaunchPinFor(matchedSession ?? { ptyId, sessionId }); + const pin = collapseActiveBindingPtyPin( + workPtyLaunchPinFor(matchedSession ?? { ptyId, sessionId }), + ); const previousSessions = sessionsRef.current.filter((session) => session.ptyId === ptyId || (sessionId != null && session.id === sessionId), ); @@ -864,7 +871,7 @@ export function useLaneWorkSessions(laneId: string | null) { await refresh({ showLoading: false, force: true }); } if (disposeError) throw disposeError; - }, [refresh]); + }, [collapseActiveBindingPtyPin, refresh]); return { lane: currentLane, diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index e36e44263..2bc6bcfff 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -883,6 +883,13 @@ export const SessionListPane = React.memo(function SessionListPane({ visibleSessionIds: string[], binding?: OpenProjectBinding | null, ) => void; + /** + * A CLI/shell row on another machine. Same in-place open as a chat — the page + * carries `binding` as a per-session runtime pin and leaves the project tab + * alone. Only when that binding is not open in this window (nothing to pin to) + * does the page fall back to rebinding the tab; deciding that needs the open + * bindings, which live on the page, not here. + */ onSelectForeignRuntimeSession?: ( session: TerminalSessionSummary, binding: OpenProjectBinding, @@ -1799,6 +1806,13 @@ export const SessionListPane = React.memo(function SessionListPane({ isSelected={selectedSessionId === session.id} isMultiSelected={selectedSessionIds?.has(session.id) ?? false} onSelect={(id, event) => { + // Both foreign branches open the row IN PLACE and carry the owning + // binding as a per-session runtime pin; neither rebinds the project + // tab. They differ only in who resolves the pin: a chat's is derived + // from its lane by the chat pane itself, while a CLI/shell session's + // has to be handed to the PTY surface, so it goes through the page's + // foreign-runtime handler (which also owns the not-open-binding + // fallback). The machine chip on the card is untouched either way. if (!foreignRow) { onSelectSession(id, event, renderedSessionIds); } else if (isChatToolType(session.toolType)) { diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 546ea429e..46c4475cd 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -3,6 +3,7 @@ import React from "react"; import { act, render, cleanup, waitFor } from "@testing-library/react"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenProjectBinding } from "../../../shared/types"; const MOCK_TERMINAL_FONT_FAMILY = vi.hoisted(() => "monospace"); @@ -225,6 +226,19 @@ function installWindowAde() { }; } +function remoteRuntimePin(id: string, rootPath: string): Extract { + return { + kind: "remote", + key: `remote:${id}`, + targetId: `target-${id}`, + runtimeName: `Runtime ${id}`, + hostname: `${id}.local`, + projectId: `project-${id}`, + rootPath, + displayName: `Machine ${id}`, + }; +} + async function flushAllTimers() { await act(async () => { await vi.runAllTimersAsync(); @@ -237,6 +251,12 @@ async function flushAnimationFrame() { }); } +async function flushInitialHydration() { + await act(async () => { + await vi.advanceTimersByTimeAsync(130); + }); +} + async function flushPromises() { await act(async () => { await Promise.resolve(); @@ -545,6 +565,297 @@ describe("TerminalView", () => { expect(mockState.ptyExitListeners.size).toBe(1); }); + it("keeps simultaneous pinned runtimes isolated across hydration, events, subscriptions, resize, and input", async () => { + const pinA = remoteRuntimePin("a", "/remote/a/project"); + const pinB = remoteRuntimePin("b", "/remote/b/project"); + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + const readTranscriptTailMock = window.ade.sessions.readTranscriptTail as unknown as ReturnType; + previewMock.mockResolvedValue({ + terminalId: "shared-pinned-session", + session: null, + source: "empty", + snapshot: null, + transcript: null, + capturedAt: new Date().toISOString(), + }); + readTranscriptTailMock.mockImplementation(async ({ sessionId }: { sessionId: string }) => ( + `hydrated ${sessionId}\n` + )); + + render( + <> + + + , + ); + await flushInitialHydration(); + + expect(window.ade.sessions.get).toHaveBeenCalledWith("session-pinned-a", pinA); + expect(window.ade.sessions.get).toHaveBeenCalledWith("session-pinned-b", pinB); + expect(previewMock).toHaveBeenCalledWith({ + terminalId: "session-pinned-a", + maxBytes: 2_000_000, + }, pinA); + expect(previewMock).toHaveBeenCalledWith({ + terminalId: "session-pinned-b", + maxBytes: 2_000_000, + }, pinB); + expect(readTranscriptTailMock).toHaveBeenCalledWith({ + sessionId: "session-pinned-a", + maxBytes: 2_000_000, + raw: true, + }, pinA); + expect(readTranscriptTailMock).toHaveBeenCalledWith({ + sessionId: "session-pinned-b", + maxBytes: 2_000_000, + raw: true, + }, pinB); + + const setDataSubscriptions = window.ade.pty.setDataSubscriptions as unknown as ReturnType; + expect(setDataSubscriptions).toHaveBeenCalledWith({ ptyIds: ["pty-shared-pinned"] }, pinA); + expect(setDataSubscriptions).toHaveBeenCalledWith({ ptyIds: ["pty-shared-pinned"] }, pinB); + + expect(window.ade.pty.resize).toHaveBeenCalledWith({ + ptyId: "pty-shared-pinned", + cols: 120, + rows: 40, + }, pinA); + expect(window.ade.pty.resize).toHaveBeenCalledWith({ + ptyId: "pty-shared-pinned", + cols: 120, + rows: 40, + }, pinB); + + const onDataMock = window.ade.pty.onData as unknown as ReturnType; + const onExitMock = window.ade.pty.onExit as unknown as ReturnType; + const dataListenerA = onDataMock.mock.calls.find(([, pin]) => pin === pinA)?.[0] as + | ((event: { ptyId: string; projectRoot?: string; data: string }) => void) + | undefined; + const dataListenerB = onDataMock.mock.calls.find(([, pin]) => pin === pinB)?.[0] as + | ((event: { ptyId: string; projectRoot?: string; data: string }) => void) + | undefined; + expect(dataListenerA).toBeTypeOf("function"); + expect(dataListenerB).toBeTypeOf("function"); + expect(onExitMock).toHaveBeenCalledWith(expect.any(Function), pinA); + expect(onExitMock).toHaveBeenCalledWith(expect.any(Function), pinB); + + const terminalA = mockState.terminalInstances[0] as { + element: HTMLElement | null; + write: ReturnType; + }; + const terminalB = mockState.terminalInstances[1] as { + element: HTMLElement | null; + write: ReturnType; + }; + terminalA.write.mockClear(); + terminalB.write.mockClear(); + dataListenerA?.({ + ptyId: "pty-shared-pinned", + projectRoot: pinA.rootPath, + data: "only machine a\n", + }); + await flushAnimationFrame(); + expect(terminalA.write).toHaveBeenCalledWith("only machine a\n"); + expect(terminalB.write).not.toHaveBeenCalled(); + + dataListenerB?.({ + ptyId: "pty-shared-pinned", + projectRoot: pinB.rootPath, + data: "only machine b\n", + }); + await flushAnimationFrame(); + expect(terminalB.write).toHaveBeenCalledWith("only machine b\n"); + expect(terminalA.write).not.toHaveBeenCalledWith("only machine b\n"); + + const ptyWrite = window.ade.pty.write as unknown as ReturnType; + ptyWrite.mockClear(); + terminalA.element?.dispatchEvent(createPasteEvent("input a")); + terminalB.element?.dispatchEvent(createPasteEvent("input b")); + await flushPasteWrite(); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-shared-pinned", + data: "input a", + }, pinA); + expect(ptyWrite).toHaveBeenCalledWith({ + ptyId: "pty-shared-pinned", + data: "input b", + }, pinB); + }); + + it("keeps every unpinned preload call on its original single-argument path", async () => { + render(); + await flushInitialHydration(); + + const terminal = mockState.terminalInstances.at(-1) as { + element: HTMLElement | null; + } | undefined; + terminal?.element?.dispatchEvent(createPasteEvent("local input")); + await flushPasteWrite(); + + const calls = [ + window.ade.sessions.get, + window.ade.terminal.preview, + window.ade.sessions.readTranscriptTail, + window.ade.pty.resize, + window.ade.pty.write, + window.ade.pty.setDataSubscriptions, + window.ade.pty.onData, + window.ade.pty.onExit, + ] as unknown as Array>; + for (const call of calls) { + expect(call).toHaveBeenCalled(); + expect(call.mock.calls.every((args) => args.length === 1)).toBe(true); + } + }); + + it("disposes the stranded runtime when a live session's pin resolves after first render", async () => { + const pin = remoteRuntimePin("late", "/remote/late/project"); + const setDataSubscriptions = window.ade.pty.setDataSubscriptions as unknown as ReturnType; + const onDataMock = window.ade.pty.onData as unknown as ReturnType; + + const view = render( + , + ); + await flushInitialHydration(); + + expect(mockState.terminalInstances).toHaveLength(1); + expect(mockState.ptyDataListeners.size).toBe(1); + expect(mockState.ptyExitListeners.size).toBe(1); + const strandedTerminal = mockState.terminalInstances[0] as { + dispose: ReturnType; + }; + + setDataSubscriptions.mockClear(); + view.rerender( + , + ); + await flushInitialHydration(); + + // The pre-pin runtime is disposed, not parked: exactly one xterm, one data + // pump and one exit pump survive for the PTY. + expect(strandedTerminal.dispose).toHaveBeenCalled(); + expect(mockState.terminalInstances).toHaveLength(2); + expect(mockState.ptyDataListeners.size).toBe(1); + expect(mockState.ptyExitListeners.size).toBe(1); + expect(onDataMock).toHaveBeenLastCalledWith(expect.any(Function), pin); + // The unpinned subscription is released before the pinned one takes over. + expect(setDataSubscriptions).toHaveBeenCalledWith({ ptyIds: [] }); + expect(setDataSubscriptions).toHaveBeenLastCalledWith({ ptyIds: ["pty-late-pin"] }, pin); + }); + + it("releases the previous pinned pump when a mounted session's runtime pin changes", async () => { + const pinA = remoteRuntimePin("swap-a", "/remote/swap-a/project"); + const pinB = remoteRuntimePin("swap-b", "/remote/swap-b/project"); + const setDataSubscriptions = window.ade.pty.setDataSubscriptions as unknown as ReturnType; + + const view = render( + , + ); + await flushInitialHydration(); + + expect(mockState.terminalInstances).toHaveLength(1); + const terminalA = mockState.terminalInstances[0] as { dispose: ReturnType }; + + setDataSubscriptions.mockClear(); + view.rerender( + , + ); + await flushInitialHydration(); + + expect(terminalA.dispose).toHaveBeenCalled(); + expect(mockState.terminalInstances).toHaveLength(2); + expect(mockState.ptyDataListeners.size).toBe(1); + expect(mockState.ptyExitListeners.size).toBe(1); + expect(setDataSubscriptions).toHaveBeenCalledWith({ ptyIds: [] }, pinA); + expect(setDataSubscriptions).toHaveBeenLastCalledWith({ ptyIds: ["pty-pin-swap"] }, pinB); + }); + + it("keeps a relocated runtime alive while another view still holds it, then sweeps it", async () => { + const pin = remoteRuntimePin("shared-hold", "/remote/shared-hold/project"); + + const view = render( + <> + + + , + ); + await flushInitialHydration(); + + // Both views share one runtime, so the second view keeps a live ref on it. + expect(mockState.terminalInstances).toHaveLength(1); + const sharedTerminal = mockState.terminalInstances[0] as { dispose: ReturnType }; + + view.rerender( + <> + + + , + ); + await flushInitialHydration(); + + expect(sharedTerminal.dispose).not.toHaveBeenCalled(); + expect(mockState.terminalInstances).toHaveLength(2); + + // Once the holder unmounts, the next mount effect sweeps the leftover. + view.rerender( + , + ); + await flushInitialHydration(); + + expect(sharedTerminal.dispose).toHaveBeenCalled(); + expect(mockState.terminalInstances).toHaveLength(2); + expect(mockState.ptyDataListeners.size).toBe(1); + expect(mockState.ptyExitListeners.size).toBe(1); + }); + + it("leaves an unpinned runtime in place when the mount effect re-runs without a key change", async () => { + const setDataSubscriptions = window.ade.pty.setDataSubscriptions as unknown as ReturnType; + const view = render( + , + ); + await flushInitialHydration(); + + expect(mockState.terminalInstances).toHaveLength(1); + const terminal = mockState.terminalInstances[0] as { dispose: ReturnType }; + + setDataSubscriptions.mockClear(); + view.rerender( + , + ); + await flushInitialHydration(); + + expect(terminal.dispose).not.toHaveBeenCalled(); + expect(mockState.terminalInstances).toHaveLength(1); + expect(mockState.ptyDataListeners.size).toBe(1); + expect(setDataSubscriptions.mock.calls.every((args) => args.length === 1)).toBe(true); + }); + it("subscribes PTY data only for visible mounted runtimes", async () => { const setDataSubscriptions = (window as any).ade.pty.setDataSubscriptions as ReturnType; const view = render(); @@ -1586,6 +1897,44 @@ describe("TerminalView", () => { }); }); + it("routes runtime clipboard attachments through the terminal session pin", async () => { + const runtimePin = remoteRuntimePin("image", "/remote/image/project"); + (window.ade.app.readClipboardImage as unknown as ReturnType).mockResolvedValue({ + data: "pinned-image-data", + filename: "clipboard.png", + mimeType: "image/png", + }); + (window.ade.agentChat.saveTempAttachment as unknown as ReturnType).mockResolvedValue({ + path: "/remote/image/project/.ade/attachments/clipboard.png", + }); + + render( + , + ); + await flushInitialHydration(); + + const terminal = mockState.terminalInstances.at(-1) as { + element: HTMLElement | null; + } | undefined; + terminal?.element?.dispatchEvent(createPasteEvent("")); + await flushPromises(); + + expect(window.ade.agentChat.saveTempAttachment).toHaveBeenCalledWith({ + data: "pinned-image-data", + filename: "clipboard.png", + }, runtimePin); + expect(window.ade.pty.write).toHaveBeenCalledWith({ + ptyId: "pty-pinned-image-paste", + data: "\x1b[200~ADE clipboard image attached.\nPath: /remote/image/project/.ade/attachments/clipboard.png\nType: image/png\n\x1b[201~", + }, runtimePin); + }); + it("sends Shift+Enter as a bracketed-paste newline only while the terminal requests bracketed paste mode", async () => { render(); await flushAllTimers(); @@ -1998,7 +2347,58 @@ describe("TerminalView", () => { expect(readTranscriptTailMock).not.toHaveBeenCalled(); }); - it("preserves snapshot cell colors when hydrating live terminals", async () => { + it("prefers serialized main-buffer snapshots so running terminals attach with scrollback", async () => { + const previewMock = window.ade.terminal.preview as unknown as ReturnType; + previewMock.mockResolvedValueOnce({ + terminalId: "session-main-scrollback", + session: null, + source: "snapshot", + snapshot: { + version: 1, + terminalId: "session-main-scrollback", + cols: 120, + rows: 2, + capturedAt: new Date().toISOString(), + status: "running", + runtimeState: "running", + bufferType: "normal", + cursorX: 0, + cursorY: 1, + baseY: 2_000, + viewportY: 2_000, + serialized: "serialized scrollback line\nserialized viewport line\n", + visibleRows: [ + { + text: "viewport-only repaint", + wrapped: false, + cells: "viewport-only repaint".split("").map((text) => ({ + text, + fg: null, + bg: null, + fgMode: "default" as const, + bgMode: "default" as const, + })), + }, + ], + }, + transcript: null, + capturedAt: new Date().toISOString(), + }); + + render(); + await flushInitialHydration(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal?.write).toHaveBeenCalledWith( + "serialized scrollback line\nserialized viewport line\n", + ); + const writes = terminal?.write.mock.calls.map(([value]) => String(value)) ?? []; + expect(writes.some((value) => value.includes("viewport-only repaint"))).toBe(false); + }); + + it("keeps alternate-buffer hydration on the viewport-only repaint with cell colors", async () => { const previewMock = window.ade.terminal.preview as unknown as ReturnType; const readTranscriptTailMock = window.ade.sessions.readTranscriptTail as unknown as ReturnType; previewMock.mockResolvedValueOnce({ diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 769b36f74..6166e9b63 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -17,6 +17,7 @@ import { installMacShiftSelectionBridge } from "./terminalMacShiftSelection"; import { openUrlInAdeBrowser } from "../../lib/openExternal"; import { peekPendingSessionAnchor, takePendingSessionAnchor } from "./pendingSessionAnchors"; import type { + OpenProjectBinding, PtyDataEvent, PtyExitEvent, TerminalSerializedSnapshot, @@ -54,6 +55,10 @@ type CachedRuntime = { key: string; ptyId: string; sessionId: string; + // Runtime routing belongs to this cached session, not to the window. Keeping + // the pin on the runtime prevents simultaneously parked terminals from + // different machines from borrowing whichever project the window opened last. + runtimePin: OpenProjectBinding | null; projectKey: string | null; projectRoot: string | null; projectRevision: number; @@ -161,14 +166,24 @@ const ptyDataRuntimesByPtyId = new Map>(); const ptyExitRuntimesByPtyId = new Map>(); let sharedPtyDataUnsub: (() => void) | null = null; let sharedPtyExitUnsub: (() => void) | null = null; +const pinnedPtyDataUnsubs = new Map void>(); +const pinnedPtyExitUnsubs = new Map void>(); +let pinnedPtyRuntimeCount = 0; let ptyDataSubscriptionSignature: string | null = null; +const pinnedPtyDataSubscriptionSignatures = new Map(); function terminalRuntimeKey(args: { sessionId: string; ptyId?: string | null; projectKey?: string | null; + runtimePin?: OpenProjectBinding | null; }): string { - return `${args.projectKey ?? ""}::${args.sessionId}::${args.ptyId ?? ""}`; + const projectRuntimeKey = `${args.projectKey ?? ""}::${args.sessionId}::${args.ptyId ?? ""}`; + // Preserve the exact local/unpinned cache key while making an explicitly + // pinned session distinct from an otherwise identical active-project view. + return args.runtimePin + ? `pin:${args.runtimePin.kind}:${args.runtimePin.key}::${projectRuntimeKey}` + : projectRuntimeKey; } let parkedRoot: HTMLDivElement | null = null; @@ -506,6 +521,21 @@ function serializeSnapshotVisibleRows(snapshot: TerminalSerializedSnapshot): str return parts.join(""); } +function serializeSnapshotForHydration(snapshot: TerminalSerializedSnapshot): string | null { + // Alternate-screen TUIs own a viewport, not a scrollback transcript. Repaint + // their structured visible rows first so replaying an older serialized main + // buffer cannot corrupt Codex/Claude's full-screen state. + if (snapshot.bufferType === "alternate") { + return serializeSnapshotVisibleRows(snapshot) || snapshot.serialized || null; + } + + // Normal/main-buffer snapshots include the persisted scrollback (bounded by + // the main process). Prefer it over the viewport-only repaint so attaching to + // a running shell starts with scrollable history; retain the structured rows + // as the fallback for legacy/empty serialized snapshots. + return snapshot.serialized || serializeSnapshotVisibleRows(snapshot); +} + function configureParkedRoot(root: HTMLDivElement): void { root.setAttribute("data-ade-terminal-parking", "true"); root.setAttribute("aria-hidden", "true"); @@ -582,7 +612,13 @@ function sendPtyResize(runtime: CachedRuntime, dims: TerminalDims): void { if (runtime.disposed) return; runtime.ptyResizeInFlight = true; runtime.inFlightPtyResizeDims = dims; - window.ade.pty.resize({ ptyId: runtime.ptyId, cols: dims.cols, rows: dims.rows }) + const resize = runtime.runtimePin + ? window.ade.pty.resize( + { ptyId: runtime.ptyId, cols: dims.cols, rows: dims.rows }, + runtime.runtimePin, + ) + : window.ade.pty.resize({ ptyId: runtime.ptyId, cols: dims.cols, rows: dims.rows }); + resize .then( () => { runtime.lastPtyResizeDims = dims; @@ -779,25 +815,105 @@ function shouldRuntimeReceivePtyData(runtime: CachedRuntime): boolean { return !runtime.disposed && runtime.refs > 0 && runtime.visible; } -function updatePtyDataSubscriptions(): void { - const ptyIds = new Set(); +function runtimePinSubscriptionKey(pin: OpenProjectBinding): string { + return `${pin.kind}:${pin.key}`; +} + +function updatePtyDataSubscriptions(removedRuntime?: CachedRuntime): void { + if ( + pinnedPtyRuntimeCount === 0 + && pinnedPtyDataSubscriptionSignatures.size === 0 + && !removedRuntime?.runtimePin + ) { + // Preserve the original unpinned subscription path: one Set, one sorted + // array, the existing signature check, and the one-argument preload call. + const ptyIds = new Set(); + for (const [ptyId, runtimes] of ptyDataRuntimesByPtyId) { + for (const runtime of runtimes) { + if (shouldRuntimeReceivePtyData(runtime)) { + ptyIds.add(ptyId); + break; + } + } + } + + const next = [...ptyIds].sort(); + const signature = next.join("\0"); + if (signature === ptyDataSubscriptionSignature) return; + ptyDataSubscriptionSignature = signature; + + const setDataSubscriptions = window.ade.pty.setDataSubscriptions; + if (typeof setDataSubscriptions !== "function") return; + setDataSubscriptions({ ptyIds: next }).catch(() => {}); + return; + } + + const unpinnedPtyIds = new Set(); + const pinnedGroups = new Map; + }>(); + let hasUnpinnedRuntime = false; + for (const [ptyId, runtimes] of ptyDataRuntimesByPtyId) { for (const runtime of runtimes) { + if (runtime.runtimePin) { + const key = runtimePinSubscriptionKey(runtime.runtimePin); + let group = pinnedGroups.get(key); + if (!group) { + group = { pin: runtime.runtimePin, ptyIds: new Set() }; + pinnedGroups.set(key, group); + } + if (shouldRuntimeReceivePtyData(runtime)) group.ptyIds.add(ptyId); + continue; + } + + hasUnpinnedRuntime = true; if (shouldRuntimeReceivePtyData(runtime)) { - ptyIds.add(ptyId); - break; + unpinnedPtyIds.add(ptyId); } } } - const next = [...ptyIds].sort(); - const signature = next.join("\0"); - if (signature === ptyDataSubscriptionSignature) return; - ptyDataSubscriptionSignature = signature; - const setDataSubscriptions = window.ade.pty.setDataSubscriptions; - if (typeof setDataSubscriptions !== "function") return; - setDataSubscriptions({ ptyIds: next }).catch(() => {}); + if (hasUnpinnedRuntime || ptyDataSubscriptionSignature != null) { + const next = [...unpinnedPtyIds].sort(); + const signature = next.join("\0"); + if (signature !== ptyDataSubscriptionSignature) { + ptyDataSubscriptionSignature = signature; + if (typeof setDataSubscriptions === "function") { + // The overwhelmingly common local path intentionally retains the + // original one-argument call shape and allocation count. + setDataSubscriptions({ ptyIds: next }).catch(() => {}); + } + } + } + + for (const [key, group] of pinnedGroups) { + const next = [...group.ptyIds].sort(); + const signature = next.join("\0"); + if (signature === pinnedPtyDataSubscriptionSignatures.get(key)) continue; + pinnedPtyDataSubscriptionSignatures.set(key, signature); + if (typeof setDataSubscriptions === "function") { + setDataSubscriptions({ ptyIds: next }, group.pin).catch(() => {}); + } + } + + // A removed runtime is the authoritative source for its pin even after the + // runtime leaves the shared PTY map. Clear that machine's subscription + // without retaining a module-global binding that another session could reuse. + if (removedRuntime?.runtimePin) { + const key = runtimePinSubscriptionKey(removedRuntime.runtimePin); + if ( + !pinnedGroups.has(key) + && pinnedPtyDataSubscriptionSignatures.has(key) + ) { + pinnedPtyDataSubscriptionSignatures.delete(key); + if (typeof setDataSubscriptions === "function") { + setDataSubscriptions({ ptyIds: [] }, removedRuntime.runtimePin).catch(() => {}); + } + } + } } function clearRuntimeHydrationTimers(runtime: CachedRuntime): void { @@ -863,6 +979,10 @@ function clearPtyInputFlushTimer(runtime: CachedRuntime): void { function writePtyInputNow(runtime: CachedRuntime, data: string) { if (!data || runtime.disposed) return; + if (runtime.runtimePin) { + window.ade.pty.write({ ptyId: runtime.ptyId, data }, runtime.runtimePin).catch(() => {}); + return; + } window.ade.pty.write({ ptyId: runtime.ptyId, data }).catch(() => {}); } @@ -1054,10 +1174,13 @@ async function pasteRuntimeClipboardImageAttachment(runtime: CachedRuntime): Pro try { const image = await window.ade.app.readClipboardImage(); if (!image || runtime.disposed) return false; - const saved = await window.ade.agentChat.saveTempAttachment({ + const attachmentArgs = { data: image.data, filename: image.filename || "clipboard.png", - }); + }; + const saved = runtime.runtimePin + ? await window.ade.agentChat.saveTempAttachment(attachmentArgs, runtime.runtimePin) + : await window.ade.agentChat.saveTempAttachment(attachmentArgs); if (runtime.disposed) return false; writePtyInput(runtime, bracketedPaste(formatClipboardImageForPty(saved.path, image.mimeType))); return true; @@ -1469,6 +1592,43 @@ function removeRuntimePtySubscription( if (runtimes.size === 0) map.delete(runtime.ptyId); } +function hasRuntimeForSubscriptionKey( + map: Map>, + subscriptionKey: string | null, +): boolean { + for (const runtimes of map.values()) { + for (const runtime of runtimes) { + const runtimeKey = runtime.runtimePin + ? runtimePinSubscriptionKey(runtime.runtimePin) + : null; + if (runtimeKey === subscriptionKey) return true; + } + } + return false; +} + +function dispatchPtyDataEvent(ev: PtyDataEvent, subscriptionKey: string | null): void { + const targets = ptyDataRuntimesByPtyId.get(ev.ptyId); + if (!targets) return; + for (const target of [...targets]) { + const targetKey = target.runtimePin + ? runtimePinSubscriptionKey(target.runtimePin) + : null; + if (targetKey === subscriptionKey) handleRuntimePtyData(target, ev); + } +} + +function dispatchPtyExitEvent(ev: PtyExitEvent, subscriptionKey: string | null): void { + const targets = ptyExitRuntimesByPtyId.get(ev.ptyId); + if (!targets) return; + for (const target of [...targets]) { + const targetKey = target.runtimePin + ? runtimePinSubscriptionKey(target.runtimePin) + : null; + if (targetKey === subscriptionKey) handleRuntimePtyExit(target, ev); + } +} + function subscribeRuntimePtyData(runtime: CachedRuntime): () => void { let runtimes = ptyDataRuntimesByPtyId.get(runtime.ptyId); if (!runtimes) { @@ -1476,18 +1636,35 @@ function subscribeRuntimePtyData(runtime: CachedRuntime): () => void { ptyDataRuntimesByPtyId.set(runtime.ptyId, runtimes); } runtimes.add(runtime); + if (runtime.runtimePin) pinnedPtyRuntimeCount += 1; updatePtyDataSubscriptions(); - if (!sharedPtyDataUnsub) { - sharedPtyDataUnsub = window.ade.pty.onData((ev) => { - const targets = ptyDataRuntimesByPtyId.get(ev.ptyId); - if (!targets) return; - for (const target of [...targets]) handleRuntimePtyData(target, ev); - }); + const runtimePin = runtime.runtimePin; + const subscriptionKey = runtimePin + ? runtimePinSubscriptionKey(runtimePin) + : null; + if (runtimePin) { + const pinnedSubscriptionKey = runtimePinSubscriptionKey(runtimePin); + if (!pinnedPtyDataUnsubs.has(pinnedSubscriptionKey)) { + pinnedPtyDataUnsubs.set( + pinnedSubscriptionKey, + window.ade.pty.onData( + (ev) => dispatchPtyDataEvent(ev, pinnedSubscriptionKey), + runtimePin, + ), + ); + } + } else if (!sharedPtyDataUnsub) { + // Keep the original active-project listener and call arity untouched. + sharedPtyDataUnsub = window.ade.pty.onData((ev) => dispatchPtyDataEvent(ev, null)); } return () => { removeRuntimePtySubscription(ptyDataRuntimesByPtyId, runtime); - updatePtyDataSubscriptions(); - if (ptyDataRuntimesByPtyId.size === 0 && sharedPtyDataUnsub) { + if (runtime.runtimePin) pinnedPtyRuntimeCount = Math.max(0, pinnedPtyRuntimeCount - 1); + updatePtyDataSubscriptions(runtime); + if (subscriptionKey && !hasRuntimeForSubscriptionKey(ptyDataRuntimesByPtyId, subscriptionKey)) { + pinnedPtyDataUnsubs.get(subscriptionKey)?.(); + pinnedPtyDataUnsubs.delete(subscriptionKey); + } else if (!subscriptionKey && !hasRuntimeForSubscriptionKey(ptyDataRuntimesByPtyId, null) && sharedPtyDataUnsub) { ptyDataSubscriptionSignature = null; sharedPtyDataUnsub(); sharedPtyDataUnsub = null; @@ -1502,16 +1679,31 @@ function subscribeRuntimePtyExit(runtime: CachedRuntime): () => void { ptyExitRuntimesByPtyId.set(runtime.ptyId, runtimes); } runtimes.add(runtime); - if (!sharedPtyExitUnsub) { - sharedPtyExitUnsub = window.ade.pty.onExit((ev) => { - const targets = ptyExitRuntimesByPtyId.get(ev.ptyId); - if (!targets) return; - for (const target of [...targets]) handleRuntimePtyExit(target, ev); - }); + const runtimePin = runtime.runtimePin; + const subscriptionKey = runtimePin + ? runtimePinSubscriptionKey(runtimePin) + : null; + if (runtimePin) { + const pinnedSubscriptionKey = runtimePinSubscriptionKey(runtimePin); + if (!pinnedPtyExitUnsubs.has(pinnedSubscriptionKey)) { + pinnedPtyExitUnsubs.set( + pinnedSubscriptionKey, + window.ade.pty.onExit( + (ev) => dispatchPtyExitEvent(ev, pinnedSubscriptionKey), + runtimePin, + ), + ); + } + } else if (!sharedPtyExitUnsub) { + // Keep the original active-project listener and call arity untouched. + sharedPtyExitUnsub = window.ade.pty.onExit((ev) => dispatchPtyExitEvent(ev, null)); } return () => { removeRuntimePtySubscription(ptyExitRuntimesByPtyId, runtime); - if (ptyExitRuntimesByPtyId.size === 0 && sharedPtyExitUnsub) { + if (subscriptionKey && !hasRuntimeForSubscriptionKey(ptyExitRuntimesByPtyId, subscriptionKey)) { + pinnedPtyExitUnsubs.get(subscriptionKey)?.(); + pinnedPtyExitUnsubs.delete(subscriptionKey); + } else if (!subscriptionKey && !hasRuntimeForSubscriptionKey(ptyExitRuntimesByPtyId, null) && sharedPtyExitUnsub) { sharedPtyExitUnsub(); sharedPtyExitUnsub = null; } @@ -1569,14 +1761,18 @@ async function readPreviewHydrationData( runtime: CachedRuntime, options: PreviewHydrationOptions = {}, ): Promise { - const preview = await window.ade.terminal.preview({ - terminalId: runtime.sessionId, - maxBytes: HYDRATE_TAIL_BYTES, - }); + const preview = runtime.runtimePin + ? await window.ade.terminal.preview({ + terminalId: runtime.sessionId, + maxBytes: HYDRATE_TAIL_BYTES, + }, runtime.runtimePin) + : await window.ade.terminal.preview({ + terminalId: runtime.sessionId, + maxBytes: HYDRATE_TAIL_BYTES, + }); if (preview?.snapshot) { - const visibleRows = serializeSnapshotVisibleRows(preview.snapshot); - if (visibleRows) return { source: "snapshot", text: visibleRows }; - if (preview.snapshot.serialized) return { source: "snapshot", text: preview.snapshot.serialized }; + const snapshot = serializeSnapshotForHydration(preview.snapshot); + if (snapshot) return { source: "snapshot", text: snapshot }; } if (options.snapshotOnly) return { source: "empty", text: "" }; if (preview?.transcript) return { source: "transcript", text: preview.transcript }; @@ -1586,11 +1782,14 @@ async function readPreviewHydrationData( async function readTerminalInputModeRefreshData(runtime: CachedRuntime): Promise { let transcript = ""; try { - transcript = await window.ade.sessions.readTranscriptTail({ + const args = { sessionId: runtime.sessionId, maxBytes: HYDRATE_TAIL_BYTES, raw: true, - }) || ""; + }; + transcript = (runtime.runtimePin + ? await window.ade.sessions.readTranscriptTail(args, runtime.runtimePin) + : await window.ade.sessions.readTranscriptTail(args)) || ""; if (hasTerminalPrivateModeSequence(transcript, TERMINAL_BRACKETED_PASTE_MODE)) { return transcript; } @@ -1599,10 +1798,15 @@ async function readTerminalInputModeRefreshData(runtime: CachedRuntime): Promise } try { - const preview = await window.ade.terminal.preview({ - terminalId: runtime.sessionId, - maxBytes: HYDRATE_TAIL_BYTES, - }); + const preview = runtime.runtimePin + ? await window.ade.terminal.preview({ + terminalId: runtime.sessionId, + maxBytes: HYDRATE_TAIL_BYTES, + }, runtime.runtimePin) + : await window.ade.terminal.preview({ + terminalId: runtime.sessionId, + maxBytes: HYDRATE_TAIL_BYTES, + }); const snapshot = preview?.snapshot?.serialized ?? ""; const previewTranscript = preview?.transcript ?? ""; return `${snapshot}${previewTranscript}${transcript}`; @@ -1614,11 +1818,14 @@ async function readTerminalInputModeRefreshData(runtime: CachedRuntime): Promise async function readReplayHydrationData(runtime: CachedRuntime): Promise { // Use sessions.readTranscriptTail (not terminal.read) so this works for chat-CLI // tool types — terminal.read/preview throws for isPersistedChatToolType sessions. - const data = await window.ade.sessions.readTranscriptTail({ + const args = { sessionId: runtime.sessionId, maxBytes: REPLAY_TRANSCRIPT_MAX_BYTES, raw: true, - }); + }; + const data = runtime.runtimePin + ? await window.ade.sessions.readTranscriptTail(args, runtime.runtimePin) + : await window.ade.sessions.readTranscriptTail(args); if (!data) return { source: "empty", text: "" }; return { source: "replay", text: stripFullScreenRedrawSequences(data) }; } @@ -1649,7 +1856,9 @@ async function readInitialHydrationData(runtime: CachedRuntime): Promise 0`) are left alone: + * they are not garbage, and that view's own key change sweeps them once it + * drops the last ref. + */ +function teardownRelocatedRuntimes(key: string, sessionId: string, ptyId: string): void { + for (const runtime of runtimeCache.values()) { + if (runtime.key === key || runtime.disposed) continue; + if (runtime.sessionId !== sessionId || runtime.ptyId !== ptyId) continue; + if (runtime.refs > 0) continue; + // teardownRuntime handles a parked host (it removes the host from the + // parking root) and clears any pending dispose timer. + teardownRuntime(runtime); + } +} + function ensureRuntime(args: { ptyId: string; sessionId: string; + runtimePin: OpenProjectBinding | null; projectKey: string | null; projectRoot: string | null; projectRevision: number; @@ -2199,14 +2454,23 @@ function ensureRuntime(args: { imagePasteMode: TerminalImagePasteMode; }): CachedRuntime { const key = terminalRuntimeKey(args); + // Only walks the handful of cached runtimes, and only from the mount effect + // (not per render). Runs on the reuse path too so a runtime stranded while a + // second view still held it gets swept once that view lets go. + teardownRelocatedRuntimes(key, args.sessionId, args.ptyId); const existing = runtimeCache.get(key); if (existing && !existing.disposed) { if ( existing.ptyId === args.ptyId + && existing.runtimePin?.kind === args.runtimePin?.kind + && existing.runtimePin?.key === args.runtimePin?.key && existing.projectKey === args.projectKey && existing.projectRoot === args.projectRoot ) { clearDisposeTimer(existing); + // Refresh same-session binding metadata without moving the pin outside + // the runtime record; callers may rebuild an equivalent binding object. + existing.runtimePin = args.runtimePin; existing.projectRevision = args.projectRevision; existing.imagePasteMode = args.imagePasteMode; applyRuntimeVisualOptions(existing, { @@ -2242,12 +2506,15 @@ export function __resetTerminalRuntimesForTests(): void { teardownRuntime(runtime); } runtimeCache.clear(); + pinnedPtyRuntimeCount = 0; ptyDataSubscriptionSignature = null; + pinnedPtyDataSubscriptionSignatures.clear(); } export function TerminalView({ ptyId, sessionId, + runtimePin, className, isActive, isVisible = isActive, @@ -2255,6 +2522,7 @@ export function TerminalView({ }: { ptyId: string; sessionId: string; + runtimePin?: OpenProjectBinding | null; className?: string; isActive: boolean; isVisible?: boolean; @@ -2265,8 +2533,13 @@ export function TerminalView({ const projectRoot = useAppStore(selectActiveProjectRoot); const projectKey = useAppStore(selectActiveProjectStateKey); const projectRevision = useAppStore((s) => s.projectRevision); + const runtimePinIdentity = runtimePin + ? runtimePinSubscriptionKey(runtimePin) + : null; const runtimeProjectScopeRef = useRef<{ sessionId: string; + runtimePinIdentity: string | null; + runtimePin: OpenProjectBinding | null; projectKey: string | null; projectRoot: string | null; projectRevision: number; @@ -2274,15 +2547,19 @@ export function TerminalView({ if ( !runtimeProjectScopeRef.current || runtimeProjectScopeRef.current.sessionId !== sessionId + || runtimeProjectScopeRef.current.runtimePinIdentity !== runtimePinIdentity || (runtimeProjectScopeRef.current.projectKey == null && projectKey != null) ) { runtimeProjectScopeRef.current = { sessionId, - projectKey, - projectRoot, + runtimePinIdentity, + runtimePin: runtimePin ?? null, + projectKey: runtimePin?.key ?? projectKey, + projectRoot: runtimePin?.rootPath ?? projectRoot, projectRevision, }; } + const runtimeRuntimePin = runtimeProjectScopeRef.current.runtimePin; const runtimeProjectKey = runtimeProjectScopeRef.current.projectKey; const runtimeProjectRoot = runtimeProjectScopeRef.current.projectRoot; const runtimeProjectRevision = runtimeProjectScopeRef.current.projectRevision; @@ -2314,6 +2591,7 @@ export function TerminalView({ const runtime = ensureRuntime({ ptyId, sessionId, + runtimePin: runtimeRuntimePin, projectKey: runtimeProjectKey, projectRoot: runtimeProjectRoot, projectRevision: runtimeProjectRevision, @@ -2526,7 +2804,7 @@ export function TerminalView({ scheduleRuntimeDispose(runtime, EXITED_RUNTIME_KEEPALIVE_MS); } }; - }, [imagePasteMode, runtimeProjectRevision, runtimeProjectRoot, ptyId, sessionId]); + }, [imagePasteMode, runtimePinIdentity, runtimeProjectRevision, runtimeProjectRoot, ptyId, sessionId]); useEffect(() => { const runtime = runtimeRef.current; diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index 3a8abf8e0..b91f1492c 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -12,6 +12,11 @@ import type { } from "../../../shared/types"; import type { AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import { TerminalsPage } from "./TerminalsPage"; +import { + forgetWorkPtyLaunchPin, + rememberWorkPtyLaunchPin, + workPtyLaunchPinFor, +} from "./cliLaunch"; const crossMachineMocks = vi.hoisted(() => ({ cancelOptimistic: vi.fn(), @@ -118,6 +123,7 @@ const workMocks = vi.hoisted(() => { filtered: [], sessionsGroupedByLane: [], loading: false, + canPruneSessionIndex: () => true, gridLayoutId: "work-grid", gridSets: [], setGridSets: vi.fn(), @@ -167,6 +173,20 @@ const workMocks = vi.hoisted(() => { projectRoot: null as string | null, projectBinding: null as OpenProjectBinding | null, handoffLaunchJobsByScope: {} as Record, + /** Bindings this window has open besides the active one (per-session pin targets). */ + openRemoteProjectTabs: [] as OpenProjectBinding[], + /** Cross-machine union slices — lane ownership for `useWorkMachineRouter`. */ + crossMachineLanesByMachineId: {} as Record, + crossMachineLaneIntendedMachineIds: null as string[] | null, fns, makeTerminalSession, }; @@ -175,6 +195,7 @@ const workMocks = vi.hoisted(() => { const sidebarProps = vi.hoisted(() => ({ latest: null as null | { laneId: string | null; + activeSession: TerminalSessionSummary | null; contextTarget: unknown; contextDisabledReason: string | null; }, @@ -206,6 +227,12 @@ const sessionListPaneProps = vi.hoisted(() => ({ latest: null as null | MockSessionListPaneProps, })); +const workViewAreaProps = vi.hoisted(() => ({ + latest: null as null | { + resolveSessionRuntimePin?: (session: TerminalSessionSummary) => OpenProjectBinding | null; + }, +})); + vi.mock("../../state/appStore", () => ({ selectActiveProjectRoot: (state: { projectBinding?: { kind?: string; rootPath?: string | null } | null; @@ -231,11 +258,17 @@ vi.mock("../../state/appStore", () => ({ selectLane: typeof workMocks.fns.selectLane; focusSession: typeof workMocks.fns.focusSession; setWorkViewState: typeof workMocks.fns.setWorkViewState; + lanes: LaneSummary[]; + openRemoteProjectTabs: OpenProjectBinding[]; + openProjectTabRoots: string[]; }) => T): T => selector({ selectedLaneId: "lane-primary", laneDeleteProgressByLaneId: {}, projectBinding: workMocks.projectBinding, + lanes: workMocks.currentWork.lanes, + openRemoteProjectTabs: workMocks.openRemoteProjectTabs, + openProjectTabRoots: [], switchRemoteProject: workMocks.fns.switchRemoteProject, switchProjectToPath: workMocks.fns.switchProjectToPath, selectLane: workMocks.fns.selectLane, @@ -247,15 +280,43 @@ vi.mock("../../state/appStore", () => ({ }), useRootAppStore: (selector: (state: { handoffLaunchJobsByScope: typeof workMocks.handoffLaunchJobsByScope; + crossMachineLanesByMachineId: typeof workMocks.crossMachineLanesByMachineId; + crossMachineLaneIntendedMachineIds: typeof workMocks.crossMachineLaneIntendedMachineIds; }) => T): T => selector({ handoffLaunchJobsByScope: workMocks.handoffLaunchJobsByScope, + crossMachineLanesByMachineId: workMocks.crossMachineLanesByMachineId, + crossMachineLaneIntendedMachineIds: workMocks.crossMachineLaneIntendedMachineIds, }), })); -vi.mock("./useWorkSessions", () => ({ - useWorkSessions: () => workMocks.currentWork, -})); +vi.mock("./useWorkSessions", async () => { + const { + useRetainedCrossMachineSlices, + useWorkMachineRouter, + } = await vi.importActual( + "./useWorkMachineRouter", + ); + return { + useWorkSessions: () => { + const retainedCrossMachineSlices = useRetainedCrossMachineSlices(); + const machineRouter = useWorkMachineRouter(retainedCrossMachineSlices); + const sessionsById = workMocks.currentWork.sessionsById + ?? new Map([ + ...workMocks.currentWork.sessions.map((session: TerminalSessionSummary) => [session.id, session] as const), + ...retainedCrossMachineSlices + .flatMap((machine) => machine.sessions) + .map((session) => [session.id, session] as const), + ]); + return { + ...workMocks.currentWork, + sessionsById, + machineRouter, + resolveSessionRuntimePin: machineRouter.pinForSession, + }; + }, + }; +}); vi.mock("./useWorkLaneDeleteProgress", () => ({ useWorkLaneDeleteProgress: () => undefined, @@ -318,6 +379,7 @@ vi.mock("./SessionListPane", () => ({ vi.mock("./WorkSidebar", () => ({ WorkSidebar: (props: { laneId: string | null; + activeSession: TerminalSessionSummary | null; contextTarget: unknown; contextDisabledReason: string | null; }) => { @@ -389,7 +451,10 @@ vi.mock("./WorkViewArea", () => ({ onToggleTerminalPane?: () => void; onOpenTerminalPane?: () => void; terminalPaneOpen?: boolean; - }) => ( + resolveSessionRuntimePin?: (session: TerminalSessionSummary) => OpenProjectBinding | null; + }) => { + workViewAreaProps.latest = props; + return (
- ), + ); + }, })); describe("TerminalsPage chat session activation", () => { @@ -446,8 +512,14 @@ describe("TerminalsPage chat session activation", () => { workMocks.projectRoot = null; workMocks.projectBinding = null; workMocks.handoffLaunchJobsByScope = {}; + workMocks.openRemoteProjectTabs = []; + workMocks.crossMachineLanesByMachineId = {}; + workMocks.crossMachineLaneIntendedMachineIds = null; sidebarProps.latest = null; sessionListPaneProps.latest = null; + workViewAreaProps.latest = null; + forgetWorkPtyLaunchPin({ sessionId: "shell-foreign", ptyId: "pty-shell-foreign" }); + forgetWorkPtyLaunchPin({ sessionId: "shell-now-active", ptyId: "pty-shell-now-active" }); vi.clearAllMocks(); }); @@ -518,7 +590,210 @@ describe("TerminalsPage chat session activation", () => { ); }); - it("switches to a foreign shell's owning project before selecting it", async () => { + it("opens a foreign CLI session in place, pinned, without switching projects", async () => { + const studioBinding: OpenProjectBinding = { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/remote/repo-a", + displayName: "repo-a", + }; + const session = workMocks.makeTerminalSession("shell-foreign", "lane-foreign", "shell"); + // The owning machine is open in this window, so it is a live pin target. + workMocks.openRemoteProjectTabs = [studioBinding]; + workMocks.crossMachineLanesByMachineId = { + "target-studio": { + machineId: "target-studio", + machineName: "Mac Studio", + targetId: "target-studio", + projectId: "project-a", + binding: studioBinding, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-foreign" }], + sessions: [session], + online: true, + }, + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + render(); + await screen.findByTestId("session-list-pane"); + + const event = { shiftKey: false, metaKey: false, ctrlKey: false } as React.MouseEvent; + act(() => { + sessionListPaneProps.latest?.onSelectForeignRuntimeSession?.( + session, + studioBinding, + event, + [session.id], + ); + }); + + // The whole point: the project tab (Lanes/PRs/Files) never moves. + expect(workMocks.fns.switchRemoteProject).not.toHaveBeenCalled(); + expect(workMocks.fns.switchProjectToPath).not.toHaveBeenCalled(); + expect(workMocks.fns.setWorkViewState).not.toHaveBeenCalled(); + // Selected/opened in the CURRENT view state, exactly like a chat. + expect(workMocks.currentWork.setSelectedSessionId).toHaveBeenCalledWith("shell-foreign"); + expect(workMocks.currentWork.openSessionTab).toHaveBeenCalledWith("shell-foreign"); + // And its runtime calls carry the owning machine as a per-session pin. + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(session)).toEqual(studioBinding); + }); + + it("keeps B's runtime pin through an A-before-B scope refill without a launch-registry entry", async () => { + const studioBinding: OpenProjectBinding = { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/remote/repo-a", + displayName: "repo-a", + }; + const session = workMocks.makeTerminalSession("shell-foreign", "lane-foreign", "shell"); + const machineABinding: OpenProjectBinding = { + kind: "remote", + key: "remote:target-a:project-a", + targetId: "target-a", + runtimeName: "Machine A", + projectId: "project-a", + rootPath: "/remote/repo-a-copy", + displayName: "repo-a-copy", + }; + const machineASession = workMocks.makeTerminalSession("shell-a", "lane-a", "shell"); + // This binding is discoverable only through the replace-on-refresh + // cross-machine scope, matching the production flap. + workMocks.openRemoteProjectTabs = []; + workMocks.crossMachineLaneIntendedMachineIds = ["target-a", "target-studio"]; + workMocks.crossMachineLanesByMachineId = { + "target-a": { + machineId: "target-a", + machineName: "Machine A", + targetId: "target-a", + projectId: "project-a", + binding: machineABinding, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-a" }], + sessions: [machineASession], + online: true, + }, + "target-studio": { + machineId: "target-studio", + machineName: "Mac Studio", + targetId: "target-studio", + projectId: "project-a", + binding: studioBinding, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-foreign" }], + sessions: [session], + online: true, + }, + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + const rendered = render(); + await screen.findByTestId("session-list-pane"); + + expect(workPtyLaunchPinFor(session)).toBeNull(); + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(session)?.key).toBe(studioBinding.key); + + workMocks.crossMachineLanesByMachineId = {}; + rendered.rerender(); + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(session)?.key).toBe(studioBinding.key); + + // A arrives first. B's retained complete slice still owns the restored + // terminal's session and lane indexes, so hydration/input cannot fall back + // to the active machine while B remains intended. + workMocks.crossMachineLanesByMachineId = { + "target-a": { + machineId: "target-a", + machineName: "Machine A", + targetId: "target-a", + projectId: "project-a", + binding: machineABinding, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-a" }], + sessions: [{ ...machineASession, lastOutputPreview: "fresh-a" }], + online: true, + }, + }; + rendered.rerender(); + + expect(workPtyLaunchPinFor(session)).toBeNull(); + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(session)?.key).toBe(studioBinding.key); + }); + + it("leaves a session on the tab's own machine unpinned", async () => { + workMocks.projectRoot = "/repo"; + workMocks.projectBinding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "repo", + }; + // A foreign machine is present, so the lane index is non-empty and the null + // below is a real "this lane is on the active binding", not an empty map. + workMocks.crossMachineLanesByMachineId = { + "target-studio": { + machineId: "target-studio", + machineName: "Mac Studio", + targetId: "target-studio", + projectId: "project-a", + binding: { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/remote/repo-a", + displayName: "repo-a", + }, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-foreign" }], + sessions: [], + online: true, + }, + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + render(); + await screen.findByTestId("session-list-pane"); + + const local = workMocks.makeTerminalSession("shell-local", "lane-primary", "shell"); + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(local)).toBeNull(); + }); + + it("drops a remembered pin when that binding is now active", async () => { + const activeBinding: OpenProjectBinding = { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/remote/repo-a", + displayName: "repo-a", + }; + const session = workMocks.makeTerminalSession("shell-now-active", "lane-primary", "shell"); + // This pin was remembered while the same binding was foreign. After the + // project tab rebinds to it, lane routing returns null and the registry is + // the fallback that must also collapse to the unpinned fast path. + rememberWorkPtyLaunchPin(session, activeBinding); + workMocks.projectBinding = activeBinding; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + + render(); + await screen.findByTestId("session-list-pane"); + + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(session)).toBeNull(); + }); + + it("falls back to switching projects when the foreign shell's binding is not open", async () => { let resolveSwitch!: () => void; workMocks.fns.switchRemoteProject.mockImplementationOnce( () => new Promise((resolve) => { @@ -662,6 +937,52 @@ describe("TerminalsPage chat session activation", () => { expect(workMocks.currentWork.setSelectedSessionId).toHaveBeenCalledWith("chat-spawned-child"); }); + it("opens a bindingless foreign select-session target from the union without selecting a local lane", async () => { + const binding: OpenProjectBinding = { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/remote/repo-a", + displayName: "repo-a", + }; + const foreign = workMocks.makeTerminalSession("chat-foreign-child", "lane-foreign", "codex-chat"); + workMocks.crossMachineLanesByMachineId = { + "target-studio": { + machineId: "target-studio", + machineName: "Mac Studio", + targetId: "target-studio", + projectId: "project-a", + binding, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-foreign" }], + sessions: [foreign], + online: true, + }, + }; + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [], + sessionsById: new Map([[foreign.id, foreign]]), + closingPtyIds: new Set(), + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + + render(); + await screen.findByTestId("work-view-area"); + window.dispatchEvent(new CustomEvent("ade:work:select-session", { + detail: { sessionId: foreign.id }, + })); + + expect(workMocks.fns.selectLane).not.toHaveBeenCalled(); + expect(workMocks.fns.focusSession).toHaveBeenCalledWith(foreign.id); + expect(workMocks.fns.openSessionTab).toHaveBeenCalledWith(foreign.id); + expect(workMocks.currentWork.setSelectedSessionId).toHaveBeenCalledWith(foreign.id); + }); + it("writes a foreign select-session event into the destination project state", async () => { Object.defineProperty(window, "ade", { configurable: true, @@ -893,6 +1214,245 @@ describe("TerminalsPage chat session activation", () => { expect(sidebarProps.latest?.contextDisabledReason).toBeNull(); }); + it("resolves a foreign active session from the union and disables sidebar context insertion", async () => { + const studioBinding: OpenProjectBinding = { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/remote/repo-a", + displayName: "repo-a", + }; + const foreignSession = workMocks.makeTerminalSession("term-studio", "lane-studio", "codex"); + workMocks.crossMachineLanesByMachineId = { + "target-studio": { + machineId: "target-studio", + machineName: "Mac Studio", + targetId: "target-studio", + projectId: "project-a", + binding: studioBinding, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-studio" }], + sessions: [foreignSession], + online: true, + }, + }; + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [], + sessionsById: new Map([[foreignSession.id, foreignSession]]), + visibleSessions: [foreignSession], + activeItemId: foreignSession.id, + workSidebarOpen: true, + closingPtyIds: new Set(), + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + + render(); + + expect(await screen.findByTestId("work-sidebar")).toBeTruthy(); + expect(sidebarProps.latest?.activeSession).toBe(foreignSession); + expect(sidebarProps.latest).toEqual(expect.objectContaining({ + contextTarget: null, + contextDisabledReason: "Tool context insertion is not available for sessions on another machine.", + })); + }); + + it("keeps a foreign grid member through an active-machine-only refresh", async () => { + const localSession = workMocks.makeTerminalSession("term-local", "lane-primary", "codex"); + const foreignSession = workMocks.makeTerminalSession("term-foreign", "lane-foreign", "codex"); + const gridSets = [{ + id: "grid-1", + layoutId: "layout-grid-1", + sessionIds: [localSession.id, foreignSession.id], + }]; + const setGridSets = vi.fn(); + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [localSession], + sessionsById: new Map([ + [localSession.id, localSession], + [foreignSession.id, foreignSession], + ]), + gridSets, + setGridSets, + closingPtyIds: new Set(), + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + const rendered = render(); + await waitFor(() => expect(setGridSets).toHaveBeenCalled()); + + setGridSets.mockClear(); + const refreshedLocal = { ...localSession, lastOutputPreview: "refreshed" }; + workMocks.currentWork = { + ...workMocks.currentWork, + sessions: [refreshedLocal], + sessionsById: new Map([ + [refreshedLocal.id, refreshedLocal], + [foreignSession.id, foreignSession], + ]), + }; + rendered.rerender(); + + await waitFor(() => expect(setGridSets).toHaveBeenCalledTimes(1)); + const update = setGridSets.mock.calls[0]?.[0]; + expect(typeof update).toBe("function"); + expect(update(gridSets)).toBe(gridSets); + }); + + it("prunes a foreign grid member missing from its present machine slice", async () => { + const studioBinding: OpenProjectBinding = { + kind: "remote", + key: "remote:target-studio:project-a", + targetId: "target-studio", + runtimeName: "Mac Studio", + projectId: "project-a", + rootPath: "/remote/repo-a", + displayName: "repo-a", + }; + const localSession = workMocks.makeTerminalSession("term-local", "lane-primary", "codex"); + const foreignSession = workMocks.makeTerminalSession("term-foreign", "lane-foreign", "codex"); + const gridSets = [{ + id: "grid-1", + layoutId: "layout-grid-1", + sessionIds: [localSession.id, foreignSession.id], + }]; + const setGridSets = vi.fn(); + const foreignMachine = { + machineId: "target-studio", + machineName: "Mac Studio", + targetId: "target-studio", + projectId: "project-a", + binding: studioBinding, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-foreign" }], + sessions: [foreignSession], + online: true, + }; + workMocks.crossMachineLanesByMachineId = { "target-studio": foreignMachine }; + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [localSession], + sessionsById: new Map([ + [localSession.id, localSession], + [foreignSession.id, foreignSession], + ]), + gridSets, + setGridSets, + closingPtyIds: new Set(), + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + const rendered = render(); + await waitFor(() => expect(setGridSets).toHaveBeenCalled()); + + setGridSets.mockClear(); + workMocks.crossMachineLanesByMachineId = { + "target-studio": { ...foreignMachine, sessions: [] }, + }; + workMocks.currentWork = { + ...workMocks.currentWork, + sessionsById: new Map([[localSession.id, localSession]]), + }; + rendered.rerender(); + + await waitFor(() => expect(setGridSets).toHaveBeenCalledTimes(1)); + const update = setGridSets.mock.calls[0]?.[0]; + expect(typeof update).toBe("function"); + expect(update(gridSets)).toEqual([]); + }); + + it("prunes a retained grid and runtime pin when scope intent removes a pending machine", async () => { + const bindingA: OpenProjectBinding = { + kind: "remote", + key: "remote:target-a:project-a", + targetId: "target-a", + runtimeName: "Machine A", + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + }; + const bindingB: OpenProjectBinding = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + }; + const sessionA = workMocks.makeTerminalSession("term-a", "lane-a", "codex"); + const sessionB = workMocks.makeTerminalSession("term-b", "lane-b", "codex"); + const machineA = { + machineId: "target-a", + machineName: "Machine A", + targetId: "target-a", + projectId: "project-a", + binding: bindingA, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-a" }], + sessions: [sessionA], + online: true, + }; + const machineB = { + machineId: "target-b", + machineName: "Machine B", + targetId: "target-b", + projectId: "project-b", + binding: bindingB, + lanes: [{ ...workMocks.baseWork.lanes[1] as LaneSummary, id: "lane-b" }], + sessions: [sessionB], + online: true, + }; + const gridSets = [{ + id: "grid-1", + layoutId: "layout-grid-1", + sessionIds: [sessionA.id, sessionB.id], + }]; + const setGridSets = vi.fn(); + workMocks.crossMachineLaneIntendedMachineIds = ["target-a", "target-b"]; + workMocks.crossMachineLanesByMachineId = { + "target-a": machineA, + "target-b": machineB, + }; + workMocks.currentWork = { + ...workMocks.baseWork, + gridSets, + setGridSets, + closingPtyIds: new Set(), + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, + }); + + const rendered = render(); + await waitFor(() => expect(setGridSets).toHaveBeenCalled()); + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(sessionB)?.key).toBe(bindingB.key); + + workMocks.crossMachineLanesByMachineId = {}; + rendered.rerender(); + workMocks.crossMachineLanesByMachineId = { "target-a": machineA }; + rendered.rerender(); + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(sessionB)?.key).toBe(bindingB.key); + + setGridSets.mockClear(); + workMocks.crossMachineLaneIntendedMachineIds = ["target-a"]; + rendered.rerender(); + + expect(workViewAreaProps.latest?.resolveSessionRuntimePin?.(sessionB)).toBeNull(); + await waitFor(() => expect(setGridSets).toHaveBeenCalledTimes(1)); + const update = setGridSets.mock.calls[0]?.[0]; + expect(typeof update).toBe("function"); + expect(update(gridSets)).toEqual([]); + }); + it("bulk deletes selected running chat sessions from the session list", async () => { const runningCodexChat = workMocks.makeTerminalSession("chat-running-codex", "lane-primary", "codex-chat"); const runningClaudeChat = workMocks.makeTerminalSession("chat-running-claude", "lane-primary", "claude-chat", { diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 18cb12ea5..33fa88dfa 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -12,7 +12,13 @@ import { } from "./SessionContextMenu"; import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; -import type { AgentChatSession, OpenProjectBinding, TerminalResumeLaunchConfig, TerminalSessionSummary } from "../../../shared/types"; +import type { + AgentChatSession, + OpenProjectBinding, + PtyResumeSessionResult, + TerminalResumeLaunchConfig, + TerminalSessionSummary, +} from "../../../shared/types"; import { buildDeeplink } from "../../../shared/deeplinks"; import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; import { buildWebClientUrl } from "../../../shared/webClientUrl"; @@ -134,6 +140,7 @@ async function allSettledWithConcurrency( export function TerminalsPage({ active = true }: { active?: boolean }) { const work = useWorkSessions({ active }); + const { machineRouter, resolveSessionRuntimePin } = work; const projectRoot = useAppStore(selectActiveProjectRoot); const projectStateKey = useAppStore(selectActiveProjectStateKey); const projectBinding = useAppStore((s) => s.projectBinding); @@ -193,6 +200,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }, []); const selectableSessions = useMemo( + // Bulk-selection RPCs are deliberately active-binding-only. Foreign rows + // keep their per-row pinned actions until the bulk contract can carry pins. () => [ ...work.runningFiltered, ...work.awaitingInputFiltered, @@ -266,6 +275,26 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { event: React.MouseEvent, visibleSessionIds: string[], ) => { + // A CLI/shell session on a binding this window still has open is opened IN + // PLACE, exactly like a chat: the click selects/opens its tab in the + // CURRENT view state and every runtime call for it carries `binding` as a + // per-session pin. The tab keeps pointing wherever the user put it — + // switching projects here used to drag Lanes/PRs/Files to the session's + // machine, which is precisely the bug per-session routing removes. + // + // Remembering the pin against the session/pty id means the paths that + // already consult the launch-pin registry (stop/dispose, and this page's + // resume/continue) reach the right machine without threading a binding + // through every one of them. + if (machineRouter.isLivePin(binding)) { + machineRouter.rememberSessionPin(session, binding); + handleSelectSession(session.id, event, visibleSessionIds, binding); + return; + } + // Fallback: the owning binding is not open in this window, so there is + // nothing to pin to. Rebinding the tab is then the only way to reach the + // session at all — the old behavior, now the exception rather than the + // rule. const switchProject = binding.kind === "remote" ? switchRemoteProject(binding.targetId, binding.projectId) : switchProjectToPath(binding.rootPath); @@ -310,13 +339,26 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { if (session.wokeAt) clearSessionWokeMarker(session.id); }) .catch((reason: unknown) => { - // A shell/CLI has no per-session runtime pin. If its owning project - // cannot be selected, leaving it closed is safer than opening its id - // against whichever runtime the tab currently owns. + // Unreachable owning project: leaving the session closed is safer than + // opening its id against whichever runtime the tab currently owns. + // Surface the failure like every other switch path on this page — + // without it the click reads as a silent no-op. console.error("work.foreign_session_switch_failed", reason); + const machineName = binding.kind === "remote" ? binding.runtimeName : binding.displayName; + setSessionActionError( + `Could not open this session on ${machineName}: ${reason instanceof Error ? reason.message : String(reason)}`, + ); + window.setTimeout(() => setSessionActionError(null), 6000); }); }, - [selectionAnchorId, setWorkViewState, switchProjectToPath, switchRemoteProject], + [ + handleSelectSession, + machineRouter, + selectionAnchorId, + setWorkViewState, + switchProjectToPath, + switchRemoteProject, + ], ); const handleInfoClick = useCallback( @@ -408,8 +450,9 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { // Callers (spawn cards, subagents pane) often don't know the target's // lane. Resolve it from the loaded session list so cross-lane jumps // land on the right lane instead of focusing an off-lane session. - const laneId = detail.laneId ?? work.sessions.find((s) => s.id === sessionId)?.laneId ?? null; - if (laneId) work.selectLane(laneId); + const session = work.sessionsById.get(sessionId) ?? null; + const laneId = detail.laneId ?? session?.laneId ?? null; + if (laneId && (!session || !resolveSessionRuntimePin(session))) work.selectLane(laneId); work.focusSession(sessionId); work.openSessionTab(sessionId); work.setSelectedSessionId(sessionId); @@ -814,79 +857,90 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { })(); }, [selectedSessions, stopAndDeleteConfirm, work]); - const handleContinueCliSession = useCallback( - async (session: TerminalSessionSummary, text: string, launch: TerminalResumeLaunchConfig | null) => { - setSessionActionError(null); + const finalizeCliResumeResult = useCallback( + async ( + session: TerminalSessionSummary, + pin: OpenProjectBinding | null, + result: PtyResumeSessionResult, + ) => { + invalidateSessionListCache(); + // Patch the local sessions list with the freshly-resumed snapshot so the + // Work view flips to TerminalView immediately. A pinned session belongs + // to the cross-machine union, so its own sync round owns that snapshot and + // lane selection instead of inventing a local lane here. + if (result.session && !pin) work.upsertSessionSnapshot(result.session); + if (pin) { + machineRouter.rememberSessionPin( + { sessionId: result.sessionId, ptyId: result.session?.ptyId ?? session.ptyId }, + pin, + ); + } try { - const result = await window.ade.pty.sendToSession({ - sessionId: session.id, - text, - cols: 100, - rows: 30, - ...buildPtyContinuationLaunchFields(launch), - }); - invalidateSessionListCache(); - // Patch the local sessions list with the freshly-resumed snapshot so - // the Work view flips from ClosedCliSessionSurface to the live - // TerminalView immediately. Without this the user sees the frozen - // pre-resume snapshot until the next refresh round-trip completes — - // and the PTY data events stream to a TerminalView that hasn't been - // mounted yet. - if (result.session) { - work.upsertSessionSnapshot(result.session); - } - try { - await work.refresh({ showLoading: false, force: true }); - } catch { - // Best-effort after reattach; the PTY events will also refresh state. - } - work.selectLane(session.laneId); - work.focusSession(result.sessionId); - work.setActiveItemId(result.sessionId); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setSessionActionError(`Send failed: ${message}`); - window.setTimeout(() => setSessionActionError(null), 6000); - throw err; + await work.refresh({ showLoading: false, force: true }); + } catch { + // Best-effort after reattach; the PTY events will also refresh state. } + if (!pin) work.selectLane(session.laneId); + work.focusSession(result.sessionId); + work.setActiveItemId(result.sessionId); }, - [work], + [machineRouter, work], ); - const handleResumeCliSession = useCallback( - async (session: TerminalSessionSummary) => { + const runCliResumeRequest = useCallback( + async ( + session: TerminalSessionSummary, + errorLabel: "Send" | "Resume", + request: (pin: OpenProjectBinding | null) => Promise, + ) => { setSessionActionError(null); + const pin = resolveSessionRuntimePin(session); try { - const result = await window.ade.pty.resumeSession({ - sessionId: session.id, - cols: 100, - rows: 30, - }); - invalidateSessionListCache(); - if (result.session) { - work.upsertSessionSnapshot(result.session); - } - try { - await work.refresh({ showLoading: false, force: true }); - } catch { - // Best-effort after reattach; the PTY events will also refresh state. - } - work.selectLane(session.laneId); - work.focusSession(result.sessionId); - work.setActiveItemId(result.sessionId); + const result = await request(pin); + await finalizeCliResumeResult(session, pin, result); } catch (err) { const message = err instanceof Error ? err.message : String(err); - setSessionActionError(`Resume failed: ${message}`); + setSessionActionError(`${errorLabel} failed: ${message}`); window.setTimeout(() => setSessionActionError(null), 6000); throw err; } }, - [work], + [finalizeCliResumeResult, resolveSessionRuntimePin], + ); + + const handleContinueCliSession = useCallback( + async (session: TerminalSessionSummary, text: string, launch: TerminalResumeLaunchConfig | null) => { + const args = { + sessionId: session.id, + text, + cols: 100, + rows: 30, + ...buildPtyContinuationLaunchFields(launch), + }; + await runCliResumeRequest(session, "Send", (pin) => ( + pin ? window.ade.pty.sendToSession(args, pin) : window.ade.pty.sendToSession(args) + )); + }, + [runCliResumeRequest], + ); + + const handleResumeCliSession = useCallback( + async (session: TerminalSessionSummary) => { + const args = { sessionId: session.id, cols: 100, rows: 30 }; + await runCliResumeRequest(session, "Resume", (pin) => ( + pin ? window.ade.pty.resumeSession(args, pin) : window.ade.pty.resumeSession(args) + )); + }, + [runCliResumeRequest], ); const activeWorkSession = useMemo( - () => (work.activeItemId ? work.sessions.find((session) => session.id === work.activeItemId) ?? null : null), - [work.activeItemId, work.sessions], + () => (work.activeItemId ? work.sessionsById.get(work.activeItemId) ?? null : null), + [work.activeItemId, work.sessionsById], + ); + const activeWorkSessionRuntimePin = useMemo( + () => (activeWorkSession ? resolveSessionRuntimePin(activeWorkSession) : null), + [activeWorkSession, resolveSessionRuntimePin], ); const activeLaneId = useMemo(() => { @@ -916,6 +970,9 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { } : null; } + // WorkSidebar's composer events and terminal.write call do not carry a + // runtime pin. Fail closed instead of inserting into the active machine. + if (activeWorkSessionRuntimePin) return null; if (activeWorkSession.laneId !== activeLaneId) return null; if (isChatToolType(activeWorkSession.toolType)) { return { kind: "chat", sessionId: activeWorkSession.id }; @@ -933,7 +990,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }; } return null; - }, [activeLaneId, activeWorkSession, draftContextTargetId, work.draftKind]); + }, [activeLaneId, activeWorkSession, activeWorkSessionRuntimePin, draftContextTargetId, work.draftKind]); let contextDisabledReason: string | null; if (!activeWorkSession && contextTarget) { @@ -941,6 +998,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { contextDisabledReason = null; } else if (!activeWorkSession) { contextDisabledReason = "Select a lane before inserting tool context."; + } else if (activeWorkSessionRuntimePin) { + contextDisabledReason = "Tool context insertion is not available for sessions on another machine."; } else if (activeWorkSession.laneId !== activeLaneId) { contextDisabledReason = "Open a Work session in the active lane to insert tool context."; } else if (activeWorkSession.toolType === "shell") { @@ -1065,8 +1124,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { // exist (closed/deleted) and dissolve any set that falls below two tiles. const setGridSets = work.setGridSets; useEffect(() => { - if (work.loading || work.sessions.length === 0) return; - const liveIds = new Set(work.sessions.map((s) => s.id)); + if (work.loading || !work.canPruneSessionIndex()) return; + const liveIds = new Set(work.sessionsById.keys()); setGridSets((prev) => { let changed = false; const next = prev @@ -1079,7 +1138,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { if (next.length !== prev.length) changed = true; return changed ? next : prev; }); - }, [work.sessions, work.loading, setGridSets]); + }, [work.canPruneSessionIndex, work.loading, work.sessionsById, setGridSets]); const handleStopRunningSession = useCallback((session: TerminalSessionSummary) => { if (!session.ptyId) return; work.stopRuntime(session.ptyId, session.id).catch((err: unknown) => { @@ -1167,6 +1226,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { onContextMenu={handleContextMenu} onContinueCliSession={handleContinueCliSession} onResumeCliSession={handleResumeCliSession} + resolveSessionRuntimePin={resolveSessionRuntimePin} sessionsPaneCollapsed={work.workFocusSessionsHidden} onToggleSessionsPane={toggleSessionsPane} sessionsPaneListCount={work.filtered.length} @@ -1191,7 +1251,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { handleAddSessionToGrid, handleCreateGridFromSingle, handleRemoveSessionFromGrid, - work.sessions, + resolveSessionRuntimePin, work.visibleSessions, work.activeItemId, work.draftKind, @@ -1314,6 +1374,8 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { // Stable automation anchor for the whole sessions pane. children: (
+ {/* Active-binding inventory only: this pane reads and filters foreign + rows from its own cross-machine union subscription. */} ({ unmounts: new Map(), })); +const prsMocks = vi.hoisted(() => ({ + getForLane: vi.fn(), + syncLanePr: vi.fn(), + getChecks: vi.fn(), + getReviews: vi.fn(), + getStatus: vi.fn(), + onEvent: vi.fn(), +})); + vi.mock("@emoji-mart/data", () => ({ default: { categories: [], emojis: {}, aliases: {}, sheet: { cols: 0, rows: 0 } }, })); @@ -77,6 +86,8 @@ vi.mock("./CliSessionWorkSurfaceHeader", () => ({ sessionsPaneCount, onToggleToolsPane, toolsPaneOpen = false, + onTogglePrPane, + prPaneOpen = false, }: { session: TerminalSessionSummary; onToggleSessionsPane?: () => void; @@ -84,6 +95,8 @@ vi.mock("./CliSessionWorkSurfaceHeader", () => ({ sessionsPaneCount?: number; onToggleToolsPane?: () => void; toolsPaneOpen?: boolean; + onTogglePrPane?: () => void; + prPaneOpen?: boolean; }) => (
({ Tools ) : null} + {onTogglePrPane ? ( + + ) : null}
), CliSurfaceTrailingActions: () => null, @@ -149,6 +172,16 @@ vi.mock("../chat/AgentChatPane", async () => { }; }); +vi.mock("../chat/ChatPrPane", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ChatPrPane: ({ laneId }: { laneId: string }) => ( +
+ ), + }; +}); + vi.mock("./WorkStartSurface", () => ({ WorkStartSurface: () =>
, })); @@ -242,6 +275,18 @@ beforeEach(() => { }); externalSessionsListMock.mockReset(); externalSessionsListMock.mockResolvedValue([]); + prsMocks.getForLane.mockReset(); + prsMocks.getForLane.mockResolvedValue(null); + prsMocks.syncLanePr.mockReset(); + prsMocks.syncLanePr.mockResolvedValue(null); + prsMocks.getChecks.mockReset(); + prsMocks.getChecks.mockResolvedValue([]); + prsMocks.getReviews.mockReset(); + prsMocks.getReviews.mockResolvedValue([]); + prsMocks.getStatus.mockReset(); + prsMocks.getStatus.mockResolvedValue(null); + prsMocks.onEvent.mockReset(); + prsMocks.onEvent.mockImplementation(() => () => {}); Object.defineProperty(window, "ade", { configurable: true, value: { @@ -263,6 +308,7 @@ beforeEach(() => { terminal: { preview: terminalPreviewMock, }, + prs: prsMocks, }, }); vi.mocked(isChatToolType).mockReturnValue(false); @@ -515,6 +561,71 @@ describe("WorkViewArea", () => { expect(terminals.map((terminal) => terminal.getAttribute("data-session-id"))).toContain("session-1"); }); + it("suppresses the PR pane and auto-pop reads for a foreign running CLI", async () => { + const session = { ...makeRunningSession("session-foreign", "pty-foreign"), toolType: "codex" as const }; + const runtimePin = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + + const view = render( + {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={resolvePtyLaunch} + onShowDraftKind={() => {}} + closingPtyIds={new Set()} + resolveSessionRuntimePin={() => runtimePin} + />, + ); + const local = within(view.container); + + expect(local.queryByRole("button", { name: "Toggle PR pane" })).toBeNull(); + expect(local.queryByTestId("chat-pr-pane")).toBeNull(); + await waitFor(() => { + for (const mock of Object.values(prsMocks)) expect(mock).not.toHaveBeenCalled(); + }); + }); + + it("keeps PR auto-pop and pane controls enabled for a local running CLI", async () => { + const session = { ...makeRunningSession("session-local", "pty-local"), toolType: "codex" as const }; + const view = render( + {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={resolvePtyLaunch} + onShowDraftKind={() => {}} + closingPtyIds={new Set()} + resolveSessionRuntimePin={() => null} + />, + ); + const local = within(view.container); + + await waitFor(() => { + expect(prsMocks.getForLane).toHaveBeenCalledWith("lane-1"); + expect(prsMocks.onEvent).toHaveBeenCalledTimes(1); + }); + fireEvent.click(local.getByRole("button", { name: "Toggle PR pane" })); + expect((await local.findByTestId("chat-pr-pane")).getAttribute("data-lane-id")).toBe("lane-1"); + }); + it("shows the transcript for closed agent CLI sessions instead of the generic ended card", async () => { terminalPreviewMock.mockResolvedValueOnce({ terminalId: "session-1", @@ -608,6 +719,64 @@ describe("WorkViewArea", () => { expect(modelsMock).not.toHaveBeenCalled(); }); + it("pins every available closed-surface read for a foreign CLI", async () => { + const runtimePin = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const session = { + ...makeSession(), + id: "foreign-closed", + toolType: "codex" as const, + resumeCommand: "codex resume foreign-thread", + resumeMetadata: { + provider: "codex" as const, + targetKind: "thread" as const, + targetId: "foreign-thread", + launch: {}, + importedFrom: { + provider: "codex" as const, + targetId: "foreign-thread", + mode: "resume" as const, + }, + }, + }; + + const view = render( + {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={resolvePtyLaunch} + onShowDraftKind={() => {}} + closingPtyIds={new Set()} + resolveSessionRuntimePin={() => runtimePin} + />, + ); + const local = within(view.container); + + expect(await local.findByLabelText("Continue Codex session")).toBeTruthy(); + expect(terminalPreviewMock).toHaveBeenCalledWith( + { terminalId: session.id, maxBytes: 160_000 }, + runtimePin, + ); + expect(slashCommandsMock).toHaveBeenCalledWith( + { laneId: "lane-1", provider: "codex" }, + runtimePin, + ); + expect(externalSessionsListMock).not.toHaveBeenCalled(); + }); + it("keeps the Work sidebar toggles available on closed agent CLI sessions", () => { const onToggleSessionsPane = vi.fn(); const onToggleWorkSidebar = vi.fn(); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 2311b276b..effaff46b 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -13,6 +13,7 @@ import type { ChatTerminalPreviewResult, LaneLinearIssue, LaneSummary, + OpenProjectBinding, TerminalResumeProvider, TerminalResumeLaunchConfig, TerminalSessionSummary, @@ -328,9 +329,11 @@ function continuationPermissionLabel(launch: TerminalResumeLaunchConfig | null): function WorkCliContinuationComposer({ session, + runtimePin, onContinue, }: { session: TerminalSessionSummary; + runtimePin: OpenProjectBinding | null; onContinue?: ( session: TerminalSessionSummary, text: string, @@ -393,7 +396,12 @@ function WorkCliContinuationComposer({ )) return () => { cancelled = true; }; - const request = recoverImportedContinuationLaunch(provider, importedProvider, importedTargetId); + // Native provider history is machine-local and externalSessions has no + // pinned surface. A foreign session keeps its durable stored launch instead + // of consulting unrelated history on the active machine. + const request = runtimePin + ? null + : recoverImportedContinuationLaunch(provider, importedProvider, importedTargetId); if (!request) return () => { cancelled = true; }; @@ -406,7 +414,7 @@ function WorkCliContinuationComposer({ return () => { cancelled = true; }; - }, [importedProvider, importedTargetId, provider, recoveryIdentity]); + }, [importedProvider, importedTargetId, provider, recoveryIdentity, runtimePin]); useEffect(() => { let cancelled = false; @@ -414,7 +422,10 @@ function WorkCliContinuationComposer({ if (!provider) return () => { cancelled = true; }; - void window.ade.agentChat.slashCommands({ laneId: session.laneId, provider }) + const args = { laneId: session.laneId, provider }; + void (runtimePin + ? window.ade.agentChat.slashCommands(args, runtimePin) + : window.ade.agentChat.slashCommands(args)) .then((commands) => { if (!cancelled) { setSlashCommands(commands.filter((command) => command.source !== "local")); @@ -426,7 +437,7 @@ function WorkCliContinuationComposer({ return () => { cancelled = true; }; - }, [provider, session.laneId]); + }, [provider, runtimePin, session.laneId]); const updateDraft = useCallback((next: string, element: HTMLTextAreaElement | null) => { setDraft(next); @@ -593,6 +604,7 @@ function WorkCliContinuationComposer({ function ClosedCliSessionSurface({ session, lanes, + runtimePin, layoutVariant, onInfoClick, onContextMenu, @@ -606,6 +618,7 @@ function ClosedCliSessionSurface({ }: { session: TerminalSessionSummary; lanes: LaneSummary[]; + runtimePin: OpenProjectBinding | null; layoutVariant: "standard" | "grid-tile"; onInfoClick?: (session: TerminalSessionSummary, event: React.MouseEvent) => void; onContextMenu?: (session: TerminalSessionSummary, event: React.MouseEvent) => void; @@ -649,7 +662,10 @@ function ClosedCliSessionSurface({ let cancelled = false; setPreview(null); setError(null); - void window.ade.terminal.preview({ terminalId: session.id, maxBytes: 160_000 }) + const args = { terminalId: session.id, maxBytes: 160_000 }; + void (runtimePin + ? window.ade.terminal.preview(args, runtimePin) + : window.ade.terminal.preview(args)) .then((result) => { if (!cancelled) setPreview(result); }) @@ -659,7 +675,7 @@ function ClosedCliSessionSurface({ return () => { cancelled = true; }; - }, [session.id, session.endedAt, session.status]); + }, [runtimePin, session.id, session.endedAt, session.status]); const snapshotRows = preview?.snapshot?.visibleRows ?? []; const useSnapshotPreview = snapshotRows.length > 0 && ( @@ -717,7 +733,13 @@ function ClosedCliSessionSurface({ {transcriptText} )} - {showComposer ? : null} + {showComposer ? ( + + ) : null}
); @@ -738,6 +760,7 @@ const CLI_FLOATING_PANE_CARD_CLASS = function CliSessionSurface({ session, lanes, + runtimePin = null, stopping = false, layoutVariant = "standard", surfaceActive, @@ -754,6 +777,8 @@ function CliSessionSurface({ }: { session: TerminalSessionSummary & { ptyId: string }; lanes: LaneSummary[]; + /** See `SessionSurface.runtimePin`. */ + runtimePin?: OpenProjectBinding | null; stopping?: boolean; layoutVariant?: "standard" | "grid-tile"; surfaceActive: boolean; @@ -770,11 +795,14 @@ function CliSessionSurface({ }) { // Persist the pane per CLI session so reopening the surface restores it, the // same way the ADE chat pane keys its companion UI state. - const { prPaneOpen, setPrPaneOpen, prPaneDelta } = useChatPrAutoPop(session.laneId, { + // PR data lives on the owning machine, but the prs preload surface is + // unpinned. Foreign CLI sessions therefore expose neither auto-pop nor pane. + const prLaneId = runtimePin ? null : session.laneId; + const { prPaneOpen, setPrPaneOpen, prPaneDelta } = useChatPrAutoPop(prLaneId, { persistKey: session.id, }); const supportsSplit = layoutVariant !== "grid-tile"; - const prFloating = prPaneOpen && Boolean(session.laneId) && supportsSplit; + const prFloating = prPaneOpen && Boolean(prLaneId) && supportsSplit; return (
{layoutVariant !== "grid-tile" ? ( @@ -790,7 +818,7 @@ function CliSessionSurface({ sessionsPaneCount={sessionsPaneCount} onToggleToolsPane={onToggleToolsPane} toolsPaneOpen={toolsPaneOpen} - onTogglePrPane={session.laneId ? () => setPrPaneOpen((v) => !v) : undefined} + onTogglePrPane={prLaneId ? () => setPrPaneOpen((v) => !v) : undefined} prPaneOpen={prPaneOpen} /> ) : null} @@ -801,11 +829,12 @@ function CliSessionSurface({ sessionId={session.id} isActive={surfaceActive} isVisible={pageActive && terminalVisible} + runtimePin={runtimePin} imagePasteMode="runtime-attachment" className="h-full w-full" /> - {prFloating && session.laneId ? ( + {prFloating && prLaneId ? (
setPrPaneOpen(false)} @@ -835,6 +864,7 @@ function SessionSurface({ sessionTitleById, lanes, isActive, + runtimePin = null, pageActive = true, shouldAutofocus = false, layoutVariant = "standard", @@ -859,6 +889,13 @@ function SessionSurface({ sessionTitleById?: ReadonlyMap; lanes: LaneSummary[]; isActive: boolean; + /** + * Set only for a session that lives on another open binding; `null` means the + * tab's own machine (the hot path — same calls as before per-session routing). + * The ADE chat pane resolves its own pin from the lane, so this is consumed by + * the PTY surfaces only. + */ + runtimePin?: OpenProjectBinding | null; pageActive?: boolean; shouldAutofocus?: boolean; layoutVariant?: "standard" | "grid-tile"; @@ -924,6 +961,7 @@ function SessionSurface({ ); @@ -957,6 +996,7 @@ function SessionSurface({ void; /** A grid tile was dragged out of the grid — pop it back to single view. */ onRemoveSessionFromGrid?: (sessionId: string) => void; + /** + * Per-session runtime routing: the binding a session's PTY calls must target, + * or `null` when it lives on the machine the project tab is already bound to. + * + * The Work sidebar is a union across machines, so a CLI/shell surface here can + * belong to another machine — it is opened in place and its calls carry this + * pin instead of the tab being rebound. Omitted (or `null`) is the hot path + * for every local terminal and is behaviorally identical to before. + */ + resolveSessionRuntimePin?: (session: TerminalSessionSummary) => OpenProjectBinding | null; }) { const { menu: laneContextMenuPortal } = useWorkLaneContextMenu(); const sessionsById = useMemo(() => { @@ -1213,6 +1265,7 @@ export function WorkViewArea({ // transfers activeItemId (WorkGridView's onPaneMouseDown) before typing. isActive={session.id === activeItemId} pageActive={pageActive} + runtimePin={resolveSessionRuntimePin?.(session) ?? null} shouldAutofocus={session.id === activeItemId} terminalVisible onInfoClick={onInfoClick} @@ -1265,6 +1318,7 @@ export function WorkViewArea({ lanes={lanes} isActive pageActive={pageActive} + runtimePin={resolveSessionRuntimePin?.(activeSession) ?? null} terminalVisible onInfoClick={onInfoClick} onContextMenu={onContextMenu} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts b/apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts new file mode 100644 index 000000000..1680a5409 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts @@ -0,0 +1,225 @@ +import { useMemo, useRef } from "react"; +import type { OpenProjectBinding } from "../../../shared/types"; +import { + buildChatMachineRoutingState, + collectOpenProjectBindings, + createChatMachineRouter, + isLivePinnedBinding, + type ChatMachineRouter, + type LaneBindingSource, +} from "../../lib/chatMachineRouting"; +import { + selectActiveProjectStateKey, + useAppStore, + useRootAppStore, + type CrossMachineMachineLanes, +} from "../../state/appStore"; +import { + forgetWorkPtyLaunchPin, + rememberWorkPtyLaunchPin, + workPtyLaunchPinFor, +} from "./cliLaunch"; + +type WorkRuntimePinLookup = { + id?: string | null; + sessionId?: string | null; + ptyId?: string | null; + laneId?: string | null; +}; + +export type WorkMachineRouter = ChatMachineRouter & { + /** Resolve session-slice ownership, lane ownership, then the remembered launch fallback. */ + pinForSession: (session: WorkRuntimePinLookup) => OpenProjectBinding | null; + /** Keep the launch-pin registry behind the Work routing authority. */ + rememberSessionPin: ( + session: WorkRuntimePinLookup, + pin: OpenProjectBinding | null | undefined, + ) => void; + /** Remove both the session-id and PTY-id entries from the launch-pin registry. */ + forgetSessionPin: (session: WorkRuntimePinLookup) => void; +}; + +type RetainedCrossMachineSlices = { + projectStateKey: string | null; + machinesById: Map; + /** Fallback for older callers that have not supplied authoritative scope intent yet. */ + pendingMachineIds: Set | null; +}; + +/** + * One retained cross-machine slice lifecycle for both Work rows and runtime pins. + * + * `crossMachineLanesByMachineId` is replace-on-refill. The separate intended-id + * list is the authoritative membership contract: an absent but intended machine + * is still loading, while an id removed from that list is gone immediately. + * Keeping complete slices here means the session index, lane index, and binding + * index cannot disagree during a partial refill. + */ +export function useRetainedCrossMachineSlices(): readonly CrossMachineMachineLanes[] { + const projectStateKey = useAppStore(selectActiveProjectStateKey); + const crossMachineLanesByMachineId = useRootAppStore((s) => s.crossMachineLanesByMachineId); + const intendedMachineIds = useRootAppStore((s) => s.crossMachineLaneIntendedMachineIds); + const retainedRef = useRef({ + projectStateKey: null, + machinesById: new Map(), + pendingMachineIds: null, + }); + + return useMemo(() => { + let retained = retainedRef.current; + if (retained.projectStateKey !== projectStateKey) { + retained = { + projectStateKey, + machinesById: new Map(), + pendingMachineIds: null, + }; + retainedRef.current = retained; + } + + const machines = Object.values(crossMachineLanesByMachineId); + if (intendedMachineIds != null) { + const intended = new Set(intendedMachineIds); + for (const machineId of retained.machinesById.keys()) { + if (!intended.has(machineId)) retained.machinesById.delete(machineId); + } + for (const machine of machines) { + if (intended.has(machine.machineId)) { + retained.machinesById.set(machine.machineId, machine); + } + } + // Authoritative intent makes arrival bookkeeping unnecessary: absence is + // pending until membership says otherwise, however many peers arrive first. + retained.pendingMachineIds = null; + } else if (machines.length === 0) { + // Compatibility fallback while scope identity is still unresolved. Once + // intent is published, the branch above becomes the only lifecycle rule. + if (retained.machinesById.size > 0 && retained.pendingMachineIds == null) { + retained.pendingMachineIds = new Set(retained.machinesById.keys()); + } + } else { + const presentMachineIds = new Set(machines.map((machine) => machine.machineId)); + if (retained.pendingMachineIds == null) { + for (const machineId of retained.machinesById.keys()) { + if (!presentMachineIds.has(machineId)) retained.machinesById.delete(machineId); + } + } + for (const machine of machines) { + retained.machinesById.set(machine.machineId, machine); + retained.pendingMachineIds?.delete(machine.machineId); + } + if (retained.pendingMachineIds?.size === 0) retained.pendingMachineIds = null; + } + + return Array.from(retained.machinesById.values()); + }, [crossMachineLanesByMachineId, intendedMachineIds, projectStateKey]); +} + +/** + * Per-session runtime routing for the Work tab. + * + * Same model as the chat pane's router (see `lib/chatMachineRouting`): a lane + * owns its machine, a session inherits its machine from its lane, and the pin + * resolves to `null` whenever that machine is the one the project tab is + * already bound to — so every local session keeps taking the existing unpinned + * path with no extra work. + * + * This exists so a CLI/shell session is routed exactly like a chat. The Work + * sidebar is a union across machines, and clicking a row must reach ITS machine + * without rebinding the tab (rebinding would drag Lanes/PRs/Files along). + */ +export function useWorkMachineRouter( + crossMachineSlices: readonly CrossMachineMachineLanes[], +): WorkMachineRouter { + const projectBinding = useAppStore((s) => s.projectBinding); + const lanes = useAppStore((s) => s.lanes); + const openRemoteProjectTabs = useAppStore((s) => s.openRemoteProjectTabs); + const openProjectTabRoots = useAppStore((s) => s.openProjectTabRoots); + const liveCrossMachineSlices = useRootAppStore((s) => s.crossMachineLanesByMachineId); + + return useMemo(() => { + const machines = crossMachineSlices; + const openBindings = collectOpenProjectBindings({ + activeBinding: projectBinding ?? null, + remoteBindings: openRemoteProjectTabs ?? [], + localProjects: (openProjectTabRoots ?? []).map((rootPath) => ({ rootPath })), + additionalBindings: machines.map((machine) => machine.binding), + }); + const additionalLaneSources: LaneBindingSource[] = []; + // Session ids are globally stable across the Work union, while lane ids can + // legitimately exist on more than one machine. Preserve the owning slice's + // binding in a parallel index instead of decorating TerminalSessionSummary + // rows or depending on the transient launch-pin registry. Build it only + // when a machine actually contributes sessions, and only when the memoized + // router inputs change, so the local-only render path allocates nothing. + let sessionBindingsById: Map | null = null; + for (const machine of machines) { + if (!machine.binding) continue; + additionalLaneSources.push({ + bindingKey: machine.binding.key, + laneIds: machine.lanes.map((lane) => lane.id), + }); + for (const session of machine.sessions) { + const sessionId = session.id?.trim(); + if (!sessionId) continue; + sessionBindingsById ??= new Map(); + if (!sessionBindingsById.has(sessionId)) { + sessionBindingsById.set(sessionId, machine.binding); + } + } + } + const router = createChatMachineRouter(buildChatMachineRoutingState({ + activeBinding: projectBinding ?? null, + openBindings, + activeLaneIds: (lanes ?? []).map((lane) => lane.id), + additionalLaneSources, + })); + // Runtime ownership survives the refill, but liveness keeps its existing + // contract: only bindings present in the live store (or open project tabs) + // may mutate UI state. This preserves the sticky-pin fallback's behavior + // while retained session/lane indexes keep calls aimed at the right machine. + const liveOpenBindings = collectOpenProjectBindings({ + activeBinding: projectBinding ?? null, + remoteBindings: openRemoteProjectTabs ?? [], + localProjects: (openProjectTabRoots ?? []).map((rootPath) => ({ rootPath })), + additionalBindings: Object.values(liveCrossMachineSlices).map((machine) => machine.binding), + }); + + return { + ...router, + isLivePin: (pin) => isLivePinnedBinding(pin, liveOpenBindings), + pinForSession: (session) => { + const sessionId = session.sessionId ?? session.id ?? null; + const slicePin = sessionId ? sessionBindingsById?.get(sessionId) : undefined; + if (slicePin) { + // The retained cross-machine map can include the active remote + // binding's slice. Its session identity is authoritative too, but it + // still takes the existing unpinned path. + return slicePin.key === projectBinding?.key ? null : slicePin; + } + const lanePin = router.pinForLane(session.laneId); + if (lanePin) return lanePin; + const rememberedPin = workPtyLaunchPinFor(session); + // A remembered pin can become the active binding after the tab is + // rebound. Keep that case on the unpinned fast path; the pinned path has + // no local IPC fallback and would create a redundant event pump. + if (!rememberedPin || rememberedPin.key === projectBinding?.key) return null; + // Do not liveness-gate the remembered foreign pin here. The + // cross-machine lane scope is replaced wholesale while it reloads, so + // an otherwise healthy binding briefly disappears from `openBindings`. + // Keeping the pin makes that short window fail against the RIGHT + // machine and recover when the scope returns. Falling back to the + // unpinned path would silently query the tab's machine, discard the + // parked terminal buffer, and hydrate a foreign session id there. + // Click-time rebinding still uses `isLivePin`; only runtime ownership + // remembered for an already-open session survives this transient flap. + return rememberedPin; + }, + rememberSessionPin: (session, pin) => { + rememberWorkPtyLaunchPin(session, pin); + }, + forgetSessionPin: (session) => { + forgetWorkPtyLaunchPin(session); + }, + }; + }, [crossMachineSlices, lanes, liveCrossMachineSlices, openProjectTabRoots, openRemoteProjectTabs, projectBinding]); +} diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index d69727b43..c29dd6be5 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import type { WorkChatSessionCreatedDetail } from "../../lib/chatSessionEvents"; +import { createDefaultWorkProjectViewState } from "../../state/appStore"; // --------------------------------------------------------------------------- // Spies used across all tests @@ -31,6 +32,7 @@ function resetFakeAppStoreState() { setWorkViewState: setWorkViewStateSpy, sessionsCacheByProject: {}, crossMachineLanesByMachineId: {}, + crossMachineLaneIntendedMachineIds: null, }; routerLocation.pathname = "/work"; routerLocation.search = ""; @@ -146,6 +148,7 @@ vi.mock("../../state/appStore", async (importOriginal) => { // Import the hook under test (after mocks are declared) // --------------------------------------------------------------------------- import { buildWorkTabGroupModel, reorderLaneSessionIdsForDisplay, useWorkSessions } from "./useWorkSessions"; +import { forgetWorkPtyLaunchPin, workPtyLaunchPinFor } from "./cliLaunch"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; @@ -827,6 +830,265 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { })); }); + it("retains a slow machine's sessions and runtime pin through repeated A-only refill applies", async () => { + const foreignA = makeSession("foreign-a", "lane-a"); + const foreignB = makeSession("foreign-b", "lane-b"); + const bindingA = { + kind: "remote", + key: "remote:target-a:project-a", + targetId: "target-a", + runtimeName: "Machine A", + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + } as const; + const bindingB = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const workState = { + openItemIds: [foreignA.id, foreignB.id], + activeItemId: foreignB.id, + selectedItemId: foreignB.id, + gridSets: [{ id: "grid-1", layoutId: "layout-grid-1", sessionIds: [foreignA.id, foreignB.id] }], + activeGridSetId: null, + draftKind: "chat" as const, + orchestratorEnabled: false, + draftLaneId: null, + laneFilter: "all", + search: "", + sessionListOrganization: "by-lane" as const, + workCollapsedLaneIds: [], + workCollapsedSectionIds: [], + workCollapsedTabGroupIds: [], + workFocusSessionsHidden: false, + workSidebarOpen: false, + workSidebarTab: "git" as const, + workSidebarWidthPct: 36, + laneSessionOrder: {}, + pinnedSessionIds: [], + }; + fakeAppStoreState = { + ...fakeAppStoreState, + workViewByProject: { "/fake/project": workState }, + crossMachineLaneIntendedMachineIds: ["target-a", "target-b"], + crossMachineLanesByMachineId: { + "target-a": { + machineId: "target-a", + machineName: "Machine A", + binding: bindingA, + lanes: [{ id: "lane-a" }], + sessions: [foreignA], + }, + "target-b": { + machineId: "target-b", + machineName: "Machine B", + binding: bindingB, + lanes: [{ id: "lane-b" }], + sessions: [foreignB], + }, + }, + }; + + const { result, rerender } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(result.current.sessionsById.get(foreignB.id)).toBe(foreignB)); + + // applyCrossMachineLaneScope clears the replacement map before reads settle. + fakeAppStoreState = { + ...fakeAppStoreState, + crossMachineLanesByMachineId: {}, + }; + rerender(); + + expect(result.current.sessionsById.get(foreignA.id)).toBe(foreignA); + expect(result.current.sessionsById.get(foreignB.id)).toBe(foreignB); + + // Machine A resolves first. Its slice is authoritative for A only; B remains + // visible and its grid membership stays valid while B's read is still pending. + fakeAppStoreState = { + ...fakeAppStoreState, + crossMachineLanesByMachineId: { + "target-a": { + machineId: "target-a", + machineName: "Machine A", + binding: bindingA, + lanes: [{ id: "lane-a" }], + sessions: [{ ...foreignA, lastOutputPreview: "fresh-a" }], + }, + }, + }; + rerender(); + + expect(result.current.sessionsById.get(foreignA.id)?.lastOutputPreview).toBe("fresh-a"); + expect(result.current.sessionsById.get(foreignB.id)).toBe(foreignB); + expect(result.current.resolveSessionRuntimePin(foreignB)?.key).toBe(bindingB.key); + expect(result.current.visibleSessions).toContainEqual(foreignB); + expect(result.current.gridSets[0]?.sessionIds).toEqual([foreignA.id, foreignB.id]); + + // Another A-only apply is a new slice result, not completion of the whole + // scope. B remains retained because membership still says it is intended. + fakeAppStoreState = { + ...fakeAppStoreState, + crossMachineLanesByMachineId: { + "target-a": { + machineId: "target-a", + machineName: "Machine A", + binding: bindingA, + lanes: [{ id: "lane-a" }], + sessions: [{ ...foreignA, lastOutputPreview: "fresher-a" }], + }, + }, + }; + rerender(); + + expect(result.current.sessionsById.get(foreignA.id)?.lastOutputPreview).toBe("fresher-a"); + expect(result.current.sessionsById.get(foreignB.id)).toBe(foreignB); + expect(result.current.resolveSessionRuntimePin(foreignB)?.key).toBe(bindingB.key); + }); + + it("prunes retained sessions, tabs, and pins when a machine is removed mid-refill", async () => { + const foreignA = makeSession("scope-a", "lane-a"); + const foreignB = makeSession("scope-b", "lane-b"); + const bindingA = { + kind: "remote", + key: "remote:target-a:project-a", + targetId: "target-a", + runtimeName: "Machine A", + projectId: "project-a", + rootPath: "/repo-a", + displayName: "Repo A", + } as const; + const bindingB = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const workState = { + ...createDefaultWorkProjectViewState(), + openItemIds: [foreignA.id, foreignB.id], + activeItemId: foreignB.id, + selectedItemId: foreignB.id, + gridSets: [{ + id: "grid-1", + layoutId: "layout-grid-1", + sessionIds: [foreignA.id, foreignB.id], + }], + }; + fakeAppStoreState = { + ...fakeAppStoreState, + workViewByProject: { "/fake/project": workState }, + crossMachineLaneIntendedMachineIds: ["target-a", "target-b"], + crossMachineLanesByMachineId: { + "target-a": { + machineId: "target-a", + machineName: "Machine A", + binding: bindingA, + lanes: [{ id: "lane-a" }], + sessions: [foreignA], + }, + "target-b": { + machineId: "target-b", + machineName: "Machine B", + binding: bindingB, + lanes: [{ id: "lane-b" }], + sessions: [foreignB], + }, + }, + }; + + const { result, rerender } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(result.current.sessionsById.has(foreignB.id)).toBe(true)); + expect(result.current.resolveSessionRuntimePin(foreignB)?.key).toBe(bindingB.key); + + // The wholesale clear starts the refill; both machines are still intended. + fakeAppStoreState = { + ...fakeAppStoreState, + crossMachineLanesByMachineId: {}, + }; + rerender(); + expect(result.current.sessionsById.has(foreignB.id)).toBe(true); + expect(result.current.resolveSessionRuntimePin(foreignB)?.key).toBe(bindingB.key); + + setWorkViewStateSpy.mockClear(); + // B is deliberately forgotten before its slice arrives. Scope intent is + // authoritative even though the arriving record only contains A. + fakeAppStoreState = { + ...fakeAppStoreState, + crossMachineLaneIntendedMachineIds: ["target-a"], + crossMachineLanesByMachineId: { + "target-a": { + machineId: "target-a", + machineName: "Machine A", + binding: bindingA, + lanes: [{ id: "lane-a" }], + sessions: [foreignA], + }, + }, + }; + rerender(); + + expect(result.current.sessionsById.has(foreignA.id)).toBe(true); + expect(result.current.sessionsById.has(foreignB.id)).toBe(false); + expect(result.current.visibleSessions).not.toContainEqual(foreignB); + expect(result.current.resolveSessionRuntimePin(foreignB)).toBeNull(); + + await waitFor(() => expect(setWorkViewStateSpy).toHaveBeenCalled()); + const pruneUpdate = [...setWorkViewStateSpy.mock.calls] + .reverse() + .map((call) => call[1]) + .find((next) => typeof next === "function"); + expect(typeof pruneUpdate).toBe("function"); + expect((pruneUpdate as (prev: typeof workState) => typeof workState)(workState)).toMatchObject({ + openItemIds: [foreignA.id], + activeItemId: foreignA.id, + selectedItemId: foreignA.id, + }); + }); + + it("removes a foreign union member when its present machine slice no longer reports it", async () => { + const foreign = makeSession("foreign-removed", "foreign-lane"); + fakeAppStoreState = { + ...fakeAppStoreState, + crossMachineLanesByMachineId: { + "target-b": { + machineId: "target-b", + machineName: "Machine B", + lanes: [{ id: "foreign-lane" }], + sessions: [foreign], + }, + }, + }; + + const { result, rerender } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(result.current.sessionsById.has(foreign.id)).toBe(true)); + + fakeAppStoreState = { + ...fakeAppStoreState, + crossMachineLanesByMachineId: { + "target-b": { + machineId: "target-b", + machineName: "Machine B", + lanes: [{ id: "foreign-lane" }], + sessions: [], + }, + }, + }; + rerender(); + + expect(result.current.sessionsById.has(foreign.id)).toBe(false); + expect(result.current.visibleSessions).not.toContainEqual(foreign); + expect(result.current.runningSessions).not.toContainEqual(foreign); + }); + it("launchPtySession carries its project pin into stopRuntime", async () => { const pin = { kind: "local", @@ -834,6 +1096,16 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { rootPath: "/origin/project", displayName: "Origin", } as const; + fakeAppStoreState = { + ...fakeAppStoreState, + projectBinding: { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "Fake", + }, + openProjectTabRoots: [pin.rootPath], + }; (window as any).ade.pty.create.mockResolvedValueOnce({ sessionId: "pinned-pty-session", ptyId: "pinned-pty", @@ -871,6 +1143,296 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { }, pin); }); + it("keeps a remembered foreign pin while the cross-machine lane map is cleared", async () => { + const foreignBinding = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const foreignSession = makeSession("pin-flap", "lane-b", { + ptyId: "pty-pin-flap", + toolType: "codex", + }); + fakeAppStoreState = { + ...fakeAppStoreState, + projectBinding: { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "Fake", + }, + crossMachineLanesByMachineId: { + "target-b": { + binding: foreignBinding, + lanes: [{ id: "lane-b" }], + sessions: [foreignSession], + }, + }, + }; + + const { result, rerender } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(listSessionsCachedMock).toHaveBeenCalled()); + act(() => { + result.current.machineRouter.rememberSessionPin(foreignSession, foreignBinding); + }); + expect(result.current.resolveSessionRuntimePin(foreignSession)).toBe(foreignBinding); + + fakeAppStoreState = { + ...fakeAppStoreState, + crossMachineLanesByMachineId: {}, + }; + rerender(); + + expect(result.current.machineRouter.isLivePin(foreignBinding)).toBe(false); + expect(result.current.resolveSessionRuntimePin(foreignSession)).toBe(foreignBinding); + act(() => { + result.current.machineRouter.forgetSessionPin(foreignSession); + }); + }); + + it("collapses a remembered pin to null after the tab rebinds to that machine", async () => { + const activeBinding = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const session = makeSession("pin-now-active", "lane-b", { + ptyId: "pty-pin-now-active", + toolType: "codex", + }); + fakeAppStoreState = { + ...fakeAppStoreState, + projectBinding: activeBinding, + crossMachineLanesByMachineId: {}, + }; + + const { result } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(listSessionsCachedMock).toHaveBeenCalled()); + act(() => { + result.current.machineRouter.rememberSessionPin(session, activeBinding); + }); + + expect(result.current.resolveSessionRuntimePin(session)).toBeNull(); + act(() => { + result.current.machineRouter.forgetSessionPin(session); + }); + }); + + it("stops a restored foreign session on its owning binding without a launch-registry entry", async () => { + const foreignBinding = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const foreignSession = makeSession("restored-foreign", "lane-b", { + ptyId: "pty-restored-foreign", + toolType: "codex", + }); + fakeAppStoreState = { + ...fakeAppStoreState, + projectBinding: { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "Fake", + }, + openRemoteProjectTabs: [foreignBinding], + crossMachineLanesByMachineId: { + "target-b": { + binding: foreignBinding, + lanes: [{ id: "lane-b" }], + sessions: [foreignSession], + }, + }, + }; + + const { result } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(listSessionsCachedMock).toHaveBeenCalled()); + + await act(async () => { + await result.current.stopRuntime("pty-restored-foreign", "restored-foreign"); + }); + + expect((window as any).ade.pty.dispose).toHaveBeenLastCalledWith({ + ptyId: "pty-restored-foreign", + sessionId: "restored-foreign", + }, foreignBinding); + }); + + it("uses the owning machine slice after reload when active and foreign sessions share a lane id", async () => { + const foreignBinding = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const localSession = makeSession("duplicate-lane-local", "lane-1", { + ptyId: "pty-duplicate-lane-local", + toolType: "codex", + }); + const foreignSession = makeSession("duplicate-lane-foreign", "lane-1", { + ptyId: "pty-duplicate-lane-foreign", + toolType: "codex", + }); + // Simulate a fresh renderer: neither stable id has an in-memory launch pin. + forgetWorkPtyLaunchPin(foreignSession); + expect(workPtyLaunchPinFor(foreignSession)).toBeNull(); + fakeAppStoreState = { + ...fakeAppStoreState, + projectBinding: { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "Fake", + }, + crossMachineLanesByMachineId: { + "target-b": { + machineId: "target-b", + machineName: "Machine B", + targetId: "target-b", + projectId: "project-b", + binding: foreignBinding, + online: true, + lanes: [{ id: "lane-1" }], + sessions: [foreignSession], + lastSyncedAtMs: Date.now(), + error: null, + }, + }, + }; + listSessionsCachedMock.mockResolvedValue([localSession]); + + const { result } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(result.current.sessions).toContainEqual(localSession)); + + expect(result.current.resolveSessionRuntimePin(foreignSession)).toBe(foreignBinding); + expect(result.current.resolveSessionRuntimePin(localSession)).toBeNull(); + }); + + it("keeps an active-binding session stop on the unpinned fast path", async () => { + const localSession = makeSession("active-local", "lane-1", { + ptyId: "pty-active-local", + toolType: "codex", + }); + fakeAppStoreState = { + ...fakeAppStoreState, + projectBinding: { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "Fake", + }, + }; + listSessionsCachedMock.mockResolvedValue([localSession]); + + const { result } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(result.current.sessions).toContainEqual(localSession)); + + await act(async () => { + await result.current.stopRuntime("pty-active-local", "active-local"); + }); + + expect((window as any).ade.pty.dispose).toHaveBeenLastCalledWith({ + ptyId: "pty-active-local", + sessionId: "active-local", + }); + }); + + it("stopAllRuntimes stops local and foreign PTYs, pinning only the foreign row", async () => { + const foreignBinding = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const localSession = makeSession("stop-all-local", "lane-1", { + ptyId: "pty-stop-all-local", + toolType: "codex", + }); + const foreignSession = makeSession("stop-all-foreign", "lane-b", { + ptyId: "pty-stop-all-foreign", + toolType: "codex", + }); + fakeAppStoreState = { + ...fakeAppStoreState, + projectBinding: { + kind: "local", + key: "local:/fake/project", + rootPath: "/fake/project", + displayName: "Fake", + }, + openRemoteProjectTabs: [foreignBinding], + crossMachineLanesByMachineId: { + "target-b": { + binding: foreignBinding, + lanes: [{ id: "lane-b" }], + sessions: [foreignSession], + }, + }, + }; + listSessionsCachedMock.mockResolvedValue([localSession]); + + const { result } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(result.current.runningSessions).toHaveLength(2)); + + await act(async () => { + await result.current.stopAllRuntimes(); + }); + + expect((window as any).ade.pty.dispose).toHaveBeenCalledWith({ + ptyId: "pty-stop-all-local", + sessionId: "stop-all-local", + }); + expect((window as any).ade.pty.dispose).toHaveBeenCalledWith({ + ptyId: "pty-stop-all-foreign", + sessionId: "stop-all-foreign", + }, foreignBinding); + }); + + it("keeps routing and stop callback identities stable across session refreshes", async () => { + const first = makeSession("identity-stable", "lane-1", { + ptyId: "pty-identity-stable", + toolType: "codex", + lastOutputPreview: "before", + }); + const refreshed = { ...first, lastOutputPreview: "after" }; + listSessionsCachedMock.mockResolvedValue([first]); + + const { result } = renderHook(() => useWorkSessions()); + await waitFor(() => expect(result.current.sessions).toContainEqual(first)); + const initialResolver = result.current.resolveSessionRuntimePin; + const initialStopRuntime = result.current.stopRuntime; + const initialStopAllRuntimes = result.current.stopAllRuntimes; + + listSessionsCachedMock.mockResolvedValue([refreshed]); + await act(async () => { + await result.current.refresh({ force: true }); + }); + await waitFor(() => expect(result.current.sessions).toContainEqual(refreshed)); + + expect(result.current.resolveSessionRuntimePin).toBe(initialResolver); + expect(result.current.stopRuntime).toBe(initialStopRuntime); + expect(result.current.stopAllRuntimes).toBe(initialStopAllRuntimes); + }); + it("launchPtySession skips Work UI mutations when a pinned launch resolves after project switch", async () => { const pin = { kind: "local", @@ -1216,6 +1778,49 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(workState.selectedItemId).toBe("session-1"); }); + it("opens a foreign URL session from the union without selecting its lane locally", async () => { + const foreign = makeSession("session-foreign", "lane-foreign"); + useSearchParamsMock.mockReturnValue([ + new URLSearchParams(`sessionId=${foreign.id}`), + vi.fn(), + ]); + const workState = { + openItemIds: [] as string[], + activeItemId: null as string | null, + selectedItemId: null as string | null, + draftKind: "chat" as const, + laneFilter: "all", + search: "", + sessionListOrganization: "by-lane" as const, + workCollapsedLaneIds: [] as string[], + workCollapsedTabGroupIds: [] as string[], + workFocusSessionsHidden: false, + }; + fakeAppStoreState = { + ...fakeAppStoreState, + workViewByProject: { "/fake/project": workState }, + crossMachineLanesByMachineId: { + "target-b": { + machineId: "target-b", + machineName: "Machine B", + lanes: [{ id: foreign.laneId }], + sessions: [foreign], + }, + }, + }; + setWorkViewStateSpy.mockImplementation((_projectRoot: string, next: any) => { + const resolved = typeof next === "function" ? next(workState) : { ...workState, ...next }; + Object.assign(workState, resolved); + }); + + const { result } = renderHook(() => useWorkSessions()); + + await waitFor(() => expect(focusSessionSpy).toHaveBeenCalledWith(foreign.id)); + expect(selectLaneSpy).not.toHaveBeenCalledWith(foreign.laneId); + expect(workState.openItemIds).toContain(foreign.id); + expect(result.current.selectedSession).toBe(foreign); + }); + it("falls back to URL lane/status filters when the requested sessionId is stale", async () => { // Only session-2 exists in the list — the URL's sessionId=missing-session // is stale (e.g. deleted). The laneId/status hints must still apply so diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index dfa984180..4de18a890 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; -import type { AgentChatSession, LaneSummary, OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; -import { isLivePinnedBinding } from "../../lib/chatMachineRouting"; +import type { AgentChatSession, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import { PROVIDER_TOOL_TYPE, type ExternalSessionImportResult, @@ -13,7 +12,6 @@ import { selectActiveProjectRoot, useAppStore, useAppStoreApi, - useRootAppStore, type WorkDraftKind, type WorkGridSet, type WorkProjectViewState, @@ -42,17 +40,18 @@ import { subscribeWorkChatSessionCreated, } from "../../lib/chatSessionEvents"; import { - forgetWorkPtyLaunchPin, LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, - rememberWorkPtyLaunchPin, resolveLaunchFields, - workPtyLaunchPinFor, type WorkPtyLaunchArgs, type WorkPtyLaunchResult, } from "./cliLaunch"; import { sortLanesForTabs } from "../lanes/laneUtils"; import { setPendingSessionAnchor } from "./pendingSessionAnchors"; +import { + useRetainedCrossMachineSlices, + useWorkMachineRouter, +} from "./useWorkMachineRouter"; type WorkStatusNavigation = "all" | "running" | "awaiting-input" | "ended" | "settled"; @@ -440,7 +439,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const refreshLanes = useAppStore((s) => s.refreshLanes); const workViewByProject = useAppStore((s) => s.workViewByProject); const setWorkViewState = useAppStore((s) => s.setWorkViewState); - const crossMachineLanesByMachineId = useRootAppStore((s) => s.crossMachineLanesByMachineId); + const retainedCrossMachineSlices = useRetainedCrossMachineSlices(); + const machineRouter = useWorkMachineRouter(retainedCrossMachineSlices); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(false); @@ -459,19 +459,10 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) // tab. The question is no longer "is this the active binding?" but "is this // binding still open?", so updates for a foreign chat are applied and only a // pin for a closed project is discarded. - const canMutatePinnedProjectUi = useCallback((pin: WorkPtyLaunchArgs["pin"] | undefined) => { - const state = appStore.getState(); - const open: OpenProjectBinding[] = []; - if (state.projectBinding) open.push(state.projectBinding); - for (const binding of state.openRemoteProjectTabs ?? []) open.push(binding); - for (const machine of Object.values(crossMachineLanesByMachineId)) { - if (machine.binding) open.push(machine.binding); - } - for (const rootPath of state.openProjectTabRoots ?? []) { - open.push({ kind: "local", key: `local:${rootPath}`, rootPath, displayName: rootPath } as OpenProjectBinding); - } - return isLivePinnedBinding(pin, open); - }, [appStore, crossMachineLanesByMachineId]); + const canMutatePinnedProjectUi = useCallback( + (pin: WorkPtyLaunchArgs["pin"] | undefined) => machineRouter.isLivePin(pin), + [machineRouter], + ); const hasRunningSessionsRef = useRef(false); const backgroundRefreshTimerRef = useRef(null); const pendingHiddenSessionRefreshRef = useRef(false); @@ -583,21 +574,36 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const workLaneSortMode = projectViewState.workLaneSortMode ?? "created"; const workLaneOrder = projectViewState.workLaneOrder ?? EMPTY_STRING_ARRAY; const workSessionFilters = projectViewState.workSessionFilters ?? EMPTY_WORK_SESSION_FILTERS; + // This index is intentionally active-binding-only: local lane selection, + // refresh cadence, and optimistic writes must never target a foreign slice. const localSessionsById = useMemo(() => { const map = new Map(); for (const session of sessions) map.set(session.id, session); return map; }, [sessions]); - const sessionsById = useMemo(() => { - const map = new Map(localSessionsById); - for (const machine of Object.values(crossMachineLanesByMachineId)) { + const crossMachineSessionsById = useMemo(() => { + const map = new Map(); + for (const machine of retainedCrossMachineSlices) { for (const session of machine.sessions) { if (!map.has(session.id)) map.set(session.id, session); } } return map; - }, [crossMachineLanesByMachineId, localSessionsById]); + }, [retainedCrossMachineSlices]); + const sessionsById = useMemo(() => { + const map = new Map(localSessionsById); + for (const [sessionId, session] of crossMachineSessionsById) { + if (!map.has(sessionId)) map.set(sessionId, session); + } + return map; + }, [crossMachineSessionsById, localSessionsById]); + const sessionsByIdRef = useRef(sessionsById); + useLayoutEffect(() => { + sessionsByIdRef.current = sessionsById; + }, [sessionsById]); const missingSessionLaneIdsSignature = useMemo(() => { + // Lane recovery refreshes only the active binding's lane service; foreign + // rows are reconciled by their owning machine slice instead. if (sessions.length === 0) return ""; const knownLaneIds = new Set(lanes.map((lane) => lane.id)); const missingLaneIds = new Set(); @@ -1291,6 +1297,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) }, [isWorkRoute]); useEffect(() => { + // Refresh scheduling is active-binding-only; foreign slices have their own + // cross-machine sync cadence and must not drive this project's IPC polling. sessionsRef.current = sessions; hasRunningSessionsRef.current = sessions.some((s) => s.status === "running"); }, [sessions]); @@ -1306,7 +1314,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) // load completes) we fall through so the URL's laneId/status hints still // narrow the view instead of dumping the user into an unrelated context. if (sessionParam) { - const sessionExists = sessions.some((s) => s.id === sessionParam); + const sessionExists = sessionsById.has(sessionParam); if (sessionExists) { appliedUrlFilterKeyRef.current = `${sessionParam}|${laneParam}|${statusParam}`; partiallyAppliedUrlFilterKeyRef.current = null; @@ -1361,7 +1369,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) : prev?.expandSectionId ?? null, })); if (laneDeterminable) stripUrlFilterParams(); - }, [isWorkRoute, lanes, searchParams, sessions, setProjectViewState, stripUrlFilterParams]); + }, [isWorkRoute, lanes, searchParams, sessionsById, setProjectViewState, stripUrlFilterParams]); // Migrate legacy org modes to supported modes useEffect(() => { @@ -1397,7 +1405,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) if (appliedQuerySessionIdRef.current === applyKey) return; if (pendingProjectSwitchRef.current != null) return; - const session = sessions.find((entry) => entry.id === sessionParam); + const session = sessionsById.get(sessionParam); if (!session) return; appliedQuerySessionIdRef.current = applyKey; @@ -1407,7 +1415,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) event: /^\d+$/.test(eventRaw) ? Number(eventRaw) : undefined, offset: /^\d+$/.test(offsetRaw) ? Number(offsetRaw) : undefined, }); - selectLane(session.laneId); + selectLaneForActiveTab(session.id); focusSession(session.id); setProjectViewState((prev) => { const nextOpen = prev.openItemIds.includes(session.id) @@ -1427,7 +1435,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) selectedItemId: session.id, }; }); - }, [focusSession, isWorkRoute, searchParams, selectLane, sessions, setProjectViewState]); + }, [focusSession, isWorkRoute, searchParams, selectLaneForActiveTab, sessionsById, setProjectViewState]); useEffect(() => { if (!isWorkRoute) return; @@ -1498,6 +1506,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) }, [isRemoteProject, isWorkRoute, scheduleBackgroundRefresh]); const filtered = useMemo(() => { + // Filtering here is active-binding-only: SessionListPane applies the same + // controls to its separately rendered cross-machine rows. const needle = q.trim().toLowerCase(); return sessions.filter((session) => { if (filterLaneId !== "all" && session.laneId !== filterLaneId) return false; @@ -1634,8 +1644,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) }, [sessionListOrganization, chipFiltered]); const runningSessions = useMemo( - () => sessions.filter((session) => session.status === "running"), - [sessions], + () => [...sessionsById.values()].filter((session) => session.status === "running"), + [sessionsById], ); const gridLayoutId = useMemo( @@ -1644,8 +1654,12 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) ); const selectedSession = useMemo( - () => (selectedSessionId ? sessions.find((session) => session.id === selectedSessionId) ?? null : null), - [sessions, selectedSessionId], + () => (selectedSessionId ? sessionsById.get(selectedSessionId) ?? null : null), + [selectedSessionId, sessionsById], + ); + const canPruneSessionIndex = useCallback( + () => pendingProjectSwitchRef.current == null && hasAuthoritativeSessionsRef.current, + [], ); useEffect(() => { @@ -1745,7 +1759,10 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) }); invalidateSessionListCache(); const endedAt = markPtyClosed(ptyId, sessionId); - const pin = workPtyLaunchPinFor({ ptyId, sessionId }); + const session = sessionId + ? sessionsByIdRef.current.get(sessionId) + : [...sessionsByIdRef.current.values()].find((candidate) => candidate.ptyId === ptyId); + const pin = machineRouter.pinForSession(session ?? { ptyId, sessionId }); let disposeError: unknown = null; try { @@ -1759,11 +1776,11 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) restorePtyClosed(previousSessions); } else { rememberStoppedRuntime(ptyId, sessionId, endedAt); - forgetWorkPtyLaunchPin({ ptyId, sessionId }); + machineRouter.forgetSessionPin({ ptyId, sessionId }); } } else { rememberStoppedRuntime(ptyId, sessionId, endedAt); - forgetWorkPtyLaunchPin({ ptyId, sessionId }); + machineRouter.forgetSessionPin({ ptyId, sessionId }); } } catch (error) { disposeError = error; @@ -1782,16 +1799,21 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) } if (disposeError) throw disposeError; }, - [refresh], + [machineRouter, refresh], ); const stopAllRuntimes = useCallback(async () => { + // "Stop all" spans the combined Work union, including foreign rows. Each + // stop goes through the same session resolver as an individual stop, so a + // foreign PTY is disposed on its owning binding while local PTYs keep the + // unpinned fast path. Chat rows without a PTY are intentionally skipped. await Promise.allSettled([ - ...runningSessions + ...[...sessionsByIdRef.current.values()] .filter((session) => Boolean(session.ptyId)) + .filter((session) => session.status === "running") .map((session) => stopRuntime(session.ptyId as string, session.id)), ]); - }, [runningSessions, stopRuntime]); + }, [stopRuntime]); const launchPtySession = useCallback( async (args: WorkPtyLaunchArgs): Promise => { @@ -1826,7 +1848,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const result = args.pin ? await window.ade.pty.create(createArgs, args.pin) : await window.ade.pty.create(createArgs); - rememberWorkPtyLaunchPin(result, args.pin); + machineRouter.rememberSessionPin(result, args.pin); if (!canMutatePinnedProjectUi(args.pin)) { return result; } @@ -1878,7 +1900,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) void refresh({ showLoading: false, force: true }).catch(() => {}); return result; }, - [canMutatePinnedProjectUi, focusSession, lanes, openSessionTab, refresh, selectLane], + [canMutatePinnedProjectUi, focusSession, lanes, machineRouter, openSessionTab, refresh, selectLane], ); const removeSessionFromList = useCallback((sessionId: string) => { @@ -1987,7 +2009,11 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) ); return { + // Raw active-binding inventory; SessionListPane layers foreign rows from + // the cross-machine store rather than treating them as local refresh data. sessions, + sessionsById, + canPruneSessionIndex, lanes, filtered, runningFiltered, @@ -2073,5 +2099,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) navigate, selectLane, focusSession, + machineRouter, + resolveSessionRuntimePin: machineRouter.pinForSession, }; } diff --git a/apps/desktop/src/renderer/lib/chatMachineRouting.test.ts b/apps/desktop/src/renderer/lib/chatMachineRouting.test.ts index 7868541aa..af0dd3473 100644 --- a/apps/desktop/src/renderer/lib/chatMachineRouting.test.ts +++ b/apps/desktop/src/renderer/lib/chatMachineRouting.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + buildChatMachineRoutingState, buildLaneBindingIndex, + collectOpenProjectBindings, createChatMachineRouter, isLivePinnedBinding, resolveChatRuntimePin, @@ -59,6 +61,46 @@ describe("buildLaneBindingIndex", () => { }); }); +describe("collectOpenProjectBindings", () => { + it("collects active, remote, local-root, and additional bindings once", () => { + expect(collectOpenProjectBindings({ + activeBinding: machineA, + remoteBindings: [machineB, machineA], + localProjects: [ + { rootPath: " /repo-c ", displayName: " Repo C " }, + { rootPath: "" }, + ], + additionalBindings: [machineB], + })).toEqual([ + machineA, + machineB, + { + kind: "local", + key: "local:/repo-c", + rootPath: "/repo-c", + displayName: "Repo C", + }, + ]); + }); +}); + +describe("buildChatMachineRoutingState", () => { + it("puts the active binding's live lanes ahead of cached sources", () => { + const routingState = buildChatMachineRoutingState({ + activeBinding: machineA, + openBindings: [machineA, machineB], + activeLaneIds: ["lane-shared", "lane-a"], + additionalLaneSources: [ + { bindingKey: machineB.key, laneIds: ["lane-shared", "lane-b"] }, + ], + }); + + expect(routingState.laneBindingIndex.get("lane-shared")).toBe(machineA.key); + expect(routingState.laneBindingIndex.get("lane-a")).toBe(machineA.key); + expect(routingState.laneBindingIndex.get("lane-b")).toBe(machineB.key); + }); +}); + describe("resolveLaneBindingKey", () => { it("resolves known lanes and returns null for unknown or blank ones", () => { const s = state(); diff --git a/apps/desktop/src/renderer/lib/chatMachineRouting.ts b/apps/desktop/src/renderer/lib/chatMachineRouting.ts index b31e2bd84..d41281be5 100644 --- a/apps/desktop/src/renderer/lib/chatMachineRouting.ts +++ b/apps/desktop/src/renderer/lib/chatMachineRouting.ts @@ -36,6 +36,67 @@ export type ChatMachineRoutingState = { laneBindingIndex: LaneBindingIndex; }; +export type OpenProjectBindingCollectionArgs = { + activeBinding: OpenProjectBinding | null; + remoteBindings?: readonly (OpenProjectBinding | null | undefined)[]; + localProjects?: readonly { rootPath: string; displayName?: string | null }[]; + additionalBindings?: readonly (OpenProjectBinding | null | undefined)[]; +}; + +/** Collect and de-duplicate every project binding that is open in this window. */ +export function collectOpenProjectBindings( + args: OpenProjectBindingCollectionArgs, +): OpenProjectBinding[] { + const bindings: OpenProjectBinding[] = []; + const seen = new Set(); + const push = (binding: OpenProjectBinding | null | undefined) => { + if (!binding || seen.has(binding.key)) return; + seen.add(binding.key); + bindings.push(binding); + }; + + push(args.activeBinding); + for (const binding of args.remoteBindings ?? []) push(binding); + for (const project of args.localProjects ?? []) { + const rootPath = normalizeId(project.rootPath); + if (!rootPath) continue; + push({ + kind: "local", + key: `local:${rootPath}`, + rootPath, + displayName: project.displayName?.trim() || rootPath, + }); + } + for (const binding of args.additionalBindings ?? []) push(binding); + return bindings; +} + +export type ChatMachineRoutingStateArgs = { + activeBinding: OpenProjectBinding | null; + openBindings: readonly OpenProjectBinding[]; + activeLaneIds?: readonly string[]; + additionalLaneSources?: readonly LaneBindingSource[]; +}; + +/** Build router state with the active binding's live lanes taking precedence. */ +export function buildChatMachineRoutingState( + args: ChatMachineRoutingStateArgs, +): ChatMachineRoutingState { + const sources: LaneBindingSource[] = []; + if (args.activeBinding) { + sources.push({ + bindingKey: args.activeBinding.key, + laneIds: args.activeLaneIds ?? [], + }); + } + sources.push(...(args.additionalLaneSources ?? [])); + return { + activeBinding: args.activeBinding, + openBindings: args.openBindings, + laneBindingIndex: buildLaneBindingIndex(sources), + }; +} + function normalizeId(value: string | null | undefined): string { return typeof value === "string" ? value.trim() : ""; } @@ -95,18 +156,10 @@ export function resolveChatRuntimePin( * Is `pin` still a live target — i.e. a binding this window still has open? * * INTEGRATION NOTE (must not be lost): - * `useWorkSessions.canMutatePinnedProjectUi` (apps/desktop/src/renderer/components/terminals/useWorkSessions.ts, - * ~:463) and `AgentChatPane.canRefreshPinnedProject` currently test - * `activeBinding.key === pin.key` and DROP the UI mutation when it differs. - * That predicate encodes a pre-per-chat-routing assumption: "pin ≠ active - * binding" used to mean only "a stale detached launch from a project the user - * has since switched away from". Under per-chat routing it is the NORMAL, - * CORRECT state of every chat whose lane lives on another machine, so the old - * guard would silently suppress legitimate updates on exactly those chats. - * - * `useWorkSessions.canMutatePinnedProjectUi` MUST be switched to this predicate - * (that file is owned by the union-sidebar work; the swap is a one-line change: - * `isLivePinnedBinding(pin, openBindings)`). + * `useWorkSessions.canMutatePinnedProjectUi` delegates to the Work machine + * router's instance of this predicate, and `AgentChatPane` calls it directly. + * Keep both on open-binding liveness: requiring `activeBinding.key === pin.key` + * would drop the normal, correct updates for every foreign session. */ export function isLivePinnedBinding( pin: { key: string } | null | undefined, diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 83e201fee..90c51ae3a 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -1149,6 +1149,13 @@ export type AppState = { * repos with a `main` lane would look like the same branch on two machines. */ crossMachineLaneScopeKey: string | null; + /** + * Authoritative machine membership for the current cross-machine scope. + * `null` means the scope is still resolving; `[]` means it resolved empty. + * Kept separately from arriving slices so a partial refill can distinguish a + * slow machine from one that was deliberately removed. + */ + crossMachineLaneIntendedMachineIds: string[] | null; /** * Cross-machine Work union, keyed by machine id. Shared store state on * purpose: every surface that needs it (sidebar markers, the push-divergence @@ -1187,7 +1194,10 @@ export type AppState = { * union; re-applying the same scope is a no-op (identity preserved), so this * is safe to call on every render pass of the sidebar. */ - applyCrossMachineLaneScope: (scopeKey: string | null) => void; + applyCrossMachineLaneScope: ( + scopeKey: string | null, + intendedMachineIds?: readonly string[] | null, + ) => void; /** * Merges one machine's slice. Omitted `lanes` / `sessions` are RETAINED, which * is what makes a failed read leave the machine's rows on screen instead of @@ -1519,6 +1529,7 @@ const createAppState: StateCreator = (set, get) => { dismissedGithubBannerRoots: {}, openRemoteProjectTabs: [], crossMachineLaneScopeKey: null, + crossMachineLaneIntendedMachineIds: null, crossMachineLanesByMachineId: {}, setProject: (project) => @@ -1726,15 +1737,37 @@ const createAppState: StateCreator = (set, get) => { }, }; }), - applyCrossMachineLaneScope: (scopeKey) => + applyCrossMachineLaneScope: (scopeKey, intendedMachineIds) => set((prev) => { - if (prev.crossMachineLaneScopeKey === scopeKey) return {}; + const scopeChanged = prev.crossMachineLaneScopeKey !== scopeKey; + const normalizedIntendedMachineIds = intendedMachineIds === undefined + ? undefined + : intendedMachineIds === null + ? null + : Array.from(new Set( + intendedMachineIds.map((machineId) => machineId.trim()).filter(Boolean), + )).sort(); + const nextIntendedMachineIds = normalizedIntendedMachineIds === undefined + ? scopeChanged + // Preserve the membership needle across the wholesale clear. A later + // sync pass replaces it once the new scope's machine set is known. + ? Object.keys(prev.crossMachineLanesByMachineId).sort() + : prev.crossMachineLaneIntendedMachineIds + : normalizedIntendedMachineIds; + const stableIntendedMachineIds = reuseStructurallyEqualArray( + nextIntendedMachineIds ?? undefined, + prev.crossMachineLaneIntendedMachineIds ?? undefined, + ) ?? null; + const intendedChanged = stableIntendedMachineIds !== prev.crossMachineLaneIntendedMachineIds; + + if (!scopeChanged && !intendedChanged) return {}; // Identity matters: consumers select this record straight out of the // store, so a fresh `{}` on every call would re-render the whole sidebar. const alreadyEmpty = Object.keys(prev.crossMachineLanesByMachineId).length === 0; return { crossMachineLaneScopeKey: scopeKey, - ...(alreadyEmpty ? {} : { crossMachineLanesByMachineId: {} }), + crossMachineLaneIntendedMachineIds: stableIntendedMachineIds, + ...(scopeChanged && !alreadyEmpty ? { crossMachineLanesByMachineId: {} } : {}), }; }), @@ -1742,6 +1775,10 @@ const createAppState: StateCreator = (set, get) => { set((prev) => { const machineId = entry.machineId.trim(); if (!machineId) return {}; + const nextIntendedMachineIds = prev.crossMachineLaneIntendedMachineIds?.includes(machineId) + ? prev.crossMachineLaneIntendedMachineIds + : [...(prev.crossMachineLaneIntendedMachineIds ?? []), machineId].sort(); + const intendedChanged = nextIntendedMachineIds !== prev.crossMachineLaneIntendedMachineIds; const previous = prev.crossMachineLanesByMachineId[machineId] ?? null; const lanes = reuseStructurallyEqualArray(entry.lanes, previous?.lanes); const sessions = reuseStructurallyEqualArray(entry.sessions, previous?.sessions); @@ -1763,7 +1800,7 @@ const createAppState: StateCreator = (set, get) => { entry.lanes || entry.sessions ? Date.now() : previous?.lastSyncedAtMs ?? null, error: entry.error !== undefined ? entry.error : previous?.error ?? null, }; - if ( + const sliceUnchanged = ( previous && previous.machineName === next.machineName && previous.targetId === next.targetId @@ -1774,14 +1811,20 @@ const createAppState: StateCreator = (set, get) => { && previous.sessions === next.sessions && previous.lastSyncedAtMs === next.lastSyncedAtMs && previous.error === next.error - ) { - return {}; - } + ); + if (sliceUnchanged && !intendedChanged) return {}; return { - crossMachineLanesByMachineId: { - ...prev.crossMachineLanesByMachineId, - [machineId]: next, - }, + ...(sliceUnchanged + ? {} + : { + crossMachineLanesByMachineId: { + ...prev.crossMachineLanesByMachineId, + [machineId]: next, + }, + }), + ...(intendedChanged + ? { crossMachineLaneIntendedMachineIds: nextIntendedMachineIds } + : {}), }; }), @@ -1816,7 +1859,19 @@ const createAppState: StateCreator = (set, get) => { } nextRecord[machineId] = entry; } - return changed ? { crossMachineLanesByMachineId: nextRecord } : {}; + const nextIntendedMachineIds = prev.crossMachineLaneIntendedMachineIds?.filter( + (machineId) => !dropped.has(machineId), + ) ?? null; + const intendedChanged = nextIntendedMachineIds !== null + && nextIntendedMachineIds.length !== prev.crossMachineLaneIntendedMachineIds?.length; + return changed || intendedChanged + ? { + ...(changed ? { crossMachineLanesByMachineId: nextRecord } : {}), + ...(intendedChanged + ? { crossMachineLaneIntendedMachineIds: nextIntendedMachineIds } + : {}), + } + : {}; }), setLaneInspectorTab: (laneId, tab) => diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts index f8f3aa6ac..4f10961fd 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts @@ -87,6 +87,7 @@ beforeEach(() => { lanes: [], projectBinding: null, crossMachineLaneScopeKey: null, + crossMachineLaneIntendedMachineIds: null, crossMachineLanesByMachineId: {}, }); resetCrossMachineLaneSyncForTest(); @@ -177,6 +178,7 @@ describe("offline machines stay in the sidebar, dimmed", () => { }); useAppStore.getState().dropCrossMachineLanes(["target-studio"]); expect(useAppStore.getState().crossMachineLanesByMachineId["target-studio"]).toBeUndefined(); + expect(useAppStore.getState().crossMachineLaneIntendedMachineIds).toEqual([]); const empty = useAppStore.getState().crossMachineLanesByMachineId; useAppStore.getState().dropCrossMachineLanes(["target-studio"]); expect(useAppStore.getState().crossMachineLanesByMachineId).toBe(empty); @@ -260,9 +262,12 @@ describe("offline machines stay in the sidebar, dimmed", () => { }); useAppStore.getState().applyCrossMachineLaneScope("local:/repo-a"); expect(useAppStore.getState().crossMachineLanesByMachineId).toEqual({}); + expect(useAppStore.getState().crossMachineLaneIntendedMachineIds).toEqual(["target-studio"]); const empty = useAppStore.getState().crossMachineLanesByMachineId; + const intended = useAppStore.getState().crossMachineLaneIntendedMachineIds; useAppStore.getState().applyCrossMachineLaneScope("local:/repo-a"); expect(useAppStore.getState().crossMachineLanesByMachineId).toBe(empty); + expect(useAppStore.getState().crossMachineLaneIntendedMachineIds).toBe(intended); }); }); diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.ts b/apps/desktop/src/renderer/state/crossMachineLanes.ts index 649de64a0..f52374d79 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.ts @@ -1019,9 +1019,11 @@ async function runRefresh(): Promise { const generation = ++runtime.generation; const scope = runtime.scope; const store = rootAppStoreApi.getState(); - store.applyCrossMachineLaneScope(scope.scopeKey); - const targets = resolveEligibleMachines(); + store.applyCrossMachineLaneScope( + scope.scopeKey, + resolveRefillIntendedMachineIds(targets, true), + ); // Bounded fan-out. Reads are independent, so a wedged machine costs one slot // for at most `MACHINE_READ_TIMEOUT_MS` and never blocks the others. @@ -1146,6 +1148,35 @@ function resolveEligibleMachines(): LaneMachineOption[] { return eligible; } +/** + * Machine membership for one refill, independent of which slices have arrived. + * + * Existing intended ids include retained offline machines; `applyReachability` + * removes them only when its normal forgotten verdict fires. Eligible targets + * and the local counterpart are added before any read starts, so a slow slice + * remains distinguishable from a removed machine throughout the refill. + */ +function resolveRefillIntendedMachineIds( + targets: readonly LaneMachineOption[], + preserveExisting: boolean, +): string[] { + const store = rootAppStoreApi.getState(); + const intended = new Set(); + if (preserveExisting) { + for (const machineId of ( + store.crossMachineLaneIntendedMachineIds + ?? Object.keys(store.crossMachineLanesByMachineId) + )) { + intended.add(machineId); + } + } + for (const target of targets) intended.add(target.id); + if (runtime.scope.boundTargetId && runtime.scope.thisMachineBinding) { + intended.add(THIS_MACHINE_ID); + } + return Array.from(intended).sort(); +} + /** * What the newest snapshot says about one machine. A machine that is not in the * snapshot at all has no entry here — absence in the map IS that fact, which is @@ -1232,8 +1263,12 @@ function applyReachability(): void { runtime.dropsByMachineId.delete(machineId); reachable.push(machineId); } - for (const entry of Object.values(store.crossMachineLanesByMachineId)) { - const machineId = entry.machineId; + const scopedMachineIds = new Set([ + ...Object.keys(store.crossMachineLanesByMachineId), + ...(store.crossMachineLaneIntendedMachineIds ?? []), + ]); + for (const machineId of scopedMachineIds) { + const entry = store.crossMachineLanesByMachineId[machineId] ?? null; // This Mac is not a connection target and is always reachable; holding a // drop record for it would leak a map entry nothing can ever clear. if (machineId === THIS_MACHINE_ID) continue; @@ -1258,7 +1293,7 @@ function applyReachability(): void { } const drop = runtime.dropsByMachineId.get(machineId) - ?? (entry.online + ?? (entry?.online !== false ? { droppedAtMs: nowMs, sawAttempt: false, attemptFailed: false } // Already dimmed with no drop record: a remount cleared the records // while the store slice survived. A fresh record would restart the floor @@ -1280,8 +1315,8 @@ function applyReachability(): void { + (answered ? UNREACHABLE_FLOOR_MS : UNREACHABLE_CEILING_MS); // A machine that is already dimmed stays dimmed until it is eligible again, // whatever the deadline says — the verdict is the store's, not this tick's. - if (entry.online && nowMs < dimAtMs) { - reachable.push(machineId); + if (entry?.online !== false && nowMs < dimAtMs) { + if (entry) reachable.push(machineId); noteDeadline(dimAtMs); continue; } @@ -1405,6 +1440,7 @@ function detach(): void { export function startCrossMachineLaneSync(scope: CrossMachineLaneScope): () => void { const scopeChanged = !sameScope(runtime.scope, scope); if (scopeChanged) { + const preserveExistingIntent = runtime.scope.scopeKey === scope.scopeKey; // Project-tab transitions can briefly overlap React effect cleanup. Retarget // the shared runtime immediately; rejecting the new scope would leave it // permanently unsubscribed once the previous effect cleans up. @@ -1418,7 +1454,11 @@ export function startCrossMachineLaneSync(scope: CrossMachineLaneScope): () => v // over from the old one could hide a machine with no grace at all the moment // it reappears here. resetMachineTracking(); - rootAppStoreApi.getState().applyCrossMachineLaneScope(scope.scopeKey); + runtime.scope = scope; + rootAppStoreApi.getState().applyCrossMachineLaneScope( + scope.scopeKey, + resolveRefillIntendedMachineIds(resolveEligibleMachines(), preserveExistingIntent), + ); } runtime.scope = scope; runtime.refCount += 1; diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index b2c7cd18c..84d0ddcbd 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -1984,6 +1984,78 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("fails loudly when a runtime pin reaches a single-host web terminal shim", async () => { + const pin = { + kind: "remote", + key: "remote:target-b:project-b", + targetId: "target-b", + runtimeName: "Machine B", + projectId: "project-b", + rootPath: "/repo-b", + displayName: "Repo B", + } as const; + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + const unsupported = /cross-machine web routing is not implemented/; + + await expect(adapter.ade.pty.create({ + laneId: "lane-1", + toolType: "shell", + title: "Pinned shell", + cols: 80, + rows: 24, + }, pin)).rejects.toThrow(unsupported); + await expect(adapter.ade.pty.resumeSession({ + sessionId: "session-1", + cols: 80, + rows: 24, + }, pin)).rejects.toThrow(unsupported); + await expect(adapter.ade.pty.sendToSession({ + sessionId: "session-1", + text: "hello", + cols: 80, + rows: 24, + }, pin)).rejects.toThrow(unsupported); + await expect(adapter.ade.pty.write({ + ptyId: "pty-1", + data: "hello", + }, pin)).rejects.toThrow(unsupported); + await expect(adapter.ade.pty.resize({ + ptyId: "pty-1", + cols: 100, + rows: 30, + }, pin)).rejects.toThrow(unsupported); + await expect(adapter.ade.pty.dispose({ + ptyId: "pty-1", + sessionId: "session-1", + }, pin)).rejects.toThrow(unsupported); + await expect(adapter.ade.pty.setDataSubscriptions({ ptyIds: ["pty-1"] }, pin)) + .rejects.toThrow(unsupported); + expect(() => adapter.ade.pty.onData(() => {}, pin)).toThrow(unsupported); + expect(() => adapter.ade.pty.onExit(() => {}, pin)).toThrow(unsupported); + await expect(adapter.ade.terminal.preview({ terminalId: "session-1" }, pin)) + .rejects.toThrow(unsupported); + expect(() => adapter.ade.agentChat.saveTempAttachment({ + data: "data:image/png;base64,aGVsbG8=", + filename: "pinned.png", + }, pin)).toThrow(unsupported); + + // The read shims on the cross-machine lane-discovery path fail the same + // way: a pinned sessions/lanes read silently answering from the single web + // host would be a wrong-machine read, not a degraded one. + await expect(adapter.ade.sessions.list({}, pin)).rejects.toThrow(unsupported); + await expect(adapter.ade.sessions.get("session-1", pin)).rejects.toThrow(unsupported); + await expect(adapter.ade.sessions.readTranscriptTail({ + sessionId: "session-1", + maxBytes: 1024, + }, pin)).rejects.toThrow(unsupported); + expect(() => adapter.ade.lanes.list({}, pin)).toThrow(unsupported); + + expect(fake.commandCalls).toHaveLength(0); + expect(fake.terminalSubscribeCalls).toHaveLength(0); + adapter.dispose(); + }); + it("keeps the live terminal subscription while preview and transcript tail read history", async () => { fake.descriptors = descriptors(["work.listSessions"]); fake.commandResults.set("work.listSessions", [{ id: "session-1", ptyId: "pty-1", status: "running" }]); diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index be33bd816..766b18221 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -15,6 +15,7 @@ import { deriveSmartLinkPreview } from "../../../shared/smartLinks"; import type { AdapterInfra, AdeNamespace } from "./types"; import { requestDataUrl, requestFileBlob } from "./infra/fileBlob"; import { chatEventDedupKey } from "./infra/chatEventDedup"; +import { assertWebRuntimePinUnsupported } from "./runtimePinGuard"; // The browser gets authoritative ordered history through // chat.getChatEventHistory. chat_subscribe snapshots still matter as bounded @@ -302,7 +303,10 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age await call("chat.killDroidWorker", args, undefined, false); }, getSessionCapabilities: (args: unknown) => call("chat.getSessionCapabilities", args, { capabilities: [] }), - saveTempAttachment: (args: unknown) => call("chat.saveTempAttachment", args, { path: "" }, false), + saveTempAttachment: (args: unknown, pin?: unknown) => { + assertWebRuntimePinUnsupported("agentChat.saveTempAttachment", pin); + return call("chat.saveTempAttachment", args, { path: "" }, false); + }, getImageDataUrl: async (path: string) => ({ dataUrl: (await requestDataUrl(client, infra.state, "readArtifact", { path })) ?? "" }), resolveSmartLinkPreview: (args: unknown) => { const record = asRecord(args); diff --git a/apps/desktop/src/renderer/webclient/adapter/lanes.ts b/apps/desktop/src/renderer/webclient/adapter/lanes.ts index 8aad07bb2..8a323ac92 100644 --- a/apps/desktop/src/renderer/webclient/adapter/lanes.ts +++ b/apps/desktop/src/renderer/webclient/adapter/lanes.ts @@ -6,6 +6,7 @@ import type { RestoreLaneResult, } from "../../../shared/types"; import type { AdapterInfra, AdeNamespace } from "./types"; +import { assertWebRuntimePinUnsupported } from "./runtimePinGuard"; export function createLanesNamespace(infra: AdapterInfra): AdeNamespace<"lanes"> { const { commands, events } = infra; @@ -31,11 +32,14 @@ export function createLanesNamespace(infra: AdapterInfra): AdeNamespace<"lanes"> ); const lanes: Record = { - list: (args?: unknown) => commands.call("lanes.list", asRecord(args), { - fallback: [], - idempotent: true, - cacheTtlMs: 3_000, - }), + list: (args?: unknown, pin?: unknown) => { + assertWebRuntimePinUnsupported("lanes.list", pin); + return commands.call("lanes.list", asRecord(args), { + fallback: [], + idempotent: true, + cacheTtlMs: 3_000, + }); + }, listSnapshots: async (args?: unknown) => { const result = await call("lanes.refreshSnapshots", args, []); return arrayField(result, "snapshots"); diff --git a/apps/desktop/src/renderer/webclient/adapter/project.ts b/apps/desktop/src/renderer/webclient/adapter/project.ts index 144da70f6..d9880937f 100644 --- a/apps/desktop/src/renderer/webclient/adapter/project.ts +++ b/apps/desktop/src/renderer/webclient/adapter/project.ts @@ -14,6 +14,11 @@ export function createProjectNamespace(infra: AdapterInfra): AdeNamespace<"proje async function listRecent() { const projects = await refreshCatalog(); + // Deliberately no `gitOriginUrl`: cross-machine lane discovery keys off it + // (crossMachineLanes.resolveThisMachineBindingForOrigin), and the web + // adapter's lanes/sessions shims cannot route pinned reads. Adding the + // field here would silently point those reads at the wrong machine — see + // the contract-gap guard in sessionsPty.ts. return projects.map((project) => ({ rootPath: project.rootPath, displayName: project.displayName, diff --git a/apps/desktop/src/renderer/webclient/adapter/runtimePinGuard.ts b/apps/desktop/src/renderer/webclient/adapter/runtimePinGuard.ts new file mode 100644 index 000000000..fc9408445 --- /dev/null +++ b/apps/desktop/src/renderer/webclient/adapter/runtimePinGuard.ts @@ -0,0 +1,24 @@ +/** + * Adapter-wide runtime-pin boundary guard. + * + * ADE Web targets exactly one host, so an Electron-contract call that arrives + * with a per-session runtime pin cannot be routed — silently serving it from + * the single web host would be a wrong-machine read or write. Every + * pin-accepting shim in this adapter (pty/terminal, sessions reads on the + * cross-machine lane-discovery path, lanes.list, and the draft attachment + * shim) calls this guard so the gap fails loudly instead; a cross-machine web + * union must extend the adapter before relying on any pin. + */ +export function assertWebRuntimePinUnsupported( + operation: string, + pin: unknown, +): void { + if (pin == null) return; + const key = + pin && typeof pin === "object" && typeof (pin as { key?: unknown }).key === "string" + ? (pin as { key: string }).key + : "unknown binding"; + throw new Error( + `ADE Web cannot route ${operation} to pinned runtime ${key}; cross-machine web routing is not implemented.`, + ); +} diff --git a/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts b/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts index bb95a345e..54672e71a 100644 --- a/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts +++ b/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts @@ -21,6 +21,7 @@ import { type SessionLifecyclePatch, } from "./sessionLifecycleOverlay"; import { SessionLifecycleUnavailableError } from "./sessionLifecycleSupport"; +import { assertWebRuntimePinUnsupported } from "./runtimePinGuard"; // Full snapshots replace xterm state, so they must be at least as complete as // TerminalView's initial hydration. The host caps this at the same 2 MB. @@ -153,7 +154,8 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam } } - async function listSessions(args?: unknown): Promise { + async function listSessions(args?: unknown, pin?: unknown): Promise { + assertWebRuntimePinUnsupported("sessions.list", pin); const record = asRecord(args); const key = stableCacheKey(record); const mirrored = sessionMirror.get(key); @@ -271,7 +273,8 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam const sessions: Record = { list: listSessions, - get: async (sessionId: string) => { + get: async (sessionId: string, pin?: unknown) => { + assertWebRuntimePinUnsupported("sessions.get", pin); const detail = await commands.call("work.getSession", { sessionId }, { fallback: null, idempotent: true, @@ -371,7 +374,8 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam CLEAR_WOKE_PATCH, appliedToAll, )), - readTranscriptTail: async (args: unknown) => { + readTranscriptTail: async (args: unknown, pin?: unknown) => { + assertWebRuntimePinUnsupported("sessions.readTranscriptTail", pin); const record = asRecord(args); const sessionId = stringField(record, "sessionId"); const maxBytes = numberField(record, "maxBytes"); @@ -387,8 +391,16 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam onChanged: (listener: (event: unknown) => void) => events.on("sessionsChanged", listener as never), }; + // Contract gap: these Electron-shaped namespaces target one web host and do + // not accept runtime pins. The shared guard (./runtimePinGuard) covers every pty/terminal + // shim, sessions.list/get/readTranscriptTail, lanes.list, and the draft + // attachment shim in agentChat.ts — the surfaces cross-machine reads actually + // reach today. The wider lanes/sessions pin params in the Electron contract + // predate per-session routing and stay unguarded; a cross-machine web union + // must extend the adapter (and these guards) before relying on any pin. const pty: Record = { - create: async (args: unknown): Promise => { + create: async (args: unknown, pin?: unknown): Promise => { + assertWebRuntimePinUnsupported("pty.create", pin); const record = asRecord(args); const result = await commands.call | null>( "work.startCliSession", @@ -419,7 +431,8 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam }); return { ptyId, sessionId, pid: null }; }, - resumeSession: async (args: unknown): Promise => { + resumeSession: async (args: unknown, pin?: unknown): Promise => { + assertWebRuntimePinUnsupported("pty.resumeSession", pin); const result = await commands.call("work.resumeCliSession", asRecord(args), { fallback: null, idempotent: false, @@ -430,7 +443,8 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam } return result ?? fallbackSendResult(asRecord(args)); }, - sendToSession: async (args: unknown): Promise => { + sendToSession: async (args: unknown, pin?: unknown): Promise => { + assertWebRuntimePinUnsupported("pty.sendToSession", pin); const result = await commands.call("work.sendToSession", asRecord(args), { fallback: null, idempotent: false, @@ -441,19 +455,22 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam } return result ?? fallbackSendResult(asRecord(args)); }, - write: async (args: unknown) => { + write: async (args: unknown, pin?: unknown) => { + assertWebRuntimePinUnsupported("pty.write", pin); const record = asRecord(args); const sessionId = terminalRegistry.sessionForPty(stringField(record, "ptyId")); if (!sessionId) return; await client.sendTerminalInput(sessionId, stringField(record, "data")); }, - resize: async (args: unknown) => { + resize: async (args: unknown, pin?: unknown) => { + assertWebRuntimePinUnsupported("pty.resize", pin); const record = asRecord(args); const sessionId = terminalRegistry.sessionForPty(stringField(record, "ptyId")); if (!sessionId) return; await client.sendTerminalResize(sessionId, numberField(record, "cols") ?? 80, numberField(record, "rows") ?? 24); }, - dispose: async (args: unknown): Promise => { + dispose: async (args: unknown, pin?: unknown): Promise => { + assertWebRuntimePinUnsupported("pty.dispose", pin); const record = asRecord(args); const sessionId = stringField(record, "sessionId") || terminalRegistry.sessionForPty(stringField(record, "ptyId")); if (sessionId) unsubscribeSession(sessionId); @@ -462,7 +479,8 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam idempotent: false, }); }, - setDataSubscriptions: async (args: unknown) => { + setDataSubscriptions: async (args: unknown, pin?: unknown) => { + assertWebRuntimePinUnsupported("pty.setDataSubscriptions", pin); const ptyIds = Array.isArray(asRecord(args).ptyIds) ? (asRecord(args).ptyIds as unknown[]) : []; const wanted = new Set(); for (const ptyId of ptyIds) { @@ -475,8 +493,14 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam if (!wanted.has(sessionId)) unsubscribeSession(sessionId); } }, - onData: (listener: (event: unknown) => void) => events.on("ptyData", listener as never), - onExit: (listener: (event: unknown) => void) => events.on("ptyExit", listener as never), + onData: (listener: (event: unknown) => void, pin?: unknown) => { + assertWebRuntimePinUnsupported("pty.onData", pin); + return events.on("ptyData", listener as never); + }, + onExit: (listener: (event: unknown) => void, pin?: unknown) => { + assertWebRuntimePinUnsupported("pty.onExit", pin); + return events.on("ptyExit", listener as never); + }, }; const terminal: Record = { @@ -497,7 +521,8 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam const history = await client.requestTerminalHistory({ sessionId, beforeOffset, maxBytes }); return { terminalId: sessionId, data: history.data, nextSince: history.endOffset }; }, - preview: async (args?: unknown): Promise => { + preview: async (args?: unknown, pin?: unknown): Promise => { + assertWebRuntimePinUnsupported("terminal.preview", pin); const record = asRecord(args); let sessionId = terminalRegistry.resolveSessionId(record); if (!sessionId && record.chatSessionId) { @@ -610,6 +635,7 @@ function asRecord(args: unknown): Record { return args && typeof args === "object" ? (args as Record) : {}; } + function stringField(record: Record, key: string): string { const value = record[key]; return typeof value === "string" ? value : ""; diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 07875f286..2a0764494 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -20,7 +20,7 @@ import { isProviderSlashCommandInput } from "./chatSlashCommands"; import { resolveClaudeCliModelAlias } from "./claudeCliModels"; import { decodeOpenCodeRegistryId } from "./modelRegistry"; import { effectiveOrchestrationPermissionMode } from "./orchestrationRuntimePolicy"; -import { commandArrayToLine, quoteShellArg } from "./shell"; +import { commandArrayToLine, parseCommandLine, quoteShellArg } from "./shell"; import type { OrchestrationRole } from "./types/orchestration"; export type CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; @@ -337,6 +337,84 @@ export function withCodexNoAltScreen(command: string): string { : trimmed.replace(/^codex\b/, "codex --no-alt-screen"); } +export function shellWordSpans(command: string): Array<{ start: number; end: number }> { + const spans: Array<{ start: number; end: number }> = []; + let index = 0; + while (index < command.length) { + while (index < command.length && /\s/.test(command[index]!)) index += 1; + if (index >= command.length) break; + + const start = index; + let quote: "'" | "\"" | null = null; + let escaped = false; + while (index < command.length) { + const char = command[index]!; + if (escaped) { + escaped = false; + } else if (quote === "'") { + if (char === "'") quote = null; + } else if (quote === "\"") { + if (char === "\"") quote = null; + else if (char === "\\") escaped = true; + } else if (char === "\\") { + escaped = true; + } else if (char === "'" || char === "\"") { + quote = char; + } else if (/\s/.test(char)) { + break; + } + index += 1; + } + spans.push({ start, end: index }); + } + return spans; +} + +export function isClaudeBinaryCommand(command: string | null | undefined): boolean { + const trimmed = String(command ?? "").trim(); + if (!trimmed) return false; + const base = trimmed.replace(/[\\/]+$/, "").split(/[\\/]/).pop()?.toLowerCase() ?? ""; + return base === "claude" || base === "claude.exe" || base === "claude.cmd"; +} + +// Shell-wrapped launches (`/bin/bash --noprofile --norc -lc ""`) +// carry the real Claude invocation inside the argument that follows -c/-lc. +export function shellCommandLineArgIndex(args: string[]): number { + const flagIndex = args.findIndex((arg) => /^-[a-z]*c$/.test(arg)); + if (flagIndex < 0) return -1; + const commandIndex = flagIndex + 1; + return commandIndex < args.length ? commandIndex : -1; +} + +// Insert `--plugin-dir ` right after the `claude` token of a shell +// command line, leaving env-var prefixes and the caller's own flags intact. +export function withClaudePluginInCommandLine(commandLine: string, pluginRoot: string): string { + if (!commandLine?.trim()) return commandLine; + let commandArgs: string[] = []; + try { + commandArgs = parseCommandLine(commandLine); + } catch { + // Keep malformed or unsupported shell input intact. + return commandLine; + } + const claudeIndex = commandArgs.findIndex((arg, index) => + arg === "claude" + && commandArgs.slice(0, index).every((prefix) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(prefix)), + ); + const claudeArgs = claudeIndex >= 0 ? commandArgs.slice(claudeIndex + 1) : []; + const hasPluginRoot = claudeArgs.some((arg, index) => + (arg === "--plugin-dir" && claudeArgs[index + 1] === pluginRoot) + || arg === `--plugin-dir=${pluginRoot}`, + ); + if (claudeIndex < 0 || hasPluginRoot) { + return commandLine; + } + const claudeSpan = shellWordSpans(commandLine)[claudeIndex]; + if (!claudeSpan) return commandLine; + const pluginArgs = commandArrayToLine(["--plugin-dir", pluginRoot]); + return `${commandLine.slice(0, claudeSpan.end)} ${pluginArgs}${commandLine.slice(claudeSpan.end)}`; +} + export function defaultTrackedCliStartupCommand(provider: CliProvider): string { if (provider === "codex") return withCodexNoAltScreen("codex"); if (provider === "cursor") return "cursor-agent --model auto"; diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index aa3cb5153..3363ffabb 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -118,6 +118,7 @@ export const IPC = { personalChatsCall: "ade.personalChats.call", personalChatsStreamEvents: "ade.personalChats.streamEvents", runtimeEvent: "ade.runtime.event", + runtimeEventsRelease: "ade.runtime.events.release", projectStateGetSnapshot: "ade.project.state.getSnapshot", projectStateInitializeOrRepair: "ade.project.state.initializeOrRepair", projectStateRunIntegrityCheck: "ade.project.state.runIntegrityCheck", diff --git a/apps/desktop/src/shared/types/remoteRuntime.ts b/apps/desktop/src/shared/types/remoteRuntime.ts index 04d7d1cbd..2fb29f8dc 100644 --- a/apps/desktop/src/shared/types/remoteRuntime.ts +++ b/apps/desktop/src/shared/types/remoteRuntime.ts @@ -413,6 +413,31 @@ export type RemoteRuntimeEventNotificationPayload = { eventEpoch?: string | null; }; +/** + * Renderer→main release of a runtime-event subscription. Exactly one binding + * shape is valid per request — remote `{ id, projectId }` or local + * `{ rootPath }` — mirroring the subscribe descriptor so main re-derives the + * same request key. The `never` fields make a mixed or empty payload a type + * error at the call site; main still validates at runtime. + */ +export type RuntimeEventsReleaseRequest = + | { + id: string; + projectId: string; + rootPath?: never; + category?: RemoteRuntimeEventCategory; + } + | { + rootPath: string; + id?: never; + projectId?: never; + category?: RemoteRuntimeEventCategory; + }; + +export type RuntimeEventsReleaseResult = { + released: number; +}; + export type RemoteRuntimeLocalWorkMatch = { rootPath: string; displayName: string; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 462e87639..797834e1b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -221,7 +221,7 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, | Directory | Role | |-----------|------| | `apps/desktop/src/main/` | Node process with full OS access. Hosts windows, registers IPC handlers, routes runtime-backed APIs through local/remote runtime pools, spawns the local ADE runtime when needed, and owns Electron-only services that cannot run inside the runtime. Entry: `main.ts`. | -| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (paired/SSH-bound window); file APIs are strict once a local/remote runtime is bound, while usage/budget reads only route to runtime for remote-bound windows. Usage push delivery follows the active binding too: unbound windows accept main-process usage events, while bound windows accept only the runtime event stream, so a dormant local tracker cannot overwrite the active project's snapshot. During project switches, mutating runtime/sync calls that target the ambiguous active binding are blocked, read-only calls avoid refreshing stale bindings, active remote opens can be awaited before retrying reads, and remote lane preview URLs are localized through desktop-owned TCP forwards. Chat history reads are the exception to the local-IPC fallback: `isRemoteProjectRuntimeContext()` gives a synchronous, transition-safe answer to "is this window's runtime remote?" (live binding → in-flight remote open → the kind snapshotted by `detachProjectBindingForTransition()`), and a remote context returns `unavailable: true` rather than letting the local chat service answer a remote session id with a false `sessionFound: false` that would wipe the transcript. History runtime actions use one object envelope (`sessionId` plus caps/cursor) across preload and ADE Code; the action registry still normalizes the legacy positional form for packaged-client compatibility. If a packaged local window is temporarily bound to an isolated runtime whose sync service is disabled, only the exact machine-level sync-unavailable/register-project failures retry through main-process sync IPC; remote-bound failures never fall back locally. Explicitly targeted work can pass an `OpenProjectBinding` pin through `callPinnedRuntimeAction` to route to the captured project during a switch, used by detached draft launches and rollback. The same pin is the per-chat and detached-draft runtime routing mechanism: chat/session calls plus machine-owned supporting APIs (AI discovery, slash commands, file search, attachments, lane management, parallel launch state, session deltas, and computer-use snapshots) accept an optional `OpenProjectBinding` so foreign work stays on its owning machine without rebinding the window's tab. Pinned event subscriptions poll the selected runtime when Electron's bound event stream cannot represent a foreign machine. Required foreign ownership fails closed; `callPinnedOrBoundRuntimeActionOr` retains the unchanged bound path only when no pin is required. | +| `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (paired/SSH-bound window); file APIs are strict once a local/remote runtime is bound, while usage/budget reads only route to runtime for remote-bound windows. Usage push delivery follows the active binding too: unbound windows accept main-process usage events, while bound windows accept only the runtime event stream, so a dormant local tracker cannot overwrite the active project's snapshot. During project switches, mutating runtime/sync calls that target the ambiguous active binding are blocked, read-only calls avoid refreshing stale bindings, active remote opens can be awaited before retrying reads, and remote lane preview URLs are localized through desktop-owned TCP forwards. Chat history reads are the exception to the local-IPC fallback: `isRemoteProjectRuntimeContext()` gives a synchronous, transition-safe answer to "is this window's runtime remote?" (live binding → in-flight remote open → the kind snapshotted by `detachProjectBindingForTransition()`), and a remote context returns `unavailable: true` rather than letting the local chat service answer a remote session id with a false `sessionFound: false` that would wipe the transcript. History runtime actions use one object envelope (`sessionId` plus caps/cursor) across preload and ADE Code; the action registry still normalizes the legacy positional form for packaged-client compatibility. If a packaged local window is temporarily bound to an isolated runtime whose sync service is disabled, only the exact machine-level sync-unavailable/register-project failures retry through main-process sync IPC; remote-bound failures never fall back locally. Explicitly targeted work can pass an `OpenProjectBinding` pin through `callPinnedRuntimeAction` to route to the captured project during a switch, used by detached draft launches and rollback. The same pin is the per-session and detached-draft runtime routing mechanism: chat/session calls, the PTY and terminal surface (`pty.create` / `resumeSession` / `sendToSession` / `write` / `resize` / `dispose` / `setDataSubscriptions` / `onData` / `onExit`, `terminal.preview`), plus machine-owned supporting APIs (AI discovery, slash commands, file search, attachments, lane management, parallel launch state, session deltas, and computer-use snapshots) accept an optional `OpenProjectBinding` so foreign work — a CLI or shell session as much as a chat — stays on its owning machine without rebinding the window's tab. Pinned event subscriptions poll the selected runtime when Electron's bound event stream cannot represent a foreign machine, and preload releases a main-side subscription explicitly once its last pump stops reading. Required foreign ownership fails closed; `callPinnedOrBoundRuntimeActionOr` retains the unchanged bound path only when no pin is required. | | `apps/desktop/src/renderer/` | React 18 SPA. No Node access, no filesystem access, no direct process/network. Everything goes through `window.ade`. Entry: `main.tsx`. | | `apps/desktop/src/shared/` | Types, IPC channel constants (`ipc.ts`), model registry (`modelRegistry.ts`), keybindings, and cross-client derivations such as `chatScheduledWork.ts` and `externalSessionAffordances.ts` (the desktop/ADE Code Continue/Copy policy for provider-native imports). The project/machine model lives here too: `projectIdentity.ts` is the single definition of a binding key (`local:` / `remote::`) that every per-project cache and the repo tab join are keyed by, `machineIdentity.ts` is the single definition of "the machine ADE is running on" (`THIS_MACHINE_ID` / `THIS_MACHINE_NAME` / `isThisMachineId` / `machineDisplayName`, with machines named absolutely and "remote" never used as a machine name), and `laneDivergence.ts` is the pure push-time guard against stranding another machine's unpushed commits. Imported by desktop, `apps/ade-cli`, and mobile contract generation paths. New runtime-facing types live in `shared/types/remoteRuntime.ts` and `shared/types/core.ts`. | | `apps/desktop/src/generated/` | Build-time generated code (e.g., bootstrap SQL snapshots). | @@ -600,6 +600,8 @@ Related feature docs: [Chat](./features/chat/README.md), [Agents](./features/age - Methods are typed via TypeScript imports from `apps/desktop/src/shared/types/`. - Two categories: **invoke methods** (`ipcRenderer.invoke(channel, args)` returning `Promise`) and **event subscriptions** (`ipcRenderer.on(channel, handler)`). - Runtime-backed event subscriptions can merge local Electron IPC and the runtime event stream behind one renderer API. For example, `window.ade.lanes.onLifecycleEvent` listens to `ade.lanes.lifecycle.event` for desktop-local fallback paths and to runtime `lane_lifecycle_event` payloads for local-brain or SSH-bound windows. Runtime stream results (`RemoteRuntimeStreamEventsResult` in `apps/desktop/src/shared/types/remoteRuntime.ts`) carry `eventEpoch`, `gap`, and `oldestCursor`; preload resets cursors/dedupe on epoch changes and notifies project-binding refresh paths when a gap means replay history was evicted. +- **Pinned event pumps.** A window runs several independent event pumps at once: the active binding's pump (in `preload.ts`) plus, for every binding the window has open but is *not* bound to, the pumps in `apps/desktop/src/preload/pinnedRuntimeEvents.ts`. That module owns one shared PTY pump per pinned binding (fed by both polling and `ade.runtime.event` push notifications, with its own cursor, epoch generation, dedupe ring, and failure backoff) and a per-listener generic pump used by pinned chat/project subscriptions; the active pump shares its epoch-normalization, stale-event, and dedupe helpers. Each pinned binding's PTY pump cannot share the active pump's single mutable cursor — switching the window binding would reset it, and polling two machines through it would cross-contaminate epoch and dedup state. Main-side subscriptions are reference-counted per `(binding, category)` so several pumps on the same binding share one, and preload sends `ade.runtime.events.release` when the last of them goes away (see [§5.4](#54-event-subscriptions-push-not-poll)). A pushed event announcing a *new* epoch is not dispatched: it rewinds the cursor so a local pin replays the restarted buffer and a remote pin re-anchors to the live head with `{ cursor: 0, replay: false }`. +- Pinned APIs are opt-in trailing arguments, never a mode switch: `pty.create` / `resumeSession` / `sendToSession` / `write` / `resize` / `dispose` / `setDataSubscriptions` / `onData` / `onExit`, `terminal.preview`, `sessions.get` / `list` / `readTranscriptTail`, and the chat/session APIs all accept an optional `OpenProjectBinding`. Omitting it takes the byte-for-byte unchanged bound path plus its local IPC fallback (`callPinnedOrBoundRuntimeActionOr`). The hosted web adapter cannot route a pin to a second host, so its shims reject one loudly (`assertWebRuntimePinUnsupported`) instead of silently answering from the single web host. - `contextIsolation: true`, `nodeIntegration: false`, `sandbox: false` (required for preload functionality). - Global window type: `apps/desktop/src/preload/global.d.ts`. - `window.ade.sessions.settle(sessionId, { outcome?, @@ -658,6 +660,14 @@ ade.lanes.* # lane list/create/delete/stack/template/env/port/p ade.files.* # file tree, read, write, search, watch ade.diff.* # lane-scoped change list + per-file diff / patch (diffService) ade.pty.* # PTY spawn/write/kill, data/exit events +ade.runtime.* # runtime event delivery: the ade.runtime.event push channel + # (main -> renderer, carrying bindingKey + eventEpoch) and + # ade.runtime.events.release, the renderer -> main teardown a + # preload pump sends when it stops reading a binding. The + # release argument mirrors the subscribe descriptor — remote + # `{ id, projectId }` or local `{ rootPath }`, plus an optional + # category — so main re-derives the request key instead of + # trusting one the renderer guessed ade.git.* # stage/commit/push/sync/revert/cherry-pick/stash ade.github.* # PR list, review, merge, checks. Also exposes # repo-scoped helpers used by the Linear setup flow: @@ -769,6 +779,8 @@ High-frequency events flow from main → renderer via `webContents.send(channel, | `ade.project.state.event` | projectState | Startup flow | | `ade.sync.*` events | syncService | Top-bar Connections panel | +Runtime-backed events reach the renderer through `ade.runtime.event` instead, and their subscriptions are held by `apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts`. Subscriptions are keyed by **(sender, requestKey)** — the request key being `::` — because one renderer legitimately runs several pumps at once (the active binding, one pinned PTY pump per foreign lane, one pinned chat pump), and keying by sender alone would let each new pump tear down its siblings. Since key-per-sender no longer doubles as garbage collection, a stale entry is reclaimed by idle expiry: every live pump refreshes its subscription on each poll (750 ms–5 s normally, 30 s at the slowest failure backoff), a 20 s sweep drops anything unrefreshed for 60 s or whose sender is destroyed, and the renderer's explicit `ade.runtime.events.release` is the fast path so a switched-away binding stops streaming immediately rather than at expiry. A release covers the whole `(binding, category)` prefix, because a pump's replay flag flips from `live` to `replay` once it is caught up and it can therefore own both key variants. Removal has exactly one implementation (`removeRuntimeEventSubscription`), used by release, the ended callback, remote disconnect, sender death, and the sweep, so disposal and registry pruning cannot drift apart; disconnecting a remote target drops **every** subscription that window holds against it, not just the newest. + Renderer telemetry events flow back to main: `renderer.route_change`, `renderer.tab_change`, `renderer.window_error`, `renderer.unhandled_rejection`, `renderer.event_loop_stall`. --- @@ -800,7 +812,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `github/` | `githubService.ts` | GitHub REST/GraphQL access; PR CRUD; checks; reviewers. | | `history/` | `operationService.ts` | Operation audit records (one row per mutation). | | `ios/` | `iosSimulatorService.ts` | macOS-only iOS Simulator backend: tool readiness probes, simctl device + app discovery, build/install/launch with progress events (hardened with `simctl bootstatus` and `simctl install` timeouts), screenshot + ADEInspector + accessibility hit-test, Simulator.app window live-view status, idb-backed input, and single-owner chat session locking. The macOS Simulator window placement / capture state probe (`getSimulatorWindowState`, `prepareSimulatorWindowForCapture`) lives next to the IPC handlers in `ipc/registerIpc.ts` because it depends on the active `BrowserWindow`. See [features/ios-simulator/README.md](./features/ios-simulator/README.md). | -| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / project-open / action dispatch / sync dispatch / event stream, per-target `listActionRegistry` lookup against the remote daemon, LAN + Tailscale discovery with diagnostics) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. The explicit `ade.sync.getLocalStatus` handler is the exception: it calls machine-level `sync.getStatus` on `LocalRuntimeConnectionPool` (with only the local in-process diagnostics service as fallback) so a remote-bound Connections panel can still identify the physical Mac (its This Mac card, pairing code, and local Phone/Web device lists). Device and pairing *mutations* still follow the window binding, so the panel presents them read-only while remote-bound rather than routing them to the remote machine. Event-stream subscription init/results preserve replay-gap metadata (`gap`, `oldestCursor`, `eventEpoch`) for both local and remote bindings. Remote project opens are generation-guarded per window/webContents before main persists the binding. It also subscribes `powerMonitor` `resume` and `unlock-screen` to `remoteConnectionService.probeSavedConnections()` so a laptop waking up cycles dead SSH sessions before the renderer pokes them. Machine-level sync fallback recognizes only the canonical unavailable-service predicates in `shared/runtimeErrors.ts`, shared with preload and renderer recovery guidance. `ipcTimeouts.ts` carries the default 30-second handler timeout plus named channel-level overrides for long direct IPC operations; it does not inspect runtime action payloads. | +| `ipc/` | `registerIpc.ts`, `runtimeBridge.ts`, `runtimeEventSubscriptionRegistry.ts`, `ipcTimeouts.ts` | Single registration point for all IPC handlers. `runtimeEventSubscriptionRegistry.ts` holds runtime-event subscriptions keyed by (sender, requestKey) with idle expiry and a single removal path (see §5.4). `runtimeBridge.ts` owns the runtime-facing channels (remote target registry, remote-runtime connect / project list / project-open / action dispatch / sync dispatch / event stream, per-target `listActionRegistry` lookup against the remote daemon, LAN + Tailscale discovery with diagnostics) and routes runtime calls through `LocalRuntimeConnectionPool` or `RemoteConnectionPool` based on the active window binding. The explicit `ade.sync.getLocalStatus` handler is the exception: it calls machine-level `sync.getStatus` on `LocalRuntimeConnectionPool` (with only the local in-process diagnostics service as fallback) so a remote-bound Connections panel can still identify the physical Mac (its This Mac card, pairing code, and local Phone/Web device lists). Device and pairing *mutations* still follow the window binding, so the panel presents them read-only while remote-bound rather than routing them to the remote machine. Event-stream subscription init/results preserve replay-gap metadata (`gap`, `oldestCursor`, `eventEpoch`) for both local and remote bindings, and subscription bookkeeping is delegated to `runtimeEventSubscriptionRegistry.ts`; `runtimeBridge.ts` derives the request key (one helper shared by the subscribe and release paths, so a release rebuilds exactly the key subscribe registered) and registers the `ade.runtime.events.release` handler, which resolves the binding from the same descriptor shape the subscribe call used and refuses to act on an unauthorized local root. Remote project opens are generation-guarded per window/webContents before main persists the binding. It also subscribes `powerMonitor` `resume` and `unlock-screen` to `remoteConnectionService.probeSavedConnections()` so a laptop waking up cycles dead SSH sessions before the renderer pokes them. Machine-level sync fallback recognizes only the canonical unavailable-service predicates in `shared/runtimeErrors.ts`, shared with preload and renderer recovery guidance. `ipcTimeouts.ts` carries the default 30-second handler timeout plus named channel-level overrides for long direct IPC operations; it does not inspect runtime action payloads. | | `jobs/` | `jobEngine.ts` | Event-driven background scheduler for lane refresh + conflict prediction. Coalesced, debounced. | | `keybindings/` | `keybindingsService.ts` | User keybindings read/write. | | `lanes/` | `laneService.ts`, `laneEnvironmentService.ts`, `laneTemplateService.ts`, `laneProxyService.ts`, `portAllocationService.ts`, `autoRebaseService.ts`, `rebaseSuggestionService.ts`, `laneLaunchContext.ts`, `oauthRedirectService.ts`, `runtimeDiagnosticsService.ts` | Worktree lifecycle, env bootstrap, templates, reverse proxy, port leases, auto-rebase, suggestions, OAuth redirect, diagnostics. | @@ -869,10 +881,10 @@ Electron renderer runtime does **not** wrap the app in `React.StrictMode`. Brows - Narrow selectors on components to minimize re-renders. - `refreshLanes` accepts independent lane-status and lane-snapshot flags. Callers can refresh cheap runtime snapshot decorations without recomputing git status, or update git status without rebuilding conflict/rebase/auto-rebase overlays; statusless refreshes preserve the previous `LaneStatus`/`parentStatus` in store so the UI does not flicker to unknown git state. - Per-project work-view state keyed by project identity (`WorkProjectViewState`): local bindings use their root path, while remote bindings use `OpenProjectBinding.key` (`remote::`). Alongside Work filters, collapsed section ids, and right-sidebar state, version 3 includes the Lanes tab's filter, pinned lane ids, and expanded lane id. Version 4 adds the Work-sidebar-only `workPinnedLaneIds`, lane sort mode, sparse manual lane order, and structured session chips; normalization is additive, so a v3 blob retains the former Created order with no Work pins or chips. It keeps Settled reachable as a quiet collapsed tail: Status/Time mode use `status:settled`, Lane mode uses explicit `settled-open:` markers, and a fully quiet lane uses the inverted `lane-open:` marker. There is no independent Tiers/Show settled filter. The one-time status-collapse migration is gated against its own version-2 threshold, so later additive schema bumps do not re-collapse a section the user expanded. Persistence under `ade.workViewState.v1` is a scoped delta read-modify-write: every mounted project store upserts or deletes only the project/lane keys it owns instead of flushing its stale copy of the whole map. `refreshLanes` prunes lane scopes only from a non-empty lane inventory and only inside that store's project scope. `registerProjectSurfaceStore` / `workViewStoreForProject` route project-specific writes made by `AppShell` and `TopBar`, which render above `AppStoreProvider`, to the owning surface store. Lane/status deeplinks are transient view overrides and user filter, grouping, chip, pin, or ordering changes return to and then update the saved base. The right-edge fields are `workSidebarOpen`, `workSidebarTab` (`"git" | "files" | "ios" | "app-control" | "browser"`), and `workSidebarWidthPct` (clamped 26–55). The sidebar consolidates lane-scoped tools that were previously split across separate floating panes; per-chat iOS / App Control drawers still exist on `AgentChatPane` but are suppressed when the chat is mounted as a Work tile so the sidebar owns those surfaces at lane scope. Remote-bound Work sidebars expose only the runtime-backed Git and Files tabs; local-only iOS Simulator, App Control, and Browser panes stay hidden. The `browser` tab is not lane-scoped on local bindings: each ADE window/project keeps its own tab collection and inspect state, while browser authentication storage is global to the installation/channel through `persist:ade-browser`. -- Cross-machine Work union. `crossMachineLanesByMachineId` + `crossMachineLaneScopeKey` (fed by `renderer/state/crossMachineLanes.ts`) hold every *other* connected machine's lanes and sessions for the repository the active tab is showing, so the Work sidebar can list chats in flight anywhere without changing the tab's binding. Keyed by machine because a lane owns its machine (`lanes.worktree_path` is an absolute path on exactly one machine) and chats inherit theirs through `laneId` — there is no per-chat machine field. `mergeCrossMachineLanes` retains omitted `lanes`/`sessions` so a failed read leaves the machine's rows on screen, and `setCrossMachineMachinesOnline` flags rather than deletes an entry that goes offline — the sidebar renders it dimmed, collapsed, and inert, and the retained slice also backs the push-divergence guard. Presence is decided in one place, `applyReachability`, from connection state alone: a drop is believed only after a reconnect attempt has completed and failed (`connecting` observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes. Two states have no attempt left to wait for and dim on the floor alone: `idle`, which will not redial on its own, and `connected` but unable to re-prove this repository, which answers yet is never read for it. The floor and the ceiling are one deadline, not two rules. The `connecting`/`error` states a redial or sleep/wake publishes therefore do not reflow the sidebar, and reconnecting is applied instantly. The verdict belongs to the store rather than the tick: a machine that is already dimmed stays dimmed until it is eligible again, so a Work-tab remount — which tears down the shared runtime and its drop records while the store slice survives — cannot re-brighten it for another floor; the retention deadline is re-anchored to that machine's last successful read instead. Only `dropCrossMachineLanes` deletes, and only for a target gone from the snapshot, a connected machine that positively reports the repository missing *and* has a resolvable origin to prove it by (`repoMatchFor` will say "missing" off a folder-name mismatch alone, and the scope's origin is transiently null while the bound machine blips), or 24 hours unreachable. The scope key is per repository, so a project-tab switch invalidates the union wholesale. A detached chat launch whose runtime pin differs from the active binding seeds an optimistic summary directly into the owning machine slice; the binding/session-keyed pending record survives stale in-flight list responses and is replaced by the authoritative row with the same stable session id (or pruned on delete/expiry). This keeps the active binding free of foreign UUID-lane placeholders while avoiding a blank interval before the next remote list arrives. Foreign lanes use the same `sessionFilingBucket` active/snoozed/settled partition, fully quiet collapsed header, and quiet-tail renderer as local lanes, with composite machine/lane persistence keys. Their **Manage lane** dialog and mutations carry the same owning binding. Sessions whose referenced lane is absent render as explicit warning-tinted **Orphaned sessions** groups with a refresh-only recovery action; ADE preserves the sessions and never interprets unknown ownership as permission to mutate the active machine. Refreshes ride the connection-snapshot subscription plus existing lane-lifecycle/session-changed events (coalesced, bounded, timed out, capped in parallelism) and a fallback loop for machines with no renderer change feed; that loop is paused entirely while the window is hidden, re-reads chats every 10 s, and re-reads lanes on a 30 s cadence, because `lane.list` with `includeStatus` costs a git status per lane on the other machine. A chat naming a lane that machine has never reported forces the lane read immediately, but only once: lane ids a completed read did not explain are remembered until the next one, because `session.list` does not filter on lane status while `lane.list` excludes archived lanes, so a chat on an archived lane is permanently unresolvable and would otherwise demand the expensive read on every tick forever. Foreign reads never gate the local list. `selectOtherMachineBranchStates` is the memoized selector the push-divergence guard reads at click time. +- Cross-machine Work union. `crossMachineLanesByMachineId` + `crossMachineLaneScopeKey` (fed by `renderer/state/crossMachineLanes.ts`) hold every *other* connected machine's lanes and sessions for the repository the active tab is showing, so the Work sidebar can list work in flight anywhere — chats and CLI/shell sessions alike — without changing the tab's binding. Keyed by machine because a lane owns its machine (`lanes.worktree_path` is an absolute path on exactly one machine) and sessions inherit theirs through `laneId` — there is no per-session machine field. `mergeCrossMachineLanes` retains omitted `lanes`/`sessions` so a failed read leaves the machine's rows on screen, and `setCrossMachineMachinesOnline` flags rather than deletes an entry that goes offline — the sidebar renders it dimmed, collapsed, and inert, and the retained slice also backs the push-divergence guard. Presence is decided in one place, `applyReachability`, from connection state alone: a drop is believed only after a reconnect attempt has completed and failed (`connecting` observed, then a non-connected state) plus a 45 s floor, with a 120 s ceiling for a dial that never finishes. Two states have no attempt left to wait for and dim on the floor alone: `idle`, which will not redial on its own, and `connected` but unable to re-prove this repository, which answers yet is never read for it. The floor and the ceiling are one deadline, not two rules. The `connecting`/`error` states a redial or sleep/wake publishes therefore do not reflow the sidebar, and reconnecting is applied instantly. The verdict belongs to the store rather than the tick: a machine that is already dimmed stays dimmed until it is eligible again, so a Work-tab remount — which tears down the shared runtime and its drop records while the store slice survives — cannot re-brighten it for another floor; the retention deadline is re-anchored to that machine's last successful read instead. Only `dropCrossMachineLanes` deletes, and only for a target gone from the snapshot, a connected machine that positively reports the repository missing *and* has a resolvable origin to prove it by (`repoMatchFor` will say "missing" off a folder-name mismatch alone, and the scope's origin is transiently null while the bound machine blips), or 24 hours unreachable. The scope key is per repository, so a project-tab switch invalidates the union wholesale. A detached chat launch whose runtime pin differs from the active binding seeds an optimistic summary directly into the owning machine slice; the binding/session-keyed pending record survives stale in-flight list responses and is replaced by the authoritative row with the same stable session id (or pruned on delete/expiry). This keeps the active binding free of foreign UUID-lane placeholders while avoiding a blank interval before the next remote list arrives. Foreign lanes use the same `sessionFilingBucket` active/snoozed/settled partition, fully quiet collapsed header, and quiet-tail renderer as local lanes, with composite machine/lane persistence keys. Their **Manage lane** dialog and mutations carry the same owning binding. Sessions whose referenced lane is absent render as explicit warning-tinted **Orphaned sessions** groups with a refresh-only recovery action; ADE preserves the sessions and never interprets unknown ownership as permission to mutate the active machine. Refreshes ride the connection-snapshot subscription plus existing lane-lifecycle/session-changed events (coalesced, bounded, timed out, capped in parallelism) and a fallback loop for machines with no renderer change feed; that loop is paused entirely while the window is hidden, re-reads chats every 10 s, and re-reads lanes on a 30 s cadence, because `lane.list` with `includeStatus` costs a git status per lane on the other machine. A chat naming a lane that machine has never reported forces the lane read immediately, but only once: lane ids a completed read did not explain are remembered until the next one, because `session.list` does not filter on lane status while `lane.list` excludes archived lanes, so a chat on an archived lane is permanently unresolvable and would otherwise demand the expensive read on every tick forever. Foreign reads never gate the local list. `selectOtherMachineBranchStates` is the memoized selector the push-divergence guard reads at click time. - Project tab bookkeeping. `openProjectTabRoots: string[]` tracks local roots open in the window (mirrored to the main process via `ade.app.setWindowProjectTabs` so background services keep those projects warm); `openRemoteProjectTabs` tracks full remote bindings so inactive remote tabs remain first-class retained surfaces. `ProjectTabHost` applies one shared eight-surface LRU across local and remote tabs: inactive mounted surfaces are hidden, inert, and animation-paused, while an open surface that falls outside the bound snapshots its scoped state back into the root caches before unmounting. `projectInfoByRoot: Record` caches local `ProjectInfo` payloads for tab favicons and offline tab rendering. - Stale-while-revalidate switch caches. `laneSelectionByProject` remembers the `{ laneId, sessionId }` selection per project identity so switching tabs lands on the lane/chat the user last had open instead of "first lane". `laneCacheByProject` mirrors the last good `{ lanes, laneSnapshots }`; local and remote switches apply it immediately (no spinner, no chat-pane unmount) and refresh silently in the background. `sessionsCacheByProject` does the same for `useWorkSessions` so chat tabs and terminal grids do not blank during a tab swap. `projectRouteStorage.ts` persists the last route under the binding key, independent of whether the surface is currently mounted. Cache pruning retains every open local root and remote binding key; tab close and target disconnect deliberately evict only their affected remote state. The two eviction paths are distinct: `evictProjectState(key)` + `removeStoredProjectRoute(key)` is the full "forget this surface" wipe used by an explicit tab close and by removing a machine, while `evictProjectDataCaches(key)` is the disconnect-only sibling that drops just the snapshots that can go stale while a remote is unreachable (`laneCacheByProject`, `laneSelectionByProject`, `sessionsCacheByProject`, and the persisted lane cache) and preserves `workViewByProject` / `laneWorkViewByScope` plus the stored route so reconnecting restores the chat or tile that was open. `closeProject({ preserveRemoteViewState: true })` applies the same narrower rule when a disconnect closes the last remaining tab. -- `projectRevision` is a monotonically incrementing counter bumped inside `setProject` whenever the active project root actually changes. Long-lived renderer-side caches (most notably the module-level xterm runtime cache in `TerminalView.tsx`) combine it with the project identity key, so identical paths on different remote targets cannot share PTY runtimes. All project-transition paths (`refreshProject`, `openRepo`, `switchProjectToPath`, `closeProject`) go through `setProject` to keep the counter honest. +- `projectRevision` is a monotonically incrementing counter bumped inside `setProject` whenever the active project root actually changes. Long-lived renderer-side caches (most notably the module-level xterm runtime cache in `TerminalView.tsx`, whose key also carries the session's runtime pin) combine it with the project identity key, so identical paths on different remote targets cannot share PTY runtimes. All project-transition paths (`refreshProject`, `openRepo`, `switchProjectToPath`, `closeProject`) go through `setProject` to keep the counter honest. Domain stores co-located with their pages follow the same factory + context pattern when they need per-page isolation: diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 1128eea68..1c8f23bf6 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -78,7 +78,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/shared/chatMosaic.ts` | Mosaic v1 — agent-emitted interactive cards. Strict versioned (`"v":1`) parser for ```` ```mosaic ```` fence bodies (`parseMosaicCard`: unknown version/element types, duplicate ids, or malformed JSON → null → callers render the plain fence), submission serializer (`serializeMosaicSubmission`: readable lines + machine JSON, sent through the normal `agentChat.send` path with `displayText`), and `summarizeMosaicCard` for the TUI's one-line summary. Data only — no expressions, no eval, no host actions. Schema documented for agents in the `ade-mosaic` Agent Skill (`apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md`). | | `apps/desktop/src/renderer/components/chat/MosaicCard.tsx` | Interactive mosaic card renderer (text, select, multiselect, number/slider, input, approve/deny, key-value table). Hooked in at `MarkdownBlock`'s code-fence handler behind a Claude-gated `mosaic` context prop from `AgentChatPane`; answered state persists across virtualized unmounts via a session-lifetime latch that rolls back on send failure. Non-Claude sessions render the plain fence. | | `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), provider-neutral `AgentChatMcpToolSource` / app context metadata, Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), the `web_search` event's structured `CodexWebSearchResult[]` (`results`, max 8) plus `resultsTotal`, typed Codex goal/recovery control args, image generation/view events with large-inline-payload omission metadata, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `AgentChatMessageSession*` peer-message routing DTOs (`auto` / `queue` / `wake` / `interrupt-replace`), `AgentChatCreateScheduledWork*`, `AgentChatSetScheduledWorkPaused*`, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSubagentSnapshot.label` carries provider-assigned display labels such as Codex Agent #N. `AgentChatScheduledWakeMetadata` marks synthetic unattended user turns with schedule id, kind, fire time, reason, and late state. `scheduled_work_update` captures action/Claude wake/cron/background lifecycle including `paused`, `firedAt`, and `late`; `AgentChatSessionSummary.nextWakeAt` and `scheduledWorkPaused` project durable scheduler state into session lists. `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may also carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | -| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AskQuestionComposer` in place of the composer textarea when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. A visible session whose transcript is still empty but whose summary no longer looks active receives two bounded forced history reads (after 900 ms and 3 s), covering newly-created headless sessions whose first append/event raced the renderer without introducing idle polling. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes keep proof snapshot polling and proof-event subscriptions active for inline proof, while continuing to defer local-only App Control work until its drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-sessions.md](../terminals-and-sessions/pty-and-sessions.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Locked Work embeddings accept a full session-title index for spawned-chat roster labels, and spawned chats render a type-tinted **View parent thread** header control. Proof remains chat-scoped and stays on the chat header. The pane also owns **per-chat runtime routing**: a lane owns its machine and a chat inherits its machine from its lane, so a chat opened from the union Work sidebar can live on a machine this tab is not bound to. `createChatMachineRouter` (from `renderer/lib/chatMachineRouting.ts`) resolves the chat's lane to the owning `OpenProjectBinding`, and every chat-scoped `window.ade` call passes it as a trailing pin through `chatPinArgsFor`. A chat on the tab's own binding resolves to `null`, passes no extra argument, and takes the byte-for-byte unchanged path. The tab's binding is never rewritten by opening a chat — rebinding would drag Lanes / PRs / Files / Git / Run with it — and a pin that differs from the active binding is checked with `isLivePinnedBinding` (is it still open?) rather than against the active binding. | +| `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Mounts `AskQuestionComposer` in place of the composer textarea when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. A visible session whose transcript is still empty but whose summary no longer looks active receives two bounded forced history reads (after 900 ms and 3 s), covering newly-created headless sessions whose first append/event raced the renderer without introducing idle polling. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes keep proof snapshot polling and proof-event subscriptions active for inline proof, while continuing to defer local-only App Control work until its drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-sessions.md](../terminals-and-sessions/pty-and-sessions.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Locked Work embeddings accept a full session-title index for spawned-chat roster labels, and spawned chats render a type-tinted **View parent thread** header control. Proof remains chat-scoped and stays on the chat header. The pane also owns **per-chat runtime routing**: a lane owns its machine and a chat inherits its machine from its lane, so a chat opened from the union Work sidebar can live on a machine this tab is not bound to. `createChatMachineRouter` (from `renderer/lib/chatMachineRouting.ts`) resolves the chat's lane to the owning `OpenProjectBinding`, and every chat-scoped `window.ade` call passes it as a trailing pin through `chatPinArgsFor`. The pane builds the router's inputs with that module's shared constructors — `collectOpenProjectBindings` (active binding, open remote tabs, open local roots, cross-machine machine slices) and `buildChatMachineRoutingState` (which gives the active binding's live lane list precedence over any cached copy) — the same pair the Work tab's `useWorkMachineRouter` uses for CLI/shell rows, so the two surfaces cannot drift into different definitions of "open" or of lane precedence. A chat on the tab's own binding resolves to `null`, passes no extra argument, and takes the byte-for-byte unchanged path. The tab's binding is never rewritten by opening a chat — rebinding would drag Lanes / PRs / Files / Git / Run with it — and a pin that differs from the active binding is checked with `isLivePinnedBinding` (is it still open?) rather than against the active binding. | | `apps/desktop/src/renderer/components/chat/usage/contextUsageModel.ts` | Provider-neutral context meter reducer. Automatic per-turn Claude `context_usage` snapshots (`origin: "live"`) are filtered out of the transcript and feed only this reducer, so the composer meter's hover (`ContextUsageDial`) — not an inline card — is the primary context-usage surface; the user-invoked `/context` command (`origin: "command"`) is the only `context_usage` that still renders an inline breakdown card. The reducer reads the snapshot's typed breakdown (`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheCreationTokens`) so the dial's hover is a complete replacement for that card, falling back to showing the total as input when no breakdown is present. A completed `context_compact` boundary invalidates older usage for Claude, Codex, OpenCode, Cursor, and Droid; generic same-turn counters are ignored because those SDKs can report pre-compaction per-turn/cumulative totals after the history was replaced. Claude `postTokens` / exact `context_usage` and Codex `thread/tokenUsage/updated` snapshots can repopulate the meter immediately. Desktop, ADE Code, and iOS mirror this boundary rule. Codex token breakdowns are normalized in `agentChatService.ts` (`normalizeCodexTokenBreakdown`), which maps 0.145's `cacheWriteInputTokens` / `reasoningOutputTokens` onto `cacheWriteTokens` / `reasoningTokens`; the desktop `ContextUsageDial` tooltip renders `cache write` and `reasoning` segments (in addition to in/out/cached) whenever those counts are present. | | `apps/desktop/src/renderer/components/usage/ActivityModule.tsx` | Reusable activity, token, code-movement, and client-mix module. `AgentChatPane` mounts `WorkActivityModule` directly below an empty Work draft composer (desktop and web only); it reads `usage.getAdeStats` through the active `window.ade` adapter, defaults to all-time activity, and preserves explicit tab/range choices locally. | | `apps/desktop/src/renderer/lib/agentChatSessionListCache.ts` | Short-lived renderer cache for `ade.agentChat.list`, keyed by active project root, lane, automation, and archive flags. Normal reads coalesce; forced reads bypass an older in-flight promise, and promise-identity checks prevent the superseded response from repopulating the cache. Mutations invalidate by project/lane so remote Work panes do not fan out repeated list calls while still refreshing immediately after create/archive/delete. | diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 7c4d3f207..1ccdc3ff5 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -41,7 +41,31 @@ relay payload E2E encryption is planned security work. See the trust boundary in local port-forward creation for remote previews, per-target action registry lookups, replay-aware event streams, manual disconnect handling, and per-window remote-open generation guards so a slow earlier remote-project - open cannot overwrite the latest window binding. + open cannot overwrite the latest window binding. It also registers + `ade.runtime.events.release`, the renderer's explicit teardown for a + subscription it has stopped reading. +- `apps/desktop/src/main/services/ipc/runtimeEventSubscriptionRegistry.ts` — + the store behind those streams. Subscriptions are keyed by + `(sender, requestKey = ::)`, because one + window runs several pumps at once (active binding, one pinned PTY pump per + foreign lane, one pinned chat pump) and keying by sender alone would make each + new pump tear down its siblings. Stale entries are reclaimed by idle expiry + (refreshed on every poll, swept every 20 s at a 60 s idle threshold) with the + renderer's release as the fast path. Every caller — release, the ended + callback, remote disconnect, sender death, the sweep — removes through one + function, so disposal and pruning cannot drift apart, and cleanup functions are + attached through an atomic check so a subscription replaced mid-flight never + adopts a disposer the registry could not run. +- `apps/desktop/src/preload/pinnedRuntimeEvents.ts` — the renderer-side half: + every event pump that reads a binding the window is *not* bound to. It owns one + shared PTY pump per pinned binding (polling plus `ade.runtime.event` push + delivery, with its own cursor, epoch generation, dedupe ring, in-flight + epoch-rewind guard, and failure backoff), the per-listener generic pump used by + pinned chat/project subscriptions, and the helpers the active pump in + `preload.ts` shares. Main-side subscriptions are reference-counted per + `(binding, category)` so sibling pumps share one and only the last teardown + releases it; the active pump never retains a reference and therefore never + releases a subscription a pinned pump is still reading. - `apps/desktop/src/main/services/account/accountBridge.ts` and `apps/ade-cli/src/services/account/accountMachineDirectoryService.ts` — account-directory adoption. The desktop Machines row and packaged CLI both @@ -216,16 +240,26 @@ relay payload E2E encryption is planned security work. See the trust boundary in scoped per repository, so switching project tabs invalidates it wholesale. `selectOtherMachineBranchStates` is the derived-state seam the push guard reads at click time. -- `apps/desktop/src/renderer/lib/chatMachineRouting.ts` — **per-chat runtime +- `apps/desktop/src/renderer/lib/chatMachineRouting.ts` — **per-session runtime routing**. `buildLaneBindingIndex` folds each open binding's lane list into a lane→binding index (active binding first wins), and `resolveChatRuntimePin` - returns the `OpenProjectBinding` a chat's calls must target, or `null` when - the chat already lives on the active binding. `isLivePinnedBinding` asks - whether a pin is still *open* rather than whether it is *active*, because a - pin differing from the active binding is now the normal state of any chat - whose lane lives on another open machine. Clicking such a chat streams it from - its own machine without rebinding the tab, which would otherwise drag Lanes / - PRs / Files / Git / Run along with it. + returns the `OpenProjectBinding` a session's calls must target, or `null` when + it already lives on the active binding. `collectOpenProjectBindings` and + `buildChatMachineRoutingState` are the shared constructors both consumers use + so the two surfaces cannot drift into different definitions of "open" or of + lane precedence. `isLivePinnedBinding` asks whether a pin is still *open* + rather than whether it is *active*, because a pin differing from the active + binding is now the normal state of any session whose lane lives on another open + machine. Clicking such a row streams it from its own machine without rebinding + the tab, which would otherwise drag Lanes / PRs / Files / Git / Run along with + it. +- `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` and + `apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts` — the + two routers built on that module. The chat pane resolves its own pin from the + chat's lane; the Work hook is the Work tab's single routing authority for + CLI/shell rows, adding lane-then-launch-pin resolution + (`pinForSession`) and the launch-pin registry writes + (`rememberSessionPin` / `forgetSessionPin`) on top of the shared router. - `apps/desktop/src/renderer/components/lanes/laneMachines.ts`, `LaneMachineSelector.tsx`, and `PushDivergenceDialog.tsx` — machine selection during lane creation and the push-time divergence warning. @@ -397,14 +431,16 @@ Run all follow it. Two things are deliberately wider than that: Lanes not on This Mac carry a small monochrome machine marker that promotes to the machine's name when a glyph alone would be ambiguous (the machine is offline, two or more foreign machines are on screen, or the same branch exists - elsewhere). Foreign lanes appear only when they have chats — the union is about - work in flight, not an inventory. A machine that goes offline keeps its rows, + elsewhere). Foreign lanes appear only when they have sessions — the union is + about work in flight, not an inventory. A machine that goes offline keeps its rows, dimmed, folded shut, and inert, and sinks below the reachable machines. -- **A chat runs on its own lane's machine.** Opening a chat from the union - streams it from the machine that owns its lane, with its calls pinned to that - machine's runtime; the tab stays bound where it was. Clicking a foreign - *lane* (rather than a chat) is the explicit move: it switches the tab's - machine, the same thing opening a remote project does. +- **A session runs on its own lane's machine.** Opening a chat, CLI, or shell + session from the union streams it from the machine that owns its lane, with its + calls pinned to that machine's runtime; the tab stays bound where it was. A row + whose owning binding this window does not have open is the exception — there is + nothing to pin to, so the tab switches. Clicking a foreign *lane* (rather than + a session) is the explicit move: it switches the tab's machine, the same thing + opening a remote project does. Machine selection also appears at lane creation: the create-lane dialog picks which machine the new worktree is created on, matching each machine's checkout of diff --git a/docs/features/remote-runtime/internal-architecture.md b/docs/features/remote-runtime/internal-architecture.md index bb13e81d6..09afedf93 100644 --- a/docs/features/remote-runtime/internal-architecture.md +++ b/docs/features/remote-runtime/internal-architecture.md @@ -41,7 +41,9 @@ Project-scoped operations are routed through `ade/actions/call` and carry `param Runtime event streaming uses `ade/actions/call` with `name: "stream_events"` for one-shot pulls, and `runtimeEvents.subscribe` (with `runtime/event` notifications) for live streaming. For remote bindings the desktop reconnects the SSH transport before re-subscribing, matching normal remote action behavior after disconnects. The initial remote subscription starts with `replay: false` when the cursor is still zero, so opening a remote project does not flood the renderer with buffered history before live events arrive; catch-up polls still use a short delay while idle remote polls back off. For local bindings, preload polls the local runtime through `localRuntimeStreamEvents` so runtime-owned chat, terminal, pty, lane, file-watch, process, and test events are delivered through the same renderer fanout used by remote projects. -Each `stream_events` response carries a per-runtime `eventEpoch` UUID minted when the daemon's `eventBuffer` is constructed. The preload event pump compares it against the last seen epoch for the active binding; if it changes (daemon restart, ssh reconnect to a fresh process) the cursor and dedup set reset and the next poll starts from `cursor=0`. Responses can also include `gap: true` with `oldestCursor` when the requested cursor is older than the bounded replay buffer; preload clears the dedupe set and notifies the project-binding refresh callbacks so renderer projections re-hydrate from authoritative reads instead of assuming no events were missed. The `startedAtMs` "drop events older than the pump start" filter is only applied to **local** bindings — remote pumps rely on the epoch reset instead, so older events backfilled after a reconnect are still delivered. +A window runs one pump for its active binding (`preload.ts`) plus, for every binding it has open but is not bound to, the pinned pumps in `preload/pinnedRuntimeEvents.ts` — one shared PTY pump per pinned binding and one generic pump per pinned chat/project listener. Main keys its subscriptions by `(sender, requestKey)` so those pumps coexist instead of evicting each other (see [ARCHITECTURE §5.4](../../ARCHITECTURE.md#54-event-subscriptions-push-not-poll)), and preload sends `ade.runtime.events.release` when the last pump on a `(binding, category)` stops reading, so a switched-away binding stops streaming immediately rather than at idle expiry. + +Each `stream_events` response carries a per-runtime `eventEpoch` UUID minted when the daemon's `eventBuffer` is constructed. The pump compares it against the last seen epoch for its binding; if it changes (daemon restart, ssh reconnect to a fresh process) the cursor and dedup set reset and the next poll starts from `cursor=0`. Responses can also include `gap: true` with `oldestCursor` when the requested cursor is older than the bounded replay buffer; preload clears the dedupe set and notifies the project-binding refresh callbacks so renderer projections re-hydrate from authoritative reads instead of assuming no events were missed. The `startedAtMs` "drop events older than the pump start" filter is only applied to **local** bindings — remote pumps rely on the epoch reset instead, so older events backfilled after a reconnect are still delivered. The remote event buffer categories are intentionally narrow: `orchestrator`, `dag_mutation`, `runtime`, and `pty`. Preload dispatches `runtime` events by their payload `type` so domain-specific updates such as agent chat, terminal, lane, PR, file-watch, process, test, project-state, usage, automation, conflict, GitHub, Linear, feedback, Computer Use, iOS Simulator, and App Control changes still reach their dedicated remote subscribers without expanding the wire-level category enum. ade-cli wires these source-tagged payloads into the runtime event buffer in `bootstrap.ts` so a remote-bound window sees the same event fanout as the local host. Headless runtimes start `usageTrackingService` during `createAdeRuntime()` after the ADE action registry is bound, so the usage poller and threshold events run only once the runtime can answer the matching usage/budget actions. @@ -134,11 +136,13 @@ Opening a project on another machine is not gated by a confirmation. The git-ori `detectPushDivergence` runs at click time on the push button, from lane state the renderer already holds (`LaneSummary.branchRef` + `LaneStatus.ahead/behind`, unioned across machines by `renderer/state/crossMachineLanes.ts`). No lane record in ADE carries a head sha, so the rule is grounded in `ahead` instead: another machine holding the same branch with unpushed commits would have them stranded when the upstream tip moves. Head shas are used only to silence the guard when two machines are proven to sit on the same commit — an unknown head never suppresses a warning, because the false-negative direction on a destructive push is the expensive one. Machine identity is compared by id (`shared/machineIdentity.ts`), never by name, so the guard cannot mistake This Mac for another machine. -## Per-chat runtime routing +## Per-session runtime routing + +A lane owns its machine; a session — chat, CLI, or shell — inherits its machine from its lane through `laneId`. Because the Work sidebar is a union across every open machine, the user can click a row whose lane lives on a machine this window's project tab is not bound to. `renderer/lib/chatMachineRouting.ts` derives the `OpenProjectBinding` that row's calls must target and returns `null` when it already lives on the active binding. Two routers are built on it from the same shared constructors: `AgentChatPane` for chats, and `components/terminals/useWorkMachineRouter.ts` for the Work tab's CLI/shell rows, which falls back to the remembered launch pin when a row's lane is not in the index. -A lane owns its machine; a chat inherits its machine from its lane through `laneId`. Because the Work sidebar is a union across every open machine, the user can click a chat whose lane lives on a machine this window's project tab is not bound to. `renderer/lib/chatMachineRouting.ts` derives the `OpenProjectBinding` that chat's calls must target and returns `null` when the chat already lives on the active binding. +Preload consumes that as an optional trailing pin on chat/session APIs and on the PTY/terminal surface (`callPinnedOrBoundRuntimeActionOr`): with a pin the call goes to the pinned runtime through `callPinnedRuntimeAction`; without one it takes the unchanged bound path and its IPC fallback. Foreign PTY output arrives through the pinned PTY pump described above, and `TerminalView` keeps the pin on the cached runtime (it is part of the runtime cache key) so two parked terminals from different machines cannot borrow the same route. -Preload consumes that as an optional trailing pin on chat/session APIs (`callPinnedOrBoundRuntimeActionOr`): with a pin the call goes to the pinned runtime through `callPinnedRuntimeAction`; without one it takes the unchanged bound path and its IPC fallback. The tab's binding is never rewritten by opening a chat — rebinding would move Lanes, PRs, Files, Git, and Run with it. Switching the tab's machine stays an explicit action (the tab's machine menu, or clicking a foreign *lane*). +The tab's binding is never rewritten by opening a session — rebinding would move Lanes, PRs, Files, Git, and Run with it. The one exception is a row whose owning binding this window does not have open: there is nothing to pin to, so the tab switches. Switching the tab's machine otherwise stays an explicit action (the tab's machine menu, or clicking a foreign *lane*). ## Sync command scoping diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 5d6655cc1..d8d6433de 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -573,6 +573,15 @@ Cross-machine Work union: `apps/desktop/src/renderer/lib/terminalAttention.ts` — route chat-created ownership metadata into the local or foreign optimistic path and render both through the shared `sessionFilingBucket` lifecycle-plus-snooze contract. +- `apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts` — + per-session machine routing for the union. A CLI or shell session on another + machine opens **in place** with its owning `OpenProjectBinding` carried as a + per-session runtime pin, exactly like a chat: the PTY spawn/write/resize/ + dispose calls, terminal preview, transcript reads, and PTY data/exit + subscriptions all target the owning machine while the project tab stays where + the user put it. Switching the tab is reserved for a session whose binding this + window does not have open. Local sessions resolve to a `null` pin and keep the + unpinned path unchanged. Cross-machine Work chat handoff: diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index cb2de1b68..6dae51dd7 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -447,7 +447,17 @@ Renderer surfaces: `WorkPtyLaunchArgs` / `WorkPtyLaunchResult` contract. Ended tracked CLI sessions expose a prompt-free Resume action wired to `window.ade.pty.resumeSession`, alongside the continuation composer - that sends a new prompt through `window.ade.pty.sendToSession`. + that sends a new prompt through `window.ade.pty.sendToSession`. Resume and + continue share one request path (`runCliResumeRequest` + + `finalizeCliResumeResult`) that resolves the row's pin through + `resolveSessionRuntimePin` first, passes it to whichever call it is making, and + remembers it against the resulting session/PTY id. A pinned resume leaves the + local snapshot patch and lane selection to the cross-machine union's own sync + round rather than inventing a local lane for a foreign session. + It also owns `onSelectForeignRuntimeSession`: a CLI/shell row whose owning + binding is still open is remembered as a per-session pin and opened in the + current view state, and only a binding this window does not have open falls + back to switching the project tab. Opening a row that carries a woke marker clears it (`clearSessionWokeMarker`): opening *is* the acknowledgement, since the marker exists only to explain an unexpected return. @@ -578,10 +588,15 @@ Renderer surfaces: include the owning machine id (`:`), and an explicit `lane-open:` marker is cleared when active foreign work returns so a future quiet spell starts collapsed instead of inheriting stale expanded state. - Every foreign row carries its owning `OpenProjectBinding`. Chat rows open - through the per-chat runtime pin without rebinding the project tab; shell and - CLI rows switch the tab to the owning project first because a PTY has no - per-session runtime pin. Row/lane context actions pass the same binding so + Every foreign row carries its owning `OpenProjectBinding`. Both chat and + CLI/shell rows open **in place** through a per-session runtime pin, leaving the + project tab pointed wherever the user put it — rebinding would drag Lanes, PRs, + and Files to the session's machine. They differ only in who resolves the pin: a + chat's is derived from its lane by `AgentChatPane` itself, while a CLI/shell + session's is handed to the PTY surfaces through the page's + `onSelectForeignRuntimeSession` handler. Rebinding the tab survives as the + fallback for the one case that has nothing to pin to: the owning binding is not + open in this window. Row/lane context actions pass the same binding so mutations cannot fall through to the active machine. A machine that goes offline keeps its rows, dimmed and folded shut: every card is inert and reads " is offline", the lane context menu's machine-bound actions are @@ -792,6 +807,15 @@ Renderer surfaces: `replace: true` invalidates the hydration generation, clears queued frame/ hydration writes, resets xterm, and writes the authoritative snapshot so an older async preview cannot repaint stale bytes after a gap repair. + Accepts an optional `runtimePin`: the cached runtime records it, it is part of + the runtime cache key, and every PTY/preview/transcript call the runtime makes + carries it. A pin change relocates a mounted session to a new key, so + `ensureRuntime` sweeps the runtime stranded at the old key + (`teardownRelocatedRuntimes`) before reusing or creating one — otherwise the + orphan would hold its PTY subscriptions open forever and a second xterm would + be built for the same PTY. PTY data/exit subscriptions and the main-side id + filter are grouped per pin, and the unpinned local path keeps its original + single listener, single signature, and one-argument preload calls. - `apps/desktop/src/renderer/components/terminals/terminalMacShiftSelection.ts` — macOS-only capture bridge used by `TerminalView`. While terminal mouse tracking is active, it converts an unmodified left-button Shift+mousedown @@ -811,6 +835,24 @@ Renderer surfaces: `paneTreeOps.ts` — recursive pane tree component + pure operations (`reconcilePaneTree`, `splitPaneAtEdge`, `swapPanes`, `removePaneFromTree`, `detectDropEdge`) shared by every tiled surface, including the Work grid. +- `apps/desktop/src/renderer/components/terminals/useWorkMachineRouter.ts` — + the Work tab's single per-session runtime routing authority, and the CLI/shell + counterpart to the chat pane's router. It builds a `ChatMachineRouter` from the + same shared helpers (`collectOpenProjectBindings` + + `buildChatMachineRoutingState` in `renderer/lib/chatMachineRouting.ts`) over + the active binding, open remote tabs, open local project roots, and every + cross-machine machine slice, then adds `pinForSession` / `rememberSessionPin` / + `forgetSessionPin` on top. `pinForSession` resolves lane ownership first and + falls back to the remembered launch pin from `cliLaunch.ts`; a pin that equals + the active binding collapses to `null` so every local session keeps the + unpinned fast path and its local IPC fallback. The remembered foreign pin is + deliberately **not** liveness-gated: the cross-machine lane scope is replaced + wholesale while it reloads, so a healthy binding briefly vanishes from the open + set, and dropping the pin in that window would query the tab's machine, discard + the parked terminal buffer, and hydrate a foreign session id there. Click-time + rebinding still consults `isLivePin`. `useWorkSessions` owns the single + instance and re-exports it as `machineRouter` / `resolveSessionRuntimePin`; + nothing else in Work constructs one. - `apps/desktop/src/renderer/components/terminals/useWorkSessions.ts` — hook that owns work view state (open items, active tab, draft kind, view mode, filters) and persists it to `localStorage` under @@ -831,10 +873,15 @@ Renderer surfaces: non-authoritative until the active project refresh returns; cache mirroring and open-tab pruning pause during that window so the previous project's sessions cannot poison the new project's Work state. `canMutatePinnedProjectUi` - gates pinned updates on whether the pinned binding is still **open** - (`isLivePinnedBinding`), not on whether it is the active one: a pin that differs - from the active binding is the normal state of any chat whose lane lives on - another open machine, so only a pin for a closed project is discarded. + delegates to the machine router's `isLivePin`, gating pinned updates on whether + the pinned binding is still **open**, not on whether it is the active one: a + pin that differs from the active binding is the normal state of any session + whose lane lives on another open machine, so only a pin for a closed project is + discarded. `stopRuntime` resolves the row through the combined union map and + asks `machineRouter.pinForSession` for its binding, so disposing a foreign PTY + reaches its owning machine while local PTYs keep the unpinned call; `stopAll` + runs every running row in that same union through `stopRuntime` (chat rows + without a PTY are skipped). Remote-bound projects use a slower running-session refresh cadence and skip visibility-triggered refreshes unless hidden changes were observed, reducing background SSH @@ -917,7 +964,11 @@ Renderer surfaces: rebuilds a resume command line from `TerminalResumeMetadata` for any provider; `parseTrackedCliResumeCommand` (`apps/desktop/src/main/utils/terminalSessionSignals.ts`) is the - inverse it relies on for round-tripping. `resolveLaunchFields` is + inverse it relies on for round-tripping. It also owns the shell-command-line + primitives `ptyService` uses to place Claude's `--plugin-dir` flag on the real + `claude` token: `shellWordSpans`, `isClaudeBinaryCommand`, + `shellCommandLineArgIndex` (the argument after `-c`/`-lc`), and the idempotent + `withClaudePluginInCommandLine`. `resolveLaunchFields` is the atomic-override helper that mixes a caller's `command`/`args`/`startupCommand`/`env` with the profile defaults (only when the caller passed nothing). `TRACKED_CLI_PERMISSION_MODES` diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md index e6cf02dd6..afd53db5a 100644 --- a/docs/features/terminals-and-sessions/pty-and-sessions.md +++ b/docs/features/terminals-and-sessions/pty-and-sessions.md @@ -254,6 +254,31 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: failure terminates the process tree and ends the session as `failed`. Returns `{ ptyId, sessionId, pid }`. +**Claude Agent Skill plugin injection.** Before the spawn, +`withBundledClaudePlugin` adds `--plugin-dir ` (from +`claudeAgentSkillPluginRoots`) to Claude launches. Where it goes depends +on what is actually being spawned, which the helper decides from the +resolved `command`, not from the argv shape: + +- a direct Claude spawn (`isClaudeBinaryCommand(command)` — basename + `claude`, `claude.exe`, or `claude.cmd`) gets the flag prepended to + its argv; +- a shell-wrapped launch — the resume/reattach shape, + `/bin/bash --noprofile --norc -lc ""` — gets it inserted + into the command line that follows `-c`/`-lc` + (`shellCommandLineArgIndex` + `withClaudePluginInCommandLine`), right + after the `claude` token, past any `VAR=value` prefixes and ahead of + the caller's own flags; +- the typed `startupCommand` is rewritten by the same command-line + helper. + +Prepending Claude flags to a shell's argv is what this prevents: bash +receives `--plugin-dir` as its own option and dies with "invalid +option" before the CLI ever runs, which is how resumes were failing. +Injection is idempotent — an existing `--plugin-dir ` or +`--plugin-dir=` for the same root is left alone — and a command +line that fails to parse is returned unchanged. + The launch env is built layer by layer: `process.env`, the lane runtime env (from `getLaneRuntimeEnv`), the caller's `args.env`, then `withAdeTerminalContextEnv` (project / lane / chat ids plus the opaque, @@ -687,13 +712,12 @@ resolved. Strategies, in order: `maxStartDeltaMs` (drop matches whose timestamp drifts more than N ms from `startedAt`), `notBeforeMs` (ignore rollouts older than this floor — used to refuse a recycled rollout from a previous launch), - and `requiredText` (reads up to 512 KB of the candidate's prefix and - only accepts the file when that substring appears, e.g. the - `"ADE session guidance"` marker that the renderer's CLI launcher - embeds in the initial Codex prompt). The live polling backfill that - runs while a Codex session is still streaming uses all three gates; - the close-time backfill only enforces a 10-minute drift window so - it can match older sessions on resume. + and `excludedIds` (skip thread ids already owned by another + `terminal_sessions` row, so two rows can never adopt the same + rollout). The live polling backfill that runs while a Codex session + is still streaming uses all three gates; the close-time backfill + only enforces a 10-minute drift window so it can match older + sessions on resume. 4. Read Droid's local storage: `~/.factory/sessions//*.jsonl`. Each candidate's first line must be a `session_start` record whose `cwd` matches the ADE @@ -731,8 +755,17 @@ specific session. Lazy hydration over `sessions.list` therefore relies only on transcript regex matches, keeping the list-render hot path off the disk and preventing one renderer's idle refresh from adopting another lane's Codex rollout. Live Codex sessions still get their -resume target through the live capture path, which uses the strict -`requiredText: "ADE session guidance"` gate. +resume target through the live capture path below. + +Every storage strategy matches on an **exact cwd**, and that cwd is +resolved by `resolveSessionRunCwd(session)`: the lane's worktree path +from `laneService.getLaneBaseAndBranch(session.laneId)` first, and only +then the directory inferred from the transcript path. Transcripts live +under the project root even for lane sessions, so the transcript-derived +path names the project root rather than the worktree the agent actually +ran in — matching on it would miss every lane session's rollout. The +transcript fallback exists for a session whose lane has since been +deleted. ### Live Codex session-id capture @@ -746,7 +779,7 @@ UUID since codex has no pre-assigned-id flag (unlike Claude's `~/.codex/sessions/YYYY/MM/DD/` (and tomorrow's, to handle UTC rollover near midnight); each `add`/`change` event triggers a 200 ms-debounced parse pass against any new candidate file matching - the cwd / startedAt / required-text gates above. + the identification rules below. - **Staggered fallback poll.** `CODEX_FALLBACK_POLL_DELAYS_MS = [500, 2_000, 5_000, 12_000, 30_000]` schedules five timers that scan the same directory tree even when `fs.watch` is unavailable @@ -754,6 +787,30 @@ UUID since codex has no pre-assigned-id flag (unlike Claude's harness). The whole capture aborts after `CODEX_LIVE_CAPTURE_HARD_TIMEOUT_MS = 60_000`. +A candidate rollout is identified by **cwd plus a launch-time window**, +with no content gate. The window is `CODEX_LIVE_CAPTURE_MAX_START_DELTA_MS += 90_000` on either side of the row's `startedAt`, plus the existing +`notBeforeMs` floor (`startedAt - 1 s`) that rejects a rollout written +before this launch existed. Ninety seconds covers Codex CLI startup and +modest write/timestamp skew without admitting an unrelated launch minutes +later; the storage backfill keeps its wider historical window. + +Mis-adoption between two concurrent Codex runs in the *same* worktree is +prevented by exclusion rather than by text: before each pass the service +collects every thread id already owned by another `terminal_sessions` row +(`listOtherAdoptedCodexTargetIds`, reading `resumeMetadata.targetId` and +the parsed `resumeCommand`) and passes it as `excludedIds`. If that query +throws, the pass captures nothing — assigning a thread when the ownership +check could not run is worse than capturing late. Timestamp proximity +still breaks ties among the remaining candidates, but it is a tiebreak, +not proof of which PTY launched which thread. + +There is deliberately **no** `"ADE session guidance"` marker gate. Only +the Work-tab CLI preamble ever emitted that string — goal launches send +`` instead — so requiring it closed +the gate on nearly every real session and thread ids were essentially +never captured live. + When a UUID is captured, the service writes the row's `resumeMetadata.targetId` and **registers a stable thread name in codex's index**: it appends `{ id, thread_name, updated_at }` to @@ -785,6 +842,26 @@ Returning to a `waiting-input` runtime state does not auto-close a PTY. The user, owning service, or worker orchestration layer must call `dispose` explicitly when a terminal should close. +#### Resume launches that die on launch + +A resume or reattach takes over an existing row, so a launch that fails +immediately would otherwise overwrite a still-resumable session with +`failed` / exit 2 and make it look permanently dead. `create` snapshots +the row's `priorEndState` (`status`, `exitCode`, `endedAt`) when it +adopts an existing session, and `closeEntry` restores that snapshot +instead of the new exit when the process exits nonzero within +`RESUME_LAUNCH_FAILURE_WINDOW_MS = 5_000` of the launch — the window +that distinguishes a launch failure (bad flag, missing binary, shell +usage error) from a real nonzero exit after the CLI had actually been +running. Each restore logs `pty.resume_launch_failed_status_preserved`. + +`running` is deliberately unrepresentable in `priorEndState`: a row can +still read `running` after its owning brain died, and restoring that +stale value would leave a dead relaunch marked live. Only +`detached` / `completed` / `failed` are captured and restored +byte-for-byte; anything else leaves the snapshot null and the new +failure is persisted normally. + --- ## Data flow summary diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 2b473096a..5473a971a 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -63,10 +63,21 @@ progress and surface a sticky error toast rather than leaving Work disabled. It also owns the sidebar's multi-select state: - `selectedSessionIds: Set` with a `selectionAnchorId` tracker. -- `handleSelectSession(id, event, visibleSessionIds)` — plain click +- `handleSelectSession(id, event, visibleSessionIds, binding?)` — plain click clears the multi-selection and opens the tab; shift-click selects the range from the anchor; meta/ctrl-click toggles the id in/out of the - set; any of the three refresh the active single-selected item. + set; any of the three refresh the active single-selected item. The optional + binding is the per-session runtime pin carried into the opened view. +- `handleSelectForeignRuntimeSession(session, binding, event, visibleSessionIds)` + — the CLI/shell row of another machine, forwarded from `SessionListPane`. + When the owning binding is still open (`machineRouter.isLivePin`), the page + remembers it as the session's runtime pin and opens the row **in place** + through the normal selection path; the project tab is not touched, so Lanes, + PRs, and Files stay where the user put them. Only when that binding is not + open in this window does it fall back to switching the tab to the owning + project, since there is then nothing to pin to; a failed switch leaves the + session closed rather than opening a foreign session id against the tab's + current runtime. - `handleBulkStopSelected` runs on selected running PTY sessions, confirming before calling `stopRuntime(ptyId, sessionId)`; failures are counted and surfaced through `sessionActionError`. Chat rows stay @@ -565,10 +576,13 @@ chat-scoped and stays on the chat header. ## Terminal renderer: `TerminalView.tsx` Thin wrapper over xterm.js + `FitAddon`. Caches `Terminal` instances in -a module-level map keyed by `(projectRoot, sessionId, ptyId)` (via -`terminalRuntimeKey`) so a remount does not rebuild the emulator and so +a module-level map keyed by `(runtimePin, projectRoot, sessionId, ptyId)` +(via `terminalRuntimeKey`) so a remount does not rebuild the emulator and so two different project tabs can each cache their own runtime against the -same chat session id without colliding. Each cached entry also records +same chat session id without colliding. An unpinned view produces exactly the +old `(projectRoot, sessionId, ptyId)` key; a pin adds a `pin::::` +prefix, so a session opened against another machine can never share a cache +entry with a same-id view of the tab's own project. Each cached entry also records the `(projectRoot, projectRevision)` it was created under; on mount, `disposeStaleRuntimes(activeProjectRoot, activeProjectRevision)` clears out-of-date entries. With multi-project tab hosting in `App.tsx`, @@ -620,11 +634,35 @@ Key behaviors: reliable signal that "the surface is back on screen at its new size" since hidden surfaces no longer fire layout/resize events; without it, terminals come back blank after a tab swap. +- **Runtime pin** — the optional `runtimePin` prop is the per-session runtime + route for a session that lives on another open binding; `null` (the hot path) + means the tab's own machine and every call keeps its original one-argument + shape. The pin is stored on the cached runtime rather than read from the + window, so two simultaneously parked terminals from different machines cannot + borrow whichever project the window opened last. It is carried by + `pty.write` / `pty.resize`, `terminal.preview`, `sessions.get`, + `sessions.readTranscriptTail`, `agentChat.saveTempAttachment`, + `pty.setDataSubscriptions`, and the `pty.onData` / `pty.onExit` + subscriptions. Data/exit listeners and the main-side PTY id filter are grouped + per pin — events are dispatched only to runtimes whose pin matches the + subscription they arrived on — and the unpinned group keeps one shared + listener and one signature exactly as before. Because the pin is part of the + cache key, a session whose pin resolves late (`null` on first render, a real + binding once the cross-machine lane index loads) moves to a new key; + `teardownRelocatedRuntimes` disposes the unreferenced runtime left at the old + key so its subscriptions and pumps do not leak and the same PTY does not end + up with two emulators. Re-hydrating through the new binding is the point: a + buffer filled through the old transport may describe the wrong machine. - **Hydration backfill** — initial hydration prefers - `ade.terminal.preview` (serialized snapshot of the visible rows - rebuilt as SGR-bracketed ANSI through `serializeSnapshotVisibleRows`, - falling back to the snapshot's `serialized` scrollback) and only uses - the transcript tail when no snapshot is available. Before either path + `ade.terminal.preview`. `serializeSnapshotForHydration` picks which half of + the snapshot to write: an **alternate-buffer** snapshot (a full-screen TUI + such as Claude or Codex) repaints the structured visible rows first + (`serializeSnapshotVisibleRows`, SGR-bracketed ANSI) because replaying an + older serialized main buffer would corrupt its full-screen state, while a + **main-buffer** snapshot prefers the persisted `serialized` scrollback so + attaching to a running shell starts with scrollable history, falling back to + the visible rows for legacy or empty serialized snapshots. Only when no + snapshot is available does hydration use the transcript tail. Before either path runs, the runtime calls `sessions.get(sessionId)` to find out whether the session is disposed; for any disposed session that hasn't displayed live data yet, hydration first tries **replay mode** via @@ -852,6 +890,14 @@ A single hook that owns a lot of state: into `sessionsCacheByProject` and does not prune persisted open tabs, because React can briefly render the previous project's session list after `projectRoot` changes. +- the Work tab's per-session runtime routing, through the single + `useWorkMachineRouter()` instance it owns. It is re-exported as + `machineRouter` and `resolveSessionRuntimePin` (which `TerminalsPage` passes + down to `WorkViewArea`, which hands it to each `SessionSurface` as + `runtimePin`). `canMutatePinnedProjectUi` is `machineRouter.isLivePin`, launch + and resume paths remember their pin through the router, and `stopRuntime` / + `stopAll` resolve theirs from the combined cross-machine union so a foreign + PTY is disposed on its owning machine. `useWorkSessions({ active })` accepts an optional `active` flag (default `true`). When `active` is false, the hook stops scheduling background diff --git a/docs/features/web-client/README.md b/docs/features/web-client/README.md index 20749ab18..9f038bbb8 100644 --- a/docs/features/web-client/README.md +++ b/docs/features/web-client/README.md @@ -220,7 +220,15 @@ Browser `window.ade` adapter: are legitimate no-ops for a row that is not in the expected state (waking a row that was never snoozed), and reporting a no-op as applied would strand a stale overlay, so the helper checks whether the host actually changed that - row. + row. It also exports `assertWebRuntimePinUnsupported`, the fail-loud guard for + the desktop's per-session runtime pins: these namespaces target one web host + and cannot route a pinned call, so every pty/terminal shim, + `sessions.list` / `get` / `readTranscriptTail`, `lanes.list`, and the draft + attachment shim in `agentChat.ts` throw when a pin arrives rather than + answering from the wrong machine. `project.ts` deliberately omits + `gitOriginUrl` from `listRecent` for the same reason: cross-machine lane + discovery keys off it, and supplying it would start pinned reads this adapter + cannot serve. - `apps/desktop/src/renderer/webclient/adapter/sessionLifecycleOverlay.ts` - optimistic overlay for lifecycle writes. Desktop and iOS both own a local database, so a settle or snooze lands in local state instantly and the UI