@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,711 @@
1
+ // Sessions remain resident after creation so their open Remote sources keep running off-screen.
2
+ import { randomUUID } from '@deepseek-ai/dsh-util-crypto';
3
+ import { SessionEventStream } from "../transport.js";
4
+ import { MutableSessionEventSource } from "../contract/events.js";
5
+ import { Notifier } from "./notifier.js";
6
+ import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client';
7
+ import { ProjectionValueStore } from "./projection-store.js";
8
+ import { resolvedClientTimeZone } from "../time-zone.js";
9
+ import { SessionQueueMirror } from "./queue-mirror.js";
10
+ /** Messages requested per history page. */
11
+ export const PAGE_MESSAGES = 50;
12
+ /** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
13
+ export const JUMP_PAGE_MESSAGES = 200;
14
+ /**
15
+ * Owns a session's event window, lifecycle state, and observable
16
+ * snapshot. React bindings remain outside this data layer. Features see only
17
+ * the {@link SessionFace} slice (ISession verbs + the snapshot source); the
18
+ * remaining public members are Session Controller internals.
19
+ */
20
+ export class Session {
21
+ sessionId;
22
+ remote;
23
+ options;
24
+ // ---- Window and derived state (all private; the snapshot is the only read API) ----
25
+ baseSeq = 0;
26
+ hasMore = false;
27
+ openState = 'cold';
28
+ openError = null;
29
+ openPromise = null;
30
+ /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale
31
+ * passes drop all writes once the generation moves on. */
32
+ openGeneration = 0;
33
+ loadingOlder = false;
34
+ /** Shared low-water target of the running jump loop; null when no jump is paging. */
35
+ jumpTargetSeq = null;
36
+ /** The running jump loop's completion, shared by retargeting callers. */
37
+ jumpPromise = null;
38
+ /** Authoritative stream-only inbox snapshot; pending work never hits history. */
39
+ queueMirror = new SessionQueueMirror();
40
+ running = false;
41
+ address;
42
+ parentAvailable;
43
+ /**
44
+ * Sticky send marker, private input of the composerPhase derivation: set
45
+ * synchronously before prompt()'s first await, never reset — the blank →
46
+ * engaging edge of the phase machine (see ComposerPhase).
47
+ */
48
+ promptAttempted = false;
49
+ /** A first accepted prompt stays in the engaging phase until its turn is observable. */
50
+ firstPromptPendingTurn = false;
51
+ /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */
52
+ blankBit = true;
53
+ removed = false;
54
+ promptError = null;
55
+ lastAgentError = null;
56
+ /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */
57
+ pendingSubmissions = [];
58
+ /** Per-echo settlement state; `retiring` latches the first observation so a
59
+ * queue frame and its durable event cannot both retire one echo. */
60
+ submissionSettlements = new Map();
61
+ /** Owns the addressed page/follow lifecycle while this Session is open. */
62
+ events;
63
+ /**
64
+ * Per-session projection value store (push model; see the session-projection
65
+ * subsystem page, docs/subsystems/session-projection.md): finished whole
66
+ * values computed on the Host, seeded by the tail page's
67
+ * projections block and updated by Session Controller control frames under the
68
+ * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
69
+ * (the useProjection resolution face); the conversation snapshot never
70
+ * carries projection values, and no client-side domain folding exists.
71
+ * Manager-owned when constructed through SessionManager (frames route and
72
+ * the store outlives instantiation, the title-snapshot precedent); a bare
73
+ * construction gets a private store.
74
+ */
75
+ projections;
76
+ /** Contiguous history and live tail consumed by Conversation assembly. */
77
+ eventSource = new MutableSessionEventSource();
78
+ snapshotCache;
79
+ notifier;
80
+ /**
81
+ * Agent-scoped cordis context, bound once by ClientSessions when it
82
+ * mints the scope (the client mirror of the host Agent's loopCtx). The
83
+ * Session dispatches its own scoped events through it; undefined means
84
+ * unbound (bare object-layer construction) or already pruned — both skip
85
+ * dispatch-dependent behavior rather than fail.
86
+ */
87
+ actx;
88
+ /**
89
+ * @param sessionId - Host session identity (client sessions are always Host-born).
90
+ * @param remote - generated Remote namespaces this session calls.
91
+ * @param options - optional manager-owned state observers.
92
+ */
93
+ constructor(sessionId, remote, options = {}) {
94
+ this.sessionId = sessionId;
95
+ this.remote = remote;
96
+ this.options = options;
97
+ this.projections = options.projections ?? new ProjectionValueStore();
98
+ this.address = options.address;
99
+ this.parentAvailable = options.parentAvailable;
100
+ this.notifier = new Notifier(() => {
101
+ this.snapshotCache = this.buildSnapshot();
102
+ });
103
+ this.snapshotCache = this.buildSnapshot();
104
+ }
105
+ /**
106
+ * Bind the Agent-scoped context minted by ClientSessions (single write;
107
+ * a second bind is a wiring error and throws). Direction stays one-way at
108
+ * this binding boundary: consumers still reach the Session via `sessions.sessionOf`,
109
+ * while the Session holds its own dispatch point (host Agent.loopCtx
110
+ * mirror).
111
+ * @param actx - the agent's scoped context.
112
+ */
113
+ bindScope(actx) {
114
+ if (this.actx !== undefined)
115
+ throw new Error(`session ${this.sessionId} already has a bound scope`);
116
+ this.actx = actx;
117
+ }
118
+ /** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */
119
+ unbindScope() {
120
+ this.actx = undefined;
121
+ }
122
+ // ---- Operations ----
123
+ /**
124
+ * Register one local submission echo (see the ISession declaration).
125
+ * Synchronous through markDirty: the echo is in the very next snapshot, so
126
+ * the conversation can paint it before the caller starts serializing.
127
+ * @param input - echo content and the optional settlement callback.
128
+ * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path.
129
+ */
130
+ beginSubmission(input) {
131
+ const requestId = randomUUID();
132
+ this.pendingSubmissions = [...this.pendingSubmissions, {
133
+ requestId,
134
+ placement: this.running
135
+ ? input.mode === 'steer' ? 'steering' : 'queued'
136
+ : 'transcript',
137
+ time: Date.now(),
138
+ text: input.text,
139
+ images: input.images,
140
+ }];
141
+ this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false });
142
+ // The blank → engaging edge flips here, ahead of prompt(): the composer
143
+ // docks and the echo renders on the click's own frame.
144
+ this.promptAttempted = true;
145
+ this.notifier.markDirty();
146
+ return { requestId, abandon: () => { this.retireFailedSubmission(requestId); } };
147
+ }
148
+ /**
149
+ * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
150
+ * @param content - text plus browser-owned temporary image uploads.
151
+ * @param mode - queue appends after the current turn; steer interrupts it.
152
+ * @param signal - optional caller cancellation for the complete admission round-trip.
153
+ * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
154
+ * @returns the prompt result (also mirrored into promptError on failure).
155
+ */
156
+ async prompt(content, mode, signal, requestId) {
157
+ this.promptError = null;
158
+ this.lastAgentError = null;
159
+ // Synchronous, before the first await: the blank → engaging edge must be
160
+ // visible on the session area's very first frame when a caller sends
161
+ // ahead of navigation (first-send flow).
162
+ this.promptAttempted = true;
163
+ if (this.blankBit)
164
+ this.firstPromptPendingTurn = true;
165
+ this.notifier.markDirty();
166
+ let result;
167
+ if (this.address === undefined) {
168
+ const clientTimeZone = resolvedClientTimeZone();
169
+ result = await this.remote.session.prompt({
170
+ requestId: requestId ?? randomUUID(),
171
+ sessionId: this.sessionId,
172
+ mode,
173
+ content,
174
+ clientTimeZone,
175
+ }, signal);
176
+ }
177
+ else {
178
+ const routed = await this.remote.subagents.prompt({
179
+ requestId: randomUUID(),
180
+ parentSessionId: this.address.parentSessionId,
181
+ childSessionId: this.address.childSessionId,
182
+ mode: 'continuable',
183
+ content,
184
+ clientTimeZone: resolvedClientTimeZone(),
185
+ }, signal);
186
+ result = routed.ok ? { ok: true, value: { accepted: true } } : routed;
187
+ }
188
+ if (!result.ok) {
189
+ if (requestId !== undefined)
190
+ this.retireFailedSubmission(requestId);
191
+ this.promptError = { op: 'send', error: result.error };
192
+ this.notifier.markDirty();
193
+ return result;
194
+ }
195
+ // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
196
+ // conversation's first turn on the host (the host criterion — a logged
197
+ // turn/start — is fact, not optimism; standalone command and projection
198
+ // events never flip it), while a rejected first prompt must keep the
199
+ // session blank — the client-side blank mirror only ever lowers, so
200
+ // flipping early on a failure would surface the session forever and
201
+ // strip its connectWorkspace reuse eligibility against the host's
202
+ // authority.
203
+ if (this.blankBit) {
204
+ this.blankBit = false;
205
+ this.options.onEngaged?.(this);
206
+ this.notifier.markDirty();
207
+ }
208
+ return result;
209
+ }
210
+ /**
211
+ * Resolve one image referenced by this session into browser-consumable bytes.
212
+ * @param attachmentId - opaque id found in the folded session log.
213
+ * @returns the authenticated reference and decoded bytes.
214
+ */
215
+ async readAttachment(attachmentId) {
216
+ const result = await this.remote.session.attachment({
217
+ sessionId: this.sessionId,
218
+ attachmentId,
219
+ });
220
+ if (!result.ok)
221
+ return result;
222
+ const binary = atob(result.value.data);
223
+ const data = Uint8Array.from(binary, char => char.charCodeAt(0));
224
+ return { ok: true, value: { attachment: result.value.attachment, data } };
225
+ }
226
+ /** Apply one operation to a still-pending queue occurrence. */
227
+ async updateQueue(itemId, action) {
228
+ return this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action });
229
+ }
230
+ /**
231
+ * Stop the active turn while the Host preserves pending inbox work; failures
232
+ * land in promptError (same error-strip display slot). A subagent address
233
+ * routes through `subagents.interruptByParent`, whose durable parent-address
234
+ * authority works without a live parent Agent.
235
+ * @returns the cancel result.
236
+ */
237
+ async cancel() {
238
+ const address = this.address;
239
+ const result = address !== undefined
240
+ ? await this.remote.subagents.interruptByParent(address.childSessionId, address.parentSessionId, 'continuable')
241
+ : await this.remote.session.cancel({ sessionId: this.sessionId });
242
+ if (!result.ok) {
243
+ this.promptError = { op: 'stop', error: result.error };
244
+ this.notifier.markDirty();
245
+ }
246
+ return result;
247
+ }
248
+ /**
249
+ * Rename: contract session.rename 1:1. On success settle the 'title'
250
+ * projection cell from the response's `{title, seq}` under the store's
251
+ * higher-seq-wins rule (the push frame arriving later is a no-op replay),
252
+ * so the list row and any useProjection('title') reader update without
253
+ * waiting for the control-stream projection update.
254
+ * @param title - raw title text (the host normalizes acceptance).
255
+ * @returns the rename result (normalized accepted title + title event seq).
256
+ */
257
+ async rename(title) {
258
+ const result = await this.remote.session.rename({ sessionId: this.sessionId, title });
259
+ if (result.ok)
260
+ this.projections.apply('title', result.value.title, result.value.seq);
261
+ return result;
262
+ }
263
+ /**
264
+ * Execute one slash-command line against this session's agent — pure
265
+ * admission semantics (the host executor durably logs the lifecycle;
266
+ * outcomes render as flow nodes, never as a response echo).
267
+ * @param line - the full command line, leading slash included.
268
+ * @returns the admission result.
269
+ */
270
+ async command(line) {
271
+ const result = await this.remote.commands.execute(this.sessionId, line, []);
272
+ if (!result.ok)
273
+ return result;
274
+ return { ok: true, value: { matched: result.value !== undefined } };
275
+ }
276
+ /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
277
+ open() {
278
+ if (this.openState === 'open')
279
+ return Promise.resolve();
280
+ if (this.openPromise !== null)
281
+ return this.openPromise;
282
+ const promise = this.doOpen(this.openGeneration).finally(() => {
283
+ // Identity-guarded: a superseded open must not null out the promise resync just started.
284
+ if (this.openPromise === promise)
285
+ this.openPromise = null;
286
+ });
287
+ this.openPromise = promise;
288
+ return promise;
289
+ }
290
+ /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
291
+ async loadOlder() {
292
+ if (this.openState !== 'open' || !this.hasMore || this.loadingOlder)
293
+ return;
294
+ const events = this.events;
295
+ if (events === undefined)
296
+ return;
297
+ this.loadingOlder = true;
298
+ this.notifier.markDirty();
299
+ try {
300
+ await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES });
301
+ }
302
+ catch (error) {
303
+ if (!isRemoteFailure(error)) {
304
+ console.error('[session-controller] loadOlder failed:', error);
305
+ }
306
+ }
307
+ finally {
308
+ this.loadingOlder = false;
309
+ this.notifier.markDirty();
310
+ }
311
+ }
312
+ /** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */
313
+ loadThrough(seq) {
314
+ if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq)
315
+ return Promise.resolve();
316
+ if (this.jumpPromise !== null) {
317
+ // Retarget the running loop to the lowest requested seq.
318
+ this.jumpTargetSeq = Math.min(this.jumpTargetSeq ?? seq, seq);
319
+ return this.jumpPromise;
320
+ }
321
+ // A plain single-page pull owns the busy flag; the jump does not queue
322
+ // behind it (the caller retries once it settles) and must leave no
323
+ // target behind — only the loop's finally clears that field, and no
324
+ // loop starts here.
325
+ if (this.loadingOlder)
326
+ return Promise.resolve();
327
+ this.jumpTargetSeq = seq;
328
+ this.loadingOlder = true;
329
+ this.notifier.markDirty();
330
+ // Stale-pass guard (the doOpen pattern): a resync mid-loop replaces the
331
+ // stream generation; this pass then stops instead of paging the new
332
+ // generation toward its old target.
333
+ const generation = this.openGeneration;
334
+ this.jumpPromise = (async () => {
335
+ try {
336
+ while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
337
+ if (generation !== this.openGeneration)
338
+ return;
339
+ const events = this.events;
340
+ if (events === undefined)
341
+ return;
342
+ const before = this.baseSeq;
343
+ await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES });
344
+ // No-progress guard: an empty or dropped page that still claims more
345
+ // history must end the loop, not spin it.
346
+ if (this.baseSeq >= before)
347
+ return;
348
+ }
349
+ }
350
+ catch (error) {
351
+ if (!isRemoteFailure(error)) {
352
+ console.error('[session-controller] loadThrough failed:', error);
353
+ }
354
+ }
355
+ finally {
356
+ this.jumpTargetSeq = null;
357
+ this.jumpPromise = null;
358
+ this.loadingOlder = false;
359
+ this.notifier.markDirty();
360
+ }
361
+ })();
362
+ return this.jumpPromise;
363
+ }
364
+ /** Rebuild an opened history source after address replacement.
365
+ * Invalidates any in-flight open first; queue state belongs to the independently
366
+ * reconnecting control stream and remains untouched. */
367
+ async resync() {
368
+ if (this.openState === 'cold')
369
+ return; // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
370
+ this.openGeneration++;
371
+ const events = this.events;
372
+ this.events = undefined;
373
+ await events?.dispose();
374
+ this.openPromise = null;
375
+ this.openState = 'cold';
376
+ this.openError = null;
377
+ this.baseSeq = 0;
378
+ this.notifier.markDirty();
379
+ await this.open();
380
+ }
381
+ // ---- Subscription API (useSyncExternalStore direct wiring) ----
382
+ /**
383
+ * uSES subscription entry.
384
+ * @param listener - change callback.
385
+ * @returns the unsubscribe function.
386
+ */
387
+ subscribe(listener) {
388
+ return this.notifier.subscribe(listener);
389
+ }
390
+ /**
391
+ * Cached Session snapshot (rebuilt lazily when dirty with no listeners).
392
+ * @returns the cached reference (stable until the next flush).
393
+ */
394
+ getSnapshot() {
395
+ this.notifier.ensureFresh();
396
+ return this.snapshotCache;
397
+ }
398
+ // ---- Manager-only entry points (@internal; never called by the UI) ----
399
+ /**
400
+ * Replace every transient control value for this Session from one stream baseline.
401
+ * @param queue - complete pending queue for this Session.
402
+ */
403
+ replaceControl(queue) {
404
+ this.queueMirror.replace(queue);
405
+ this.observeSubmissionQueue(queue);
406
+ this.notifier.markDirty();
407
+ }
408
+ /**
409
+ * Apply one Session-addressed live control update.
410
+ * @param frame - queue replacement addressed to this Session.
411
+ */
412
+ handleControlFrame(frame) {
413
+ this.queueMirror.replace(frame.items);
414
+ this.observeSubmissionQueue(frame.items);
415
+ this.notifier.markDirty();
416
+ }
417
+ /**
418
+ * Running-bit relay from the host stream (list entry and snapshot stay consistent).
419
+ * @param running - the new running state.
420
+ */
421
+ handleRunning(running) {
422
+ // Turn-start conversion: a blank session never runs, so the first
423
+ // running:true proves another side's first message landed.
424
+ if (running && this.blankBit) {
425
+ this.blankBit = false;
426
+ this.notifier.markDirty();
427
+ }
428
+ if (running)
429
+ this.firstPromptPendingTurn = false;
430
+ if (this.running === running)
431
+ return;
432
+ this.running = running;
433
+ this.notifier.markDirty();
434
+ }
435
+ /**
436
+ * Install or clear the catalog-discovered transport address. A changed
437
+ * address rebuilds an already-open window through its new history route.
438
+ * @param address - direct parent/child address, or undefined for ordinary transport.
439
+ * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read.
440
+ */
441
+ configureSubagent(address, parentAvailable) {
442
+ const same = this.address?.parentSessionId === address?.parentSessionId
443
+ && this.address?.childSessionId === address?.childSessionId
444
+ && this.address?.mode === address?.mode;
445
+ this.address = address;
446
+ this.parentAvailable = parentAvailable;
447
+ if (!same && this.openState !== 'cold')
448
+ void this.resync();
449
+ else
450
+ this.notifier.markDirty();
451
+ }
452
+ /**
453
+ * Update only the parent availability hint from a catalog refresh.
454
+ * @param available - whether the exact direct parent is live.
455
+ */
456
+ handleSubagentParentAvailable(available) {
457
+ if (this.parentAvailable === available)
458
+ return;
459
+ this.parentAvailable = available;
460
+ this.notifier.markDirty();
461
+ }
462
+ /**
463
+ * Blank-bit relay from the authoritative summary source (`session.list` and
464
+ * `api-session/added`). Monotone: once any signal (local first send,
465
+ * running flip, an earlier summary) cleared it, a stale true never
466
+ * re-blanks.
467
+ * @param blank - the summary's derived empty-log bit.
468
+ */
469
+ handleBlank(blank) {
470
+ if (blank === this.blankBit)
471
+ return;
472
+ if (blank && (this.promptAttempted || this.running))
473
+ return;
474
+ this.blankBit = blank;
475
+ this.notifier.markDirty();
476
+ }
477
+ /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */
478
+ handleRemoved() {
479
+ this.removed = true;
480
+ this.notifier.markDirty();
481
+ }
482
+ /**
483
+ * `api-session/error` relay: the outlet for live failures with no turn position.
484
+ * @param message - the stringified error.
485
+ */
486
+ handleAgentError(message) {
487
+ this.lastAgentError = message;
488
+ this.notifier.markDirty();
489
+ }
490
+ /**
491
+ * Stop the Session's live Remote source.
492
+ * @returns when the Remote iterator has completed teardown.
493
+ */
494
+ async dispose() {
495
+ // Unsettled echoes retire as failed so their owners can restore or
496
+ // release browser resources; echoes already scheduled as observed keep
497
+ // that settlement.
498
+ for (const requestId of [...this.submissionSettlements.keys()]) {
499
+ this.retireFailedSubmission(requestId);
500
+ }
501
+ this.openGeneration++;
502
+ const events = this.events;
503
+ this.events = undefined;
504
+ await events?.dispose();
505
+ }
506
+ // ---- Private ----
507
+ /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */
508
+ async doOpen(generation) {
509
+ this.openState = 'loading';
510
+ this.openError = null;
511
+ this.notifier.markDirty();
512
+ const events = new SessionEventStream(this.remote, this.sessionAddress(), {
513
+ publish: (change) => {
514
+ if (generation !== this.openGeneration || this.events !== events)
515
+ return;
516
+ this.acceptEventChange(change);
517
+ },
518
+ failed: (error) => {
519
+ this.failEventStream(events, generation, error);
520
+ },
521
+ });
522
+ this.events = events;
523
+ try {
524
+ await events.open({ maxMessages: PAGE_MESSAGES });
525
+ if (generation !== this.openGeneration || this.events !== events)
526
+ return;
527
+ this.openState = 'open';
528
+ }
529
+ catch (error) {
530
+ if (generation !== this.openGeneration || this.events !== events)
531
+ return;
532
+ if (!isRemoteFailure(error))
533
+ throw error;
534
+ this.events = undefined;
535
+ this.openState = 'error';
536
+ this.openError = error;
537
+ }
538
+ finally {
539
+ if (generation === this.openGeneration)
540
+ this.notifier.markDirty();
541
+ }
542
+ }
543
+ /** Apply one contiguous journal update already reconciled by the Remote stream. */
544
+ acceptEventChange(change) {
545
+ switch (change.type) {
546
+ case 'replace':
547
+ this.installWindow(change.entries, change.hasMore, change.page.projections);
548
+ return;
549
+ case 'prepend':
550
+ this.prependWindow(change.entries, change.hasMore);
551
+ return;
552
+ case 'append':
553
+ if (this.appendLive(change.entry))
554
+ this.notifier.markDirty();
555
+ }
556
+ }
557
+ /** Replace the complete contiguous window and apply page-owned projection metadata. */
558
+ installWindow(entries, hasMore, projections) {
559
+ this.baseSeq = entries[0]?.event.seq ?? 0;
560
+ this.hasMore = hasMore;
561
+ if (entries.some(entry => entry.event.type === 'turn/start'))
562
+ this.firstPromptPendingTurn = false;
563
+ if (projections !== undefined)
564
+ this.projections.seed(projections);
565
+ this.eventSource.replace(entries, hasMore);
566
+ for (const entry of entries)
567
+ this.observeSubmissionEvent(entry.event);
568
+ this.notifier.markDirty();
569
+ }
570
+ /** Prepend one stream-validated history page. */
571
+ prependWindow(entries, hasMore) {
572
+ this.baseSeq = entries[0]?.event.seq ?? this.baseSeq;
573
+ this.hasMore = hasMore;
574
+ this.eventSource.prepend(entries, hasMore);
575
+ }
576
+ /** Append one stream-validated live event. */
577
+ appendLive(entry) {
578
+ const event = entry.event;
579
+ const awaitingFirstTurn = this.firstPromptPendingTurn;
580
+ if (event.type === 'turn/start')
581
+ this.firstPromptPendingTurn = false;
582
+ const queueChanged = this.queueMirror.acceptDurable(event);
583
+ this.eventSource.append(entry);
584
+ // After the feed append: the conversation assembly's animation frame is
585
+ // registered by the feed subscribers above, so the echo-retirement frame
586
+ // scheduled here always runs after the durable node became renderable.
587
+ this.observeSubmissionEvent(event);
588
+ return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn;
589
+ }
590
+ /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */
591
+ observeSubmissionEvent(event) {
592
+ if (this.submissionSettlements.size === 0 || event.type !== 'user/message')
593
+ return;
594
+ // Structural read: window entries may be compact history records, so the
595
+ // fields are narrowed rather than trusted (same posture as Conversation
596
+ // assembly matchers).
597
+ const data = event.data;
598
+ const source = data?.source;
599
+ if (source?.kind !== 'user' || typeof source.rpcId !== 'string')
600
+ return;
601
+ this.scheduleObservedRetirement(source.rpcId, imageRefsIn(data?.content));
602
+ }
603
+ /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */
604
+ observeSubmissionQueue(items) {
605
+ if (this.submissionSettlements.size === 0)
606
+ return;
607
+ for (const item of items) {
608
+ if (item.rpcId !== undefined) {
609
+ this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content));
610
+ }
611
+ }
612
+ }
613
+ /**
614
+ * Latch one observed settlement and remove the echo an animation frame
615
+ * later. The delay keeps the echo in the snapshot until the frame in which
616
+ * the durable node (whose assembly frame was registered first) is
617
+ * renderable; the render-time rpcId dedupe hides the one-frame overlap.
618
+ */
619
+ scheduleObservedRetirement(requestId, attachments) {
620
+ const settlement = this.submissionSettlements.get(requestId);
621
+ if (settlement === undefined || settlement.retiring)
622
+ return;
623
+ settlement.retiring = true;
624
+ scheduleFrame(() => { this.finishSubmission(requestId, { reason: 'observed', attachments }); });
625
+ }
626
+ /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */
627
+ retireFailedSubmission(requestId) {
628
+ const settlement = this.submissionSettlements.get(requestId);
629
+ if (settlement === undefined || settlement.retiring)
630
+ return;
631
+ settlement.retiring = true;
632
+ this.finishSubmission(requestId, { reason: 'failed' });
633
+ }
634
+ /** Single removal point: drop the echo, publish, then notify the owner. */
635
+ finishSubmission(requestId, retirement) {
636
+ const settlement = this.submissionSettlements.get(requestId);
637
+ /* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */
638
+ if (settlement === undefined)
639
+ return;
640
+ this.submissionSettlements.delete(requestId);
641
+ this.pendingSubmissions = this.pendingSubmissions.filter(echo => echo.requestId !== requestId);
642
+ this.notifier.markDirty();
643
+ settlement.onRetire?.(retirement);
644
+ }
645
+ /** Publish a terminal background failure only while this stream still owns the Session. */
646
+ failEventStream(events, generation, error) {
647
+ if (generation !== this.openGeneration || this.events !== events)
648
+ return;
649
+ if (!isRemoteFailure(error))
650
+ throw error;
651
+ this.openGeneration++;
652
+ this.events = undefined;
653
+ this.openPromise = null;
654
+ this.openState = 'error';
655
+ this.openError = error;
656
+ void events.dispose();
657
+ this.notifier.markDirty();
658
+ }
659
+ buildSnapshot() {
660
+ return {
661
+ sessionId: this.sessionId,
662
+ queue: this.queueMirror.snapshot(),
663
+ pendingSubmissions: this.pendingSubmissions,
664
+ running: this.running,
665
+ subagent: this.address === undefined
666
+ ? null
667
+ : {
668
+ address: this.address,
669
+ ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }),
670
+ },
671
+ removed: this.removed,
672
+ openState: this.openState,
673
+ openError: this.openError,
674
+ hasMore: this.hasMore,
675
+ loadingOlder: this.loadingOlder,
676
+ promptError: this.promptError,
677
+ blank: this.blankBit,
678
+ lastAgentError: this.lastAgentError,
679
+ promptAttempted: this.promptAttempted,
680
+ awaitingFirstTurn: this.firstPromptPendingTurn,
681
+ };
682
+ }
683
+ sessionAddress() {
684
+ return this.address === undefined
685
+ ? { kind: 'session', sessionId: this.sessionId }
686
+ : { kind: 'subagent', ...this.address };
687
+ }
688
+ }
689
+ /** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */
690
+ function scheduleFrame(fn) {
691
+ if (typeof requestAnimationFrame === 'function')
692
+ requestAnimationFrame(() => { fn(); });
693
+ else
694
+ setTimeout(fn, 0);
695
+ }
696
+ /** Image attachment references in one structurally-read content block list, in block order. */
697
+ function imageRefsIn(content) {
698
+ if (!Array.isArray(content))
699
+ return [];
700
+ const refs = [];
701
+ for (const block of content) {
702
+ if (typeof block !== 'object' || block === null)
703
+ continue;
704
+ const candidate = block;
705
+ if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
706
+ refs.push(candidate.attachment);
707
+ }
708
+ }
709
+ return refs;
710
+ }
711
+ //# sourceMappingURL=session.js.map