diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index f21001be00..e177c0ec1d 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -3976,6 +3976,179 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('/resume opens a picker containing only resumable sessions when none is attached', async () => { + const terminal = new FakeTerminal(); + const resumable = fakeSessionSummary('resumable', '/repo'); + const unavailable = fakeSessionSummary('unavailable', ''); + const driver = new SlashCommandDriver([resumable, unavailable]); + (driver as unknown as { sessionId: string | null }).sessionId = null; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session Current')); + const output = plainTerminalOutput(terminal.output()); + assert.match(output, /resumabl/); + assert.doesNotMatch(output, /unavailable/); + + terminal.input('\r'); + await waitFor(() => driver.resumeCalls === 1); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); + + test('/resume does not resume after a stale selection fails to switch', async () => { + const terminal = new FakeTerminal(); + const session = fakeSessionSummary('stale', '/repo'); + const driver = new RejectingSwitchSessionDriver([session]); + (driver as unknown as { sessionId: string | null }).sessionId = null; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session Current')); + terminal.input('\r'); + await waitFor(() => driver.switchCalls === 1); + await delay(0); + assert.equal(driver.resumeCalls, 0); + + exitMaka(terminal); + await run; + }); + + test('/resume bounds concurrent resumability checks for large session catalogs', async () => { + const terminal = new FakeTerminal(); + const sessions = Array.from({ length: 24 }, (_, index) => + fakeSessionSummary(`session-${index}`, '/repo'), + ); + const driver = new BoundedResumeAvailabilityDriver(sessions); + (driver as unknown as { sessionId: string | null }).sessionId = null; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session Current')); + assert.ok(driver.availabilityCalls >= sessions.length); + assert.ok(driver.maxActiveCalls <= 8); + + terminal.input('\x1b'); + exitMaka(terminal); + await run; + }); + + test('/resume excludes foreign sessions when none is attached', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver([]); + (driver as unknown as { sessionId: string | null }).sessionId = null; + const foreignSession = { + source: 'claude-code' as const, + id: 'foreign-resume', + title: 'Foreign interrupted work', + cwd: '/repo', + updatedAtMs: Date.now(), + transcriptPath: '/home/u/.claude/projects/-repo/foreign-resume.jsonl', + }; + let listSessionsCalls = 0; + let readDigestCalls = 0; + const foreignSessions = { + availableSources: async () => ['claude-code' as const], + listSessions: async () => { + listSessionsCalls += 1; + return [foreignSession]; + }, + readDigest: async () => { + readDigestCalls += 1; + throw new Error('foreign import must not run from /resume'); + }, + }; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + foreignSessions, + }); + + terminal.input('/resume'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session Current')); + const output = plainTerminalOutput(terminal.output()); + assert.doesNotMatch(output, /Foreign interrupted work/); + assert.equal(listSessionsCalls, 0); + + terminal.input('\x1b'); + terminal.input('/exit'); + terminal.input('\r'); + await run; + assert.equal(readDigestCalls, 0); + assert.equal(driver.startNewSessionCalls, 0); + assert.equal(driver.prompts.length, 0); + }); + + test('/session keeps attachable rows when resume discovery fails for another session', async () => { + const terminal = new FakeTerminal(); + const attachable = fakeSessionSummary('attachable', '/repo'); + const archived = fakeSessionSummary('archived', '/repo'); + const driver = new SlashCommandDriver([attachable, archived]); + driver.getSessionResumeAvailability = async (session) => { + if (session.id === archived.id) throw new Error('session archived'); + return { available: false, reason: 'no resumable turn' }; + }; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('/session'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('Resume Session Current')); + assert.match(plainTerminalOutput(terminal.output()), /attachab/); + + let switched = false; + try { + terminal.input('\r'); + await waitFor(() => driver.sessionIds.includes(attachable.id)); + switched = true; + } finally { + exitMaka(terminal); + await run; + } + assert.equal(switched, true); + }); + test('surfaces a notice when the foreign-session scan fails', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver([]); @@ -4588,6 +4761,7 @@ describe('Maka Pi TUI runner', () => { driver.releaseList(); // The rendered picker is the observable arming signal for the Escape. await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Existing chat')); + assert.equal(driver.listCalls, 1); terminal.input('\x1b'); exitMaka(terminal); @@ -8419,6 +8593,32 @@ class SlashCommandDriver extends FakeSessionDriver { } } +class RejectingSwitchSessionDriver extends SlashCommandDriver { + switchCalls = 0; + + override async switchSession(_sessionId: string): Promise { + this.switchCalls += 1; + throw new Error('session became unavailable'); + } +} + +class BoundedResumeAvailabilityDriver extends SlashCommandDriver { + availabilityCalls = 0; + activeCalls = 0; + maxActiveCalls = 0; + + async getSessionResumeCandidateAvailability( + _session: SessionSummary, + ): Promise { + this.availabilityCalls += 1; + this.activeCalls += 1; + this.maxActiveCalls = Math.max(this.maxActiveCalls, this.activeCalls); + await delay(1); + this.activeCalls -= 1; + return { available: true }; + } +} + class UserCommandDriver extends SlashCommandDriver { readonly commands: string[] = []; @@ -9133,6 +9333,12 @@ class DeferredListSessionsDriver extends SlashCommandDriver { listCalls = 0; private resolveList: (() => void) | null = null; + async getSessionResumeCandidateAvailability( + _session: SessionSummary, + ): Promise { + return { available: true }; + } + override async listSessions(): Promise { this.listCalls += 1; await new Promise((resolve) => { diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 571afe359e..c8266a76c0 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -2636,6 +2636,15 @@ class FakeConnection { goal: this.goalQueryResults.shift() ?? null, } as OperationOutput; } + if (operation === 'turn.resume.query') { + return { + sessionId: (input as OperationInput<'turn.resume.query'>).sessionId, + disposition: 'ready', + sourceRunId: 'source-run-1', + sourceTurnId: 'source-turn-1', + sourceRuntimeEventHighWater: 1, + } as OperationOutput; + } if (operation === 'session.configuration.update') { const update = input as OperationInput<'session.configuration.update'>; const outcome = this.configurationOutcomes.shift(); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index d5db8c0568..9d6fe40428 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -354,6 +354,8 @@ function sessionConnectionIdentityNotice( return undefined; } +const SESSION_RESUME_AVAILABILITY_CONCURRENCY = 8; + export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const locale = input.locale ?? 'en'; const primaryGuidance = getTuiPrimaryGuidance(locale); @@ -1798,7 +1800,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // serial lock like any control action; mid-turn that lock is held by the // running Turn, so the switch goes through the detach path instead of // silently no-oping on the busy gate. - const goToSession = async (sessionId: string): Promise => { + const goToSession = async (sessionId: string): Promise => { const pair = sideConversation; if ( pair && @@ -1806,24 +1808,31 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { (sessionId === pair.parentSessionId || sessionId === pair.sideSessionId) ) { await toggleSideConversation(); - return; + return true; } const leavesPair = pair !== undefined && sessionId !== pair.parentSessionId && sessionId !== pair.sideSessionId; if (!turnRunning) { + let switched = false; await runControl(async () => { await switchSession(sessionId); + switched = true; if (leavesPair) await discardCurrentSidePair(); }); - return; + return switched; } // One detach at a time (#3380): a second mid-turn switch while the first // is still handing the view over would clear `detaching` early, reopen // the interrupt window, and double-apply the adoption. - if (detaching) return; - await switchAwayMidTurn(sessionId) - .then(() => (leavesPair ? discardCurrentSidePair() : undefined)) - .catch(reportError); + if (detaching) return false; + try { + await switchAwayMidTurn(sessionId); + if (leavesPair) await discardCurrentSidePair(); + return true; + } catch (error) { + reportError(error); + return false; + } }; const openSideConversation = async (prompt: string): Promise => { @@ -2407,7 +2416,35 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }); }; + let sessionListPromise: Promise | undefined; + let activeResumeAvailabilityChecks = 0; + const queuedResumeAvailabilityChecks: Array<() => void> = []; + const runResumeAvailabilityCheck = async (task: () => Promise): Promise => { + if (activeResumeAvailabilityChecks >= SESSION_RESUME_AVAILABILITY_CONCURRENCY) { + await new Promise((resolve) => queuedResumeAvailabilityChecks.push(resolve)); + } + activeResumeAvailabilityChecks += 1; + try { + return await task(); + } finally { + activeResumeAvailabilityChecks -= 1; + queuedResumeAvailabilityChecks.shift()?.(); + } + }; + const listSessions = (): Promise => { + if (!sessionListPromise) { + sessionListPromise = input.driver.listSessions().finally(() => { + sessionListPromise = undefined; + }); + } + return sessionListPromise; + }; + const resumeSession = async () => { + if (!input.driver.getSessionId()) { + await showSessionList({ onlyResumable: true }); + return; + } if (!input.driver.resumeLatest) { throw new Error('Safe-boundary resume is unavailable on this runtime.'); } @@ -2441,8 +2478,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } }; - const showSessionList = async () => { - const sessions = await input.driver.listSessions(); + const showSessionList = async (options: { onlyResumable?: boolean } = {}) => { + const sessions = await listSessions(); const sessionTree = projectRevisionLinkedSessionTree( sessions, input.driver.getSessionId() ?? undefined, @@ -2457,18 +2494,33 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const [availabilityEntries, foreignScan] = await Promise.all([ Promise.all( sessions.map(async (session) => { - return [ - session.id, - (await input.driver.getSessionResumeAvailability?.(session)) ?? - (await inspectSessionResumeAvailability(session)), - ] as const; + try { + return await runResumeAvailabilityCheck(async () => { + if (!session.cwd) { + return [ + session.id, + { available: false, reason: 'Missing working directory' }, + ] as const; + } + const availability = options.onlyResumable + ? ((await input.driver.getSessionResumeCandidateAvailability?.(session)) ?? + (await input.driver.getSessionResumeAvailability?.(session)) ?? + (await inspectSessionResumeAvailability(session))) + : ((await input.driver.getSessionResumeAvailability?.(session)) ?? + (await inspectSessionResumeAvailability(session))); + return [session.id, availability] as const; + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return [session.id, { available: false, reason: detail }] as const; + } }), ), // Foreign (Claude Code / Codex) rows are an import flow: it starts a NEW // Session and hands off a turn, which cannot detach from the running // one (#3380). Skip the scan mid-turn instead of offering rows whose // selection would silently no-op on importForeignSession's busy guard. - input.foreignSessions && !turnRunning + !options.onlyResumable && input.foreignSessions && !turnRunning ? input.foreignSessions.listSessions({ cwd }).then( (summaries) => ({ summaries }), (error: unknown) => ({ error }), @@ -2499,7 +2551,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { sessionListScope === 'current' ? projectedSessions.filter(({ session }) => session.cwd === cwd) : projectedSessions; - const items: SelectItem[] = visibleSessions.map(({ session, depth }) => { + const selectableSessions = options.onlyResumable + ? visibleSessions.filter(({ session }) => availability.get(session.id)?.available === true) + : visibleSessions; + const items: SelectItem[] = selectableSessions.map(({ session, depth }) => { const state = availability.get(session.id); const statusBadge = sessionStatusBadge(session, locale); const statusDetail = statusBadge ? ` · ${statusBadge}` : ''; @@ -2517,14 +2572,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { : `${shortSessionId(session.id)}${statusDetail}${location}${childDetail} ${session.llmConnectionSlug} ${session.model}`, }; }); - // Foreign sessions are cwd-scoped; show them in both scope views (they - // belong to this project) so a Tab toggle never makes them vanish. - for (const [value, summary] of foreignByValue) { - items.push({ - value, - label: summary.title, - description: `↩ resume from ${foreignSourceLabel(summary.source)}`, - }); + if (!options.onlyResumable) { + // Foreign sessions are cwd-scoped; show them in both scope views (they + // belong to this project) so a Tab toggle never makes them vanish. + for (const [value, summary] of foreignByValue) { + items.push({ + value, + label: summary.title, + description: `↩ resume from ${foreignSourceLabel(summary.source)}`, + }); + } } const list = new SelectList(items, 10, selectListTheme(), { minPrimaryColumnWidth: 20, @@ -2542,9 +2599,18 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void importForeignSession(foreign); return; } - if (availability.get(item.value)?.available === false) return; + const itemAvailability = availability.get(item.value); + if ( + itemAvailability?.available === false && + itemAvailability.reason === 'Missing working directory' + ) { + return; + } closeOverlay(); - void goToSession(item.value); + void (async () => { + const switched = await goToSession(item.value); + if (switched && options.onlyResumable) await runControl(resumeSession); + })().catch(reportError); }; list.onCancel = () => closeOverlay(); sessionPickerOverlayOpen = true; @@ -2566,6 +2632,31 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { renderScope(); }; + const announceResumeAvailability = async (): Promise => { + const sessionId = input.driver.getSessionId(); + try { + if (!input.driver.getSessionResumeCandidateAvailability) return; + const sessions = await listSessions(); + const session = + sessions.find((candidate) => candidate.id === sessionId) ?? + sessions.find((candidate) => candidate.cwd === cwd); + if (!session) return; + const availability = await runResumeAvailabilityCheck(() => + input.driver.getSessionResumeCandidateAvailability!(session), + ); + if (availability.available) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: 'This session has an interrupted run — /resume to continue from the safe boundary.', + }); + requestRender(); + } + } catch { + // Resume discovery is advisory and must never prevent the TUI from starting. + } + }; + const showRewindPicker = async () => { const targets = await input.driver.listRewindTargets(); if (targets.length === 0) { @@ -3877,6 +3968,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // line discipline and leaks onto the screen as a stray `^[[I` on launch. terminal.write(ENABLE_FOCUS_REPORTING); if (input.firstRun) void showSetupWizard(); + setTimeout(() => void announceResumeAvailability(), 0); } catch (error) { beginClose(error instanceof Error ? error : new Error(String(error))); } diff --git a/packages/cli/src/runtime-host-session-driver.ts b/packages/cli/src/runtime-host-session-driver.ts index 36ba9707c8..217263f262 100644 --- a/packages/cli/src/runtime-host-session-driver.ts +++ b/packages/cli/src/runtime-host-session-driver.ts @@ -327,10 +327,20 @@ class RuntimeHostMakaSessionDriverImpl implements RuntimeHostMakaSessionDriver { .map(({ session }) => session); } - getSessionResumeAvailability(session: SessionSummary): Promise { + async getSessionResumeAvailability(session: SessionSummary): Promise { return inspectRuntimeHostSessionResumeAvailability(session, this.#executionLocation); } + async getSessionResumeCandidateAvailability( + session: SessionSummary, + ): Promise { + if (!session.cwd) return { available: false, reason: 'Missing working directory' }; + const plan = await this.#request('turn.resume.query', { sessionId: session.id }); + return plan.disposition === 'ready' + ? { available: true } + : { available: false, reason: plan.reason }; + } + async preparePrompt( prompt: string, options: MakaPreparePromptOptions = {}, @@ -1748,9 +1758,8 @@ function inspectRuntimeHostSessionResumeAvailability( if (!summary.cwd) { return Promise.resolve({ available: false, reason: 'Missing working directory' }); } - return location.kind === 'host' - ? Promise.resolve({ available: true }) - : inspectSessionResumeAvailability(summary); + if (location.kind !== 'host') return inspectSessionResumeAvailability(summary); + return Promise.resolve({ available: true }); } async function assertSessionResumeAvailable( diff --git a/packages/cli/src/session-driver.ts b/packages/cli/src/session-driver.ts index 4dac719577..2b81162cdf 100644 --- a/packages/cli/src/session-driver.ts +++ b/packages/cli/src/session-driver.ts @@ -143,6 +143,9 @@ export interface MakaUserCommand { export interface MakaSessionDriver { listSessions(): Promise; getSessionResumeAvailability?(session: SessionSummary): Promise; + getSessionResumeCandidateAvailability?( + session: SessionSummary, + ): Promise; preparePrompt( prompt: string, options?: MakaPreparePromptOptions,