@crazx/dsh-api-session-controller 0.1.6-alpha.1.zw.2 → 0.1.6-alpha.2.zw.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +15 -3
  3. package/README.zh.md +15 -3
  4. package/lib/client.js +428 -471
  5. package/lib/index.js +16 -47
  6. package/lib/typert.host.js +179 -161
  7. package/lib/typert.remote-client.js +168 -150
  8. package/lib/types/agent.d.ts +1 -1
  9. package/lib/types/agent.js +8 -1
  10. package/lib/types/client/contract/sessions.d.ts +55 -20
  11. package/lib/types/client/contract/snapshot.d.ts +0 -14
  12. package/lib/types/client/index.d.ts +11 -2
  13. package/lib/types/client/index.js +16 -5
  14. package/lib/types/client/scope.d.ts +13 -3
  15. package/lib/types/client/scope.js +15 -22
  16. package/lib/types/client/sessions/lineage.d.ts +1 -4
  17. package/lib/types/client/sessions/lineage.js +1 -3
  18. package/lib/types/client/sessions/manager.d.ts +28 -49
  19. package/lib/types/client/sessions/manager.js +75 -174
  20. package/lib/types/client/sessions/projection-store.d.ts +5 -11
  21. package/lib/types/client/sessions/projection-store.js +6 -14
  22. package/lib/types/client/sessions/service.d.ts +34 -119
  23. package/lib/types/client/sessions/service.js +259 -244
  24. package/lib/types/client/sessions/session.d.ts +7 -20
  25. package/lib/types/client/sessions/session.js +29 -33
  26. package/lib/types/commands.d.ts +2 -2
  27. package/lib/types/commands.js +10 -4
  28. package/lib/types/control.d.ts +1 -1
  29. package/lib/types/control.js +1 -41
  30. package/lib/types/index.d.ts +2 -2
  31. package/lib/types/index.js +1 -1
  32. package/lib/types/types.d.ts +3 -17
  33. package/package.json +70 -70
  34. package/lib/types/client/sessions/queue-mirror.d.ts +0 -26
  35. package/lib/types/client/sessions/queue-mirror.js +0 -61
@@ -1,8 +1,8 @@
1
1
  import { SessionSeq } from '@deepseek-ai/dsh-session/types';
2
2
  import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path';
3
3
  import { SESSION_SEARCH_RESULT_LIMIT } from "../../types.js";
4
- import { createSnapshotStore, } from '@deepseek-ai/dsh-client-store';
5
- import { createScope, scopeOf as scopeTagOf } from "../scope.js";
4
+ import { createSnapshotStore, notifySubscribers, } from '@deepseek-ai/dsh-client-store';
5
+ import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from "../scope.js";
6
6
  import { SessionManager } from "./manager.js";
7
7
  /** Structured session-create failure. */
8
8
  export class SessionCreateError extends Error {
@@ -69,7 +69,74 @@ function increasedForkTitle(title) {
69
69
  }
70
70
  return `${title} (1)`;
71
71
  }
72
- /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
72
+ /** Source labels are dictionary keys, including names also present on Object.prototype. */
73
+ function freezeRetainedBy(counts) {
74
+ Object.setPrototypeOf(counts, null);
75
+ return Object.freeze(counts);
76
+ }
77
+ const EMPTY_RETAIN_INFO = Object.freeze({ referenceCount: 0, retainedBy: freezeRetainedBy({}) });
78
+ /** A cancelled waiter releases only its own reference, not the shared opening. */
79
+ async function waitForOpen(opening, signal) {
80
+ if (signal === undefined)
81
+ return opening;
82
+ const aborted = Promise.withResolvers();
83
+ const onAbort = () => { aborted.reject(signal.reason); };
84
+ signal.addEventListener('abort', onAbort, { once: true });
85
+ try {
86
+ if (signal.aborted)
87
+ onAbort();
88
+ await Promise.race([opening, aborted.promise]);
89
+ }
90
+ finally {
91
+ signal.removeEventListener('abort', onAbort);
92
+ }
93
+ }
94
+ class ClientSessionReference {
95
+ sessionId;
96
+ record;
97
+ releaseReference;
98
+ released = new AbortController();
99
+ readiness = Promise.withResolvers();
100
+ ready = this.readiness.promise;
101
+ constructor(sessionId, record, releaseReference) {
102
+ this.sessionId = sessionId;
103
+ this.record = record;
104
+ this.releaseReference = releaseReference;
105
+ void this.ready.catch(() => { });
106
+ }
107
+ get binding() {
108
+ if (this.record === undefined || !this.record.live)
109
+ throw new Error(`Session reference "${this.sessionId}" is released`);
110
+ return this.record.binding;
111
+ }
112
+ attachOpening(opening, signal) {
113
+ const waitSignal = signal === undefined
114
+ ? this.released.signal
115
+ : AbortSignal.any([this.released.signal, signal]);
116
+ void waitForOpen(opening, waitSignal).then(() => {
117
+ try {
118
+ waitSignal.throwIfAborted();
119
+ this.readiness.resolve(this.binding);
120
+ }
121
+ catch (error) {
122
+ this.readiness.reject(error);
123
+ }
124
+ }, (error) => { this.readiness.reject(error); });
125
+ }
126
+ release() {
127
+ const reason = new Error(`Session reference "${this.sessionId}" is released`);
128
+ const release = this.releaseReference;
129
+ this.released.abort(reason);
130
+ this.readiness.reject(reason);
131
+ this.record = undefined;
132
+ this.releaseReference = undefined;
133
+ release?.();
134
+ }
135
+ [Symbol.dispose]() {
136
+ this.release();
137
+ }
138
+ }
139
+ /** Host catalog and local reference allocator; view selection remains outside the Controller. */
73
140
  export class ClientSessions {
74
141
  rootCtx;
75
142
  /**
@@ -79,85 +146,89 @@ export class ClientSessions {
79
146
  * reports the same number.
80
147
  */
81
148
  searchResultLimit = SESSION_SEARCH_RESULT_LIMIT;
82
- /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
149
+ /** Catalog metadata and local reference-source projection. */
83
150
  list;
84
151
  /** The object-layer instance cluster and frame dispatch entry. */
85
152
  manager;
86
- /**
87
- * Persisted selection cell (the durable half of `list.current`). Private on
88
- * purpose: reads go through the list snapshot; writes through {@link
89
- * ClientSessions.open} / {@link ClientSessions.clear}. Projection
90
- * validates it against the live list instead of destructively pruning, so a
91
- * selection survives transient list states (reconnect re-pull) and
92
- * resurfaces when its session returns.
93
- */
94
- selection;
95
153
  scopes = new Map();
96
- /** In-flight scope drops remain here after records leave `scopes`, so root disposal can await quiescence. */
154
+ /** Stable per-id sources retained for the Client root lifetime, including across generation replacement. */
155
+ retainObservers = new Map();
97
156
  scopeDrops = new Set();
98
- /**
99
- * The staged session id — follows `list.current` exactly, holding its last
100
- * defined value across masked gaps (a transiently absent selection blanks
101
- * `current` without moving the stage, so reconnect re-pulls and removals
102
- * keep the staged scope's frozen view alive until the stage moves on).
103
- */
104
- watched;
105
- /** Removed-while-staged sessions whose teardown waits for the stage to move away. */
106
- deferredRemovals = new Set();
157
+ closed = false;
107
158
  /**
108
159
  * @param ctx - client root context (scope fibers mount under it).
109
160
  * @param remote - generated Remote namespaces shared with every Session.
110
161
  */
111
162
  constructor(rootCtx, remote) {
112
163
  this.rootCtx = rootCtx;
113
- this.selection = createSnapshotStore({}, { persist: { name: 'dsh.sessions.current' } });
114
- const restored = this.selection.getSnapshot();
115
- this.manager = new SessionManager(remote, restored.sessionId, restored.subagentAddress);
164
+ this.manager = new SessionManager(remote);
116
165
  this.list = createSnapshotStore({
117
- ids: [], byId: {}, current: undefined, phase: 'pending',
118
- subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
119
- });
120
- // The manager owns wire truth; the store is its projection. Manager
121
- // notifications are already microtask-batched.
122
- const disposeManagerProjection = this.manager.subscribe(() => {
123
- this.projectList();
124
- });
125
- // Stage follower: every current write (open() and projection alike)
126
- // re-evaluates staging, so startup restore (persisted selection validated
127
- // by the projection) and reconnect resurfacing open their window with no
128
- // dedicated code path. Safe to run synchronously inside the store notify:
129
- // the follower writes no list state — session.open()'s synchronous prefix
130
- // touches only session-side state and its own microtask-batched notifier.
131
- const disposeStageFollower = this.list.subscribe(() => {
132
- this.followCurrent();
166
+ ids: [], byId: {}, phase: 'pending', subagentsByParent: {}, jobsBySession: {},
133
167
  });
168
+ const disposeManagerProjection = this.manager.subscribe(() => { this.projectList(); });
134
169
  rootCtx.effect(() => async () => {
135
- disposeStageFollower();
170
+ this.closed = true;
136
171
  disposeManagerProjection();
137
172
  const scopes = [...this.scopes];
138
173
  this.scopes.clear();
139
- this.deferredRemovals.clear();
140
- this.watched = undefined;
141
- for (const [id, record] of scopes)
174
+ for (const [, record] of scopes) {
175
+ record.live = false;
176
+ record.session.unbindScope();
177
+ }
178
+ const managerDisposal = this.manager.dispose();
179
+ for (const [id, record] of scopes) {
142
180
  this.startScopeDrop(id, record);
181
+ this.publishRetention(id);
182
+ }
143
183
  await this.drainScopeDrops();
144
- await this.manager.dispose();
184
+ await managerDisposal;
145
185
  }, 'session-controller.client.sessions');
146
186
  rootCtx.reflect.provide('sessions', this, undefined);
147
187
  }
148
- /**
149
- * Select a listed or retained catalog-addressed session as current.
150
- * @param id - listed or addressed session id.
151
- */
152
- open(id) {
153
- this.manager.select(id);
188
+ retain(target, options) {
189
+ const { source, signal } = options;
190
+ signal?.throwIfAborted();
191
+ if (this.closed)
192
+ throw new Error('Session Controller is disposed');
193
+ const id = this.manager.resolveTarget(target);
194
+ const reference = this.retainScope(id, source);
195
+ try {
196
+ reference.attachOpening(this.manager.get(id).open(), signal);
197
+ return reference;
198
+ }
199
+ catch (error) {
200
+ reference.release();
201
+ throw error;
202
+ }
154
203
  }
155
- /**
156
- * Open a healthy catalog child through its direct-parent address.
157
- * @param address - catalog-derived parent and child ids.
158
- */
159
- openSubagent(address) {
160
- this.manager.selectSubagent(address);
204
+ async using(target, options, operation) {
205
+ const reference = this.retain(target, options);
206
+ try {
207
+ await reference.ready;
208
+ return await operation(reference);
209
+ }
210
+ finally {
211
+ reference.release();
212
+ }
213
+ }
214
+ retainInfo(id) {
215
+ let observer = this.retainObservers.get(id);
216
+ if (observer === undefined) {
217
+ const listeners = new Set();
218
+ observer = {
219
+ listeners,
220
+ published: this.retentionSnapshot(id),
221
+ source: {
222
+ getSnapshot: () => this.retentionSnapshot(id),
223
+ subscribe: (listener) => {
224
+ listeners.add(listener);
225
+ return () => { listeners.delete(listener); };
226
+ },
227
+ },
228
+ };
229
+ this.retainObservers.set(id, observer);
230
+ }
231
+ return observer.source;
161
232
  }
162
233
  /**
163
234
  * Resolve an already discovered direct-parent address without opening it.
@@ -183,16 +254,6 @@ export class ClientSessions {
183
254
  refreshSubagents(parentSessionId) {
184
255
  return this.manager.refreshSubagents(parentSessionId);
185
256
  }
186
- /**
187
- * Clear the current selection so the layout shows the no-session empty
188
- * state (new-session affordance and the workspace preselection flow).
189
- * Wipes the persisted selection too — a reload stays on empty until the
190
- * user opens or starts a session. The staged scope keeps its frozen view
191
- * per the masked-gap contract until the next open() moves the stage.
192
- */
193
- clear() {
194
- this.manager.clearSelection();
195
- }
196
257
  /**
197
258
  * Refresh the real Session baseline, reusing an in-flight pull.
198
259
  * @returns completion of the current or newly started baseline pull.
@@ -257,12 +318,8 @@ export class ClientSessions {
257
318
  this.manager.handleConnected();
258
319
  }
259
320
  /**
260
- * Create a session on the host. Resolution guarantee: by the time the
261
- * promise resolves, the created session is in the list store and
262
- * {@link ClientSessions.binding} resolves it — callers (New Session
263
- * draft hand-off) may address the scope synchronously, without waiting a
264
- * notifier flush. The synchronous projection below makes this structural
265
- * rather than an accident of microtask ordering.
321
+ * Create a Host Session and publish its catalog row before resolving.
322
+ * Callers retain the returned identity before borrowing its binding.
266
323
  * @param opts - target workspace or directory and an optional preallocated id.
267
324
  * @returns the new session id.
268
325
  * @throws {SessionCreateError} with the requested id.
@@ -275,9 +332,8 @@ export class ClientSessions {
275
332
  return result.value.sessionId;
276
333
  }
277
334
  /**
278
- * Fork a session from a completed-turn prefix of the source (same
279
- * synchronous-addressability guarantee as {@link ClientSessions.create}:
280
- * on resolution the child is in the list store and open() can target it).
335
+ * Fork a Session from a completed-turn prefix of the source and publish
336
+ * the child in the catalog before resolving.
281
337
  * @param opts - source session id, the optional event seq anchoring the
282
338
  * cut (the boundary is the first turn/end at or after it; an in-log
283
339
  * anchor in an open turn is unavailable rather than clipped backward),
@@ -305,33 +361,36 @@ export class ClientSessions {
305
361
  this.projectList();
306
362
  const childId = result.value.sessionId;
307
363
  if (sourceTitle !== undefined) {
308
- const child = this.binding(childId)?.session;
309
- if (child === undefined)
310
- throw new Error(`fork child "${childId}" is not locally addressable`);
311
- const renamed = await child.rename(increasedForkTitle(sourceTitle));
312
- if (!renamed.ok)
313
- throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`);
364
+ const reference = this.retain(childId, { source: 'controllerOperation' });
365
+ try {
366
+ await reference.ready;
367
+ const renamed = await reference.binding.session.rename(increasedForkTitle(sourceTitle));
368
+ if (!renamed.ok)
369
+ throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`);
370
+ }
371
+ finally {
372
+ reference.release();
373
+ }
314
374
  }
315
375
  return childId;
316
376
  }
317
377
  /**
318
- * Resolve an Agent-scoped context view (use-and-discard).
378
+ * Borrow an already-retained Agent-scoped Context.
319
379
  * @param id - session id (the agent identity — 1:1 same axis).
320
- * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
380
+ * @returns the scoped Context, or undefined without a retained generation.
321
381
  */
322
382
  scope(id) {
323
- return this.resolve(id)?.ctx;
383
+ return this.scopes.get(id)?.ctx;
324
384
  }
325
385
  /**
326
- * Materialize the Agent scope named by a validated Host Remote Event.
327
- * The first successful Session-list baseline becomes authoritative for its
328
- * lifetime; until then, transport streams may address the scope in either
329
- * arrival order.
330
- * @param id - Host-projected Agent identity (the matching Session id).
331
- * @returns the identity-stable Agent Context.
386
+ * Retain a validated Gateway identity synchronously, without history or catalog I/O.
387
+ * @param id - Host-projected Session identity, possibly not yet catalogued.
388
+ * @returns a Gateway-source reference owned by the invocation.
332
389
  */
333
- resolveAgentScope(id) {
334
- return (this.scopes.get(id) ?? this.materializeScope(id)).ctx;
390
+ retainAgentScope(id) {
391
+ if (this.closed)
392
+ throw new Error('Session Controller is disposed');
393
+ return this.retainScope(id, 'gateway');
335
394
  }
336
395
  /**
337
396
  * Read the Agent scope tag off a context. Service-method boundary: fetch
@@ -351,62 +410,79 @@ export class ClientSessions {
351
410
  * `agent.session`). Same service-method boundary as
352
411
  * {@link ClientSessions.scopeOf}.
353
412
  * @param ctx - an Agent-scoped context.
354
- * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
413
+ * @returns the matching live Session, or undefined for an untagged or ended generation.
355
414
  */
356
415
  sessionOf(ctx) {
357
416
  const id = scopeTagOf(ctx);
358
417
  if (id === undefined)
359
418
  return undefined;
360
- return this.scopes.get(id)?.binding.session;
419
+ const record = this.scopes.get(id);
420
+ return record !== undefined && scopeIdentityOf(record.ctx) === scopeIdentityOf(ctx)
421
+ ? record.binding.session
422
+ : undefined;
361
423
  }
362
424
  /**
363
- * Resolve the stable session binding (scope-addressed assembly feed). Pure
364
- * resolution — no staging, no window side effects.
365
- * @param id - session id.
366
- * @returns binding, or undefined for a session neither listed nor already scoped.
425
+ * Borrow an already-retained binding without extending its lifetime.
426
+ * @param id - Session identity.
427
+ * @returns the live binding, or undefined without a retained generation.
367
428
  */
368
429
  binding(id) {
369
- return this.resolve(id)?.binding;
370
- }
371
- /**
372
- * Move the stage to the list's current session: sweep teardowns deferred
373
- * behind the previous occupant and pull the new occupant's history window.
374
- * Staging IS the open signal — the window opens ⟺ the session is on stage
375
- * — and open() is idempotent (an in-flight or completed open no-ops; a
376
- * failed one retries the next time current is touched).
377
- */
378
- followCurrent() {
379
- const snapshot = this.list.getSnapshot();
380
- const current = snapshot.current;
381
- // A masked gap (current blanked while the selection's session is
382
- // transiently absent) holds the stage: tearing down on the gap would
383
- // destroy exactly the frozen scope the mask exists to preserve.
384
- if (current === undefined || snapshot.byId[current] === undefined || current === this.watched)
385
- return;
386
- this.watched = current;
387
- this.sweepDeferred();
388
- const record = this.resolve(current);
389
- /* v8 ignore next 3 -- defensive: current is always a listed id (open()
390
- * validates and the projection masks absent selections), so resolve
391
- * cannot miss; kept so a future current writer cannot crash the notify. */
392
- if (record !== undefined) {
393
- void record.session.open();
394
- void this.manager.refreshSubagents(current);
430
+ return this.scopes.get(id)?.binding;
431
+ }
432
+ retainScope(id, source) {
433
+ const record = this.scopes.get(id) ?? this.materializeScope(id);
434
+ const previous = record.retention;
435
+ record.retention = Object.freeze({
436
+ referenceCount: previous.referenceCount + 1,
437
+ retainedBy: freezeRetainedBy({ ...previous.retainedBy, [source]: (previous.retainedBy[source] ?? 0) + 1 }),
438
+ });
439
+ const reference = new ClientSessionReference(id, record, () => {
440
+ if (!record.live)
441
+ return;
442
+ const count = record.retention.referenceCount - 1;
443
+ const { [source]: sourceCount = 0, ...otherSources } = record.retention.retainedBy;
444
+ const retainedBy = sourceCount > 1 ? { ...otherSources, [source]: sourceCount - 1 } : otherSources;
445
+ record.retention = count === 0
446
+ ? EMPTY_RETAIN_INFO
447
+ : Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) });
448
+ if (count === 0)
449
+ this.retireScope(id, record);
450
+ else
451
+ this.publishRetention(id);
452
+ });
453
+ if (this.list.getSnapshot().byId[id] === undefined)
454
+ this.projectList();
455
+ this.publishRetention(id);
456
+ return reference;
457
+ }
458
+ retentionSnapshot(id) {
459
+ return this.scopes.get(id)?.retention ?? EMPTY_RETAIN_INFO;
460
+ }
461
+ publishRetention(id) {
462
+ const state = this.list.getSnapshot();
463
+ const row = state.byId[id];
464
+ const retainedBy = this.retentionSnapshot(id).retainedBy;
465
+ if (row !== undefined && row.retainedBy !== retainedBy) {
466
+ this.list.set({ ...state, byId: { ...state.byId, [id]: { ...row, retainedBy } } });
395
467
  }
468
+ const observer = this.retainObservers.get(id);
469
+ const snapshot = this.retentionSnapshot(id);
470
+ if (observer === undefined || observer.published === snapshot)
471
+ return;
472
+ observer.published = snapshot;
473
+ notifySubscribers(observer.listeners, '[session-controller] reference sources');
396
474
  }
397
- /**
398
- * Lazily mint the scope + binding for an eligible session. Eligibility and
399
- * prune share one predicate: listed on the host or selected
400
- * through a retained subagent address. Breadcrumb-only ancestors remain
401
- * summary data and do not keep scopes alive.
402
- */
403
- resolve(id) {
404
- const existing = this.scopes.get(id);
405
- if (existing !== undefined)
406
- return existing;
407
- if (!this.eligible(id))
408
- return undefined;
409
- return this.materializeScope(id);
475
+ retireScope(id, record, disposeFiber = true) {
476
+ if (!record.live)
477
+ return;
478
+ record.live = false;
479
+ if (this.scopes.get(id) === record)
480
+ this.scopes.delete(id);
481
+ record.session.unbindScope();
482
+ const sessionDisposal = this.manager.drop(id, record.session);
483
+ this.projectList();
484
+ this.publishRetention(id);
485
+ this.startScopeDrop(id, record, disposeFiber, sessionDisposal);
410
486
  }
411
487
  /** Materialize one scope after its caller establishes that the id may be addressed. */
412
488
  materializeScope(id) {
@@ -421,18 +497,17 @@ export class ClientSessions {
421
497
  ctx,
422
498
  binding,
423
499
  session,
500
+ retention: EMPTY_RETAIN_INFO,
501
+ live: true,
424
502
  };
425
503
  this.scopes.set(id, record);
504
+ ctx.effect(() => () => { this.retireScope(id, record, false); }, 'session-controller: exact generation');
426
505
  return record;
427
506
  }
428
- /** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
429
- eligible(id) {
430
- const { ids, current } = this.list.getSnapshot();
431
- return current === id || ids.includes(id);
432
- }
433
507
  /** Project the manager's list snapshot into the store (title derivation is display-only). */
434
508
  projectList() {
435
- const { items, current, phase, subagentsByParent, jobsBySession, currentAddress, } = this.manager.getListSnapshot();
509
+ const previousById = this.list.getSnapshot().byId;
510
+ const { items, phase, subagentsByParent, jobsBySession, } = this.manager.getListSnapshot();
436
511
  const ids = [];
437
512
  const byId = {};
438
513
  for (const entry of items) {
@@ -441,7 +516,7 @@ export class ClientSessions {
441
516
  id: entry.sessionId,
442
517
  displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
443
518
  running: entry.running,
444
- ...(entry.completed ? { completed: true } : {}),
519
+ retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy,
445
520
  blank: entry.blank,
446
521
  updatedAt: entry.updatedAt,
447
522
  ...(entry.projectionValues === undefined
@@ -453,76 +528,52 @@ export class ClientSessions {
453
528
  ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
454
529
  };
455
530
  }
456
- if (current !== undefined && currentAddress !== undefined) {
457
- const seen = new Set();
458
- let address = currentAddress;
459
- while (address !== undefined && !seen.has(address.childSessionId)) {
460
- const childId = address.childSessionId;
461
- seen.add(childId);
462
- const child = subagentsByParent[address.parentSessionId]?.entries
463
- .find(entry => entry.kind === 'child' && entry.id === childId);
464
- if (child?.kind !== 'child')
465
- break;
466
- const displayTitle = child.label ?? childId;
531
+ for (const [parentId, catalog] of Object.entries(subagentsByParent)) {
532
+ for (const child of catalog.entries) {
533
+ if (child.kind !== 'child')
534
+ continue;
535
+ const childId = child.id;
467
536
  const summary = byId[childId];
537
+ const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId);
538
+ const projectedTitle = projectionValues?.title;
539
+ const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined;
540
+ const displayTitle = title ?? child.label ?? childId;
468
541
  if (summary === undefined) {
469
542
  byId[childId] = {
470
- id: childId,
471
- displayTitle,
472
- parentId: address.parentSessionId,
473
- origin: 'subagent',
474
- running: child.activity === 'running',
475
- blank: false,
476
- updatedAt: 0,
543
+ id: childId, displayTitle, parentId: parentId,
544
+ origin: 'subagent', running: child.activity === 'running', blank: false, updatedAt: 0,
545
+ retainedBy: this.retentionSnapshot(childId).retainedBy,
546
+ ...(projectionValues === undefined ? {} : { projectionValues }),
547
+ ...(title === undefined ? {} : { title }),
477
548
  };
478
549
  }
479
- else if (summary.displayTitle !== displayTitle) {
480
- byId[childId] = { ...summary, displayTitle };
550
+ else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) {
551
+ byId[childId] = {
552
+ ...summary,
553
+ displayTitle,
554
+ ...(projectionValues === undefined ? {} : { projectionValues }),
555
+ };
481
556
  }
482
- const parent = byId[address.parentSessionId];
483
- if (parent !== undefined && parent.origin !== 'subagent')
484
- break;
485
- address = this.manager.navigationAddress(address.parentSessionId);
486
557
  }
487
558
  }
488
- const persisted = this.selection.getSnapshot().sessionId;
489
- // No current (cleared, or masked gap) wipes the persisted cell — a reload
490
- // stays on empty; the in-memory selection still resurfaces a masked id.
491
- if (current === undefined) {
492
- if (persisted !== undefined)
493
- this.selection.set({});
494
- }
495
- else if (byId[current] !== undefined
496
- && (persisted !== current
497
- || this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
498
- || this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
499
- || this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
500
- this.selection.set({
501
- sessionId: current,
502
- ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
503
- });
504
- }
505
- this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress });
506
- this.pruneScopes();
507
- }
508
- /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
509
- pruneScopes() {
510
- if (this.list.getSnapshot().phase === 'pending')
511
- return;
512
559
  for (const [id, record] of this.scopes) {
513
- if (this.eligible(id))
560
+ if (byId[id] !== undefined)
514
561
  continue;
515
- if (id === this.watched) {
516
- this.deferredRemovals.add(id);
517
- continue;
518
- }
519
- this.scopes.delete(id);
520
- this.deferredRemovals.delete(id);
521
- this.startScopeDrop(id, record);
562
+ const previous = previousById[id];
563
+ const snapshot = record.session.getSnapshot();
564
+ const address = this.manager.subagentAddress(id);
565
+ byId[id] = {
566
+ ...(previous ?? { id, displayTitle: id, updatedAt: 0 }),
567
+ running: snapshot.running,
568
+ retainedBy: record.retention.retainedBy,
569
+ blank: snapshot.blank,
570
+ ...(address === undefined ? {} : { parentId: address.parentSessionId, origin: 'subagent' }),
571
+ };
522
572
  }
573
+ this.list.set({ ids, byId, phase, subagentsByParent, jobsBySession });
523
574
  }
524
- startScopeDrop(id, record) {
525
- const drop = this.dropScope(id, record);
575
+ startScopeDrop(id, record, disposeFiber = true, sessionDisposal = this.manager.drop(id, record.session)) {
576
+ const drop = this.dropScope(record, disposeFiber, sessionDisposal);
526
577
  this.scopeDrops.add(drop);
527
578
  void drop.then(() => { this.scopeDrops.delete(drop); }, () => { this.scopeDrops.delete(drop); });
528
579
  }
@@ -531,45 +582,9 @@ export class ClientSessions {
531
582
  await Promise.allSettled([...this.scopeDrops]);
532
583
  }
533
584
  }
534
- /**
535
- * One teardown for the whole per-session axis: the scope
536
- * fiber (cascading every actx-registered effect: input shell, slash
537
- * controller, popup, plugin stores, listeners), the session-keyed slot
538
- * registrations and the Session instance itself — the host session log is the
539
- * durable truth, a reopen lazily rebuilds and backfills via open().
540
- */
541
- async dropScope(id, record) {
542
- // Release the Session's dispatch point with the scope it belongs to (a
543
- // surviving instance — the live Intent — rebinds when resolve re-mints).
544
- record.session.unbindScope();
545
- await Promise.allSettled([
546
- record.fiber.dispose(),
547
- this.manager.drop(id),
548
- ]);
549
- }
550
- /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
551
- sweepDeferred() {
552
- for (const id of [...this.deferredRemovals]) {
553
- /* v8 ignore next -- defensive: only the staged id ever defers, and every
554
- * stage move sweeps first, so the set cannot contain the id the stage just
555
- * moved to; kept as a guard against future extra sweep call sites. */
556
- if (id === this.watched)
557
- continue;
558
- // Eligible again? (A re-added id cancels the deferred teardown.)
559
- if (this.eligible(id)) {
560
- this.deferredRemovals.delete(id);
561
- continue;
562
- }
563
- const record = this.scopes.get(id);
564
- this.deferredRemovals.delete(id);
565
- /* v8 ignore next -- defensive: prune deletes a scope and its deferral
566
- * together, so a deferred id always still owns its record; kept so a
567
- * future teardown path cannot double-dispose. */
568
- if (record !== undefined) {
569
- this.scopes.delete(id);
570
- this.startScopeDrop(id, record);
571
- }
572
- }
585
+ /** Await the already-withdrawn Session and scoped cleanup to quiescence. */
586
+ async dropScope(record, disposeFiber, sessionDisposal) {
587
+ await Promise.allSettled([sessionDisposal, ...disposeFiber ? [record.fiber.dispose()] : []]);
573
588
  }
574
589
  }
575
590
  //# sourceMappingURL=service.js.map