@deepseek-ai/dsh-api-session-controller 0.1.6-alpha.1 → 0.1.7-alpha.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 (44) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +42 -7
  3. package/README.zh.md +42 -7
  4. package/lib/client.js +1037 -992
  5. package/lib/index.js +314 -183
  6. package/lib/typert.host.js +539 -331
  7. package/lib/typert.remote-client.d.ts +10 -5
  8. package/lib/typert.remote-client.js +374 -264
  9. package/lib/types/agent.d.ts +1 -1
  10. package/lib/types/agent.js +8 -1
  11. package/lib/types/archived-session-gate.d.ts +30 -0
  12. package/lib/types/archived-session-gate.js +52 -0
  13. package/lib/types/client/contract/session.d.ts +2 -0
  14. package/lib/types/client/contract/sessions.d.ts +65 -35
  15. package/lib/types/client/contract/snapshot.d.ts +0 -14
  16. package/lib/types/client/index.d.ts +12 -3
  17. package/lib/types/client/index.js +16 -5
  18. package/lib/types/client/scope.d.ts +13 -3
  19. package/lib/types/client/scope.js +15 -22
  20. package/lib/types/client/sessions/lineage.d.ts +5 -5
  21. package/lib/types/client/sessions/lineage.js +3 -4
  22. package/lib/types/client/sessions/manager.d.ts +91 -105
  23. package/lib/types/client/sessions/manager.js +307 -434
  24. package/lib/types/client/sessions/projection-store.d.ts +34 -21
  25. package/lib/types/client/sessions/projection-store.js +48 -24
  26. package/lib/types/client/sessions/remotes.d.ts +1 -2
  27. package/lib/types/client/sessions/service.d.ts +53 -150
  28. package/lib/types/client/sessions/service.js +267 -264
  29. package/lib/types/client/sessions/session.d.ts +24 -35
  30. package/lib/types/client/sessions/session.js +131 -73
  31. package/lib/types/commands.d.ts +6 -4
  32. package/lib/types/commands.js +82 -40
  33. package/lib/types/control.d.ts +2 -4
  34. package/lib/types/control.js +2 -84
  35. package/lib/types/history.js +1 -1
  36. package/lib/types/index.d.ts +34 -7
  37. package/lib/types/index.js +118 -14
  38. package/lib/types/list.js +24 -15
  39. package/lib/types/skill-catalog.js +60 -50
  40. package/lib/types/types.d.ts +40 -40
  41. package/lib/types/types.js +0 -1
  42. package/package.json +71 -76
  43. package/lib/types/client/sessions/queue-mirror.d.ts +0 -26
  44. 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,119 +146,105 @@ 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', projectionsBySession: {},
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.
164
235
  * Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
165
236
  * @param id - possible addressed child id.
166
- * @returns The retained address, when present.
237
+ * @returns A retained or loaded-catalog address, without retaining a new selection or scope.
167
238
  */
168
239
  subagentAddress(id) {
169
240
  return this.manager.subagentAddress(id);
170
241
  }
171
242
  /**
172
- * Inform the Session Controller whether a catalog menu is consuming membership updates.
173
- * @param parentSessionId - selected parent.
174
- * @param open - menu state.
243
+ * Load all Session projections once per connection; retry an unsuccessful initial read.
244
+ * @param sessionId - Session to inspect without opening its conversation.
175
245
  */
176
- setSubagentCatalogOpen(parentSessionId, open) {
177
- this.manager.setSubagentCatalogOpen(parentSessionId, open);
178
- }
179
- /**
180
- * Refresh one direct-child catalog.
181
- * @param parentSessionId - catalog owner.
182
- */
183
- refreshSubagents(parentSessionId) {
184
- return this.manager.refreshSubagents(parentSessionId);
185
- }
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();
246
+ refreshProjections(sessionId) {
247
+ return this.manager.refreshProjections(sessionId);
195
248
  }
196
249
  /**
197
250
  * Refresh the real Session baseline, reusing an in-flight pull.
@@ -257,12 +310,8 @@ export class ClientSessions {
257
310
  this.manager.handleConnected();
258
311
  }
259
312
  /**
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.
313
+ * Create a Host Session and publish its catalog row before resolving.
314
+ * Callers retain the returned identity before borrowing its binding.
266
315
  * @param opts - target workspace or directory and an optional preallocated id.
267
316
  * @returns the new session id.
268
317
  * @throws {SessionCreateError} with the requested id.
@@ -275,16 +324,14 @@ export class ClientSessions {
275
324
  return result.value.sessionId;
276
325
  }
277
326
  /**
278
- * Fork a session from a completed-turn prefix of the source (same
327
+ * Fork a session from an exact inclusive prefix of the source (same
279
328
  * synchronous-addressability guarantee as {@link ClientSessions.create}:
280
- * on resolution the child is in the list store and open() can target it).
281
- * @param opts - source session id, the optional event seq anchoring the
282
- * cut (the boundary is the first turn/end at or after it; an in-log
283
- * anchor in an open turn is unavailable rather than clipped backward),
284
- * and whether to increment an inherited durable title before resolving.
285
- * A fractional anchor floors to a real event seq: the frozen nodes of an
286
- * interrupted turn carry flow-ordering seqs between two events, and the
287
- * wire takes integers only.
329
+ * on resolution the child is catalogued and may be explicitly retained).
330
+ * @param opts - source session id, the optional exact inclusive boundary
331
+ * seq (a real event seq the caller already knows; a cut inside an open
332
+ * turn is balanced Host-side with synthetic closers, and omission selects
333
+ * the latest completed-turn prefix), and whether to increment an
334
+ * inherited durable title before resolving.
288
335
  * @returns the child session id.
289
336
  * @throws {SessionForkError} with the source id.
290
337
  * @throws {Error} when a requested child-title rename fails after creation.
@@ -295,43 +342,36 @@ export class ClientSessions {
295
342
  : undefined;
296
343
  const result = await this.manager.fork({
297
344
  sessionId: opts.sessionId,
298
- // Flooring lands inside the anchor's own turn (every turn opens with a
299
- // turn/start), so the host's first-turn/end-at-or-after cut still ends
300
- // on that turn — never clipped back to the previous one.
301
- ...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(Math.floor(opts.atSeq)) }),
345
+ ...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }),
302
346
  });
303
347
  if (!result.ok)
304
348
  throw new SessionForkError(result.error, opts.sessionId);
305
349
  this.projectList();
306
350
  const childId = result.value.sessionId;
307
351
  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));
352
+ const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle));
312
353
  if (!renamed.ok)
313
354
  throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`);
314
355
  }
315
356
  return childId;
316
357
  }
317
358
  /**
318
- * Resolve an Agent-scoped context view (use-and-discard).
359
+ * Borrow an already-retained Agent-scoped Context.
319
360
  * @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.
361
+ * @returns the scoped Context, or undefined without a retained generation.
321
362
  */
322
363
  scope(id) {
323
- return this.resolve(id)?.ctx;
364
+ return this.scopes.get(id)?.ctx;
324
365
  }
325
366
  /**
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.
367
+ * Retain a validated Gateway identity synchronously, without history or catalog I/O.
368
+ * @param id - Host-projected Session identity, possibly not yet catalogued.
369
+ * @returns a Gateway-source reference owned by the invocation.
332
370
  */
333
- resolveAgentScope(id) {
334
- return (this.scopes.get(id) ?? this.materializeScope(id)).ctx;
371
+ retainAgentScope(id) {
372
+ if (this.closed)
373
+ throw new Error('Session Controller is disposed');
374
+ return this.retainScope(id, 'gateway');
335
375
  }
336
376
  /**
337
377
  * Read the Agent scope tag off a context. Service-method boundary: fetch
@@ -351,62 +391,80 @@ export class ClientSessions {
351
391
  * `agent.session`). Same service-method boundary as
352
392
  * {@link ClientSessions.scopeOf}.
353
393
  * @param ctx - an Agent-scoped context.
354
- * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
394
+ * @returns the matching live Session, or undefined for an untagged or ended generation.
355
395
  */
356
396
  sessionOf(ctx) {
357
397
  const id = scopeTagOf(ctx);
358
398
  if (id === undefined)
359
399
  return undefined;
360
- return this.scopes.get(id)?.binding.session;
400
+ const record = this.scopes.get(id);
401
+ return record !== undefined && scopeIdentityOf(record.ctx) === scopeIdentityOf(ctx)
402
+ ? record.binding.session
403
+ : undefined;
361
404
  }
362
405
  /**
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.
406
+ * Borrow an already-retained binding without extending its lifetime.
407
+ * @param id - Session identity.
408
+ * @returns the live binding, or undefined without a retained generation.
367
409
  */
368
410
  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);
411
+ return this.scopes.get(id)?.binding;
412
+ }
413
+ retainScope(id, source) {
414
+ const record = this.scopes.get(id) ?? this.materializeScope(id);
415
+ const previous = record.retention;
416
+ record.retention = Object.freeze({
417
+ referenceCount: previous.referenceCount + 1,
418
+ retainedBy: freezeRetainedBy({ ...previous.retainedBy, [source]: (previous.retainedBy[source] ?? 0) + 1 }),
419
+ });
420
+ const reference = new ClientSessionReference(id, record, () => {
421
+ if (!record.live)
422
+ return;
423
+ const count = record.retention.referenceCount - 1;
424
+ const { [source]: sourceCount = 0, ...otherSources } = record.retention.retainedBy;
425
+ const retainedBy = sourceCount > 1 ? { ...otherSources, [source]: sourceCount - 1 } : otherSources;
426
+ record.retention = count === 0
427
+ ? EMPTY_RETAIN_INFO
428
+ : Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) });
429
+ if (count === 0)
430
+ this.retireScope(id, record);
431
+ else
432
+ this.publishRetention(id);
433
+ });
434
+ if (this.list.getSnapshot().byId[id] === undefined && this.manager.subagentAddress(id) !== undefined) {
435
+ this.projectList();
436
+ }
437
+ this.publishRetention(id);
438
+ return reference;
439
+ }
440
+ retentionSnapshot(id) {
441
+ return this.scopes.get(id)?.retention ?? EMPTY_RETAIN_INFO;
442
+ }
443
+ publishRetention(id) {
444
+ const state = this.list.getSnapshot();
445
+ const row = state.byId[id];
446
+ const retainedBy = this.retentionSnapshot(id).retainedBy;
447
+ if (row !== undefined && row.retainedBy !== retainedBy) {
448
+ this.list.set({ ...state, byId: { ...state.byId, [id]: { ...row, retainedBy } } });
395
449
  }
450
+ const observer = this.retainObservers.get(id);
451
+ const snapshot = this.retentionSnapshot(id);
452
+ if (observer === undefined || observer.published === snapshot)
453
+ return;
454
+ observer.published = snapshot;
455
+ notifySubscribers(observer.listeners, '[session-controller] reference sources');
396
456
  }
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);
457
+ retireScope(id, record, disposeFiber = true) {
458
+ if (!record.live)
459
+ return;
460
+ record.live = false;
461
+ if (this.scopes.get(id) === record)
462
+ this.scopes.delete(id);
463
+ record.session.unbindScope();
464
+ const sessionDisposal = this.manager.drop(id, record.session);
465
+ this.projectList();
466
+ this.publishRetention(id);
467
+ this.startScopeDrop(id, record, disposeFiber, sessionDisposal);
410
468
  }
411
469
  /** Materialize one scope after its caller establishes that the id may be addressed. */
412
470
  materializeScope(id) {
@@ -421,18 +479,17 @@ export class ClientSessions {
421
479
  ctx,
422
480
  binding,
423
481
  session,
482
+ retention: EMPTY_RETAIN_INFO,
483
+ live: true,
424
484
  };
425
485
  this.scopes.set(id, record);
486
+ ctx.effect(() => () => { this.retireScope(id, record, false); }, 'session-controller: exact generation');
426
487
  return record;
427
488
  }
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
489
  /** Project the manager's list snapshot into the store (title derivation is display-only). */
434
490
  projectList() {
435
- const { items, current, phase, subagentsByParent, jobsBySession, currentAddress, } = this.manager.getListSnapshot();
491
+ const previousById = this.list.getSnapshot().byId;
492
+ const { items, phase, projectionsBySession, } = this.manager.getListSnapshot();
436
493
  const ids = [];
437
494
  const byId = {};
438
495
  for (const entry of items) {
@@ -441,7 +498,7 @@ export class ClientSessions {
441
498
  id: entry.sessionId,
442
499
  displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
443
500
  running: entry.running,
444
- ...(entry.completed ? { completed: true } : {}),
501
+ retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy,
445
502
  blank: entry.blank,
446
503
  updatedAt: entry.updatedAt,
447
504
  ...(entry.projectionValues === undefined
@@ -453,76 +510,58 @@ export class ClientSessions {
453
510
  ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
454
511
  };
455
512
  }
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;
513
+ for (const [parentId, projection] of Object.entries(projectionsBySession)) {
514
+ for (const child of projection.values.subagentCatalog ?? []) {
515
+ const childId = child.id;
467
516
  const summary = byId[childId];
517
+ const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId);
518
+ const projectedTitle = projectionValues?.title;
519
+ const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined;
520
+ const displayTitle = title ?? child.label ?? childId;
468
521
  if (summary === undefined) {
469
522
  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,
523
+ id: childId, displayTitle, parentId: parentId,
524
+ origin: 'subagent', running: this.scopes.get(childId)?.session.getSnapshot().running ?? false, blank: false, updatedAt: 0,
525
+ retainedBy: this.retentionSnapshot(childId).retainedBy,
526
+ ...(projectionValues === undefined ? {} : { projectionValues }),
527
+ ...(title === undefined ? {} : { title }),
477
528
  };
478
529
  }
479
- else if (summary.displayTitle !== displayTitle) {
480
- byId[childId] = { ...summary, displayTitle };
530
+ else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) {
531
+ byId[childId] = {
532
+ ...summary,
533
+ displayTitle,
534
+ ...(projectionValues === undefined ? {} : { projectionValues }),
535
+ };
481
536
  }
482
- const parent = byId[address.parentSessionId];
483
- if (parent !== undefined && parent.origin !== 'subagent')
484
- break;
485
- address = this.manager.navigationAddress(address.parentSessionId);
486
537
  }
487
538
  }
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
539
  for (const [id, record] of this.scopes) {
513
- if (this.eligible(id))
540
+ if (byId[id] !== undefined)
514
541
  continue;
515
- if (id === this.watched) {
516
- this.deferredRemovals.add(id);
542
+ const address = this.manager.subagentAddress(id);
543
+ if (address === undefined)
517
544
  continue;
518
- }
519
- this.scopes.delete(id);
520
- this.deferredRemovals.delete(id);
521
- this.startScopeDrop(id, record);
545
+ const previous = previousById[id];
546
+ const snapshot = record.session.getSnapshot();
547
+ const projectionValues = this.manager.projectionValues(id);
548
+ const projectedTitle = projectionValues?.title;
549
+ const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : previous?.title;
550
+ byId[id] = {
551
+ ...(previous ?? { id, displayTitle: id, updatedAt: 0 }),
552
+ running: snapshot.running,
553
+ retainedBy: record.retention.retainedBy,
554
+ blank: snapshot.blank,
555
+ parentId: address.parentSessionId,
556
+ origin: 'subagent',
557
+ ...(projectionValues === undefined ? {} : { projectionValues }),
558
+ ...(title === undefined ? {} : { title, displayTitle: title }),
559
+ };
522
560
  }
561
+ this.list.set({ ids, byId, phase, projectionsBySession });
523
562
  }
524
- startScopeDrop(id, record) {
525
- const drop = this.dropScope(id, record);
563
+ startScopeDrop(id, record, disposeFiber = true, sessionDisposal = this.manager.drop(id, record.session)) {
564
+ const drop = this.dropScope(record, disposeFiber, sessionDisposal);
526
565
  this.scopeDrops.add(drop);
527
566
  void drop.then(() => { this.scopeDrops.delete(drop); }, () => { this.scopeDrops.delete(drop); });
528
567
  }
@@ -531,45 +570,9 @@ export class ClientSessions {
531
570
  await Promise.allSettled([...this.scopeDrops]);
532
571
  }
533
572
  }
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
- }
573
+ /** Await the already-withdrawn Session and scoped cleanup to quiescence. */
574
+ async dropScope(record, disposeFiber, sessionDisposal) {
575
+ await Promise.allSettled([sessionDisposal, ...disposeFiber ? [record.fiber.dispose()] : []]);
573
576
  }
574
577
  }
575
578
  //# sourceMappingURL=service.js.map