@@ -134,7 +134,9 @@ import {
134134} from "../session/runtime-assembly.js" ;
135135import { createAttachmentRehydrateTransform } from "../session/attachment-store.js" ;
136136import { createModelSummarizer } from "../session/summarizer.js" ;
137- import { ID_PREFIX , LOG_NAMESPACE_ROOT } from "../branding.js" ;
137+ import { COMMAND_NAME , ID_PREFIX , LOG_NAMESPACE_ROOT } from "../branding.js" ;
138+
139+ const tuiLogger = getLogger ( [ LOG_NAMESPACE_ROOT , "tui" ] ) ;
138140
139141export function createTUIEventEmitter ( ) : EventEmitter {
140142 return new EventEmitter ( ) ;
@@ -156,6 +158,30 @@ export function resolveExitCode(args: ResolveExitCodeArgs): number {
156158 return 0 ;
157159}
158160
161+ /** One-line transcript block when resume history fails to load. */
162+ export function resumeTranscriptLoadErrorBlock ( err : unknown ) : {
163+ type : "error" ;
164+ message : string ;
165+ } {
166+ const message = err instanceof Error ? err . message : String ( err ) ;
167+ return { type : "error" , message : `Could not load prior session transcript: ${ message } ` } ;
168+ }
169+
170+ /**
171+ * Resolve the base for a local-settings read-modify-write.
172+ * Absent file → empty object; unreadable/invalid → null (caller must skip write).
173+ */
174+ export async function loadLocalSettingsWriteBase (
175+ path : string ,
176+ load : ( path : string ) => Promise < LocalSettings | null > = loadLocalSettings ,
177+ ) : Promise < LocalSettings | null > {
178+ try {
179+ return ( await load ( path ) ) ?? { } ;
180+ } catch {
181+ return null ;
182+ }
183+ }
184+
159185function buildCompactionContinuationMessage ( ) : InboundMessage {
160186 return {
161187 ref : { uid : 0 , mailbox : "system" } ,
@@ -270,7 +296,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
270296 const finalizeOnCrash = async ( err : unknown ) : Promise < void > => {
271297 if ( finalized ) return ;
272298 finalized = true ;
273- await flushPartialOnCrash ( ) . catch ( ( ) => undefined ) ;
299+ await flushPartialOnCrash ( ) . catch ( ( flushErr : unknown ) => {
300+ // Best-effort only — still attempt saveState below. Log so a flush
301+ // failure is not invisible when diagnosing a crash exit.
302+ const flushMessage = flushErr instanceof Error ? flushErr . message : String ( flushErr ) ;
303+ tuiLogger . warn ( "crash finalize: partial flush failed: {error}" , { error : flushMessage } ) ;
304+ process . stderr . write ( `${ COMMAND_NAME } : crash finalize partial flush failed: ${ flushMessage } \n` ) ;
305+ } ) ;
274306 const message = err instanceof Error ? err . message : String ( err ) ;
275307 await saveState ( config . cwd , sessionId , {
276308 status : "failed" ,
@@ -281,7 +313,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
281313 error : message ,
282314 model : `${ config . providerName } :${ config . model } ` ,
283315 mcpServers : [ ] ,
284- } ) . catch ( ( ) => undefined ) ;
316+ } ) . catch ( ( saveErr : unknown ) => {
317+ const saveMessage = saveErr instanceof Error ? saveErr . message : String ( saveErr ) ;
318+ tuiLogger . warn (
319+ "crash finalize: saveState failed for session {sessionId}: {error}" ,
320+ { sessionId, error : saveMessage } ,
321+ ) ;
322+ process . stderr . write (
323+ `${ COMMAND_NAME } : crash finalize saveState failed for ${ sessionId } : ${ saveMessage } \n` ,
324+ ) ;
325+ } ) ;
285326 } ;
286327
287328 try {
@@ -411,8 +452,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
411452 let liveWebOverride : string | undefined = config . settings ?. web ;
412453 const livePluginPaths : string [ ] = [ ...( config . settings ?. pluginPaths ?? [ ] ) ] ;
413454 const persistPluginSettings = async ( ) : Promise < void > => {
414- const current = await loadSettings ( config . globalSettingsPath ) . catch ( ( ) => null ) ;
415- const base : Settings = current ?? { providers : { } } ;
455+ // Absent file → fresh base; unreadable/invalid → skip write so we never
456+ // clobber a corrupt settings file by rewriting from a minimal shell.
457+ const base = await loadGlobalSettingsWriteBase ( config . globalSettingsPath ) ;
458+ if ( base === null ) {
459+ tuiLogger . warn (
460+ "Skipping plugin settings write: unreadable global settings at {path}" ,
461+ { path : config . globalSettingsPath } ,
462+ ) ;
463+ return ;
464+ }
416465 const next : Settings = { ...base , plugins : livePluginConfig } ;
417466 if ( livePluginPaths . length > 0 ) next . pluginPaths = livePluginPaths ;
418467 else delete next . pluginPaths ;
@@ -637,7 +686,15 @@ export async function runTUI(initialConfig: Config): Promise<number> {
637686 const picked = await promptSessionModeIfUnset ( config . globalSettingsPath ) ;
638687 liveSessionMode = picked ?? "orchestrator" ;
639688 if ( picked !== undefined ) {
640- const refreshed = await loadSettings ( config . globalSettingsPath ) . catch ( ( ) => null ) ;
689+ const refreshed = await loadSettings ( config . globalSettingsPath ) . catch ( ( err : unknown ) => {
690+ // loadSettings already maps ENOENT → null; a throw is a real I/O or
691+ // schema failure. Keep the in-memory config rather than pretending
692+ // settings are empty.
693+ tuiLogger . warn ( "Failed to reload settings after session mode pick: {error}" , {
694+ error : err instanceof Error ? err . message : String ( err ) ,
695+ } ) ;
696+ return null ;
697+ } ) ;
641698 if ( refreshed !== null ) config = { ...config , settings : refreshed } ;
642699 }
643700 }
@@ -1405,8 +1462,14 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14051462 { ...( config . settings !== undefined ? { initialSettings : config . settings } : { } ) }
14061463 onChangeCompactionMode = { async ( mode ) => {
14071464 liveCompactionMode = mode ;
1408- const current = await loadSettings ( config . globalSettingsPath ) . catch ( ( ) => null ) ;
1409- const base : Settings = current ?? { providers : { } } ;
1465+ const base = await loadGlobalSettingsWriteBase ( config . globalSettingsPath ) ;
1466+ if ( base === null ) {
1467+ tuiLogger . warn (
1468+ "Skipping compaction mode write: unreadable global settings at {path}" ,
1469+ { path : config . globalSettingsPath } ,
1470+ ) ;
1471+ return ;
1472+ }
14101473 await saveGlobalSettings ( config . globalSettingsPath , { ...base , compactionMode : mode } ) ;
14111474 } }
14121475 onChangeMaxConcurrentSubAgents = { async ( limit ) => {
@@ -1438,12 +1501,25 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14381501 onChangeSessionMode = { async ( mode , scope ) => {
14391502 if ( scope === "local" ) {
14401503 const path = localSettingsPath ( config . cwd ) ;
1441- const existing = ( await loadLocalSettings ( path ) . catch ( ( ) => null ) ) ?? { } ;
1442- const next : LocalSettings = { ...existing , sessionMode : mode } ;
1443- await saveLocalSettings ( path , next ) ;
1504+ // Absent → {}; unreadable/invalid → null so we never clobber.
1505+ const existing = await loadLocalSettingsWriteBase ( path ) ;
1506+ if ( existing === null ) {
1507+ tuiLogger . warn (
1508+ "Skipping local session mode write: unreadable settings at {path}" ,
1509+ { path } ,
1510+ ) ;
1511+ return ;
1512+ }
1513+ await saveLocalSettings ( path , { ...existing , sessionMode : mode } ) ;
14441514 } else {
1445- const current = await loadSettings ( config . globalSettingsPath ) . catch ( ( ) => null ) ;
1446- const base : Settings = current ?? { providers : { } } ;
1515+ const base = await loadGlobalSettingsWriteBase ( config . globalSettingsPath ) ;
1516+ if ( base === null ) {
1517+ tuiLogger . warn (
1518+ "Skipping global session mode write: unreadable settings at {path}" ,
1519+ { path : config . globalSettingsPath } ,
1520+ ) ;
1521+ return ;
1522+ }
14471523 await saveGlobalSettings ( config . globalSettingsPath , { ...base , sessionMode : mode } ) ;
14481524 }
14491525 } }
@@ -1496,7 +1572,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14961572 const blocks = turnsToContentBlocks ( turns , { maxBlocks : RESUME_TRANSCRIPT_BLOCK_LIMIT } ) ;
14971573 if ( blocks . length > 0 ) emitter . emit ( "history.hydrate" , blocks ) ;
14981574 } )
1499- . catch ( ( ) => undefined ) ;
1575+ . catch ( ( err : unknown ) => {
1576+ // Resume still works without painted history, but a silent empty
1577+ // transcript looks like a brand-new session. Log and surface a one-line
1578+ // error block so the operator knows history failed to load.
1579+ const block = resumeTranscriptLoadErrorBlock ( err ) ;
1580+ tuiLogger . warn ( "Failed to load resume transcript from {workdir}: {error}" , {
1581+ workdir,
1582+ error : err instanceof Error ? err . message : String ( err ) ,
1583+ } ) ;
1584+ emitter . emit ( "history.hydrate" , [ block ] ) ;
1585+ } ) ;
15001586
15011587 // Connect MCP servers after the TUI is up so the UI is usable immediately and
15021588 // any OAuth authorization is surfaced as a copyable link rather than a browser
0 commit comments