@@ -211,6 +211,29 @@ export function resumeTranscriptLoadErrorBlock(err: unknown): {
211211 return { type : "error" , message : `Could not load prior session transcript: ${ message } ` } ;
212212}
213213
214+ export type ResumeSeed = {
215+ turnsUsed : number ;
216+ mcpServers : ConnectedMcpServer [ ] ;
217+ } ;
218+
219+ const FRESH_RESUME_SEED : ResumeSeed = { turnsUsed : 0 , mcpServers : [ ] } ;
220+
221+ /**
222+ * Fold a resumed session's run.json into a concrete seed once, at the
223+ * resume boundary, so every downstream reader (the run sink, the
224+ * connected-servers list, the immediate post-resume saveState) trusts a
225+ * fully-populated value instead of each repeating its own `?? 0` / `?? []`
226+ * default. A fresh (non-resumed) run gets the same shape via
227+ * FRESH_RESUME_SEED, so callers never branch on "was this a resume."
228+ */
229+ export function resolveResumeSeed ( pickedState : RunState | null ) : ResumeSeed {
230+ if ( pickedState === null ) return FRESH_RESUME_SEED ;
231+ return {
232+ turnsUsed : pickedState . turnsUsed ,
233+ mcpServers : pickedState . mcpServers ?? [ ] ,
234+ } ;
235+ }
236+
214237const GRANT_SCOPE_LABEL : Record < GrantScope , string > = {
215238 session : "This session" ,
216239 project : "This project" ,
@@ -406,13 +429,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
406429 let resumeSkipInitialTask = config . skipInitialTask === true ;
407430 let startedAt = Date . now ( ) ;
408431 let runTaskTitle = config . task ;
432+ // Resolved once at the resume boundary so turnsUsed/mcpServers reads
433+ // downstream never repeat their own omission-handling default.
434+ let resumeSeed : ResumeSeed = FRESH_RESUME_SEED ;
409435
410436 if ( config . resumePicker ) {
411437 const picked = await pickSession ( config . cwd , { includeCompleted : config . force } ) ;
412438 if ( picked === null ) return 0 ;
413439 sessionId = picked . sessionId ;
414440 resumeSkipInitialTask = true ;
415441 const pickedState = await loadState ( config . cwd , sessionId ) ;
442+ resumeSeed = resolveResumeSeed ( pickedState ) ;
416443 if ( pickedState !== null ) {
417444 startedAt = pickedState . startedAt ;
418445 runTaskTitle = pickedState . task ;
@@ -435,20 +462,28 @@ export async function runTUI(initialConfig: Config): Promise<number> {
435462 // run.json at all.
436463 await saveState ( config . cwd , sessionId , {
437464 status : "running" ,
438- turnsUsed : 0 ,
465+ turnsUsed : resumeSeed . turnsUsed ,
439466 task : runTaskTitle . trim ( ) . length > 0 ? runTaskTitle . trim ( ) : "(conversation)" ,
440467 startedAt,
441468 model : `${ config . providerName } :${ config . model } ` ,
442- mcpServers : [ ] ,
469+ mcpServers : resumeSeed . mcpServers ,
443470 } ) ;
444471
445472 // Crash guard: if anything from setup onward throws all the way out of
446473 // runTUI instead of reaching the normal finalize block, this still closes
447474 // out run.json so status and finishedAt never disagree. Declared before the
448475 // try so every fallible step after the minimal write above is covered.
449476 // `finalized` is set by the normal finalize path so this never double-writes
450- // on a clean exit; it also gates straggler snapshot writes (see
451- // persistRunSnapshot) from resurrecting a closed record.
477+ // on a clean exit. It also gates persistRunSnapshot (below) from *issuing*
478+ // a straggler write at all once the run is closed — a different job from
479+ // saveState's per-session write ordering in state.ts. That ordering only
480+ // decides which already-issued write lands last; it has no way to know a
481+ // "running" snapshot fired after finalize is stale and should never be
482+ // written in the first place. Without this flag such a snapshot would
483+ // still queue behind the terminal write and legitimately "win" the
484+ // ordering, resurrecting a closed run.json. Two different constraints
485+ // (don't issue a stale write vs. order the writes you do issue), each
486+ // owned by its own layer — not a duplicate check.
452487 let finalized = false ;
453488 // Bound after the cycle recorder exists (it needs the session workdir); the
454489 // crash guard is declared first so it covers every fallible step below.
@@ -1305,6 +1340,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13051340 const runSink = createRunSink ( {
13061341 emitter,
13071342 hookManager,
1343+ initialTurnCount : resumeSeed . turnsUsed ,
13081344 onTurnComplete : ( ctx ) => {
13091345 // provider_id is the canonical provider kind, never ctx.source.sourceId:
13101346 // sourceId is the user-typed label from onboarding/settings, and free
@@ -1324,7 +1360,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13241360
13251361 // MCP servers connected so far, keyed by name so a reconnect after a failure
13261362 // replaces rather than duplicates the entry.
1327- let connectedMcpServers : ConnectedMcpServer [ ] = [ ] ;
1363+ let connectedMcpServers : ConnectedMcpServer [ ] = resumeSeed . mcpServers ;
13281364 // Every configured server's latest state, for the /mcp surface. Unlike
13291365 // `connectedMcpServers` (persisted run metadata) this keeps the ones that
13301366 // failed or are still waiting on authorization.
0 commit comments