@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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +74 -0
- package/README.zh.md +74 -0
- package/lib/client.js +2724 -0
- package/lib/index.js +2826 -0
- package/lib/invariant.js +13 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +2574 -0
- package/lib/typert.remote-client.d.ts +67 -0
- package/lib/typert.remote-client.js +1119 -0
- package/lib/types/agent.d.ts +156 -0
- package/lib/types/agent.js +537 -0
- package/lib/types/catalog.d.ts +11 -0
- package/lib/types/catalog.js +58 -0
- package/lib/types/client/contract/events.d.ts +71 -0
- package/lib/types/client/contract/events.js +91 -0
- package/lib/types/client/contract/session.d.ts +150 -0
- package/lib/types/client/contract/session.js +2 -0
- package/lib/types/client/contract/sessions.d.ts +125 -0
- package/lib/types/client/contract/sessions.js +2 -0
- package/lib/types/client/contract/snapshot.d.ts +82 -0
- package/lib/types/client/contract/snapshot.js +2 -0
- package/lib/types/client/index.d.ts +30 -0
- package/lib/types/client/index.js +48 -0
- package/lib/types/client/ordered-baseline.d.ts +12 -0
- package/lib/types/client/ordered-baseline.js +41 -0
- package/lib/types/client/scope.d.ts +36 -0
- package/lib/types/client/scope.js +54 -0
- package/lib/types/client/sessions/history-records.d.ts +22 -0
- package/lib/types/client/sessions/history-records.js +31 -0
- package/lib/types/client/sessions/lineage.d.ts +38 -0
- package/lib/types/client/sessions/lineage.js +56 -0
- package/lib/types/client/sessions/manager.d.ts +280 -0
- package/lib/types/client/sessions/manager.js +894 -0
- package/lib/types/client/sessions/notifier.d.ts +39 -0
- package/lib/types/client/sessions/notifier.js +98 -0
- package/lib/types/client/sessions/projection-store.d.ts +108 -0
- package/lib/types/client/sessions/projection-store.js +129 -0
- package/lib/types/client/sessions/queue-mirror.d.ts +26 -0
- package/lib/types/client/sessions/queue-mirror.js +61 -0
- package/lib/types/client/sessions/remotes.d.ts +30 -0
- package/lib/types/client/sessions/remotes.js +8 -0
- package/lib/types/client/sessions/service.d.ts +349 -0
- package/lib/types/client/sessions/service.js +574 -0
- package/lib/types/client/sessions/session.d.ts +294 -0
- package/lib/types/client/sessions/session.js +711 -0
- package/lib/types/client/time-zone.d.ts +8 -0
- package/lib/types/client/time-zone.js +14 -0
- package/lib/types/client/transport.d.ts +73 -0
- package/lib/types/client/transport.js +106 -0
- package/lib/types/commands.d.ts +69 -0
- package/lib/types/commands.js +544 -0
- package/lib/types/control.d.ts +23 -0
- package/lib/types/control.js +192 -0
- package/lib/types/file-references.d.ts +27 -0
- package/lib/types/file-references.js +69 -0
- package/lib/types/history.d.ts +31 -0
- package/lib/types/history.js +376 -0
- package/lib/types/index.d.ts +171 -0
- package/lib/types/index.js +424 -0
- package/lib/types/invariant.d.ts +9 -0
- package/lib/types/invariant.js +12 -0
- package/lib/types/list.d.ts +53 -0
- package/lib/types/list.js +405 -0
- package/lib/types/model-selection-projection.d.ts +8 -0
- package/lib/types/model-selection-projection.js +66 -0
- package/lib/types/remote-events.d.ts +8 -0
- package/lib/types/remote-events.js +2 -0
- package/lib/types/skill-catalog.d.ts +28 -0
- package/lib/types/skill-catalog.js +192 -0
- package/lib/types/types.d.ts +505 -0
- package/lib/types/types.js +6 -0
- 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
|