55 * Mutable by design — this is session-scoped imperative state.
66 */
77
8+ import type { AgentSession } from "@earendil-works/pi-coding-agent" ;
9+
810export interface AgenticodingState {
911 /** Compact ledger entries keyed by kebab-case name */
1012 ledger : Map < string , string > ;
@@ -24,24 +26,83 @@ export interface AgenticodingState {
2426 enforcementAttempts : number ;
2527 toolCalled : boolean ;
2628 } | null ;
29+
30+ /**
31+ * Published child agent sessions keyed by toolCallId.
32+ * Lifecycle: executeSpawn publishes → renderSpawnResult claims via get+delete.
33+ * This is only the render handoff queue, not the full live-session registry.
34+ */
35+ childSessions : Map < string , AgentSession > ;
36+
37+ /**
38+ * All live child agent sessions keyed by toolCallId, including claimed ones.
39+ * Reset/teardown aborts this registry so claimed children cannot outlive /new or UI disposal.
40+ *
41+ * INVARIANT: This Map is never replaced — only cleared via .clear().
42+ * NestedAgentSessionComponent holds a direct reference and depends on it
43+ * staying valid. If you change this, update attachSession in spawn/renderer.ts.
44+ */
45+ liveChildSessions : Map < string , AgentSession > ;
46+
47+ /**
48+ * Generation counter for child-session ownership.
49+ * Increment on /new so stale child updates/results cannot touch fresh state.
50+ */
51+ childSessionEpoch : number ;
2752}
2853
2954/** Create a fresh state instance. Call reset() on /new. */
3055export function createState ( ) : AgenticodingState {
31- return {
56+ const childSessions = new Map < string , AgentSession > ( ) ;
57+ const liveChildSessions = new Map < string , AgentSession > ( ) ;
58+ const state : AgenticodingState = {
3259 ledger : new Map ( ) ,
3360 epoch : 0 ,
3461 lastContextPercent : null ,
3562 pendingHandoff : null ,
3663 pendingRequestedHandoff : null ,
64+ childSessions,
65+ liveChildSessions,
66+ childSessionEpoch : 0 ,
3767 } ;
68+ // Prevent replacement — NestedAgentSessionComponent holds direct references
69+ // to both maps and depends on reference stability. Only .clear() and .delete()
70+ // are valid — assigning a new Map would silently break session lifecycle.
71+ Object . defineProperty ( state , 'childSessions' , {
72+ get : ( ) => childSessions ,
73+ set : ( ) => { throw new Error ( 'childSessions cannot be replaced — use .clear() instead' ) ; } ,
74+ enumerable : true ,
75+ configurable : false ,
76+ } ) ;
77+ Object . defineProperty ( state , 'liveChildSessions' , {
78+ get : ( ) => liveChildSessions ,
79+ set : ( ) => { throw new Error ( 'liveChildSessions cannot be replaced — use .clear() instead' ) ; } ,
80+ enumerable : true ,
81+ configurable : false ,
82+ } ) ;
83+ return state ;
3884}
3985
4086/** Reset all state. Used on /new or session reset. */
4187export function resetState ( state : AgenticodingState ) : void {
88+ state . childSessionEpoch ++ ;
4289 state . ledger . clear ( ) ;
4390 state . epoch = 0 ;
4491 state . lastContextPercent = null ;
4592 state . pendingHandoff = null ;
4693 state . pendingRequestedHandoff = null ;
94+ abortAndClearChildSessions ( state ) ;
95+ }
96+
97+ /** Abort all active child sessions and clear both registries. Called on /new (session reset). */
98+ export function abortAndClearChildSessions ( state : AgenticodingState ) : void {
99+ const seen = new Map < any , string > ( ) ; // session → first id (for logging)
100+ for ( const [ id , session ] of [ ...state . childSessions . entries ( ) , ...state . liveChildSessions . entries ( ) ] ) {
101+ if ( ! seen . has ( session ) ) seen . set ( session , id ) ;
102+ }
103+ state . childSessions . clear ( ) ;
104+ state . liveChildSessions . clear ( ) ;
105+ for ( const [ session , id ] of seen ) {
106+ session . abort ( ) . catch ( e => console . warn ( "[spawn] abort failed:" , id , e ) ) ;
107+ }
47108}
0 commit comments