@crazx/dsh-api-session-controller 0.1.2-alpha.3.zw.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +74 -0
  4. package/README.zh.md +74 -0
  5. package/lib/client.js +2724 -0
  6. package/lib/index.js +2826 -0
  7. package/lib/invariant.js +13 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +2574 -0
  10. package/lib/typert.remote-client.d.ts +67 -0
  11. package/lib/typert.remote-client.js +1119 -0
  12. package/lib/types/agent.d.ts +156 -0
  13. package/lib/types/agent.js +537 -0
  14. package/lib/types/catalog.d.ts +11 -0
  15. package/lib/types/catalog.js +58 -0
  16. package/lib/types/client/contract/events.d.ts +71 -0
  17. package/lib/types/client/contract/events.js +91 -0
  18. package/lib/types/client/contract/session.d.ts +150 -0
  19. package/lib/types/client/contract/session.js +2 -0
  20. package/lib/types/client/contract/sessions.d.ts +125 -0
  21. package/lib/types/client/contract/sessions.js +2 -0
  22. package/lib/types/client/contract/snapshot.d.ts +82 -0
  23. package/lib/types/client/contract/snapshot.js +2 -0
  24. package/lib/types/client/index.d.ts +30 -0
  25. package/lib/types/client/index.js +48 -0
  26. package/lib/types/client/ordered-baseline.d.ts +12 -0
  27. package/lib/types/client/ordered-baseline.js +41 -0
  28. package/lib/types/client/scope.d.ts +36 -0
  29. package/lib/types/client/scope.js +54 -0
  30. package/lib/types/client/sessions/history-records.d.ts +22 -0
  31. package/lib/types/client/sessions/history-records.js +31 -0
  32. package/lib/types/client/sessions/lineage.d.ts +38 -0
  33. package/lib/types/client/sessions/lineage.js +56 -0
  34. package/lib/types/client/sessions/manager.d.ts +280 -0
  35. package/lib/types/client/sessions/manager.js +894 -0
  36. package/lib/types/client/sessions/notifier.d.ts +39 -0
  37. package/lib/types/client/sessions/notifier.js +98 -0
  38. package/lib/types/client/sessions/projection-store.d.ts +108 -0
  39. package/lib/types/client/sessions/projection-store.js +129 -0
  40. package/lib/types/client/sessions/queue-mirror.d.ts +26 -0
  41. package/lib/types/client/sessions/queue-mirror.js +61 -0
  42. package/lib/types/client/sessions/remotes.d.ts +30 -0
  43. package/lib/types/client/sessions/remotes.js +8 -0
  44. package/lib/types/client/sessions/service.d.ts +349 -0
  45. package/lib/types/client/sessions/service.js +574 -0
  46. package/lib/types/client/sessions/session.d.ts +294 -0
  47. package/lib/types/client/sessions/session.js +711 -0
  48. package/lib/types/client/time-zone.d.ts +8 -0
  49. package/lib/types/client/time-zone.js +14 -0
  50. package/lib/types/client/transport.d.ts +73 -0
  51. package/lib/types/client/transport.js +106 -0
  52. package/lib/types/commands.d.ts +69 -0
  53. package/lib/types/commands.js +544 -0
  54. package/lib/types/control.d.ts +23 -0
  55. package/lib/types/control.js +192 -0
  56. package/lib/types/file-references.d.ts +27 -0
  57. package/lib/types/file-references.js +69 -0
  58. package/lib/types/history.d.ts +31 -0
  59. package/lib/types/history.js +376 -0
  60. package/lib/types/index.d.ts +171 -0
  61. package/lib/types/index.js +424 -0
  62. package/lib/types/invariant.d.ts +9 -0
  63. package/lib/types/invariant.js +12 -0
  64. package/lib/types/list.d.ts +53 -0
  65. package/lib/types/list.js +405 -0
  66. package/lib/types/model-selection-projection.d.ts +8 -0
  67. package/lib/types/model-selection-projection.js +66 -0
  68. package/lib/types/remote-events.d.ts +8 -0
  69. package/lib/types/remote-events.js +2 -0
  70. package/lib/types/skill-catalog.d.ts +28 -0
  71. package/lib/types/skill-catalog.js +192 -0
  72. package/lib/types/types.d.ts +505 -0
  73. package/lib/types/types.js +6 -0
  74. package/package.json +154 -0
@@ -0,0 +1,574 @@
1
+ import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path';
2
+ import { SESSION_SEARCH_RESULT_LIMIT } from "../../types.js";
3
+ import { createSnapshotStore, } from '@deepseek-ai/dsh-client-store';
4
+ import { createScope, scopeOf as scopeTagOf } from "../scope.js";
5
+ import { SessionManager } from "./manager.js";
6
+ /** Structured session-create failure. */
7
+ export class SessionCreateError extends Error {
8
+ rpcError;
9
+ requestedSessionId;
10
+ name = 'SessionCreateError';
11
+ /**
12
+ * @param rpcError - Host business or folded transport error.
13
+ * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
14
+ */
15
+ constructor(rpcError, requestedSessionId) {
16
+ super(`session create failed: ${rpcError.code}: ${rpcError.message}`);
17
+ this.rpcError = rpcError;
18
+ this.requestedSessionId = requestedSessionId;
19
+ }
20
+ }
21
+ /** Structured session-fork failure. */
22
+ export class SessionForkError extends Error {
23
+ rpcError;
24
+ sourceSessionId;
25
+ name = 'SessionForkError';
26
+ /**
27
+ * @param rpcError - Host business or folded transport error.
28
+ * @param sourceSessionId - the session the fork was cut from.
29
+ */
30
+ constructor(rpcError, sourceSessionId) {
31
+ super(`session fork failed: ${rpcError.code}: ${rpcError.message}`);
32
+ this.rpcError = rpcError;
33
+ this.sourceSessionId = sourceSessionId;
34
+ }
35
+ }
36
+ // Scope primitives live in ../scope.ts (the client mirror of host
37
+ // dsh-scope, keyed by Agent identity); re-exported here so existing
38
+ // consumers keep their import site.
39
+ export { scopeOf } from "../scope.js";
40
+ /**
41
+ * Display title projection: durable title, project directory basename, then
42
+ * the raw id.
43
+ */
44
+ function displayTitleOf(title, cwd, id) {
45
+ if (title !== undefined)
46
+ return title;
47
+ if (cwd !== undefined && cwd !== '') {
48
+ const base = workspaceTitleOf(cwd);
49
+ if (base !== '')
50
+ return base;
51
+ }
52
+ return id;
53
+ }
54
+ /**
55
+ * Increment a trailing fork number while preserving its half-width or
56
+ * full-width parentheses; an unnumbered title starts with ` (1)`.
57
+ * @param title - source session's durable title.
58
+ * @returns the title assigned to the fork child.
59
+ */
60
+ function increasedForkTitle(title) {
61
+ const ascii = /^(.*?)\((\d+)\)$/u.exec(title);
62
+ if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
63
+ return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`;
64
+ }
65
+ const fullWidth = /^(.*?)((\d+))$/u.exec(title);
66
+ if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
67
+ return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`;
68
+ }
69
+ return `${title} (1)`;
70
+ }
71
+ /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
72
+ export class ClientSessions {
73
+ rootCtx;
74
+ /**
75
+ * The wire schema's own result bound, re-exposed for presentation plugins as
76
+ * injected data. Not per-connection state: the `session.search` response
77
+ * schema caps `items` at this constant, so every transport (fixture included)
78
+ * reports the same number.
79
+ */
80
+ searchResultLimit = SESSION_SEARCH_RESULT_LIMIT;
81
+ /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
82
+ list;
83
+ /** The object-layer instance cluster and frame dispatch entry. */
84
+ manager;
85
+ /**
86
+ * Persisted selection cell (the durable half of `list.current`). Private on
87
+ * purpose: reads go through the list snapshot; writes through {@link
88
+ * ClientSessions.open} / {@link ClientSessions.clear}. Projection
89
+ * validates it against the live list instead of destructively pruning, so a
90
+ * selection survives transient list states (reconnect re-pull) and
91
+ * resurfaces when its session returns.
92
+ */
93
+ selection;
94
+ scopes = new Map();
95
+ /** In-flight scope drops remain here after records leave `scopes`, so root disposal can await quiescence. */
96
+ scopeDrops = new Set();
97
+ /**
98
+ * The staged session id — follows `list.current` exactly, holding its last
99
+ * defined value across masked gaps (a transiently absent selection blanks
100
+ * `current` without moving the stage, so reconnect re-pulls and removals
101
+ * keep the staged scope's frozen view alive until the stage moves on).
102
+ */
103
+ watched;
104
+ /** Removed-while-staged sessions whose teardown waits for the stage to move away. */
105
+ deferredRemovals = new Set();
106
+ /**
107
+ * @param ctx - client root context (scope fibers mount under it).
108
+ * @param remote - generated Remote namespaces shared with every Session.
109
+ */
110
+ constructor(rootCtx, remote) {
111
+ this.rootCtx = rootCtx;
112
+ this.selection = createSnapshotStore({}, { persist: { name: 'dsh.sessions.current' } });
113
+ const restored = this.selection.getSnapshot();
114
+ this.manager = new SessionManager(remote, restored.sessionId, restored.subagentAddress);
115
+ this.list = createSnapshotStore({
116
+ ids: [], byId: {}, current: undefined, phase: 'pending',
117
+ subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
118
+ });
119
+ // The manager owns wire truth; the store is its projection. Manager
120
+ // notifications are already microtask-batched.
121
+ const disposeManagerProjection = this.manager.subscribe(() => {
122
+ this.projectList();
123
+ });
124
+ // Stage follower: every current write (open() and projection alike)
125
+ // re-evaluates staging, so startup restore (persisted selection validated
126
+ // by the projection) and reconnect resurfacing open their window with no
127
+ // dedicated code path. Safe to run synchronously inside the store notify:
128
+ // the follower writes no list state — session.open()'s synchronous prefix
129
+ // touches only session-side state and its own microtask-batched notifier.
130
+ const disposeStageFollower = this.list.subscribe(() => {
131
+ this.followCurrent();
132
+ });
133
+ rootCtx.effect(() => async () => {
134
+ disposeStageFollower();
135
+ disposeManagerProjection();
136
+ const scopes = [...this.scopes];
137
+ this.scopes.clear();
138
+ this.deferredRemovals.clear();
139
+ this.watched = undefined;
140
+ for (const [id, record] of scopes)
141
+ this.startScopeDrop(id, record);
142
+ await this.drainScopeDrops();
143
+ await this.manager.dispose();
144
+ }, 'session-controller.client.sessions');
145
+ rootCtx.reflect.provide('sessions', this, undefined);
146
+ }
147
+ /**
148
+ * Select a listed or retained catalog-addressed session as current.
149
+ * @param id - listed or addressed session id.
150
+ */
151
+ open(id) {
152
+ this.manager.select(id);
153
+ }
154
+ /**
155
+ * Open a healthy catalog child through its direct-parent address.
156
+ * @param address - catalog-derived parent and child ids.
157
+ */
158
+ openSubagent(address) {
159
+ this.manager.selectSubagent(address);
160
+ }
161
+ /**
162
+ * Resolve an already discovered direct-parent address without opening it.
163
+ * Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
164
+ * @param id - possible addressed child id.
165
+ * @returns The retained address, when present.
166
+ */
167
+ subagentAddress(id) {
168
+ return this.manager.subagentAddress(id);
169
+ }
170
+ /**
171
+ * Inform the Session Controller whether a catalog menu is consuming membership updates.
172
+ * @param parentSessionId - selected parent.
173
+ * @param open - menu state.
174
+ */
175
+ setSubagentCatalogOpen(parentSessionId, open) {
176
+ this.manager.setSubagentCatalogOpen(parentSessionId, open);
177
+ }
178
+ /**
179
+ * Refresh one direct-child catalog.
180
+ * @param parentSessionId - catalog owner.
181
+ */
182
+ refreshSubagents(parentSessionId) {
183
+ return this.manager.refreshSubagents(parentSessionId);
184
+ }
185
+ /**
186
+ * Clear the current selection so the layout shows the no-session empty
187
+ * state (new-session affordance and the workspace preselection flow).
188
+ * Wipes the persisted selection too — a reload stays on empty until the
189
+ * user opens or starts a session. The staged scope keeps its frozen view
190
+ * per the masked-gap contract until the next open() moves the stage.
191
+ */
192
+ clear() {
193
+ this.manager.clearSelection();
194
+ }
195
+ /**
196
+ * Refresh the real Session baseline, reusing an in-flight pull.
197
+ * @returns completion of the current or newly started baseline pull.
198
+ */
199
+ refresh() {
200
+ return this.manager.refreshList();
201
+ }
202
+ /**
203
+ * Search the Host's visible message-content index. Results stay
204
+ * request-local; the list snapshot remains the metadata authority.
205
+ * @param query - non-blank literal phrase.
206
+ * @param signal - cancellation for a superseded search.
207
+ * @returns bounded results or a business/transport error.
208
+ */
209
+ search(query, signal) {
210
+ return this.manager.search(query, signal);
211
+ }
212
+ /**
213
+ * Apply one Session Controller live-control frame.
214
+ * @param frame - baseline or live control replacement.
215
+ */
216
+ handleControlFrame(frame) {
217
+ this.manager.handleControlFrame(frame);
218
+ }
219
+ /**
220
+ * Apply one remotely forwarded Session-list addition.
221
+ * @param summary - current Host summary for the added Session.
222
+ */
223
+ handleSessionAdded(summary) {
224
+ this.manager.handleSessionAdded(summary);
225
+ }
226
+ /**
227
+ * Apply one remotely forwarded Session removal.
228
+ * @param sessionId - removed Session identity.
229
+ */
230
+ handleSessionRemoved(sessionId) {
231
+ this.manager.handleSessionRemoved(sessionId);
232
+ }
233
+ /**
234
+ * Apply one remotely forwarded running-state change.
235
+ * @param args - Session identity and current Agent running state.
236
+ */
237
+ handleSessionStatus(...args) {
238
+ this.manager.handleSessionStatus(...args);
239
+ }
240
+ /**
241
+ * Apply one remotely forwarded list-activity change.
242
+ * @param args - Session identity and durable activity timestamp.
243
+ */
244
+ handleSessionActivity(...args) {
245
+ this.manager.handleSessionActivity(...args);
246
+ }
247
+ /**
248
+ * Apply one remotely forwarded Agent failure.
249
+ * @param args - Session identity and caller-visible failure description.
250
+ */
251
+ handleSessionError(...args) {
252
+ this.manager.handleSessionError(...args);
253
+ }
254
+ /** Rebuild the Session baseline and every opened window after connection. */
255
+ handleConnected() {
256
+ this.manager.handleConnected();
257
+ }
258
+ /**
259
+ * Create a session on the host. Resolution guarantee: by the time the
260
+ * promise resolves, the created session is in the list store and
261
+ * {@link ClientSessions.binding} resolves it — callers (New Session
262
+ * draft hand-off) may address the scope synchronously, without waiting a
263
+ * notifier flush. The synchronous projection below makes this structural
264
+ * rather than an accident of microtask ordering.
265
+ * @param opts - target workspace or directory and an optional preallocated id.
266
+ * @returns the new session id.
267
+ * @throws {SessionCreateError} with the requested id.
268
+ */
269
+ async create(opts = {}) {
270
+ const result = await this.manager.create(opts);
271
+ if (!result.ok)
272
+ throw new SessionCreateError(result.error, opts.sessionId);
273
+ this.projectList();
274
+ return result.value.sessionId;
275
+ }
276
+ /**
277
+ * Fork a session from a completed-turn prefix of the source (same
278
+ * synchronous-addressability guarantee as {@link ClientSessions.create}:
279
+ * on resolution the child is in the list store and open() can target it).
280
+ * @param opts - source session id, the optional event seq anchoring the
281
+ * cut (the boundary is the first turn/end at or after it; an in-log
282
+ * anchor in an open turn is unavailable rather than clipped backward),
283
+ * and whether to increment an inherited durable title before resolving.
284
+ * A fractional anchor floors to a real event seq: the frozen nodes of an
285
+ * interrupted turn carry flow-ordering seqs between two events, and the
286
+ * wire takes integers only.
287
+ * @returns the child session id.
288
+ * @throws {SessionForkError} with the source id.
289
+ * @throws {Error} when a requested child-title rename fails after creation.
290
+ */
291
+ async fork(opts) {
292
+ const sourceTitle = opts.increaseTitle
293
+ ? this.list.getSnapshot().byId[opts.sessionId]?.title
294
+ : undefined;
295
+ const result = await this.manager.fork({
296
+ sessionId: opts.sessionId,
297
+ // Flooring lands inside the anchor's own turn (every turn opens with a
298
+ // turn/start), so the host's first-turn/end-at-or-after cut still ends
299
+ // on that turn — never clipped back to the previous one.
300
+ ...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
301
+ });
302
+ if (!result.ok)
303
+ throw new SessionForkError(result.error, opts.sessionId);
304
+ this.projectList();
305
+ const childId = result.value.sessionId;
306
+ if (sourceTitle !== undefined) {
307
+ const child = this.binding(childId)?.session;
308
+ if (child === undefined)
309
+ throw new Error(`fork child "${childId}" is not locally addressable`);
310
+ const renamed = await child.rename(increasedForkTitle(sourceTitle));
311
+ if (!renamed.ok)
312
+ throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`);
313
+ }
314
+ return childId;
315
+ }
316
+ /**
317
+ * Resolve an Agent-scoped context view (use-and-discard).
318
+ * @param id - session id (the agent identity — 1:1 same axis).
319
+ * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
320
+ */
321
+ scope(id) {
322
+ return this.resolve(id)?.ctx;
323
+ }
324
+ /**
325
+ * Materialize the Agent scope named by a validated Host Remote Event.
326
+ * The first successful Session-list baseline becomes authoritative for its
327
+ * lifetime; until then, transport streams may address the scope in either
328
+ * arrival order.
329
+ * @param id - Host-projected Agent identity (the matching Session id).
330
+ * @returns the identity-stable Agent Context.
331
+ */
332
+ resolveAgentScope(id) {
333
+ return (this.scopes.get(id) ?? this.materializeScope(id)).ctx;
334
+ }
335
+ /**
336
+ * Read the Agent scope tag off a context. Service-method boundary: fetch
337
+ * bundles must reach scope resolution through ctx.sessions — a cross-bundle
338
+ * value import of the standalone helper would inline a second module
339
+ * instance whose private tag Symbol never matches.
340
+ * @param ctx - any client context.
341
+ * @returns the session id, or undefined on root contexts.
342
+ */
343
+ scopeOf(ctx) {
344
+ return scopeTagOf(ctx);
345
+ }
346
+ /**
347
+ * Resolve the business Session behind an Agent-scoped context — the one
348
+ * hop every scoped consumer (event listeners, per-session controllers)
349
+ * takes from ctx-space into object-space (the client mirror of host
350
+ * `agent.session`). Same service-method boundary as
351
+ * {@link ClientSessions.scopeOf}.
352
+ * @param ctx - an Agent-scoped context.
353
+ * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
354
+ */
355
+ sessionOf(ctx) {
356
+ const id = scopeTagOf(ctx);
357
+ if (id === undefined)
358
+ return undefined;
359
+ return this.scopes.get(id)?.binding.session;
360
+ }
361
+ /**
362
+ * Resolve the stable session binding (scope-addressed assembly feed). Pure
363
+ * resolution — no staging, no window side effects.
364
+ * @param id - session id.
365
+ * @returns binding, or undefined for a session neither listed nor already scoped.
366
+ */
367
+ binding(id) {
368
+ return this.resolve(id)?.binding;
369
+ }
370
+ /**
371
+ * Move the stage to the list's current session: sweep teardowns deferred
372
+ * behind the previous occupant and pull the new occupant's history window.
373
+ * Staging IS the open signal — the window opens ⟺ the session is on stage
374
+ * — and open() is idempotent (an in-flight or completed open no-ops; a
375
+ * failed one retries the next time current is touched).
376
+ */
377
+ followCurrent() {
378
+ const snapshot = this.list.getSnapshot();
379
+ const current = snapshot.current;
380
+ // A masked gap (current blanked while the selection's session is
381
+ // transiently absent) holds the stage: tearing down on the gap would
382
+ // destroy exactly the frozen scope the mask exists to preserve.
383
+ if (current === undefined || snapshot.byId[current] === undefined || current === this.watched)
384
+ return;
385
+ this.watched = current;
386
+ this.sweepDeferred();
387
+ const record = this.resolve(current);
388
+ /* v8 ignore next 3 -- defensive: current is always a listed id (open()
389
+ * validates and the projection masks absent selections), so resolve
390
+ * cannot miss; kept so a future current writer cannot crash the notify. */
391
+ if (record !== undefined) {
392
+ void record.session.open();
393
+ void this.manager.refreshSubagents(current);
394
+ }
395
+ }
396
+ /**
397
+ * Lazily mint the scope + binding for an eligible session. Eligibility and
398
+ * prune share one predicate: listed on the host or selected
399
+ * through a retained subagent address. Breadcrumb-only ancestors remain
400
+ * summary data and do not keep scopes alive.
401
+ */
402
+ resolve(id) {
403
+ const existing = this.scopes.get(id);
404
+ if (existing !== undefined)
405
+ return existing;
406
+ if (!this.eligible(id))
407
+ return undefined;
408
+ return this.materializeScope(id);
409
+ }
410
+ /** Materialize one scope after its caller establishes that the id may be addressed. */
411
+ materializeScope(id) {
412
+ const { fiber, ctx } = createScope(this.rootCtx, id);
413
+ const session = this.manager.get(id);
414
+ // The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
415
+ // mint and bind are one step so a live scope record implies a bound actx.
416
+ session.bindScope(ctx);
417
+ const binding = { sessionId: id, session, eventSource: session.eventSource, ctx };
418
+ const record = {
419
+ fiber,
420
+ ctx,
421
+ binding,
422
+ session,
423
+ };
424
+ this.scopes.set(id, record);
425
+ return record;
426
+ }
427
+ /** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
428
+ eligible(id) {
429
+ const { ids, current } = this.list.getSnapshot();
430
+ return current === id || ids.includes(id);
431
+ }
432
+ /** Project the manager's list snapshot into the store (title derivation is display-only). */
433
+ projectList() {
434
+ const { items, current, phase, subagentsByParent, jobsBySession, currentAddress, } = this.manager.getListSnapshot();
435
+ const ids = [];
436
+ const byId = {};
437
+ for (const entry of items) {
438
+ ids.push(entry.sessionId);
439
+ byId[entry.sessionId] = {
440
+ id: entry.sessionId,
441
+ displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
442
+ running: entry.running,
443
+ ...(entry.completed ? { completed: true } : {}),
444
+ blank: entry.blank,
445
+ updatedAt: entry.updatedAt,
446
+ ...(entry.projectionValues === undefined
447
+ ? {}
448
+ : { projectionValues: entry.projectionValues }),
449
+ ...(entry.title !== undefined ? { title: entry.title } : {}),
450
+ ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
451
+ ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
452
+ ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
453
+ };
454
+ }
455
+ if (current !== undefined && currentAddress !== undefined) {
456
+ const seen = new Set();
457
+ let address = currentAddress;
458
+ while (address !== undefined && !seen.has(address.childSessionId)) {
459
+ const childId = address.childSessionId;
460
+ seen.add(childId);
461
+ const child = subagentsByParent[address.parentSessionId]?.entries
462
+ .find(entry => entry.kind === 'child' && entry.id === childId);
463
+ if (child?.kind !== 'child')
464
+ break;
465
+ const displayTitle = child.label ?? childId;
466
+ const summary = byId[childId];
467
+ if (summary === undefined) {
468
+ byId[childId] = {
469
+ id: childId,
470
+ displayTitle,
471
+ parentId: address.parentSessionId,
472
+ origin: 'subagent',
473
+ running: child.activity === 'running',
474
+ blank: false,
475
+ updatedAt: 0,
476
+ };
477
+ }
478
+ else if (summary.displayTitle !== displayTitle) {
479
+ byId[childId] = { ...summary, displayTitle };
480
+ }
481
+ const parent = byId[address.parentSessionId];
482
+ if (parent !== undefined && parent.origin !== 'subagent')
483
+ break;
484
+ address = this.manager.navigationAddress(address.parentSessionId);
485
+ }
486
+ }
487
+ const persisted = this.selection.getSnapshot().sessionId;
488
+ // No current (cleared, or masked gap) wipes the persisted cell — a reload
489
+ // stays on empty; the in-memory selection still resurfaces a masked id.
490
+ if (current === undefined) {
491
+ if (persisted !== undefined)
492
+ this.selection.set({});
493
+ }
494
+ else if (byId[current] !== undefined
495
+ && (persisted !== current
496
+ || this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
497
+ || this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
498
+ || this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
499
+ this.selection.set({
500
+ sessionId: current,
501
+ ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
502
+ });
503
+ }
504
+ this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress });
505
+ this.pruneScopes();
506
+ }
507
+ /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
508
+ pruneScopes() {
509
+ if (this.list.getSnapshot().phase === 'pending')
510
+ return;
511
+ for (const [id, record] of this.scopes) {
512
+ if (this.eligible(id))
513
+ continue;
514
+ if (id === this.watched) {
515
+ this.deferredRemovals.add(id);
516
+ continue;
517
+ }
518
+ this.scopes.delete(id);
519
+ this.deferredRemovals.delete(id);
520
+ this.startScopeDrop(id, record);
521
+ }
522
+ }
523
+ startScopeDrop(id, record) {
524
+ const drop = this.dropScope(id, record);
525
+ this.scopeDrops.add(drop);
526
+ void drop.then(() => { this.scopeDrops.delete(drop); }, () => { this.scopeDrops.delete(drop); });
527
+ }
528
+ async drainScopeDrops() {
529
+ while (this.scopeDrops.size > 0) {
530
+ await Promise.allSettled([...this.scopeDrops]);
531
+ }
532
+ }
533
+ /**
534
+ * One teardown for the whole per-session axis: the scope
535
+ * fiber (cascading every actx-registered effect: input shell, slash
536
+ * controller, popup, plugin stores, listeners), the session-keyed slot
537
+ * registrations and the Session instance itself — the host session log is the
538
+ * durable truth, a reopen lazily rebuilds and backfills via open().
539
+ */
540
+ async dropScope(id, record) {
541
+ // Release the Session's dispatch point with the scope it belongs to (a
542
+ // surviving instance — the live Intent — rebinds when resolve re-mints).
543
+ record.session.unbindScope();
544
+ await Promise.allSettled([
545
+ record.fiber.dispose(),
546
+ this.manager.drop(id),
547
+ ]);
548
+ }
549
+ /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
550
+ sweepDeferred() {
551
+ for (const id of [...this.deferredRemovals]) {
552
+ /* v8 ignore next -- defensive: only the staged id ever defers, and every
553
+ * stage move sweeps first, so the set cannot contain the id the stage just
554
+ * moved to; kept as a guard against future extra sweep call sites. */
555
+ if (id === this.watched)
556
+ continue;
557
+ // Eligible again? (A re-added id cancels the deferred teardown.)
558
+ if (this.eligible(id)) {
559
+ this.deferredRemovals.delete(id);
560
+ continue;
561
+ }
562
+ const record = this.scopes.get(id);
563
+ this.deferredRemovals.delete(id);
564
+ /* v8 ignore next -- defensive: prune deletes a scope and its deferral
565
+ * together, so a deferred id always still owns its record; kept so a
566
+ * future teardown path cannot double-dispose. */
567
+ if (record !== undefined) {
568
+ this.scopes.delete(id);
569
+ this.startScopeDrop(id, record);
570
+ }
571
+ }
572
+ }
573
+ }
574
+ //# sourceMappingURL=service.js.map