@dzbot/dzstudio-mcp 0.0.0-stage → 0.1.0

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 (49) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +162 -2
  3. package/dist/config/config.js +172 -0
  4. package/dist/config/config.js.map +1 -0
  5. package/dist/config/sessions.js +67 -0
  6. package/dist/config/sessions.js.map +1 -0
  7. package/dist/index.js +16 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/mcp/agent.js +1148 -0
  10. package/dist/mcp/agent.js.map +1 -0
  11. package/dist/mcp/server.js +263 -0
  12. package/dist/mcp/server.js.map +1 -0
  13. package/dist/protocol/client.js +134 -0
  14. package/dist/protocol/client.js.map +1 -0
  15. package/dist/protocol/session.js +837 -0
  16. package/dist/protocol/session.js.map +1 -0
  17. package/dist/protocol/transport.js +66 -0
  18. package/dist/protocol/transport.js.map +1 -0
  19. package/dist/scene/chunks.js +30 -0
  20. package/dist/scene/chunks.js.map +1 -0
  21. package/dist/scene/edits.js +183 -0
  22. package/dist/scene/edits.js.map +1 -0
  23. package/dist/scene/float32.js +167 -0
  24. package/dist/scene/float32.js.map +1 -0
  25. package/dist/scene/ids.js +43 -0
  26. package/dist/scene/ids.js.map +1 -0
  27. package/dist/scene/index.js +9 -0
  28. package/dist/scene/index.js.map +1 -0
  29. package/dist/scene/json.js +294 -0
  30. package/dist/scene/json.js.map +1 -0
  31. package/dist/scene/projectload.js +652 -0
  32. package/dist/scene/projectload.js.map +1 -0
  33. package/dist/scene/scene.js +506 -0
  34. package/dist/scene/scene.js.map +1 -0
  35. package/dist/scene/srcjson.js +297 -0
  36. package/dist/scene/srcjson.js.map +1 -0
  37. package/dist/scene/textform.js +257 -0
  38. package/dist/scene/textform.js.map +1 -0
  39. package/dist/scene/types.js +35 -0
  40. package/dist/scene/types.js.map +1 -0
  41. package/dist/world/data.js +604 -0
  42. package/dist/world/data.js.map +1 -0
  43. package/dist/world/format.js +205 -0
  44. package/dist/world/format.js.map +1 -0
  45. package/dist/world/source.js +267 -0
  46. package/dist/world/source.js.map +1 -0
  47. package/dist/world/tools.js +530 -0
  48. package/dist/world/tools.js.map +1 -0
  49. package/package.json +48 -4
@@ -0,0 +1,837 @@
1
+ import { chunk } from '../scene/chunks.js';
2
+ import { IdAllocator, randomPrefix } from '../scene/ids.js';
3
+ import { Scene } from '../scene/scene.js';
4
+ import { writeOperation } from '../scene/textform.js';
5
+ import { requestId } from './client.js';
6
+ import { CollabError, UnreachableError } from './transport.js';
7
+ export const MAX_OP_BYTES = 3_670_016;
8
+ export const MAX_CHAIN_OPS = 16;
9
+ export const PART_BYTES = 1_048_576;
10
+ export const MAX_REQUEST_ITEMS = 200;
11
+ export const STATUS_MAX_BYTES = 200;
12
+ export const DEFAULT_TIMING = {
13
+ pollWaitMs: 5000,
14
+ retryDelayMs: 1000,
15
+ resumeAfterMs: 30_000,
16
+ confirmTimeoutMs: 10_000,
17
+ requestTimeoutMs: 10_000,
18
+ };
19
+ /** `session/create` found a running session of the project; join that one instead. */
20
+ export class AlreadyRunning extends Error {
21
+ sessionId;
22
+ constructor(sessionId) {
23
+ super(`A session of this project is running already: ${sessionId}`);
24
+ this.sessionId = sessionId;
25
+ }
26
+ }
27
+ function pieceBytes(p) {
28
+ return Buffer.byteLength(JSON.stringify(p), 'utf8');
29
+ }
30
+ function groupBytes(pieces, max) {
31
+ const out = [];
32
+ let cur = [];
33
+ let bytes = 0;
34
+ for (const p of pieces) {
35
+ const n = pieceBytes(p);
36
+ if (bytes + n > max && cur.length) {
37
+ out.push(cur);
38
+ cur = [];
39
+ bytes = 0;
40
+ }
41
+ cur.push(p);
42
+ bytes += n;
43
+ }
44
+ if (cur.length || !out.length)
45
+ out.push(cur);
46
+ return out;
47
+ }
48
+ /** Text of an ended session, as the editor words it. */
49
+ export function endedText(error) {
50
+ switch (error.reason) {
51
+ case 'host_ended':
52
+ case 'host_left':
53
+ return 'The host ended the session';
54
+ case 'host_gone':
55
+ return 'The host can no longer be reached';
56
+ case 'discarded':
57
+ return `${error.by ?? 'Someone'} discarded the unsaved changes`;
58
+ default:
59
+ return 'The server ended the session';
60
+ }
61
+ }
62
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
63
+ /**
64
+ * One participation in a session: the poll that applies the log to the scene and reports presence, and the send
65
+ * channel for locks and operations (one request at a time, retried unchanged).
66
+ */
67
+ export class CollabSession {
68
+ opts;
69
+ sessionId;
70
+ prefix;
71
+ timing;
72
+ scene;
73
+ state = 'joining';
74
+ stateText = '';
75
+ participantId = '';
76
+ kind = 'local';
77
+ role = '';
78
+ colour = 0;
79
+ rights = { canInvite: false, canSave: false, canManage: false };
80
+ capabilities = [];
81
+ /** Highest sequence number applied (never inside a chain). */
82
+ appliedSeq = 0;
83
+ /** Cursor for poll: also counts links of a chain received so far. */
84
+ pollCursor = 0;
85
+ chainBuf = new Map();
86
+ versions = { participants: 0, locks: 0, live: 0, notices: 0, saving: 0 };
87
+ participants = [];
88
+ presence = [];
89
+ lockHolders = [];
90
+ saving;
91
+ phase = 'waiting';
92
+ status = '';
93
+ /** Elements this participant wants locked (its selection). */
94
+ selection = new Set();
95
+ /** Elements the server has granted. */
96
+ held = new Set();
97
+ /** Sequence number of the last own operation that was applied. */
98
+ lastOwnSeq = 0;
99
+ /** Ids this participant created or changed last, for the presence position. */
100
+ lastTouched = [];
101
+ events = [];
102
+ eventWaiters = [];
103
+ pending = new Map();
104
+ /** The next clientSeq for `ops/send` and the last lockSeq, kept across a restart of the MCP. */
105
+ nextClientSeq = 1;
106
+ lockSeq = 0;
107
+ /** Called when something changes that a return after a restart needs: the counters, or the applied state. */
108
+ onPersist;
109
+ lastInputMs = Date.now();
110
+ sendChain = Promise.resolve();
111
+ polling = false;
112
+ pollAbort;
113
+ interruptedSince;
114
+ checkpointBusy = false;
115
+ pollLoopDone;
116
+ constructor(opts, sessionId, prefix) {
117
+ this.opts = opts;
118
+ this.sessionId = sessionId;
119
+ this.prefix = prefix;
120
+ this.timing = { ...DEFAULT_TIMING, ...opts.timing };
121
+ this.scene = new Scene(new IdAllocator(prefix));
122
+ }
123
+ get client() {
124
+ return this.opts.client;
125
+ }
126
+ // ------------------------------------------------------------ joining
127
+ /** Joins a session: loads the snapshot into the scene, then starts polling. */
128
+ static async join(opts, sessionId) {
129
+ for (let attempt = 0; attempt < 5; attempt++) {
130
+ const s = new CollabSession(opts, sessionId, randomPrefix());
131
+ try {
132
+ await s.doJoin();
133
+ return s;
134
+ }
135
+ catch (e) {
136
+ if (e instanceof CollabError && e.code === 'instance_key_taken')
137
+ continue;
138
+ throw e;
139
+ }
140
+ }
141
+ throw new Error('Could not find a free session prefix');
142
+ }
143
+ /**
144
+ * Creates a session of a hosted project the store has, with the state `build` puts into the (empty) scene as
145
+ * snapshot 0, then starts polling. Throws AlreadyRunning when a session of the project is running already.
146
+ */
147
+ static async createHosted(opts, params, build) {
148
+ for (let attempt = 0; attempt < 5; attempt++) {
149
+ const s = new CollabSession(opts, '', randomPrefix());
150
+ build(s.scene);
151
+ try {
152
+ await s.doCreate(params);
153
+ return s;
154
+ }
155
+ catch (e) {
156
+ if (e instanceof CollabError && e.code === 'instance_key_taken')
157
+ continue;
158
+ if (e instanceof CollabError && e.code === 'already_running' && typeof e.error.sessionId === 'string')
159
+ throw new AlreadyRunning(e.error.sessionId);
160
+ throw e;
161
+ }
162
+ }
163
+ throw new Error('Could not find a free session prefix');
164
+ }
165
+ async doCreate(params) {
166
+ const text = this.scene.stateText();
167
+ const parts = groupBytes(chunk(text), PART_BYTES);
168
+ const body = {
169
+ requestId: requestId(),
170
+ client: this.opts.clientInfo,
171
+ projectName: params.projectName,
172
+ map: params.map,
173
+ kind: 'hosted',
174
+ hostedRef: params.hostedRef,
175
+ hostedRevision: params.hostedRevision,
176
+ instanceKey: this.prefix,
177
+ snapshotBytes: Buffer.byteLength(text, 'utf8'),
178
+ snapshotParts: parts.length,
179
+ };
180
+ const res = await this.withRetry(() => this.client.request('session/create', body, this.opts.token(), this.timing.requestTimeoutMs));
181
+ this.sessionId = res.sessionId;
182
+ this.participantId = res.participantId;
183
+ this.kind = 'hosted';
184
+ this.role = res.role;
185
+ this.colour = res.colour;
186
+ this.rights = { canInvite: !!res.canInvite, canSave: !!res.canSave, canManage: !!res.canManage };
187
+ this.capabilities = Array.isArray(res.capabilities) ? res.capabilities : [];
188
+ try {
189
+ for (let part = 0; part < parts.length; part++) {
190
+ await this.withRetry(() => this.client.request('snapshot/put', { ...this.ids(), seq: 0, part, parts: parts.length, pieces: parts[part] }, this.opts.token(), this.timing.requestTimeoutMs));
191
+ }
192
+ }
193
+ catch (e) {
194
+ await this.client.request('session/leave', this.ids(), this.opts.token()).catch(() => undefined);
195
+ throw e;
196
+ }
197
+ this.state = 'active';
198
+ this.startPolling();
199
+ }
200
+ /**
201
+ * Returns to a session after the MCP restarted, as the same participant with `resume`, from a state saved at its
202
+ * cursor. Returns undefined when the server does not take the participant back (it would send a snapshot, the
203
+ * cursor is too old, or the participant timed out); the old participation is left then, and the caller joins anew.
204
+ */
205
+ static async rejoin(opts, saved, state) {
206
+ const s = new CollabSession(opts, saved.sessionId, saved.prefix);
207
+ s.participantId = saved.participantId;
208
+ s.nextClientSeq = saved.clientSeq;
209
+ s.lockSeq = saved.lockSeq;
210
+ s.phase = saved.phase;
211
+ s.status = saved.status;
212
+ const giveUp = async () => {
213
+ await s.client.request('session/leave', s.ids(), opts.token(), s.timing.requestTimeoutMs).catch(() => undefined);
214
+ return undefined;
215
+ };
216
+ if (!state)
217
+ return giveUp();
218
+ try {
219
+ s.scene.open(state.text);
220
+ }
221
+ catch {
222
+ return giveUp();
223
+ }
224
+ s.appliedSeq = s.pollCursor = state.seq;
225
+ let res;
226
+ try {
227
+ res = await s.withRetry(() => s.client.request('session/join', {
228
+ requestId: requestId(),
229
+ client: opts.clientInfo,
230
+ sessionId: saved.sessionId,
231
+ instanceKey: saved.prefix,
232
+ resume: { participantId: saved.participantId, after: state.seq },
233
+ }, opts.token(), s.timing.requestTimeoutMs));
234
+ }
235
+ catch (e) {
236
+ if (e instanceof CollabError && e.code === 'not_participant' && e.error.reason !== 'removed')
237
+ return undefined;
238
+ if (e instanceof CollabError && e.code === 'cursor_too_old')
239
+ return giveUp();
240
+ throw e;
241
+ }
242
+ if (res.mode !== 'resume')
243
+ return giveUp();
244
+ s.kind = res.kind;
245
+ s.role = res.role;
246
+ s.colour = res.colour;
247
+ s.rights = { canInvite: !!res.canInvite, canSave: !!res.canSave, canManage: !!res.canManage };
248
+ s.capabilities = Array.isArray(res.capabilities) ? res.capabilities : [];
249
+ s.state = 'active';
250
+ s.startPolling();
251
+ void s.setLocks([]).catch(() => undefined);
252
+ return s;
253
+ }
254
+ async doJoin() {
255
+ const rid = requestId();
256
+ let res;
257
+ for (let i = 0;; i++) {
258
+ try {
259
+ res = await this.client.request('session/join', { requestId: rid, client: this.opts.clientInfo, sessionId: this.sessionId, instanceKey: this.prefix }, this.opts.token(), this.timing.requestTimeoutMs);
260
+ break;
261
+ }
262
+ catch (e) {
263
+ if (e instanceof CollabError && e.code === 'snapshot_not_ready' && i < 30) {
264
+ await sleep(1000);
265
+ continue;
266
+ }
267
+ if (e instanceof UnreachableError && i < 3) {
268
+ await sleep(this.timing.retryDelayMs);
269
+ continue;
270
+ }
271
+ throw e;
272
+ }
273
+ }
274
+ this.participantId = res.participantId;
275
+ this.kind = res.kind;
276
+ this.role = res.role;
277
+ this.colour = res.colour;
278
+ this.rights = { canInvite: !!res.canInvite, canSave: !!res.canSave, canManage: !!res.canManage };
279
+ this.capabilities = Array.isArray(res.capabilities) ? res.capabilities : [];
280
+ try {
281
+ const snap = res.snapshot;
282
+ const text = await this.downloadSnapshot(snap.seq, snap.parts);
283
+ this.scene.open(text);
284
+ this.appliedSeq = this.pollCursor = snap.seq;
285
+ }
286
+ catch (e) {
287
+ await this.client.request('session/leave', this.ids(), this.opts.token()).catch(() => undefined);
288
+ throw e;
289
+ }
290
+ this.state = 'active';
291
+ this.startPolling();
292
+ }
293
+ async downloadSnapshot(seq, parts) {
294
+ const all = [];
295
+ for (let part = 0; part < parts; part++) {
296
+ const res = await this.withRetry(() => this.client.request('snapshot/get', { ...this.ids(), seq, part }, this.opts.token(), this.timing.requestTimeoutMs));
297
+ all.push(...res.pieces);
298
+ }
299
+ return all.join('');
300
+ }
301
+ ids() {
302
+ return { sessionId: this.sessionId, participantId: this.participantId };
303
+ }
304
+ async withRetry(fn, tries = 3) {
305
+ for (let i = 0;; i++) {
306
+ try {
307
+ return await fn();
308
+ }
309
+ catch (e) {
310
+ if (!(e instanceof UnreachableError) || i >= tries)
311
+ throw e;
312
+ await sleep(this.timing.retryDelayMs);
313
+ }
314
+ }
315
+ }
316
+ // ------------------------------------------------------------ events
317
+ emit(e) {
318
+ this.events.push(e);
319
+ for (const w of this.eventWaiters.splice(0))
320
+ w();
321
+ }
322
+ drainEvents() {
323
+ return this.events.splice(0);
324
+ }
325
+ /** Waits until there is at least one event or the time is up. */
326
+ async waitForEvents(ms) {
327
+ if (this.events.length)
328
+ return;
329
+ await new Promise((resolve) => {
330
+ const t = setTimeout(done, ms);
331
+ function done() {
332
+ clearTimeout(t);
333
+ resolve();
334
+ }
335
+ this.eventWaiters.push(done);
336
+ });
337
+ }
338
+ get active() {
339
+ return this.state === 'active';
340
+ }
341
+ /** How long the connection has been interrupted, 0 if it is not. */
342
+ get interruptedMs() {
343
+ return this.interruptedSince === undefined ? 0 : Date.now() - this.interruptedSince;
344
+ }
345
+ finish(state, text, kind) {
346
+ if (!this.active && this.state !== 'joining')
347
+ return;
348
+ this.state = state;
349
+ this.stateText = text;
350
+ this.polling = false;
351
+ this.pollAbort?.abort();
352
+ for (const p of this.pending.values())
353
+ p.settle({ status: 'rejected', message: text });
354
+ this.pending.clear();
355
+ this.emit({ kind, text });
356
+ }
357
+ // ------------------------------------------------------------ the scene with own unconfirmed steps
358
+ /** Runs `fn` on the scene with the own operations not yet applied by the log on top. */
359
+ view(fn) {
360
+ const ops = [...this.pending.values()].map((p) => p.op);
361
+ return this.scene.tentative(ops, () => fn(this.scene));
362
+ }
363
+ get unconfirmed() {
364
+ return this.pending.size;
365
+ }
366
+ // ------------------------------------------------------------ presence
367
+ setPhase(phase, status) {
368
+ this.phase = phase;
369
+ if (status !== undefined)
370
+ this.status = status;
371
+ this.onPersist?.('meta');
372
+ }
373
+ presenceBody() {
374
+ const p = {
375
+ idleSeconds: Math.max(0, Math.floor((Date.now() - this.lastInputMs) / 1000)),
376
+ phase: this.phase,
377
+ status: this.status,
378
+ };
379
+ const pos = this.opts.position?.();
380
+ if (pos)
381
+ p.position = pos;
382
+ return p;
383
+ }
384
+ // ------------------------------------------------------------ poll
385
+ startPolling() {
386
+ this.polling = true;
387
+ this.pollLoopDone = this.pollLoop();
388
+ }
389
+ async pollLoop() {
390
+ let waitMs = 0;
391
+ while (this.polling) {
392
+ try {
393
+ this.pollAbort = new AbortController();
394
+ const res = await this.client.request('poll', { ...this.ids(), after: this.pollCursor, waitMs, versions: this.versions, presence: this.presenceBody() }, this.opts.token(), waitMs + this.timing.requestTimeoutMs, this.pollAbort.signal);
395
+ if (!this.polling)
396
+ break;
397
+ this.endInterruption();
398
+ this.handlePoll(res);
399
+ waitMs = res.more ? 0 : this.timing.pollWaitMs;
400
+ }
401
+ catch (e) {
402
+ if (!this.polling)
403
+ break;
404
+ if (e instanceof CollabError) {
405
+ if (this.handleSessionError(e))
406
+ break;
407
+ }
408
+ else if (e instanceof UnreachableError) {
409
+ await this.interrupted();
410
+ }
411
+ else {
412
+ this.finish('lost', `Connection lost: ${e.message}`, 'lost');
413
+ break;
414
+ }
415
+ waitMs = 0;
416
+ await sleep(this.timing.retryDelayMs);
417
+ }
418
+ }
419
+ }
420
+ /** Returns true if the participation is over. */
421
+ handleSessionError(e) {
422
+ switch (e.code) {
423
+ case 'session_ended':
424
+ this.finish('ended', `Session ended: ${endedText(e.error)}`, 'ended');
425
+ return true;
426
+ case 'session_unknown':
427
+ case 'not_allowed':
428
+ this.finish('ended', 'Session ended: The server ended the session', 'ended');
429
+ return true;
430
+ case 'not_participant':
431
+ if (e.error.reason === 'removed')
432
+ this.finish('removed', `${e.error.by ?? 'Someone'} removed you from the session`, 'removed');
433
+ else
434
+ this.finish('lost', 'Away too long — join the session again', 'lost');
435
+ return true;
436
+ case 'cursor_too_old':
437
+ this.finish('lost', 'Away too long — join the session again', 'lost');
438
+ return true;
439
+ case 'token_invalid':
440
+ case 'token_expired':
441
+ case 'auth_required':
442
+ this.finish('lost', 'Not signed in any more — call login', 'lost');
443
+ return true;
444
+ default:
445
+ return false;
446
+ }
447
+ }
448
+ endInterruption() {
449
+ if (this.interruptedSince !== undefined) {
450
+ this.interruptedSince = undefined;
451
+ this.emit({ kind: 'reconnected', text: 'Connection is back' });
452
+ }
453
+ }
454
+ async interrupted() {
455
+ const now = Date.now();
456
+ if (this.interruptedSince === undefined) {
457
+ this.interruptedSince = now;
458
+ this.emit({ kind: 'interrupted', text: 'Connection interrupted' });
459
+ return;
460
+ }
461
+ if (now - this.interruptedSince >= this.timing.resumeAfterMs)
462
+ await this.resume();
463
+ }
464
+ /** Returns after an interruption with `resume`; on anything but `mode: "resume"` the participation is lost. */
465
+ async resume() {
466
+ try {
467
+ const res = await this.client.request('session/join', {
468
+ requestId: requestId(),
469
+ client: this.opts.clientInfo,
470
+ sessionId: this.sessionId,
471
+ instanceKey: this.prefix,
472
+ resume: { participantId: this.participantId, after: this.appliedSeq },
473
+ }, this.opts.token(), this.timing.requestTimeoutMs);
474
+ if (res.mode !== 'resume') {
475
+ this.finish('lost', 'Away too long — join the session again', 'lost');
476
+ return;
477
+ }
478
+ this.role = res.role;
479
+ this.colour = res.colour;
480
+ this.rights = { canInvite: !!res.canInvite, canSave: !!res.canSave, canManage: !!res.canManage };
481
+ this.pollCursor = this.appliedSeq;
482
+ this.chainBuf.clear();
483
+ this.versions = { participants: 0, locks: 0, live: 0, notices: 0, saving: 0 };
484
+ this.endInterruption();
485
+ if (this.selection.size)
486
+ void this.setLocks([...this.selection]).catch(() => undefined);
487
+ }
488
+ catch (e) {
489
+ if (e instanceof CollabError)
490
+ this.handleSessionError(e);
491
+ }
492
+ }
493
+ handlePoll(res) {
494
+ this.applyOps(Array.isArray(res.ops) ? res.ops : []);
495
+ if (res.participants) {
496
+ this.versions.participants = res.participants.version;
497
+ const before = new Map(this.participants.map((p) => [p.participantId, p]));
498
+ const after = (res.participants.list ?? []);
499
+ for (const p of after) {
500
+ if (!before.has(p.participantId) && p.participantId !== this.participantId && this.participants.length) {
501
+ this.emit({ kind: 'joined', text: `${displayName(p)} joined`, data: { participantId: p.participantId } });
502
+ }
503
+ }
504
+ const nowIds = new Set(after.map((p) => p.participantId));
505
+ const removedIds = new Set((res.notices?.list ?? []).filter((n) => n.kind === 'participant_removed').map((n) => n.participantId));
506
+ for (const [id, p] of before) {
507
+ if (!nowIds.has(id) && !removedIds.has(id))
508
+ this.emit({ kind: 'left', text: `${displayName(p)} left`, data: { participantId: id } });
509
+ }
510
+ this.participants = after;
511
+ }
512
+ if (Array.isArray(res.presence))
513
+ this.presence = res.presence;
514
+ if (res.saving) {
515
+ this.versions.saving = res.saving.version;
516
+ const prev = this.saving;
517
+ this.saving = res.saving;
518
+ const savedSeq = res.saving.savedSeq;
519
+ if (this.kind === 'hosted' && typeof savedSeq === 'number' && prev && savedSeq > (prev.savedSeq ?? 0) && res.saving.savedBy) {
520
+ this.emit({ kind: 'saved', text: `Saved by ${res.saving.savedBy.name}`, data: { savedSeq, by: res.saving.savedBy.name } });
521
+ }
522
+ }
523
+ if (res.notices) {
524
+ this.versions.notices = res.notices.version;
525
+ for (const n of (res.notices.list ?? []))
526
+ this.handleNotice(n);
527
+ }
528
+ if (res.locks) {
529
+ this.versions.locks = res.locks.version;
530
+ this.lockHolders = res.locks.holders ?? [];
531
+ const mine = new Set();
532
+ const others = new Set();
533
+ for (const h of this.lockHolders)
534
+ for (const el of h.elements ?? [])
535
+ (h.participantId === this.participantId ? mine : others).add(el);
536
+ this.held = mine;
537
+ for (const el of [...this.selection])
538
+ if (others.has(el))
539
+ this.selection.delete(el);
540
+ }
541
+ if (res.checkpointWanted === true)
542
+ void this.uploadCheckpoint();
543
+ }
544
+ handleNotice(n) {
545
+ switch (n.kind) {
546
+ case 'locks_released':
547
+ this.selection.clear();
548
+ this.held.clear();
549
+ this.emit({ kind: 'locks_released', text: `${n.by} released your selection` });
550
+ break;
551
+ case 'locks_taken': {
552
+ const count = Number(n.count ?? 0);
553
+ this.emit({ kind: 'locks_taken', text: `${n.by} took over ${count} object${count === 1 ? '' : 's'}`, data: { count } });
554
+ break;
555
+ }
556
+ case 'participant_removed':
557
+ if (n.participantId !== this.participantId)
558
+ this.emit({ kind: 'participant_removed', text: `${n.name} was removed by ${n.by}` });
559
+ break;
560
+ }
561
+ }
562
+ applyOps(entries) {
563
+ const before = this.appliedSeq;
564
+ this.applyLog(entries);
565
+ if (this.appliedSeq !== before)
566
+ this.onPersist?.('state');
567
+ }
568
+ applyLog(entries) {
569
+ let i = 0;
570
+ while (i < entries.length) {
571
+ const first = entries[i];
572
+ const group = [];
573
+ while (i < entries.length && entries[i].seq === first.seq)
574
+ group.push(entries[i++]);
575
+ if (first.seq <= this.pollCursor)
576
+ continue;
577
+ group.sort((a, b) => (a.part ?? 0) - (b.part ?? 0));
578
+ const pieces = group.flatMap((g) => g.pieces);
579
+ this.pollCursor = first.seq;
580
+ if (first.chain) {
581
+ const key = `${first.participantId}\u0000${first.opId}`;
582
+ const links = this.chainBuf.get(key) ?? [];
583
+ links[first.chain.index] = pieces;
584
+ this.chainBuf.set(key, links);
585
+ if (first.chain.index !== first.chain.count - 1)
586
+ continue;
587
+ this.chainBuf.delete(key);
588
+ this.applyOne(first, links.flat().join(''));
589
+ }
590
+ else {
591
+ this.applyOne(first, pieces.join(''));
592
+ }
593
+ this.appliedSeq = first.seq;
594
+ }
595
+ }
596
+ applyOne(entry, text) {
597
+ const result = this.scene.applyText(text);
598
+ const own = entry.participantId === this.participantId ? this.pending.get(entry.opId) : undefined;
599
+ if (own) {
600
+ this.pending.delete(entry.opId);
601
+ if (result.result === 'applied') {
602
+ this.lastOwnSeq = entry.seq;
603
+ own.settle({ status: 'applied', seq: entry.seq });
604
+ }
605
+ else {
606
+ const message = result.result === 'precondition' ? result.message : result.result;
607
+ own.settle({ status: 'dropped', seq: entry.seq, message: `Step no longer possible: ${message}` });
608
+ }
609
+ return;
610
+ }
611
+ if (result.result === 'unknown_type' && entry.participantId !== this.participantId) {
612
+ this.emit({
613
+ kind: 'unknown_type',
614
+ text: `This session needs a newer dzStudio MCP (operation type of plugin ${result.plugin}, version ${result.version})`,
615
+ });
616
+ void this.leave().catch(() => undefined);
617
+ this.finish('lost', 'This session needs a newer dzStudio MCP', 'lost');
618
+ }
619
+ }
620
+ // ------------------------------------------------------------ send channel
621
+ enqueue(fn) {
622
+ const run = this.sendChain.then(fn, fn);
623
+ this.sendChain = run.catch(() => undefined);
624
+ return run;
625
+ }
626
+ /** A request of the send channel: repeated unchanged while the server is unreachable or busy. */
627
+ async sendRequest(endpoint, body) {
628
+ for (;;) {
629
+ if (!this.active)
630
+ throw new Error(this.stateText || 'Not in a session');
631
+ try {
632
+ const res = await this.client.request(endpoint, body, this.opts.token(), this.timing.requestTimeoutMs);
633
+ this.endInterruption();
634
+ return res;
635
+ }
636
+ catch (e) {
637
+ if (e instanceof UnreachableError) {
638
+ await this.interrupted();
639
+ await sleep(this.timing.retryDelayMs);
640
+ continue;
641
+ }
642
+ if (e instanceof CollabError && (e.code === 'busy' || e.code === 'throttled')) {
643
+ await sleep(Number(e.error.retryAfterMs ?? this.timing.retryDelayMs));
644
+ continue;
645
+ }
646
+ if (e instanceof CollabError)
647
+ this.handleSessionError(e);
648
+ throw e;
649
+ }
650
+ }
651
+ }
652
+ /** Sets the whole lock set. Returns what the server granted and denied. */
653
+ async setLocks(elements) {
654
+ return this.enqueue(async () => {
655
+ const set = [...new Set(elements)];
656
+ const changed = set.length !== this.selection.size || set.some((e) => !this.selection.has(e));
657
+ this.lockSeq++;
658
+ this.onPersist?.('meta');
659
+ if (changed)
660
+ this.lastInputMs = Date.now();
661
+ const res = await this.sendRequest('locks/set', { ...this.ids(), lockSeq: this.lockSeq, elements: set });
662
+ const granted = (res.granted ?? []);
663
+ const denied = (res.denied ?? []);
664
+ this.selection = new Set(granted);
665
+ this.held = new Set(granted);
666
+ return { granted, denied };
667
+ });
668
+ }
669
+ buildItems(op) {
670
+ const pieces = chunk(writeOperation(op));
671
+ const total = pieces.reduce((n, p) => n + pieceBytes(p), 0);
672
+ const links = total > MAX_OP_BYTES ? groupBytes(pieces, MAX_OP_BYTES) : [pieces];
673
+ if (links.length > MAX_CHAIN_OPS) {
674
+ const mb = Math.ceil(total / 1_048_576);
675
+ throw new Error(`Too large for a shared session (${mb} MB, at most 56 MB)`);
676
+ }
677
+ const items = [];
678
+ links.forEach((link, index) => {
679
+ const clientSeq = this.nextClientSeq + index;
680
+ const chain = links.length > 1 ? { index, count: links.length } : undefined;
681
+ const linkBytes = link.reduce((n, p) => n + pieceBytes(p), 0);
682
+ const parts = linkBytes > PART_BYTES ? groupBytes(link, PART_BYTES) : [link];
683
+ parts.forEach((partPieces, part) => {
684
+ const item = { clientSeq, opId: op.id, pieces: partPieces };
685
+ if (parts.length > 1) {
686
+ item.part = part;
687
+ item.parts = parts.length;
688
+ }
689
+ if (chain)
690
+ item.chain = chain;
691
+ items.push(item);
692
+ });
693
+ });
694
+ this.nextClientSeq += links.length;
695
+ this.onPersist?.('meta');
696
+ return items;
697
+ }
698
+ /**
699
+ * Submits one own operation: checks it on the scene first (an operation that would not apply is not sent), sends
700
+ * it, and waits until the log has applied or dropped it, at most `confirmTimeoutMs`.
701
+ */
702
+ async submit(op) {
703
+ if (!this.active)
704
+ return { status: 'rejected', message: this.stateText || 'Not in a session' };
705
+ const check = this.view(() => this.scene.tentative([op], (r) => r[0]));
706
+ if (check.result !== 'applied') {
707
+ const message = check.result === 'precondition' ? `Step no longer possible: ${check.message}` : `Operation not valid: ${check.result}`;
708
+ return { status: 'rejected', message };
709
+ }
710
+ let items;
711
+ try {
712
+ items = this.buildItems(op);
713
+ }
714
+ catch (e) {
715
+ return { status: 'rejected', message: e.message };
716
+ }
717
+ let settle;
718
+ const done = new Promise((r) => (settle = r));
719
+ const pending = { op, settle, done };
720
+ this.pending.set(op.id, pending);
721
+ this.lastInputMs = Date.now();
722
+ const sent = this.enqueue(() => this.sendItems(items)).catch((e) => {
723
+ if (this.pending.get(op.id) === pending) {
724
+ this.pending.delete(op.id);
725
+ settle({ status: 'rejected', message: e.message });
726
+ }
727
+ });
728
+ void sent;
729
+ const timeout = sleep(this.timing.confirmTimeoutMs).then(() => ({ status: 'unconfirmed' }));
730
+ const result = await Promise.race([done, timeout]);
731
+ if (result.status === 'unconfirmed') {
732
+ void done.then((r) => {
733
+ if (r.status === 'applied')
734
+ this.emit({ kind: 'step_applied', text: `Step ${op.id} was applied`, data: { opId: op.id, seq: r.seq } });
735
+ else if (r.status === 'dropped' || r.status === 'rejected')
736
+ this.emit({ kind: 'step_dropped', text: r.message, data: { opId: op.id } });
737
+ });
738
+ }
739
+ else if (result.status === 'dropped') {
740
+ this.emit({ kind: 'step_dropped', text: result.message, data: { opId: op.id } });
741
+ }
742
+ return result;
743
+ }
744
+ async sendItems(items) {
745
+ let rest = items;
746
+ while (rest.length) {
747
+ const batch = [];
748
+ let bytes = 0;
749
+ for (const item of rest) {
750
+ const n = item.pieces.reduce((s, p) => s + pieceBytes(p), 0);
751
+ if (batch.length && (batch.length >= MAX_REQUEST_ITEMS || bytes + n > MAX_OP_BYTES))
752
+ break;
753
+ batch.push(item);
754
+ bytes += n;
755
+ }
756
+ try {
757
+ await this.sendRequest('ops/send', { ...this.ids(), ops: batch });
758
+ rest = rest.slice(batch.length);
759
+ }
760
+ catch (e) {
761
+ if (e instanceof CollabError && e.code === 'client_seq_gap') {
762
+ const expected = Number(e.error.expectedClientSeq);
763
+ const from = items.findIndex((it) => it.clientSeq >= expected);
764
+ if (from < 0)
765
+ return;
766
+ rest = items.slice(from);
767
+ continue;
768
+ }
769
+ throw e;
770
+ }
771
+ }
772
+ }
773
+ // ------------------------------------------------------------ checkpoints
774
+ async uploadCheckpoint() {
775
+ if (this.checkpointBusy)
776
+ return;
777
+ if (this.pending.size || this.chainBuf.size || this.appliedSeq === 0)
778
+ return;
779
+ const project = this.scene.get('project');
780
+ if (project && (project.fields.get('lootFiles') ?? '') !== '')
781
+ return;
782
+ this.checkpointBusy = true;
783
+ try {
784
+ const seq = this.appliedSeq;
785
+ const parts = groupBytes(chunk(this.scene.stateText()), PART_BYTES);
786
+ await this.enqueue(async () => {
787
+ for (let part = 0; part < parts.length; part++) {
788
+ await this.sendRequest('snapshot/put', { ...this.ids(), seq, part, parts: parts.length, pieces: parts[part] });
789
+ }
790
+ });
791
+ }
792
+ catch {
793
+ // the server asks again
794
+ }
795
+ finally {
796
+ this.checkpointBusy = false;
797
+ }
798
+ }
799
+ // ------------------------------------------------------------ leaving
800
+ /** Waits until all own operations are confirmed, at most `ms`. Returns how many are still open. */
801
+ async flush(ms) {
802
+ const deadline = Date.now() + ms;
803
+ while (this.pending.size && Date.now() < deadline && this.active)
804
+ await sleep(50);
805
+ return this.pending.size;
806
+ }
807
+ async leave() {
808
+ const wasActive = this.active;
809
+ this.polling = false;
810
+ this.pollAbort?.abort();
811
+ if (wasActive || this.state === 'lost') {
812
+ try {
813
+ await this.client.request('session/leave', this.ids(), this.opts.token(), this.timing.requestTimeoutMs);
814
+ }
815
+ catch {
816
+ // gone anyway
817
+ }
818
+ }
819
+ if (this.active) {
820
+ this.state = 'left';
821
+ this.stateText = 'Left the session';
822
+ }
823
+ for (const p of this.pending.values())
824
+ p.settle({ status: 'rejected', message: 'Left the session' });
825
+ this.pending.clear();
826
+ }
827
+ /** Stops polling without telling the server (the MCP is shutting down; `resume` may bring it back). */
828
+ async stop() {
829
+ this.polling = false;
830
+ this.pollAbort?.abort();
831
+ await Promise.race([this.pollLoopDone, sleep(50)]);
832
+ }
833
+ }
834
+ export function displayName(p) {
835
+ return p.kind === 'agent' ? `${p.name}'s agent` : String(p.name ?? p.participantId);
836
+ }
837
+ //# sourceMappingURL=session.js.map