@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,9 +1,8 @@
1
- // SessionManager: the instance cluster Map<SessionId, Session> (lazy-built, resident) + the frame
2
- // dispatch entry + list state, constructed and held by ClientSessions (one per browser client).
3
- // List data never enters zustand; React connects via subscribe/getListSnapshot.
1
+ /** Host catalog, durable projection caches, and explicitly retained Client instances. */
4
2
  import { SessionSeq } from '@deepseek-ai/dsh-session/types';
5
3
  import { mergeOrderedBaseline } from "../ordered-baseline.js";
6
4
  import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client';
5
+ import { assertNever } from '@deepseek-ai/dsh-util-values';
7
6
  import { flattenLineage } from "./lineage.js";
8
7
  import { Notifier } from "./notifier.js";
9
8
  import { ProjectionValueStore } from "./projection-store.js";
@@ -11,25 +10,21 @@ import { Session } from "./session.js";
11
10
  function sessionSeqCursor(value) {
12
11
  return value === -1 ? -1 : SessionSeq(value);
13
12
  }
14
- function catalogAvailability(parentAvailable) {
15
- return parentAvailable === undefined ? {} : { parentAvailable };
16
- }
17
13
  /** Instance cluster + frame entry + the session list. */
18
14
  export class SessionManager {
19
15
  remote;
20
16
  sessions = new Map();
21
17
  /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
22
18
  sessionDisposals = new Set();
23
- /** Latest transient queues, retained independently of Session object materialization. */
24
- queues = new Map();
25
19
  /**
26
- * Sessions that finished running while not selected — the sidebar's green
27
- * "done" reminder (manager-owned, survives connection generations; cleared
28
- * on select and session-removed, re-armed by the next completion).
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.
29
25
  */
30
- completedNotifications = new Set();
31
- /** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */
32
- prevRunning = new Map();
26
+ engagedSessions = new Set();
27
+ disposed = false;
33
28
  /** Per-session projection value stores, retained independently of instance arrival (the
34
29
  * title-snapshot precedent, generalized): push frames land here whether or not the Session
35
30
  * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -41,22 +36,11 @@ export class SessionManager {
41
36
  listPhase = 'pending';
42
37
  listError = null;
43
38
  listInflight = null;
44
- /** Mutations arriving after a list request starts are replayed over its response. */
39
+ /** Active list request's mutation log; its identity also fences completion after reconnect. */
45
40
  listMutations = null;
46
41
  addresses = new Map();
47
- catalogs = new Map();
48
- catalogInflight = new Map();
49
- /** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
50
- catalogStale = new Set();
51
- openCatalogs = new Set();
52
- catalogDebounce = new Map();
53
- /**
54
- * Background jobs per session, last-wins from Session Controller's control
55
- * stream. An empty set is stored as an absent key, so absence and `[]` are
56
- * one representation.
57
- */
58
- jobsBySession = new Map();
59
- selected;
42
+ projectionLoads = new Map();
43
+ projectionInflight = new Map();
60
44
  listSnapshotCache;
61
45
  /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry
62
46
  * object when every field matches — wire refreshes mint all-new summary objects, so identity
@@ -66,110 +50,87 @@ export class SessionManager {
66
50
  notifier = new Notifier(() => {
67
51
  this.listSnapshotCache = this.buildListSnapshot();
68
52
  });
69
- /**
70
- * @param remote - generated Remote namespaces the Session cluster calls.
71
- * @param restoredSelection - persisted real-Session selection candidate.
72
- */
73
- constructor(remote, restoredSelection, restoredAddress) {
53
+ /** @param remote - generated Remote namespaces used by catalog and history readers. */
54
+ constructor(remote) {
74
55
  this.remote = remote;
75
- this.selected = restoredSelection;
76
- if (restoredAddress !== undefined)
77
- this.addresses.set(restoredAddress.childSessionId, restoredAddress);
78
56
  this.listSnapshotCache = this.buildListSnapshot();
79
57
  }
80
- // ---- Selection ----
81
58
  /**
82
- * Select a listed Session or a retained catalog-addressed child.
83
- * @param sessionId - listed or catalog-addressed Session id.
59
+ * Resolve an acquisition target without materializing a Session.
60
+ * @param target - known identity or durable direct-parent address.
61
+ * @returns the resolved identity with its explicit or catalog-derived history route installed.
84
62
  */
85
- select(sessionId) {
86
- const address = this.navigationAddress(sessionId);
87
- if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
88
- throw new Error(`sessions.select: unknown session ${sessionId}`);
63
+ resolveTarget(target) {
64
+ const id = typeof target === 'string' ? target : target.childSessionId;
65
+ const address = typeof target === 'string' ? this.subagentAddress(id) : target;
66
+ if (typeof target === 'string'
67
+ && !this.sessions.has(id)
68
+ && !this.summaries.some(summary => summary.sessionId === id)
69
+ && address === undefined) {
70
+ throw new Error(`sessions.retain: unknown session ${id}`);
89
71
  }
90
72
  if (address !== undefined)
91
- this.addresses.set(sessionId, address);
92
- this.sessions.get(sessionId)?.configureSubagent(address, address === undefined
73
+ this.addresses.set(id, address);
74
+ this.sessions.get(id)?.configureSubagent(address, address === undefined
93
75
  ? undefined
94
- : this.catalogs.get(address.parentSessionId)?.parentAvailable);
95
- this.selected = sessionId;
96
- // Looking at the session consumes its completion reminder (dot clears).
97
- this.completedNotifications.delete(sessionId);
98
- void this.refreshSubagents(sessionId);
99
- this.notifier.notifyNow();
100
- }
101
- /**
102
- * Select a healthy child through its durable direct-parent address.
103
- * @param address - catalog-derived parent and child ids.
104
- */
105
- selectSubagent(address) {
106
- const catalog = this.catalogs.get(address.parentSessionId);
107
- const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId);
108
- if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) {
109
- throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`);
110
- }
111
- this.addresses.set(address.childSessionId, address);
112
- this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable);
113
- this.selected = address.childSessionId;
114
- this.completedNotifications.delete(address.childSessionId);
115
- void this.refreshSubagents(address.childSessionId);
116
- this.notifier.notifyNow();
117
- }
118
- /** Clear the selection (the layout falls to the no-session view state). */
119
- clearSelection() {
120
- this.selected = undefined;
121
- this.notifier.notifyNow();
122
- }
123
- /**
124
- * Return the durable catalog address retained for one child.
125
- * @param sessionId - possible addressed child id.
126
- * @returns The direct-parent address, when navigation discovered one.
127
- */
128
- subagentAddress(sessionId) {
129
- return this.addresses.get(sessionId);
76
+ : this.agentAvailable(address.parentSessionId));
77
+ return id;
130
78
  }
131
79
  /**
132
80
  * Resolve an address for breadcrumb navigation without retaining transport authority.
133
81
  * @param sessionId - possible child id in an already-loaded catalog.
134
82
  * @returns A retained or catalog-derived direct-parent address.
135
83
  */
136
- navigationAddress(sessionId) {
84
+ subagentAddress(sessionId) {
137
85
  const retained = this.addresses.get(sessionId);
138
86
  if (retained !== undefined)
139
87
  return retained;
140
- for (const [parentSessionId, catalog] of this.catalogs) {
141
- const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId);
142
- if (child?.kind === 'child') {
143
- 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
+ };
144
97
  }
145
98
  }
146
99
  return undefined;
147
100
  }
148
101
  // ---- Instance management ----
149
102
  /**
150
- * Drop a session instance (scope-prune companion: instance
151
- * and scope share one lifecycle). The host session log is the durable
152
- * truth — a later get() lazily rebuilds and open() backfills history.
153
- * @param sessionId - the session to drop.
103
+ * Withdraw an exact Client instance before running its teardown callbacks.
104
+ * @param sessionId - identity to withdraw.
105
+ * @param expected - instance being released; a replacement is left untouched.
106
+ * @returns completion of the detached instance's stream teardown.
154
107
  */
155
- async drop(sessionId) {
108
+ drop(sessionId, expected) {
156
109
  const session = this.sessions.get(sessionId);
110
+ if (session !== expected)
111
+ return Promise.resolve();
157
112
  this.sessions.delete(sessionId);
158
- if (session !== undefined)
159
- await this.startSessionDisposal(session);
113
+ this.addresses.delete(sessionId);
114
+ this.pruneEngagement(sessionId, this.retainedIds(this.summaries));
115
+ return this.startSessionDisposal(session);
160
116
  }
161
117
  /**
162
- * Stop owned timers and every remaining Session instance.
163
- * @returns when every Session Remote iterator has completed teardown.
118
+ * Stop catalog requests and dispose every resident Session.
119
+ * @returns once catalog requests and every Session stream have stopped.
164
120
  */
165
121
  async dispose() {
166
- for (const timer of this.catalogDebounce.values())
167
- clearTimeout(timer);
168
- this.catalogDebounce.clear();
169
- this.catalogStale.clear();
170
- 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));
171
131
  const sessions = [...this.sessions.values()];
172
132
  this.sessions.clear();
133
+ this.addresses.clear();
173
134
  for (const session of sessions)
174
135
  void this.startSessionDisposal(session);
175
136
  await this.drainSessionDisposals();
@@ -187,7 +148,8 @@ export class SessionManager {
187
148
  }
188
149
  /**
189
150
  * Lazy build: return the existing instance or construct one (no auto-open —
190
- * open is triggered by the container's select callback).
151
+ * the reference allocator opens history after binding the scope).
152
+ * New instances reconcile retained metadata before returning.
191
153
  * @param sessionId - the session to get.
192
154
  * @returns the resident instance.
193
155
  */
@@ -196,27 +158,26 @@ export class SessionManager {
196
158
  if (session === undefined) {
197
159
  session = this.createSession(sessionId);
198
160
  this.sessions.set(sessionId, session);
199
- // Install the latest control baseline before the running-bit sync: a
200
- // not-running summary must sweep replayed queue
201
- // rows the same way a live status flip would (their retirement events dropped
202
- // while the session was uninstantiated).
203
- session.replaceControl(this.queues.get(sessionId) ?? []);
204
161
  // Sync the running and blank bits from the list snapshot into the new
205
162
  // instance (consistency when the list precedes open).
206
163
  const summary = this.summaries.find(s => s.sessionId === sessionId);
207
164
  if (summary !== undefined) {
208
- session.handleBlank(summary.blank);
165
+ session.handleBlank(this.effectiveBlank(summary));
209
166
  session.handleRunning(summary.running);
210
167
  }
211
168
  else {
212
169
  const address = this.addresses.get(sessionId);
213
- const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
214
- .find(entry => entry.kind === 'child' && entry.id === sessionId);
215
- 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) {
216
173
  // A catalogued child exists only after its delegated session has
217
174
  // durable history, even though child rows do not carry `blank`.
218
175
  session.handleBlank(false);
219
- 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);
220
181
  }
221
182
  }
222
183
  }
@@ -226,131 +187,135 @@ export class SessionManager {
226
187
  const address = this.addresses.get(sessionId);
227
188
  const parentAvailable = address === undefined
228
189
  ? undefined
229
- : this.catalogs.get(address.parentSessionId)?.parentAvailable;
190
+ : this.agentAvailable(address.parentSessionId);
230
191
  return new Session(sessionId, this.remote, {
231
192
  ...(address === undefined ? {} : {
232
193
  address,
233
- ...catalogAvailability(parentAvailable),
194
+ ...parentAvailable === undefined ? {} : { parentAvailable },
234
195
  }),
235
196
  // The sender's local first-send flip mirrors into the list row so the
236
197
  // session surfaces (lists filter on blank) before any host frame lands.
237
198
  onEngaged: (engaged) => {
238
- 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);
239
207
  },
240
208
  projections: this.projectionStore(sessionId),
241
209
  });
242
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
+ }
243
237
  /** Resident per-session projection store (create-on-demand; outlives instantiation). */
244
238
  projectionStore(sessionId) {
245
239
  let store = this.projectionStores.get(sessionId);
246
240
  if (store === undefined) {
247
241
  store = new ProjectionValueStore();
248
- // List rows project off store keys (title); any-key changes re-enter
249
- // the manager's own batched rebuild channel.
250
- 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
+ });
251
250
  this.projectionStores.set(sessionId, store);
252
251
  }
253
252
  return store;
254
253
  }
255
254
  /**
256
- * Refresh one direct-child catalog, reusing its in-flight request.
257
- * @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.
258
258
  */
259
- refreshSubagents(parentSessionId) {
260
- const existing = this.catalogInflight.get(parentSessionId);
259
+ refreshProjections(sessionId) {
260
+ const existing = this.projectionInflight.get(sessionId);
261
261
  if (existing !== undefined)
262
262
  return existing.promise;
263
- const previous = this.catalogs.get(parentSessionId);
264
- const expandableRows = new Set();
265
- const activityRows = new Map();
266
- this.catalogs.set(parentSessionId, {
267
- entries: previous?.entries ?? [],
268
- ...(previous?.parentAvailable === undefined
269
- ? {}
270
- : { parentAvailable: previous.parentAvailable }),
271
- state: 'loading',
272
- error: null,
273
- });
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 });
274
269
  this.notifier.markDirty();
275
270
  const operation = (async () => {
276
271
  try {
277
- 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;
278
275
  if (result.ok) {
279
- const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
280
- ?? result.value.parentAvailable;
281
- this.catalogs.set(parentSessionId, {
282
- ...result.value,
283
- entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
284
- parentAvailable,
285
- state: 'ready',
286
- error: null,
287
- });
288
- for (const [childId, address] of this.addresses) {
289
- if (address.parentSessionId !== parentSessionId)
290
- continue;
291
- 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();
292
282
  }
283
+ this.projectionLoads.set(sessionId, { state: 'ready', error: null });
293
284
  }
294
285
  else {
295
- this.catalogs.set(parentSessionId, {
296
- entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
297
- ...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
298
- ?? previous?.parentAvailable),
299
- state: 'error',
300
- error: result.error,
301
- });
286
+ this.projectionLoads.set(sessionId, { state: 'error', error: result.error });
302
287
  }
303
288
  }
304
289
  catch (error) {
290
+ if (controller.signal.aborted)
291
+ return;
305
292
  if (!isRemoteFailure(error))
306
293
  throw error;
307
- this.catalogs.set(parentSessionId, {
308
- entries: this.withCatalogMutations(previous?.entries ?? [], expandableRows, activityRows),
309
- ...catalogAvailability(this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
310
- ?? previous?.parentAvailable),
311
- state: 'error',
312
- error,
313
- });
294
+ this.projectionLoads.set(sessionId, { state: 'error', error });
314
295
  }
315
296
  finally {
316
- this.catalogInflight.delete(parentSessionId);
317
- // Re-arm the trailing pull before the dirty notify: the response the
318
- // caller observed predates the stale-marking change, so the follow-up
319
- // refresh is the only carrier of that change.
320
- if (this.catalogStale.delete(parentSessionId))
321
- void this.refreshSubagents(parentSessionId);
322
- this.notifier.markDirty();
297
+ if (!controller.signal.aborted) {
298
+ this.projectionInflight.delete(sessionId);
299
+ this.notifier.markDirty();
300
+ }
323
301
  }
324
302
  })();
325
- this.catalogInflight.set(parentSessionId, {
326
- promise: operation,
327
- expandableRows,
328
- activityRows,
329
- parentAvailableOverride: undefined,
330
- });
303
+ this.projectionInflight.set(sessionId, { promise: operation, controller });
331
304
  return operation;
332
305
  }
333
- /**
334
- * Mark whether a catalog menu is consuming live membership updates.
335
- * @param parentSessionId - catalog owner.
336
- * @param open - current menu state.
337
- */
338
- setSubagentCatalogOpen(parentSessionId, open) {
339
- if (open) {
340
- this.openCatalogs.add(parentSessionId);
341
- void this.refreshSubagents(parentSessionId);
342
- }
343
- else {
344
- this.openCatalogs.delete(parentSessionId);
345
- const timer = this.catalogDebounce.get(parentSessionId);
346
- if (timer !== undefined) {
347
- clearTimeout(timer);
348
- this.catalogDebounce.delete(parentSessionId);
349
- }
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);
350
315
  }
351
316
  }
352
317
  // ---- List API ----
353
- /** Full refresh via session.list (single-flight: an in-flight call is reused). */
318
+ /** Full refresh via session.list (single-flight within one Host generation). */
354
319
  refreshList() {
355
320
  if (this.listInflight !== null)
356
321
  return this.listInflight;
@@ -363,52 +328,46 @@ export class SessionManager {
363
328
  this.listInflight = (async () => {
364
329
  try {
365
330
  const result = await this.remote.session.list({});
331
+ if (this.listMutations !== mutations)
332
+ return;
366
333
  if (result.ok) {
367
334
  const baseline = this.listPhase === 'pending'
368
335
  ? [...result.value.items]
369
336
  : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId);
370
- // Seed first observations from the pull-time baseline BEFORE replaying
371
- // in-flight mutations, then reconcile the reminders after EVERY
372
- // replayed mutation: an edge that happens entirely between mutations
373
- // (baseline idle → running → idle) must still arm, which a single
374
- // sync on the folded result would collapse away.
375
- for (const s of baseline) {
376
- if (!this.prevRunning.has(s.sessionId))
377
- this.prevRunning.set(s.sessionId, s.running);
378
- }
379
- let summaries = baseline;
337
+ // A removal supersedes running observed in the pull, including after re-addition.
338
+ const removedSincePull = new Set();
380
339
  for (const mutation of mutations) {
381
- summaries = applyMutation(summaries, mutation);
382
- this.summaries = summaries;
383
- this.syncCompletedNotifications();
340
+ if (mutation.kind === 'remove')
341
+ removedSincePull.add(mutation.sessionId);
384
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);
385
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);
386
353
  this.listState = 'idle';
387
354
  this.listPhase = 'ready';
388
- // Covers the empty-mutations pull (a plain baseline carries no edge).
389
- this.syncCompletedNotifications();
390
- // 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.
391
357
  for (const s of this.summaries) {
392
358
  const session = this.sessions.get(s.sessionId);
393
359
  if (session === undefined)
394
360
  continue;
395
- session.handleBlank(s.blank);
361
+ session.handleBlank(this.effectiveBlank(s));
396
362
  session.handleRunning(s.running);
397
363
  }
398
- // Seed each row's projection baseline into the per-session value
399
- // store (cold titles surface without opening the session). Per-key
400
- // apply, not seed(): the list block is a partial baseline — the
401
- // cold cache serves only version-matching keys — so an absent key
402
- // must not clear; higher-seq-wins still keeps a stale list block
403
- // 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.
404
368
  for (const s of result.value.items) {
405
- const block = s.projections;
406
- if (block === undefined)
407
- continue;
408
- const store = this.projectionStore(s.sessionId);
409
- const values = block.values;
410
- for (const key of Object.keys(values))
411
- store.apply(key, values[key], sessionSeqCursor(block.asOfSeq));
369
+ if (s.projections !== undefined)
370
+ this.applyListBlock(s.sessionId, s.projections);
412
371
  }
413
372
  }
414
373
  else {
@@ -419,13 +378,17 @@ export class SessionManager {
419
378
  catch (error) {
420
379
  if (!isRemoteFailure(error))
421
380
  throw error;
381
+ if (this.listMutations !== mutations)
382
+ return;
422
383
  this.listState = 'error';
423
384
  this.listError = error;
424
385
  }
425
386
  finally {
426
- this.listMutations = null;
427
- this.listInflight = null;
428
- this.notifier.markDirty();
387
+ if (this.listMutations === mutations) {
388
+ this.listMutations = null;
389
+ this.listInflight = null;
390
+ this.notifier.markDirty();
391
+ }
429
392
  }
430
393
  })();
431
394
  return this.listInflight;
@@ -463,7 +426,7 @@ export class SessionManager {
463
426
  : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared };
464
427
  const result = await this.remote.session.create(payload);
465
428
  if (result.ok) {
466
- this.recordMutation({ kind: 'upsert', summary: {
429
+ this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
467
430
  sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
468
431
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
469
432
  } });
@@ -474,7 +437,7 @@ export class SessionManager {
474
437
  // so expose it immediately as Ungrouped while the caller keeps the
475
438
  // prompt buffer and decides whether to retry attachment.
476
439
  if (publishedSessionId !== undefined) {
477
- this.recordMutation({ kind: 'upsert', summary: {
440
+ this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
478
441
  sessionId: publishedSessionId,
479
442
  updatedAt: Date.now(),
480
443
  running: false,
@@ -486,11 +449,12 @@ export class SessionManager {
486
449
  }
487
450
  /**
488
451
  * Contract session.fork; on success merge the child into summaries
489
- * immediately (same synchronous-addressability guarantee as create). The
490
- * child carries the source's history, so it is never blank; lineage rides
491
- * parentSessionId so the list nests it under its source. A child published
492
- * before Workspace attachment fails is also reconciled into the list.
493
- * @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.
494
458
  * @returns the fork result (the child session id).
495
459
  */
496
460
  async fork(opts) {
@@ -503,8 +467,8 @@ export class SessionManager {
503
467
  ? result.value.sessionId
504
468
  : workspaceAttachSessionId(result.error);
505
469
  if (childId !== undefined) {
506
- this.recordMutation({ kind: 'upsert', summary: {
507
- 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,
508
472
  parentSessionId: opts.sessionId,
509
473
  ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
510
474
  } });
@@ -512,20 +476,32 @@ export class SessionManager {
512
476
  return result;
513
477
  }
514
478
  /**
515
- * Insert-or-enrich a locally synthesized summary: a new id prepends; an
516
- * existing entry only gains fields it lacks (the session-added frame and the
517
- * create() echo race — whichever lands second must fill the placeholder's
518
- * 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.
519
494
  */
520
495
  mergeSummary(summary) {
521
496
  this.recordMutation({ kind: 'upsert', summary });
497
+ this.updateParentAvailability();
522
498
  }
523
499
  /** Apply immediately and retain for replay when a list response is in flight. */
524
500
  recordMutation(mutation) {
501
+ if (this.disposed)
502
+ return;
525
503
  this.listMutations?.push(mutation);
526
504
  this.summaries = applyMutation(this.summaries, mutation);
527
- // Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames.
528
- this.syncCompletedNotifications();
529
505
  this.notifier.markDirty();
530
506
  }
531
507
  // ---- Subscription API (for useSessionList) ----
@@ -545,6 +521,14 @@ export class SessionManager {
545
521
  this.notifier.ensureFresh();
546
522
  return this.listSnapshotCache;
547
523
  }
524
+ /**
525
+ * Read cached projection values for a Session that may exist only in a loaded subagent catalog.
526
+ * @param sessionId - Session whose control or history baseline supplied projections.
527
+ * @returns current values, or undefined before any projection store exists.
528
+ */
529
+ projectionValues(sessionId) {
530
+ return this.projectionStores.get(sessionId)?.values();
531
+ }
548
532
  // ---- Live control and Host-event sinks ----
549
533
  /**
550
534
  * Apply a complete control baseline or one later replacement frame.
@@ -555,41 +539,15 @@ export class SessionManager {
555
539
  this.replaceControlBaseline(frame.value);
556
540
  return;
557
541
  }
558
- if (frame.type === 'projection') {
559
- this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq));
560
- this.notifier.markDirty();
561
- return;
562
- }
563
- if (frame.type === 'jobs') {
564
- if (frame.jobs.length === 0)
565
- this.jobsBySession.delete(frame.sessionId);
566
- else
567
- this.jobsBySession.set(frame.sessionId, frame.jobs);
568
- this.notifier.markDirty();
569
- return;
570
- }
571
- this.queues.set(frame.sessionId, frame.items);
572
- this.sessions.get(frame.sessionId)?.handleControlFrame(frame);
542
+ this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq));
543
+ this.notifier.markDirty();
573
544
  }
574
545
  replaceControlBaseline(baseline) {
575
- this.queues.clear();
576
- for (const [sessionId, items] of Object.entries(baseline.queues)) {
577
- this.queues.set(sessionId, items);
578
- }
579
- this.jobsBySession.clear();
580
- for (const [sessionId, jobs] of Object.entries(baseline.jobs)) {
581
- if (jobs.length > 0)
582
- this.jobsBySession.set(sessionId, jobs);
583
- }
584
546
  for (const [sessionId, block] of Object.entries(baseline.projections)) {
585
547
  const store = this.projectionStore(sessionId);
586
548
  const asOfSeq = sessionSeqCursor(block.asOfSeq);
587
- store.truncate(asOfSeq);
588
549
  store.seed({ ...block, asOfSeq });
589
550
  }
590
- for (const [sessionId, session] of this.sessions) {
591
- session.replaceControl(this.queues.get(sessionId) ?? []);
592
- }
593
551
  this.notifier.markDirty();
594
552
  }
595
553
  /**
@@ -598,20 +556,34 @@ export class SessionManager {
598
556
  */
599
557
  handleSessionAdded(summary) {
600
558
  this.mergeSummary(summary);
601
- this.sessions.get(summary.sessionId)?.handleBlank(summary.blank);
602
- const projections = summary.projections;
603
- if (projections !== undefined) {
604
- const store = this.projectionStore(summary.sessionId);
605
- for (const [key, value] of Object.entries(projections.values)) {
606
- 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;
607
581
  }
608
- }
609
- if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) {
610
- this.markCatalogParentExpandable(summary.parentSessionId);
611
- }
612
- if (summary.parentSessionId !== undefined
613
- && (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) {
614
- 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');
615
587
  }
616
588
  }
617
589
  /**
@@ -619,29 +591,23 @@ export class SessionManager {
619
591
  * @param sessionId - removed Session identity.
620
592
  */
621
593
  handleSessionRemoved(sessionId) {
622
- const summary = this.summaries.find(candidate => candidate.sessionId === sessionId);
623
- 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');
624
596
  this.recordMutation(durableSubagent
625
- ? { kind: 'status', sessionId, running: false }
597
+ ? { kind: 'status', sessionId, running: false, agentAvailable: false }
626
598
  : { kind: 'remove', sessionId });
627
- this.updateCatalogActivity(sessionId, false);
628
599
  if (durableSubagent)
629
600
  this.sessions.get(sessionId)?.handleRunning(false);
630
601
  else
631
602
  this.sessions.get(sessionId)?.handleRemoved();
632
- this.queues.delete(sessionId);
633
- this.jobsBySession.delete(sessionId);
634
- if (!durableSubagent)
603
+ const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog;
604
+ if (!durableSubagent && (catalog === undefined || catalog.length === 0)) {
635
605
  this.projectionStores.delete(sessionId);
636
- const inflightCatalog = this.catalogInflight.get(sessionId);
637
- if (inflightCatalog !== undefined) {
638
- inflightCatalog.parentAvailableOverride = false;
639
- this.catalogStale.add(sessionId);
640
- }
641
- const ownedCatalog = this.catalogs.get(sessionId);
642
- if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
643
- this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false });
644
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);
645
611
  for (const [childId, address] of this.addresses) {
646
612
  if (address.parentSessionId === sessionId) {
647
613
  this.sessions.get(childId)?.handleSubagentParentAvailable(false);
@@ -654,9 +620,11 @@ export class SessionManager {
654
620
  * @param running - current Agent running state.
655
621
  */
656
622
  handleSessionStatus(sessionId, running) {
657
- 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();
658
627
  this.sessions.get(sessionId)?.handleRunning(running);
659
- this.updateCatalogActivity(sessionId, running);
660
628
  }
661
629
  /**
662
630
  * Advance Session-list activity from one user-authored durable message.
@@ -676,128 +644,28 @@ export class SessionManager {
676
644
  }
677
645
  /**
678
646
  * Repair one re-established Host-event generation with queryable baselines.
647
+ * Discard old projection cuts before new queries, including cold Sessions
648
+ * absent from the process-local control baseline.
679
649
  * Opened Session follow streams resume independently through API Gateway.
680
650
  */
681
651
  handleConnected() {
652
+ for (const store of this.projectionStores.values())
653
+ store.clear();
654
+ this.listMutations = null;
655
+ this.listInflight = null;
682
656
  void this.refreshList();
683
- const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected);
684
- if (selectedAddress !== undefined)
685
- void this.refreshSubagents(selectedAddress.parentSessionId);
686
- if (this.selected !== undefined)
687
- void this.refreshSubagents(this.selected);
688
- for (const parentSessionId of this.openCatalogs)
689
- void this.refreshSubagents(parentSessionId);
690
- }
691
- /** Debounce membership refetches while one parent catalog is selected or open. */
692
- scheduleCatalogRefresh(parentSessionId) {
693
- if (this.catalogDebounce.has(parentSessionId))
694
- return;
695
- const timer = setTimeout(() => {
696
- this.catalogDebounce.delete(parentSessionId);
697
- // The in-flight response predates the membership frame that scheduled
698
- // this callback. Queue one post-settlement pull instead of treating an
699
- // ordinary overlapping read as evidence that catalog membership changed.
700
- if (this.catalogInflight.has(parentSessionId)) {
701
- this.catalogStale.add(parentSessionId);
702
- return;
703
- }
704
- void this.refreshSubagents(parentSessionId);
705
- }, 50);
706
- this.catalogDebounce.set(parentSessionId, timer);
707
- }
708
- /** Apply one Agent-driver transition to loaded and in-flight catalogs. */
709
- updateCatalogActivity(childSessionId, running) {
710
- const activity = running ? 'running' : 'inactive';
711
- for (const inflight of this.catalogInflight.values()) {
712
- inflight.activityRows.set(childSessionId, activity);
713
- }
714
- let changed = false;
715
- for (const [parentSessionId, catalog] of this.catalogs) {
716
- if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity))
717
- continue;
718
- const entries = catalog.entries.map((entry) => {
719
- if (entry.kind !== 'child' || entry.id !== childSessionId)
720
- return entry;
721
- return { ...entry, activity };
722
- });
723
- changed = true;
724
- this.catalogs.set(parentSessionId, { ...catalog, entries });
725
- }
726
- if (changed)
727
- this.notifier.markDirty();
728
- }
729
- /** Preserve and project a positive expandability hint after one direct subagent publishes. */
730
- markCatalogParentExpandable(parentSessionId) {
731
- this.applyCatalogParentExpandable(parentSessionId);
732
- for (const inflight of this.catalogInflight.values())
733
- inflight.expandableRows.add(parentSessionId);
734
- }
735
- /** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
736
- applyCatalogParentExpandable(parentSessionId) {
737
- let changed = false;
738
- for (const [catalogParentId, catalog] of this.catalogs) {
739
- if (!catalog.entries.some(entry => entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren))
740
- continue;
741
- const entries = catalog.entries.map((entry) => {
742
- if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren)
743
- return entry;
744
- return { ...entry, hasChildren: true };
745
- });
746
- changed = true;
747
- this.catalogs.set(catalogParentId, { ...catalog, entries });
748
- }
749
- if (changed)
750
- this.notifier.markDirty();
751
- }
752
- /** Fold request-local row mutations into one catalog result before publication. */
753
- withCatalogMutations(entries, expandableRows, activityRows) {
754
- return entries.map((entry) => {
755
- if (entry.kind !== 'child')
756
- return entry;
757
- const activity = activityRows.get(entry.id);
758
- if (!expandableRows.has(entry.id) && activity === undefined)
759
- return entry;
760
- return {
761
- ...entry,
762
- ...expandableRows.has(entry.id) ? { hasChildren: true } : {},
763
- ...activity === undefined ? {} : { activity },
764
- };
765
- });
766
- }
767
- /**
768
- * Reconcile completion reminders against the latest summaries, eagerly after
769
- * every mutation and pull (a snapshot-build-time pass would collapse
770
- * consecutive status frames into one observation). A running→idle edge of a
771
- * non-selected session arms its reminder; running disarms it; removal drops
772
- * it. First observation only records the running bit — sessions already
773
- * idle at load get no reminder.
774
- */
775
- syncCompletedNotifications() {
776
- const seen = new Set();
777
- for (const s of this.summaries) {
778
- seen.add(s.sessionId);
779
- const prev = this.prevRunning.get(s.sessionId);
780
- if (prev === undefined) {
781
- this.prevRunning.set(s.sessionId, s.running);
782
- continue;
783
- }
784
- if (prev && !s.running) {
785
- if (s.sessionId !== this.selected)
786
- this.completedNotifications.add(s.sessionId);
787
- }
788
- else if (s.running) {
789
- this.completedNotifications.delete(s.sessionId);
790
- }
791
- this.prevRunning.set(s.sessionId, s.running);
792
- }
793
- for (const id of this.prevRunning.keys()) {
794
- if (!seen.has(id))
795
- this.prevRunning.delete(id);
796
- }
797
- for (const id of this.completedNotifications) {
798
- if (!seen.has(id))
799
- this.completedNotifications.delete(id);
800
- }
657
+ const parents = new Set(this.projectionLoads.keys());
658
+ for (const id of this.sessions.keys()) {
659
+ const address = this.addresses.get(id);
660
+ if (address !== undefined)
661
+ parents.add(address.parentSessionId);
662
+ }
663
+ for (const { controller } of this.projectionInflight.values())
664
+ controller.abort();
665
+ this.projectionInflight.clear();
666
+ this.projectionLoads.clear();
667
+ for (const parentSessionId of parents)
668
+ void this.refreshProjections(parentSessionId);
801
669
  }
802
670
  buildListSnapshot() {
803
671
  const merged = this.summaries.map((summary) => {
@@ -806,53 +674,53 @@ export class SessionManager {
806
674
  const projectionStore = this.projectionStores.get(summary.sessionId);
807
675
  const title = projectionStore?.get('title');
808
676
  const projectionValues = projectionStore?.values();
677
+ const metadata = projectionValues?.sessionListMetadata;
809
678
  return {
810
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),
811
683
  ...(typeof title === 'string' && title !== '' ? { title } : {}),
812
684
  ...(projectionValues === undefined ? {} : { projectionValues }),
813
685
  };
814
686
  });
815
- const fresh = flattenLineage(merged, this.completedNotifications);
687
+ const fresh = flattenLineage(merged);
816
688
  const items = fresh.map((entry) => {
817
689
  const prev = this.entryCache.get(entry.sessionId);
818
690
  if (prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
819
691
  && prev.blank === entry.blank
820
692
  && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
821
693
  && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
822
- && prev.projectionValues === entry.projectionValues
823
- && prev.completed === entry.completed)
694
+ && prev.projectionValues === entry.projectionValues)
824
695
  return prev;
825
696
  this.entryCache.set(entry.sessionId, entry);
826
697
  return entry;
827
698
  });
699
+ const itemIds = new Set(items.map(entry => entry.sessionId));
828
700
  for (const id of this.entryCache.keys()) {
829
- if (!items.some(e => e.sessionId === id))
701
+ if (!itemIds.has(id))
830
702
  this.entryCache.delete(id);
831
703
  }
832
704
  const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]);
833
705
  if (!sameOrder)
834
706
  this.itemsCache = items;
835
- const selected = this.selected;
836
- const current = selected !== undefined
837
- && (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
838
- ? selected
839
- : undefined;
840
707
  return {
841
708
  items: this.itemsCache,
842
- current,
843
709
  state: this.listState,
844
710
  phase: this.listPhase,
845
711
  error: this.listError,
846
- subagentsByParent: Object.fromEntries(this.catalogs),
847
- jobsBySession: Object.fromEntries(this.jobsBySession),
848
- currentAddress: current === undefined ? undefined : this.addresses.get(current),
712
+ projectionsBySession: Object.fromEntries([...this.projectionStores].map(([sessionId, store]) => [
713
+ sessionId,
714
+ { values: store.values(), state: 'idle', error: null, ...this.projectionLoads.get(sessionId) },
715
+ ])),
849
716
  };
850
717
  }
851
718
  }
852
719
  /** Apply one list mutation without deriving display order. */
853
720
  function applyMutation(summaries, mutation) {
854
721
  switch (mutation.kind) {
855
- case 'upsert': {
722
+ case 'upsert':
723
+ case 'placeholder': {
856
724
  const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId);
857
725
  if (existing === undefined)
858
726
  return [mutation.summary, ...summaries];
@@ -861,6 +729,10 @@ function applyMutation(summaries, mutation) {
861
729
  // Blank only lowers: a stale true (session-added racing the local
862
730
  // first send) never re-hides an already-surfaced session.
863
731
  blank: existing.blank && mutation.summary.blank,
732
+ ...(mutation.kind === 'upsert' ? {
733
+ agentAvailable: mutation.summary.agentAvailable,
734
+ running: mutation.summary.running,
735
+ } : {}),
864
736
  ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
865
737
  ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
866
738
  ? { parentSessionId: mutation.summary.parentSessionId } : {}),
@@ -868,18 +740,19 @@ function applyMutation(summaries, mutation) {
868
740
  ? { origin: mutation.summary.origin } : {}),
869
741
  };
870
742
  if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
871
- && 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)
872
745
  return [...summaries];
873
746
  return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary);
874
747
  }
875
748
  case 'remove':
876
749
  return summaries.filter(summary => summary.sessionId !== mutation.sessionId);
877
750
  case 'status':
878
- // running:true doubles as the cross-client blank flip (a blank session
879
- // never runs, so the first running frame proves a message landed).
751
+ // Running converts display state; it does not establish durable history.
880
752
  return summaries.map(summary => summary.sessionId === mutation.sessionId
881
- && (summary.running !== mutation.running || (mutation.running && summary.blank))
882
- ? { ...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 }
883
756
  : summary);
884
757
  case 'activity':
885
758
  return summaries.map(summary => summary.sessionId === mutation.sessionId