@@ -22,23 +22,20 @@ export function isSafeAgentId(id: string): boolean {
2222interface StateJson {
2323 createdAt ?: string | number ;
2424 updatedAt ?: string | number ;
25+ cwd ?: string ;
26+ workDir ?: string ;
2527 title ?: string ;
2628 isCustomTitle ?: boolean ;
2729 lastPrompt ?: string ;
2830 // Agent metadata comes from an untrusted state.json (a corrupt or imported
29- // bundle may hold non-object entries like `{ "main": null }`), so the value
30- // type allows null and inventoryAgents skips anything that isn't an object .
31+ // bundle may hold non-object entries like `{ "main": null }`), so inventory
32+ // skips anything that is not a record .
3133 //
3234 // v2 writes the REAL parent / dynamic-workflow-item label under `labels`
3335 // (its top-level `parentAgentId` is a fixed 'main' placeholder for sub
3436 // agents); v1 wrote them top-level. Read labels first, top-level as
3537 // fallback — the same order the engine itself uses.
36- agents ?: Record < string , {
37- type : 'main' | 'sub' | 'independent' ;
38- parentAgentId ?: string | null ;
39- dynamicWorkflowItem ?: string ;
40- labels ?: { parentAgentId ?: string ; dynamicWorkflowItem ?: string } ;
41- } | null > ;
38+ agents ?: Record < string , unknown > ;
4239 custom ?: Record < string , unknown > ;
4340}
4441
@@ -90,7 +87,15 @@ export async function readSessionDetail(home: string, sessionId: string): Promis
9087 }
9188 if ( state . custom ?. [ 'imported_from_pythinker_cli' ] === true ) return null ;
9289 const agents = await inventoryAgents ( sessionDir , state ) ;
93- return { sessionId, sessionDir, workDir, state, agents, imported : false , importMeta : null } ;
90+ return {
91+ sessionId,
92+ sessionDir,
93+ workDir : recoverWorkDir ( state , workDir ) ,
94+ state,
95+ agents,
96+ imported : false ,
97+ importMeta : null ,
98+ } ;
9499}
95100
96101/** Detail for an imported bundle. Same readers as a local session, but the
@@ -117,7 +122,15 @@ async function readImportedDetail(home: string, importId: string): Promise<Sessi
117122 if ( agents . length === 0 ) {
118123 agents = await discoverAgentsFromDisk ( sessionDir ) ;
119124 }
120- return { sessionId : importId , sessionDir, workDir, state, agents, imported : true , importMeta : meta } ;
125+ return {
126+ sessionId : importId ,
127+ sessionDir,
128+ workDir : recoverWorkDir ( state , workDir ) ,
129+ state,
130+ agents,
131+ imported : true ,
132+ importMeta : meta ,
133+ } ;
121134}
122135
123136/** Fallback inventory used when `state.json` is unreadable: walk
@@ -154,6 +167,7 @@ async function discoverAgentsFromDisk(sessionDir: string): Promise<AgentInfo[]>
154167 agentId : id ,
155168 type : id === 'main' ? 'main' : 'independent' ,
156169 parentAgentId : null ,
170+ profileName : null ,
157171 homedir : join ( agentsDir , id ) ,
158172 wireExists : readable ,
159173 wireRecordCount : info . count ,
@@ -205,7 +219,7 @@ async function tryReadSummary(
205219 return {
206220 sessionId,
207221 sessionDir,
208- workDir,
222+ workDir : recoverWorkDir ( state , workDir ) ,
209223 title : state . title ?? null ,
210224 lastPrompt : state . lastPrompt ?? null ,
211225 isCustomTitle : state . isCustomTitle ?? false ,
@@ -271,7 +285,8 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise<Ag
271285 // A type-corrupt entry (e.g. `{ "main": null }`) must not throw on the
272286 // field dereferences below; skip it so the empty-inventory fallback in
273287 // readImportedDetail can recover the agent from disk instead.
274- if ( typeof meta !== 'object' || meta === null ) continue ;
288+ if ( ! isRecord ( meta ) ) continue ;
289+ const labels = isRecord ( meta [ 'labels' ] ) ? meta [ 'labels' ] : undefined ;
275290 const wirePath = join ( sessionDir , 'agents' , id , 'wire.jsonl' ) ;
276291 const exists = await pathExists ( wirePath ) ;
277292 let readable = exists ;
@@ -289,13 +304,18 @@ async function inventoryAgents(sessionDir: string, state: StateJson): Promise<Ag
289304 }
290305 result . push ( {
291306 agentId : id ,
292- type : meta . type ,
293- parentAgentId : meta . labels ?. parentAgentId ?? meta . parentAgentId ?? null ,
307+ type : normalizeAgentType ( meta [ 'type' ] , id ) ,
308+ parentAgentId :
309+ normalizeNonEmptyString ( labels ?. [ 'parentAgentId' ] ) ??
310+ normalizeNonEmptyString ( meta [ 'parentAgentId' ] ) ,
311+ profileName : normalizeNonEmptyString ( labels ?. [ 'profileName' ] ) ,
294312 homedir : join ( sessionDir , 'agents' , id ) ,
295313 wireExists : readable ,
296314 wireRecordCount : info . count ,
297315 wireProtocolVersion : info . protocolVersion ,
298- dynamicWorkflowItem : meta . labels ?. dynamicWorkflowItem ?? meta . dynamicWorkflowItem ?? null ,
316+ dynamicWorkflowItem :
317+ normalizeNonEmptyString ( labels ?. [ 'dynamicWorkflowItem' ] ) ??
318+ normalizeNonEmptyString ( meta [ 'dynamicWorkflowItem' ] ) ,
299319 } ) ;
300320 }
301321 return result . toSorted ( ( a , b ) => compareAgentIds ( a . agentId , b . agentId ) ) ;
@@ -354,20 +374,26 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion:
354374 let protocolVersion : string | null = null ;
355375 for await ( const line of rl ) {
356376 if ( line . length === 0 ) continue ;
377+ let parsed : unknown ;
378+ try {
379+ parsed = JSON . parse ( line ) ;
380+ } catch {
381+ continue ;
382+ }
383+ if ( typeof parsed !== 'object' || parsed === null || Array . isArray ( parsed ) ) continue ;
384+ const record = parsed as Record < string , unknown > ;
385+ if ( typeof record [ 'type' ] !== 'string' ) continue ;
357386 if ( protocolVersion === null ) {
358- // Strict: the first non-empty line MUST be a well-formed
359- // `metadata` record. Otherwise the list-view health would say
360- // "ok" while the wire-reader rejects the file on open.
361- let parsed : { type ?: unknown ; protocol_version ?: unknown } ;
362- try {
363- parsed = JSON . parse ( line ) as typeof parsed ;
364- } catch {
365- throw new Error ( `wire metadata is not valid JSON at line 1` ) ;
366- }
367- if ( parsed . type !== 'metadata' || typeof parsed . protocol_version !== 'string' ) {
368- throw new Error ( `wire is missing a metadata header on line 1` ) ;
387+ if ( record [ 'type' ] !== 'metadata' ) {
388+ protocolVersion = '1.4' ;
389+ } else {
390+ const version = record [ 'protocol_version' ] ;
391+ const createdAt = record [ 'created_at' ] ;
392+ if ( typeof version !== 'string' || typeof createdAt !== 'number' ) {
393+ throw new TypeError ( 'wire metadata is malformed' ) ;
394+ }
395+ protocolVersion = version ;
369396 }
370- protocolVersion = parsed . protocol_version ;
371397 }
372398 count += 1 ;
373399 }
@@ -377,6 +403,32 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion:
377403 return { count, protocolVersion } ;
378404}
379405
406+ function normalizeAgentType (
407+ value : unknown ,
408+ agentId : string ,
409+ ) : AgentInfo [ 'type' ] {
410+ if ( value === 'main' || value === 'sub' || value === 'independent' ) return value ;
411+ return agentId === 'main' ? 'main' : 'sub' ;
412+ }
413+
414+ function normalizeNonEmptyString ( value : unknown ) : string | null {
415+ if ( typeof value !== 'string' ) return null ;
416+ const trimmed = value . trim ( ) ;
417+ return trimmed . length > 0 ? trimmed : null ;
418+ }
419+
420+ function isRecord ( value : unknown ) : value is Record < string , unknown > {
421+ return typeof value === 'object' && value !== null && ! Array . isArray ( value ) ;
422+ }
423+
424+ function recoverWorkDir ( state : StateJson , preferred : string ) : string {
425+ if ( preferred . length > 0 ) return preferred ;
426+ if ( typeof state . cwd === 'string' && state . cwd . length > 0 ) return state . cwd ;
427+ if ( typeof state . workDir === 'string' && state . workDir . length > 0 ) return state . workDir ;
428+ const customCwd = state . custom ?. [ 'cwd' ] ;
429+ return typeof customCwd === 'string' && customCwd . length > 0 ? customCwd : '' ;
430+ }
431+
380432function parseTs ( input : string | number | undefined ) : number {
381433 if ( typeof input === 'number' ) return Number . isFinite ( input ) ? input : 0 ;
382434 if ( ! input ) return 0 ;
0 commit comments