From b051ba90e6aed5a97752e7a88aa80f401176dc69 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:14:00 -0700 Subject: [PATCH] fix(orcad): a restarted managed server keeps the tab titles of terminals it adopts (#29623) --- .../daemon-pty-adapter-session-adoption.test.ts | 10 ++++++++++ src/main/daemon/daemon-pty-session-control.ts | 10 ++++++++-- .../ipc/pty-controller-ownership-routing.test.ts | 12 ++++++++++++ src/main/ipc/pty/runtime/operations.ts | 3 +++ src/main/providers/pty-provider-contract.ts | 2 +- 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts index a603c21f037..024aeaa5718 100644 --- a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts +++ b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts @@ -154,6 +154,16 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + // A restarted server adopts surviving sessions this way and must not reset their tab titles. + it('returns the session title from attach-only adoption', async () => { + const sessionId = 'attach-title-handoff' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('\x1b]0;first\x07\x1b]0;⠋ Claude\x07') + await new Promise((r) => setTimeout(r, 50)) + + await expect(adapter.attach(sessionId)).resolves.toMatchObject({ lastTitle: '⠋ Claude' }) + }) + it('returns plain result for new sessionId', async () => { const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'brand-new' }) expect(result.id).toBe('brand-new') diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index 5f9eac765c4..f8cde245cb4 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -16,7 +16,7 @@ export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 const MAX_TOMBSTONES = 1000 export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { - async attach(id: string): Promise | void> { + async attach(id: string): Promise | void> { await this.ensureConnected() if (!this.canDelegateBackgroundToDaemon) { this.setPtyBackgrounded(id, false) @@ -41,7 +41,13 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { if (result.exitedBeforeSpawnReply) { throw new SessionNotFoundError(id) } - return result.providerSequence ? { providerSequence: result.providerSequence } : undefined + // Why lastTitle: a runtime adopting a surviving session (e.g. a restarted server) never saw it. + return result.providerSequence || result.lastTitle + ? { + ...(result.providerSequence ? { providerSequence: result.providerSequence } : {}), + ...(result.lastTitle ? { lastTitle: result.lastTitle } : {}) + } + : undefined } hasPty(id: string): boolean { diff --git a/src/main/ipc/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty-controller-ownership-routing.test.ts index 7abc9a60a28..e8bb5aa854a 100644 --- a/src/main/ipc/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty-controller-ownership-routing.test.ts @@ -186,6 +186,18 @@ describe('registerPtyHandlers', () => { 0 ) }) + it('seeds the title of a session adopted by subscriber-driven attach', async () => { + const daemonPtyId = 'repo-1::/tmp/wt@@title-handoff' + const localProvider = createAgentClaimProvider({}) + localProvider.attach.mockResolvedValueOnce({ lastTitle: '⠋ Claude' }) + setLocalPtyProvider(localProvider as never) + const seedTerminalRestoreTail = vi.fn() + const controller = registerAgentClaimController({ seedTerminalRestoreTail }) + + await expect(controller.attach(daemonPtyId)).resolves.toBe(true) + + expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, { lastTitle: '⠋ Claude' }) + }) it('does not dispatch a runtime PTY spawn after its client disconnects', async () => { const provider = createAgentClaimProvider({}) setLocalPtyProvider(provider as never) diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 45108ea4e4c..948bb4a3fce 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -139,6 +139,9 @@ export async function attachPtyFromRuntimeController( sequenceBeforeProviderAttach ) } + if (attachResult?.lastTitle) { + deps.runtime?.seedTerminalRestoreTail?.(ptyId, { lastTitle: attachResult.lastTitle }) + } return true } catch { return false diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index b16728b4423..cd68eb5df86 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -147,7 +147,7 @@ export type IPtyProvider = { providesAgentSessionOwnerListings?: (ptyId: string) => boolean /** Whether fresh structured creates can replay one spawn across a lost relay response. */ supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise - attach(id: string): Promise | void> + attach(id: string): Promise | void> hasPty?: (id: string) => boolean /** Exact provider readback: false only when the provider answered that the PTY is absent. */ probePtyLiveness?: (id: string) => Promise