@deepseek-ai/dsh-api-session-controller 0.1.6-alpha.2 → 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 (36) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +31 -8
  3. package/README.zh.md +31 -8
  4. package/lib/client.js +603 -515
  5. package/lib/index.js +299 -137
  6. package/lib/typert.host.js +373 -183
  7. package/lib/typert.remote-client.d.ts +10 -5
  8. package/lib/typert.remote-client.js +216 -124
  9. package/lib/types/archived-session-gate.d.ts +30 -0
  10. package/lib/types/archived-session-gate.js +52 -0
  11. package/lib/types/client/contract/session.d.ts +2 -0
  12. package/lib/types/client/contract/sessions.d.ts +11 -16
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/sessions/lineage.d.ts +4 -1
  15. package/lib/types/client/sessions/lineage.js +2 -1
  16. package/lib/types/client/sessions/manager.d.ts +66 -59
  17. package/lib/types/client/sessions/manager.js +249 -277
  18. package/lib/types/client/sessions/projection-store.d.ts +32 -13
  19. package/lib/types/client/sessions/projection-store.js +45 -13
  20. package/lib/types/client/sessions/remotes.d.ts +1 -2
  21. package/lib/types/client/sessions/service.d.ts +23 -35
  22. package/lib/types/client/sessions/service.js +35 -47
  23. package/lib/types/client/sessions/session.d.ts +18 -16
  24. package/lib/types/client/sessions/session.js +112 -50
  25. package/lib/types/commands.d.ts +4 -2
  26. package/lib/types/commands.js +72 -36
  27. package/lib/types/control.d.ts +2 -4
  28. package/lib/types/control.js +2 -44
  29. package/lib/types/history.js +1 -1
  30. package/lib/types/index.d.ts +32 -5
  31. package/lib/types/index.js +117 -13
  32. package/lib/types/list.js +24 -15
  33. package/lib/types/skill-catalog.js +60 -50
  34. package/lib/types/types.d.ts +37 -23
  35. package/lib/types/types.js +0 -1
  36. package/package.json +71 -76
@@ -2,6 +2,7 @@
2
2
  import { SessionSeq } from '@deepseek-ai/dsh-session/types';
3
3
  import { mergeOrderedBaseline } from "../ordered-baseline.js";
4
4
  import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client';
5
+ import { assertNever } from '@deepseek-ai/dsh-util-values';
5
6
  import { flattenLineage } from "./lineage.js";
6
7
  import { Notifier } from "./notifier.js";
7
8
  import { ProjectionValueStore } from "./projection-store.js";
@@ -9,15 +10,21 @@ import { Session } from "./session.js";
9
10
  function sessionSeqCursor(value) {
10
11
  return value === -1 ? -1 : SessionSeq(value);
11
12
  }
12
- function catalogAvailability(parentAvailable) {
13
- return parentAvailable === undefined ? {} : { parentAvailable };
14
- }
15
13
  /** Instance cluster + frame entry + the session list. */
16
14
  export class SessionManager {
17
15
  remote;
18
16
  sessions = new Map();
19
17
  /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
20
18
  sessionDisposals = new Set();
19
+ /**
20
+ * Accepted/running presentation must survive a later empty-history list
21
+ * response. Host-asserted running is recorded even before a row, instance, or
22
+ * address holds the identity — the listing that would hold it may not have
23
+ * landed yet — while the client-local acceptance callback requires a current
24
+ * holder, because it can arrive from a replaced or already-dropped Session.
25
+ */
26
+ engagedSessions = new Set();
27
+ disposed = false;
21
28
  /** Per-session projection value stores, retained independently of instance arrival (the
22
29
  * title-snapshot precedent, generalized): push frames land here whether or not the Session
23
30
  * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -32,18 +39,8 @@ export class SessionManager {
32
39
  /** Active list request's mutation log; its identity also fences completion after reconnect. */
33
40
  listMutations = null;
34
41
  addresses = new Map();
35
- catalogs = new Map();
36
- catalogInflight = new Map();
37
- /** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
38
- catalogStale = new Set();
39
- openCatalogs = new Set();
40
- catalogDebounce = new Map();
41
- /**
42
- * Background jobs per session, last-wins from Session Controller's control
43
- * stream. An empty set is stored as an absent key, so absence and `[]` are
44
- * one representation.
45
- */
46
- jobsBySession = new Map();
42
+ projectionLoads = new Map();
43
+ projectionInflight = new Map();
47
44
  listSnapshotCache;
48
45
  /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry
49
46
  * object when every field matches — wire refreshes mint all-new summary objects, so identity
@@ -65,7 +62,7 @@ export class SessionManager {
65
62
  */
66
63
  resolveTarget(target) {
67
64
  const id = typeof target === 'string' ? target : target.childSessionId;
68
- const address = typeof target === 'string' ? this.navigationAddress(id) : target;
65
+ const address = typeof target === 'string' ? this.subagentAddress(id) : target;
69
66
  if (typeof target === 'string'
70
67
  && !this.sessions.has(id)
71
68
  && !this.summaries.some(summary => summary.sessionId === id)
@@ -74,30 +71,29 @@ export class SessionManager {
74
71
  }
75
72
  if (address !== undefined)
76
73
  this.addresses.set(id, address);
77
- this.sessions.get(id)?.configureSubagent(address, address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.parentAvailable);
74
+ this.sessions.get(id)?.configureSubagent(address, address === undefined
75
+ ? undefined
76
+ : this.agentAvailable(address.parentSessionId));
78
77
  return id;
79
78
  }
80
- /**
81
- * Return the durable catalog address retained for one child.
82
- * @param sessionId - possible addressed child id.
83
- * @returns The direct-parent address, when navigation discovered one.
84
- */
85
- subagentAddress(sessionId) {
86
- return this.navigationAddress(sessionId);
87
- }
88
79
  /**
89
80
  * Resolve an address for breadcrumb navigation without retaining transport authority.
90
81
  * @param sessionId - possible child id in an already-loaded catalog.
91
82
  * @returns A retained or catalog-derived direct-parent address.
92
83
  */
93
- navigationAddress(sessionId) {
84
+ subagentAddress(sessionId) {
94
85
  const retained = this.addresses.get(sessionId);
95
86
  if (retained !== undefined)
96
87
  return retained;
97
- for (const [parentSessionId, catalog] of this.catalogs) {
98
- const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId);
99
- if (child?.kind === 'child') {
100
- return { parentSessionId, childSessionId: sessionId, mode: child.mode };
88
+ for (const parentSessionId of this.projectionStores.keys()) {
89
+ const child = this.projectionStores.get(parentSessionId)?.values().subagentCatalog
90
+ ?.find(entry => entry.id === sessionId);
91
+ if (child !== undefined) {
92
+ return {
93
+ parentSessionId,
94
+ childSessionId: sessionId,
95
+ mode: child.mode,
96
+ };
101
97
  }
102
98
  }
103
99
  return undefined;
@@ -115,6 +111,7 @@ export class SessionManager {
115
111
  return Promise.resolve();
116
112
  this.sessions.delete(sessionId);
117
113
  this.addresses.delete(sessionId);
114
+ this.pruneEngagement(sessionId, this.retainedIds(this.summaries));
118
115
  return this.startSessionDisposal(session);
119
116
  }
120
117
  /**
@@ -122,11 +119,15 @@ export class SessionManager {
122
119
  * @returns once catalog requests and every Session stream have stopped.
123
120
  */
124
121
  async dispose() {
125
- for (const timer of this.catalogDebounce.values())
126
- clearTimeout(timer);
127
- this.catalogDebounce.clear();
128
- this.catalogStale.clear();
129
- this.openCatalogs.clear();
122
+ this.disposed = true;
123
+ this.listMutations = null;
124
+ this.listInflight = null;
125
+ this.engagedSessions.clear();
126
+ const reads = [...this.projectionInflight.values()];
127
+ for (const { controller } of reads)
128
+ controller.abort();
129
+ this.projectionInflight.clear();
130
+ await Promise.all(reads.map(read => read.promise));
130
131
  const sessions = [...this.sessions.values()];
131
132
  this.sessions.clear();
132
133
  this.addresses.clear();
@@ -148,6 +149,7 @@ export class SessionManager {
148
149
  /**
149
150
  * Lazy build: return the existing instance or construct one (no auto-open —
150
151
  * the reference allocator opens history after binding the scope).
152
+ * New instances reconcile retained metadata before returning.
151
153
  * @param sessionId - the session to get.
152
154
  * @returns the resident instance.
153
155
  */
@@ -160,18 +162,22 @@ export class SessionManager {
160
162
  // instance (consistency when the list precedes open).
161
163
  const summary = this.summaries.find(s => s.sessionId === sessionId);
162
164
  if (summary !== undefined) {
163
- session.handleBlank(summary.blank);
165
+ session.handleBlank(this.effectiveBlank(summary));
164
166
  session.handleRunning(summary.running);
165
167
  }
166
168
  else {
167
169
  const address = this.addresses.get(sessionId);
168
- const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
169
- .find(entry => entry.kind === 'child' && entry.id === sessionId);
170
- if (child?.kind === 'child') {
170
+ const child = address === undefined ? undefined : this.projectionStores.get(address.parentSessionId)?.values().subagentCatalog
171
+ ?.find(entry => entry.id === sessionId);
172
+ if (child !== undefined) {
171
173
  // A catalogued child exists only after its delegated session has
172
174
  // durable history, even though child rows do not carry `blank`.
173
175
  session.handleBlank(false);
174
- session.handleRunning(child.activity === 'running');
176
+ session.handleRunning(false);
177
+ }
178
+ else {
179
+ // Retained metadata may have notified before this Session existed.
180
+ session.handleBlank(true);
175
181
  }
176
182
  }
177
183
  }
@@ -181,127 +187,131 @@ export class SessionManager {
181
187
  const address = this.addresses.get(sessionId);
182
188
  const parentAvailable = address === undefined
183
189
  ? undefined
184
- : this.catalogs.get(address.parentSessionId)?.parentAvailable;
190
+ : this.agentAvailable(address.parentSessionId);
185
191
  return new Session(sessionId, this.remote, {
186
192
  ...(address === undefined ? {} : {
187
193
  address,
188
- ...catalogAvailability(parentAvailable),
194
+ ...parentAvailable === undefined ? {} : { parentAvailable },
189
195
  }),
190
196
  // The sender's local first-send flip mirrors into the list row so the
191
197
  // session surfaces (lists filter on blank) before any host frame lands.
192
198
  onEngaged: (engaged) => {
193
- this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId });
199
+ if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId))
200
+ return;
201
+ // An identity already on the ledger needs no second row mutation.
202
+ if (!this.engagedSessions.has(engaged.sessionId)) {
203
+ this.engagedSessions.add(engaged.sessionId);
204
+ this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId });
205
+ }
206
+ this.sessions.get(engaged.sessionId)?.handleBlank(false);
194
207
  },
195
208
  projections: this.projectionStore(sessionId),
196
209
  });
197
210
  }
211
+ effectiveBlank(summary) {
212
+ return summary.blank && !this.engagedSessions.has(summary.sessionId);
213
+ }
214
+ /**
215
+ * Identities an engagement may still belong to: the given list rows, resident
216
+ * Session instances, and retained child addresses.
217
+ * @param summaries - list rows of the caller's snapshot.
218
+ * @returns the retained identity set.
219
+ */
220
+ retainedIds(summaries) {
221
+ const retained = new Set(summaries.map(summary => summary.sessionId));
222
+ for (const sessionId of this.sessions.keys())
223
+ retained.add(sessionId);
224
+ for (const sessionId of this.addresses.keys())
225
+ retained.add(sessionId);
226
+ return retained;
227
+ }
228
+ /**
229
+ * Forget one engagement that no retained identity holds.
230
+ * @param sessionId - identity whose engagement may be dropped.
231
+ * @param retained - identities from {@link retainedIds} for the caller's snapshot.
232
+ */
233
+ pruneEngagement(sessionId, retained) {
234
+ if (!retained.has(sessionId))
235
+ this.engagedSessions.delete(sessionId);
236
+ }
198
237
  /** Resident per-session projection store (create-on-demand; outlives instantiation). */
199
238
  projectionStore(sessionId) {
200
239
  let store = this.projectionStores.get(sessionId);
201
240
  if (store === undefined) {
202
241
  store = new ProjectionValueStore();
203
- // List rows project off store keys (title); any-key changes re-enter
204
- // the manager's own batched rebuild channel.
205
- store.subscribeAny(() => { this.notifier.markDirty(); });
242
+ const projections = store;
243
+ store.subscribeAny(() => {
244
+ // Newer history or control metadata corrects a resident Session's stale list hint.
245
+ if (projections.values().sessionListMetadata?.blank === false) {
246
+ this.sessions.get(sessionId)?.handleBlank(false);
247
+ }
248
+ this.notifier.markDirty();
249
+ });
206
250
  this.projectionStores.set(sessionId, store);
207
251
  }
208
252
  return store;
209
253
  }
210
254
  /**
211
- * Refresh one direct-child catalog, reusing its in-flight request.
212
- * @param parentSessionId - catalog owner.
255
+ * Load a complete projection baseline once per connection; retry unsuccessful reads.
256
+ * @param sessionId - Session to inspect without opening its conversation.
257
+ * @returns completion of the current or newly started read.
213
258
  */
214
- refreshSubagents(parentSessionId) {
215
- const existing = this.catalogInflight.get(parentSessionId);
259
+ refreshProjections(sessionId) {
260
+ const existing = this.projectionInflight.get(sessionId);
216
261
  if (existing !== undefined)
217
262
  return existing.promise;
218
- const previous = this.catalogs.get(parentSessionId);
219
- const expandableRows = new Set();
220
- const activityRows = new Map();
221
- this.catalogs.set(parentSessionId, {
222
- entries: previous?.entries ?? [],
223
- ...(previous?.parentAvailable === undefined
224
- ? {}
225
- : { parentAvailable: previous.parentAvailable }),
226
- state: 'loading',
227
- error: null,
228
- });
263
+ if (this.projectionLoads.get(sessionId)?.state === 'ready')
264
+ return Promise.resolve();
265
+ const controller = new AbortController();
266
+ const store = this.projectionStore(sessionId);
267
+ const initialValues = store.values();
268
+ this.projectionLoads.set(sessionId, { state: 'loading', error: null });
229
269
  this.notifier.markDirty();
230
270
  const operation = (async () => {
231
271
  try {
232
- const result = await this.remote.subagents.list(parentSessionId);
272
+ const result = await this.remote.session.projections({ sessionId }, controller.signal);
273
+ if (controller.signal.aborted)
274
+ return;
233
275
  if (result.ok) {
234
- const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
235
- ?? result.value.parentAvailable;
236
- this.catalogs.set(parentSessionId, {
237
- ...result.value,
238
- entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
239
- parentAvailable,
240
- state: 'ready',
241
- error: null,
242
- });
243
- for (const [childId, address] of this.addresses) {
244
- if (address.parentSessionId !== parentSessionId)
245
- continue;
246
- this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable);
276
+ if (result.value !== null) {
277
+ store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) });
278
+ }
279
+ else if (store.values() === initialValues) {
280
+ // A later frame proves existence independently of an earlier missing read.
281
+ store.clear();
247
282
  }
283
+ this.projectionLoads.set(sessionId, { state: 'ready', error: null });
248
284
  }
249
285
  else {
250
- this.catalogs.set(parentSessionId, {
251
- entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
252
- ...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
253
- ?? previous?.parentAvailable),
254
- state: 'error',
255
- error: result.error,
256
- });
286
+ this.projectionLoads.set(sessionId, { state: 'error', error: result.error });
257
287
  }
258
288
  }
259
289
  catch (error) {
290
+ if (controller.signal.aborted)
291
+ return;
260
292
  if (!isRemoteFailure(error))
261
293
  throw error;
262
- this.catalogs.set(parentSessionId, {
263
- entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
264
- ...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
265
- ?? previous?.parentAvailable),
266
- state: 'error',
267
- error,
268
- });
294
+ this.projectionLoads.set(sessionId, { state: 'error', error });
269
295
  }
270
296
  finally {
271
- this.catalogInflight.delete(parentSessionId);
272
- // Re-arm the trailing pull before the dirty notify: the response the
273
- // caller observed predates the stale-marking change, so the follow-up
274
- // refresh is the only carrier of that change.
275
- if (this.catalogStale.delete(parentSessionId))
276
- void this.refreshSubagents(parentSessionId);
277
- this.notifier.markDirty();
297
+ if (!controller.signal.aborted) {
298
+ this.projectionInflight.delete(sessionId);
299
+ this.notifier.markDirty();
300
+ }
278
301
  }
279
302
  })();
280
- this.catalogInflight.set(parentSessionId, {
281
- promise: operation,
282
- expandableRows,
283
- activityRows,
284
- parentAvailableOverride: undefined,
285
- });
303
+ this.projectionInflight.set(sessionId, { promise: operation, controller });
286
304
  return operation;
287
305
  }
288
- /**
289
- * Mark whether a catalog menu is consuming live membership updates.
290
- * @param parentSessionId - catalog owner.
291
- * @param open - current menu state.
292
- */
293
- setSubagentCatalogOpen(parentSessionId, open) {
294
- if (open) {
295
- this.openCatalogs.add(parentSessionId);
296
- void this.refreshSubagents(parentSessionId);
297
- }
298
- else {
299
- this.openCatalogs.delete(parentSessionId);
300
- const timer = this.catalogDebounce.get(parentSessionId);
301
- if (timer !== undefined) {
302
- clearTimeout(timer);
303
- this.catalogDebounce.delete(parentSessionId);
304
- }
306
+ agentAvailable(sessionId) {
307
+ return this.summaries.find(summary => summary.sessionId === sessionId)?.agentAvailable
308
+ ?? (this.listPhase === 'ready' ? false : undefined);
309
+ }
310
+ updateParentAvailability() {
311
+ for (const [childId, address] of this.addresses) {
312
+ const available = this.agentAvailable(address.parentSessionId);
313
+ if (available !== undefined)
314
+ this.sessions.get(childId)?.handleSubagentParentAvailable(available);
305
315
  }
306
316
  }
307
317
  // ---- List API ----
@@ -324,31 +334,40 @@ export class SessionManager {
324
334
  const baseline = this.listPhase === 'pending'
325
335
  ? [...result.value.items]
326
336
  : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId);
327
- this.summaries = mutations.reduce(applyMutation, baseline);
337
+ // A removal supersedes running observed in the pull, including after re-addition.
338
+ const removedSincePull = new Set();
339
+ for (const mutation of mutations) {
340
+ if (mutation.kind === 'remove')
341
+ removedSincePull.add(mutation.sessionId);
342
+ }
343
+ for (const s of baseline) {
344
+ if (s.running && !removedSincePull.has(s.sessionId))
345
+ this.engagedSessions.add(s.sessionId);
346
+ }
347
+ const summaries = mutations.reduce(applyMutation, baseline);
348
+ this.summaries = summaries;
349
+ // A full list can remove identities without a removal frame.
350
+ const retained = this.retainedIds(summaries);
351
+ for (const sessionId of this.engagedSessions)
352
+ this.pruneEngagement(sessionId, retained);
328
353
  this.listState = 'idle';
329
354
  this.listPhase = 'ready';
330
- // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
355
+ this.updateParentAvailability();
356
+ // Resident Sessions and list rows share one display blank, reconciled with the metadata projection.
331
357
  for (const s of this.summaries) {
332
358
  const session = this.sessions.get(s.sessionId);
333
359
  if (session === undefined)
334
360
  continue;
335
- session.handleBlank(s.blank);
361
+ session.handleBlank(this.effectiveBlank(s));
336
362
  session.handleRunning(s.running);
337
363
  }
338
- // Seed each row's projection baseline into the per-session value
339
- // store (cold titles surface without opening the session). Per-key
340
- // apply, not seed(): the list block is a partial baseline — the
341
- // cold cache serves only version-matching keys — so an absent key
342
- // must not clear; higher-seq-wins still keeps a stale list block
343
- // from overwriting a newer push frame or tail baseline.
364
+ // Land each row's projection block in the per-session value store
365
+ // (cold titles surface without opening the session). A list block is
366
+ // partial — only the keys its source holds — so an absent key never
367
+ // clears; how the present keys merge depends on the block's kind.
344
368
  for (const s of result.value.items) {
345
- const block = s.projections;
346
- if (block === undefined)
347
- continue;
348
- const store = this.projectionStore(s.sessionId);
349
- const values = block.values;
350
- for (const key of Object.keys(values))
351
- store.apply(key, values[key], sessionSeqCursor(block.asOfSeq));
369
+ if (s.projections !== undefined)
370
+ this.applyListBlock(s.sessionId, s.projections);
352
371
  }
353
372
  }
354
373
  else {
@@ -407,7 +426,7 @@ export class SessionManager {
407
426
  : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared };
408
427
  const result = await this.remote.session.create(payload);
409
428
  if (result.ok) {
410
- this.recordMutation({ kind: 'upsert', summary: {
429
+ this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
411
430
  sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
412
431
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
413
432
  } });
@@ -418,7 +437,7 @@ export class SessionManager {
418
437
  // so expose it immediately as Ungrouped while the caller keeps the
419
438
  // prompt buffer and decides whether to retry attachment.
420
439
  if (publishedSessionId !== undefined) {
421
- this.recordMutation({ kind: 'upsert', summary: {
440
+ this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
422
441
  sessionId: publishedSessionId,
423
442
  updatedAt: Date.now(),
424
443
  running: false,
@@ -430,11 +449,12 @@ export class SessionManager {
430
449
  }
431
450
  /**
432
451
  * Contract session.fork; on success merge the child into summaries
433
- * immediately (same synchronous-addressability guarantee as create). The
434
- * child carries the source's history, so it is never blank; lineage rides
435
- * parentSessionId so the list nests it under its source. A child published
436
- * before Workspace attachment fails is also reconciled into the list.
437
- * @param opts - source session and the optional seq anchoring the cut.
452
+ * immediately (same synchronous-addressability guarantee as create).
453
+ * Blankness starts provisionally true so the authoritative Host summary can
454
+ * preserve it or lower it after an exact cut before the first `turn/start`;
455
+ * lineage rides parentSessionId. A child published before Workspace
456
+ * attachment fails is also reconciled into the list.
457
+ * @param opts - source session and the optional exact inclusive boundary seq.
438
458
  * @returns the fork result (the child session id).
439
459
  */
440
460
  async fork(opts) {
@@ -447,8 +467,8 @@ export class SessionManager {
447
467
  ? result.value.sessionId
448
468
  : workspaceAttachSessionId(result.error);
449
469
  if (childId !== undefined) {
450
- this.recordMutation({ kind: 'upsert', summary: {
451
- sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
470
+ this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
471
+ sessionId: childId, updatedAt: Date.now(), running: false, blank: true,
452
472
  parentSessionId: opts.sessionId,
453
473
  ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
454
474
  } });
@@ -456,16 +476,30 @@ export class SessionManager {
456
476
  return result;
457
477
  }
458
478
  /**
459
- * Insert-or-enrich a locally synthesized summary: a new id prepends; an
460
- * existing entry only gains fields it lacks (the session-added frame and the
461
- * create() echo race — whichever lands second must fill the placeholder's
462
- * missing cwd/parentSessionId, never overwrite list-refresh data).
479
+ * Rename a Session and update its title projection without opening its history.
480
+ * @param sessionId - Session to rename.
481
+ * @param title - raw title text for Host normalization.
482
+ * @returns the accepted title and event position, or the Remote failure.
483
+ */
484
+ async rename(sessionId, title) {
485
+ const result = await this.remote.session.rename({ sessionId, title });
486
+ if (result.ok) {
487
+ this.projectionStore(sessionId).apply('title', result.value.title, SessionSeq(result.value.seq));
488
+ }
489
+ return result;
490
+ }
491
+ /**
492
+ * Merge a Host summary, replacing live state and filling missing metadata.
493
+ * Local create/fork placeholders only fill metadata on an existing row.
463
494
  */
464
495
  mergeSummary(summary) {
465
496
  this.recordMutation({ kind: 'upsert', summary });
497
+ this.updateParentAvailability();
466
498
  }
467
499
  /** Apply immediately and retain for replay when a list response is in flight. */
468
500
  recordMutation(mutation) {
501
+ if (this.disposed)
502
+ return;
469
503
  this.listMutations?.push(mutation);
470
504
  this.summaries = applyMutation(this.summaries, mutation);
471
505
  this.notifier.markDirty();
@@ -505,23 +539,10 @@ export class SessionManager {
505
539
  this.replaceControlBaseline(frame.value);
506
540
  return;
507
541
  }
508
- if (frame.type === 'projection') {
509
- this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq));
510
- this.notifier.markDirty();
511
- return;
512
- }
513
- if (frame.jobs.length === 0)
514
- this.jobsBySession.delete(frame.sessionId);
515
- else
516
- this.jobsBySession.set(frame.sessionId, frame.jobs);
542
+ this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq));
517
543
  this.notifier.markDirty();
518
544
  }
519
545
  replaceControlBaseline(baseline) {
520
- this.jobsBySession.clear();
521
- for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
522
- if (jobs.length > 0)
523
- this.jobsBySession.set(sessionId, jobs);
524
- }
525
546
  for (const [sessionId, block] of Object.entries(baseline.projections)) {
526
547
  const store = this.projectionStore(sessionId);
527
548
  const asOfSeq = sessionSeqCursor(block.asOfSeq);
@@ -535,20 +556,34 @@ export class SessionManager {
535
556
  */
536
557
  handleSessionAdded(summary) {
537
558
  this.mergeSummary(summary);
538
- this.sessions.get(summary.sessionId)?.handleBlank(summary.blank);
539
- const projections = summary.projections;
540
- if (projections !== undefined) {
541
- const store = this.projectionStore(summary.sessionId);
542
- for (const [key, value] of Object.entries(projections.values)) {
543
- store.apply(key, value, sessionSeqCursor(projections.asOfSeq));
559
+ if (!this.disposed && summary.running)
560
+ this.engagedSessions.add(summary.sessionId);
561
+ this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary));
562
+ if (summary.projections !== undefined)
563
+ this.applyListBlock(summary.sessionId, summary.projections);
564
+ }
565
+ /**
566
+ * Merge one list-surface projection block by the sequence space it declares.
567
+ * A `sequenced` block came from the Host's live registry for an attached
568
+ * Session, so each key lands under higher-seq-wins against that Session's
569
+ * baselines and frames. A `cached` block was viewed from the persisted
570
+ * checkpoint by a header-only listing: its watermark is not comparable with
571
+ * this connection's seqs, so it only fills keys no sequenced row holds.
572
+ */
573
+ applyListBlock(sessionId, block) {
574
+ const store = this.projectionStore(sessionId);
575
+ switch (block.kind) {
576
+ case 'sequenced': {
577
+ const seq = sessionSeqCursor(block.asOfSeq);
578
+ for (const [key, value] of Object.entries(block.values))
579
+ store.apply(key, value, seq);
580
+ return;
544
581
  }
545
- }
546
- if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) {
547
- this.markCatalogParentExpandable(summary.parentSessionId);
548
- }
549
- if (summary.parentSessionId !== undefined
550
- && this.openCatalogs.has(summary.parentSessionId)) {
551
- this.scheduleCatalogRefresh(summary.parentSessionId);
582
+ case 'cached':
583
+ store.applyCached(block.values);
584
+ return;
585
+ default:
586
+ assertNever(block.kind, 'session list projection block kind');
552
587
  }
553
588
  }
554
589
  /**
@@ -556,28 +591,23 @@ export class SessionManager {
556
591
  * @param sessionId - removed Session identity.
557
592
  */
558
593
  handleSessionRemoved(sessionId) {
559
- const summary = this.summaries.find(candidate => candidate.sessionId === sessionId);
560
- const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(sessionId);
594
+ const durableSubagent = this.subagentAddress(sessionId) !== undefined
595
+ || this.summaries.some(summary => summary.sessionId === sessionId && summary.origin === 'subagent');
561
596
  this.recordMutation(durableSubagent
562
- ? { kind: 'status', sessionId, running: false }
597
+ ? { kind: 'status', sessionId, running: false, agentAvailable: false }
563
598
  : { kind: 'remove', sessionId });
564
- this.updateCatalogActivity(sessionId, false);
565
599
  if (durableSubagent)
566
600
  this.sessions.get(sessionId)?.handleRunning(false);
567
601
  else
568
602
  this.sessions.get(sessionId)?.handleRemoved();
569
- this.jobsBySession.delete(sessionId);
570
- if (!durableSubagent)
603
+ const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog;
604
+ if (!durableSubagent && (catalog === undefined || catalog.length === 0)) {
571
605
  this.projectionStores.delete(sessionId);
572
- const inflightCatalog = this.catalogInflight.get(sessionId);
573
- if (inflightCatalog !== undefined) {
574
- inflightCatalog.parentAvailableOverride = false;
575
- this.catalogStale.add(sessionId);
576
- }
577
- const ownedCatalog = this.catalogs.get(sessionId);
578
- if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
579
- this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false });
580
606
  }
607
+ this.pruneEngagement(sessionId, this.retainedIds(this.summaries));
608
+ this.projectionInflight.get(sessionId)?.controller.abort();
609
+ this.projectionInflight.delete(sessionId);
610
+ this.projectionLoads.delete(sessionId);
581
611
  for (const [childId, address] of this.addresses) {
582
612
  if (address.parentSessionId === sessionId) {
583
613
  this.sessions.get(childId)?.handleSubagentParentAvailable(false);
@@ -590,9 +620,11 @@ export class SessionManager {
590
620
  * @param running - current Agent running state.
591
621
  */
592
622
  handleSessionStatus(sessionId, running) {
593
- this.recordMutation({ kind: 'status', sessionId, running });
623
+ if (!this.disposed && running)
624
+ this.engagedSessions.add(sessionId);
625
+ this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true });
626
+ this.updateParentAvailability();
594
627
  this.sessions.get(sessionId)?.handleRunning(running);
595
- this.updateCatalogActivity(sessionId, running);
596
628
  }
597
629
  /**
598
630
  * Advance Session-list activity from one user-authored durable message.
@@ -622,90 +654,18 @@ export class SessionManager {
622
654
  this.listMutations = null;
623
655
  this.listInflight = null;
624
656
  void this.refreshList();
625
- const parents = new Set(this.openCatalogs);
657
+ const parents = new Set(this.projectionLoads.keys());
626
658
  for (const id of this.sessions.keys()) {
627
659
  const address = this.addresses.get(id);
628
660
  if (address !== undefined)
629
661
  parents.add(address.parentSessionId);
630
662
  }
663
+ for (const { controller } of this.projectionInflight.values())
664
+ controller.abort();
665
+ this.projectionInflight.clear();
666
+ this.projectionLoads.clear();
631
667
  for (const parentSessionId of parents)
632
- void this.refreshSubagents(parentSessionId);
633
- }
634
- /** Debounce membership refetches for an explicitly consumed catalog. */
635
- scheduleCatalogRefresh(parentSessionId) {
636
- if (this.catalogDebounce.has(parentSessionId))
637
- return;
638
- const timer = setTimeout(() => {
639
- this.catalogDebounce.delete(parentSessionId);
640
- // The in-flight response predates the membership frame that scheduled
641
- // this callback. Queue one post-settlement pull instead of treating an
642
- // ordinary overlapping read as evidence that catalog membership changed.
643
- if (this.catalogInflight.has(parentSessionId)) {
644
- this.catalogStale.add(parentSessionId);
645
- return;
646
- }
647
- void this.refreshSubagents(parentSessionId);
648
- }, 50);
649
- this.catalogDebounce.set(parentSessionId, timer);
650
- }
651
- /** Apply one Agent-driver transition to loaded and in-flight catalogs. */
652
- updateCatalogActivity(childSessionId, running) {
653
- const activity = running ? 'running' : 'inactive';
654
- for (const inflight of this.catalogInflight.values()) {
655
- inflight.activityRows.set(childSessionId, activity);
656
- }
657
- let changed = false;
658
- for (const [parentSessionId, catalog] of this.catalogs) {
659
- if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity))
660
- continue;
661
- const entries = catalog.entries.map((entry) => {
662
- if (entry.kind !== 'child' || entry.id !== childSessionId)
663
- return entry;
664
- return { ...entry, activity };
665
- });
666
- changed = true;
667
- this.catalogs.set(parentSessionId, { ...catalog, entries });
668
- }
669
- if (changed)
670
- this.notifier.markDirty();
671
- }
672
- /** Preserve and project a positive expandability hint after one direct subagent publishes. */
673
- markCatalogParentExpandable(parentSessionId) {
674
- this.applyCatalogParentExpandable(parentSessionId);
675
- for (const inflight of this.catalogInflight.values())
676
- inflight.expandableRows.add(parentSessionId);
677
- }
678
- /** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
679
- applyCatalogParentExpandable(parentSessionId) {
680
- let changed = false;
681
- for (const [catalogParentId, catalog] of this.catalogs) {
682
- if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren))
683
- continue;
684
- const entries = catalog.entries.map((entry) => {
685
- if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren)
686
- return entry;
687
- return { ...entry, hasChildren: true };
688
- });
689
- changed = true;
690
- this.catalogs.set(catalogParentId, { ...catalog, entries });
691
- }
692
- if (changed)
693
- this.notifier.markDirty();
694
- }
695
- /** Fold request-local row mutations into one catalog result before publication. */
696
- withCatalogMutations(entries, expandableRows, activityRows) {
697
- return entries.map((entry) => {
698
- if (entry.kind !== 'child')
699
- return entry;
700
- const activity = activityRows.get(entry.id);
701
- if (!expandableRows.has(entry.id) && activity === undefined)
702
- return entry;
703
- return {
704
- ...entry,
705
- ...expandableRows.has(entry.id) ? { hasChildren: true } : {},
706
- ...activity === undefined ? {} : { activity },
707
- };
708
- });
668
+ void this.refreshProjections(parentSessionId);
709
669
  }
710
670
  buildListSnapshot() {
711
671
  const merged = this.summaries.map((summary) => {
@@ -714,8 +674,12 @@ export class SessionManager {
714
674
  const projectionStore = this.projectionStores.get(summary.sessionId);
715
675
  const title = projectionStore?.get('title');
716
676
  const projectionValues = projectionStore?.values();
677
+ const metadata = projectionValues?.sessionListMetadata;
717
678
  return {
718
679
  ...summary,
680
+ // Cached list hints can precede a history opening or control update.
681
+ blank: this.effectiveBlank(summary) && metadata?.blank !== false,
682
+ updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0),
719
683
  ...(typeof title === 'string' && title !== '' ? { title } : {}),
720
684
  ...(projectionValues === undefined ? {} : { projectionValues }),
721
685
  };
@@ -745,15 +709,18 @@ export class SessionManager {
745
709
  state: this.listState,
746
710
  phase: this.listPhase,
747
711
  error: this.listError,
748
- subagentsByParent: Object.fromEntries(this.catalogs),
749
- jobsBySession: Object.fromEntries(this.jobsBySession),
712
+ projectionsBySession: Object.fromEntries([...this.projectionStores].map(([sessionId, store]) => [
713
+ sessionId,
714
+ { values: store.values(), state: 'idle', error: null, ...this.projectionLoads.get(sessionId) },
715
+ ])),
750
716
  };
751
717
  }
752
718
  }
753
719
  /** Apply one list mutation without deriving display order. */
754
720
  function applyMutation(summaries, mutation) {
755
721
  switch (mutation.kind) {
756
- case 'upsert': {
722
+ case 'upsert':
723
+ case 'placeholder': {
757
724
  const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId);
758
725
  if (existing === undefined)
759
726
  return [mutation.summary, ...summaries];
@@ -762,6 +729,10 @@ function applyMutation(summaries, mutation) {
762
729
  // Blank only lowers: a stale true (session-added racing the local
763
730
  // first send) never re-hides an already-surfaced session.
764
731
  blank: existing.blank && mutation.summary.blank,
732
+ ...(mutation.kind === 'upsert' ? {
733
+ agentAvailable: mutation.summary.agentAvailable,
734
+ running: mutation.summary.running,
735
+ } : {}),
765
736
  ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
766
737
  ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
767
738
  ? { parentSessionId: mutation.summary.parentSessionId } : {}),
@@ -769,18 +740,19 @@ function applyMutation(summaries, mutation) {
769
740
  ? { origin: mutation.summary.origin } : {}),
770
741
  };
771
742
  if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
772
- && filled.origin === existing.origin && filled.blank === existing.blank)
743
+ && filled.origin === existing.origin && filled.blank === existing.blank
744
+ && filled.agentAvailable === existing.agentAvailable && filled.running === existing.running)
773
745
  return [...summaries];
774
746
  return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary);
775
747
  }
776
748
  case 'remove':
777
749
  return summaries.filter(summary => summary.sessionId !== mutation.sessionId);
778
750
  case 'status':
779
- // running:true doubles as the cross-client blank flip (a blank session
780
- // never runs, so the first running frame proves a message landed).
751
+ // Running converts display state; it does not establish durable history.
781
752
  return summaries.map(summary => summary.sessionId === mutation.sessionId
782
- && (summary.running !== mutation.running || (mutation.running && summary.blank))
783
- ? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
753
+ && (summary.running !== mutation.running
754
+ || summary.agentAvailable !== mutation.agentAvailable || (mutation.running && summary.blank))
755
+ ? { ...summary, running: mutation.running, agentAvailable: mutation.agentAvailable, blank: summary.blank && !mutation.running }
784
756
  : summary);
785
757
  case 'activity':
786
758
  return summaries.map(summary => summary.sessionId === mutation.sessionId