@deepseek-ai/dsh-api-session-controller 0.1.2-alpha.2

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