@crowdedkingdoms/crowdyjs 11.1.0 → 12.0.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 (81) hide show
  1. package/MIGRATION.md +107 -0
  2. package/README.md +203 -0
  3. package/dist/crowdy-agent/browser-dispatcher.d.ts +41 -0
  4. package/dist/crowdy-agent/browser-dispatcher.d.ts.map +1 -0
  5. package/dist/crowdy-agent/browser-dispatcher.js +264 -0
  6. package/dist/crowdy-agent/controller.d.ts +144 -0
  7. package/dist/crowdy-agent/controller.d.ts.map +1 -0
  8. package/dist/crowdy-agent/controller.js +1239 -0
  9. package/dist/crowdy-agent/errors.d.ts +37 -0
  10. package/dist/crowdy-agent/errors.d.ts.map +1 -0
  11. package/dist/crowdy-agent/errors.js +107 -0
  12. package/dist/crowdy-agent/graphql-transport.d.ts +154 -0
  13. package/dist/crowdy-agent/graphql-transport.d.ts.map +1 -0
  14. package/dist/crowdy-agent/graphql-transport.js +854 -0
  15. package/dist/crowdy-agent/index.d.ts +16 -0
  16. package/dist/crowdy-agent/index.d.ts.map +1 -0
  17. package/dist/crowdy-agent/index.js +15 -0
  18. package/dist/crowdy-agent/registry.d.ts +29 -0
  19. package/dist/crowdy-agent/registry.d.ts.map +1 -0
  20. package/dist/crowdy-agent/registry.js +281 -0
  21. package/dist/crowdy-agent/schema.d.ts +72 -0
  22. package/dist/crowdy-agent/schema.d.ts.map +1 -0
  23. package/dist/crowdy-agent/schema.js +467 -0
  24. package/dist/crowdy-agent/studio-tools.d.ts +15 -0
  25. package/dist/crowdy-agent/studio-tools.d.ts.map +1 -0
  26. package/dist/crowdy-agent/studio-tools.js +280 -0
  27. package/dist/crowdy-agent/tool-descriptors.d.ts +4 -0
  28. package/dist/crowdy-agent/tool-descriptors.d.ts.map +1 -0
  29. package/dist/crowdy-agent/tool-descriptors.js +1049 -0
  30. package/dist/crowdy-agent/transport.d.ts +152 -0
  31. package/dist/crowdy-agent/transport.d.ts.map +1 -0
  32. package/dist/crowdy-agent/transport.js +27 -0
  33. package/dist/crowdy-agent/types.d.ts +328 -0
  34. package/dist/crowdy-agent/types.d.ts.map +1 -0
  35. package/dist/crowdy-agent/types.js +37 -0
  36. package/dist/crowdy-client.d.ts +3 -0
  37. package/dist/crowdy-client.d.ts.map +1 -1
  38. package/dist/crowdy-client.js +6 -0
  39. package/dist/crowdy-studio/agent-dom-shell.d.ts +40 -0
  40. package/dist/crowdy-studio/agent-dom-shell.d.ts.map +1 -0
  41. package/dist/crowdy-studio/agent-dom-shell.js +354 -0
  42. package/dist/crowdy-studio/controller.d.ts +74 -4
  43. package/dist/crowdy-studio/controller.d.ts.map +1 -1
  44. package/dist/crowdy-studio/controller.js +450 -17
  45. package/dist/crowdy-studio/dom-shell.d.ts +4 -1
  46. package/dist/crowdy-studio/dom-shell.d.ts.map +1 -1
  47. package/dist/crowdy-studio/dom-shell.js +6 -1
  48. package/dist/crowdy-studio/index.d.ts +6 -3
  49. package/dist/crowdy-studio/index.d.ts.map +1 -1
  50. package/dist/crowdy-studio/index.js +3 -0
  51. package/dist/crowdy-studio/models.d.ts +67 -0
  52. package/dist/crowdy-studio/models.d.ts.map +1 -1
  53. package/dist/crowdy-studio/mount.d.ts +15 -0
  54. package/dist/crowdy-studio/mount.d.ts.map +1 -1
  55. package/dist/crowdy-studio/mount.js +129 -2
  56. package/dist/crowdy-studio/styles.d.ts +1 -1
  57. package/dist/crowdy-studio/styles.d.ts.map +1 -1
  58. package/dist/crowdy-studio/styles.js +2 -0
  59. package/dist/generated/graphql.d.ts +3753 -1
  60. package/dist/generated/graphql.d.ts.map +1 -1
  61. package/dist/generated/graphql.js +296 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/index.js +1 -1
  64. package/dist/kit/npcs.d.ts.map +1 -1
  65. package/dist/kit/social.d.ts.map +1 -1
  66. package/dist/player-host/index.d.ts +5 -0
  67. package/dist/player-host/index.d.ts.map +1 -0
  68. package/dist/player-host/index.js +3 -0
  69. package/dist/player-host/lease-manager.d.ts +66 -0
  70. package/dist/player-host/lease-manager.d.ts.map +1 -0
  71. package/dist/player-host/lease-manager.js +428 -0
  72. package/dist/player-host/schemas.d.ts +9 -0
  73. package/dist/player-host/schemas.d.ts.map +1 -0
  74. package/dist/player-host/schemas.js +347 -0
  75. package/dist/player-host/tools.d.ts +13 -0
  76. package/dist/player-host/tools.d.ts.map +1 -0
  77. package/dist/player-host/tools.js +79 -0
  78. package/dist/player-host/types.d.ts +203 -0
  79. package/dist/player-host/types.d.ts.map +1 -0
  80. package/dist/player-host/types.js +1 -0
  81. package/package.json +12 -2
@@ -0,0 +1,1239 @@
1
+ import { CrowdyAgentError, toAgentError, } from './errors.js';
2
+ import { isDecimalString } from './schema.js';
3
+ import { CROWDY_AGENT_EVENT_TYPES } from './types.js';
4
+ /**
5
+ * Durable session client for the `crowdy.studio-agent/1` protocol. The
6
+ * transport is injectable and contains no generated GraphQL dependency.
7
+ */
8
+ export class CrowdyStudioAgentController {
9
+ constructor(options) {
10
+ this.options = options;
11
+ this.state = {
12
+ connection: 'DISCONNECTED',
13
+ session: null,
14
+ clientEpoch: null,
15
+ lastContiguousSeq: '0',
16
+ lastAcknowledgedSeq: '0',
17
+ events: [],
18
+ messages: [],
19
+ streamingText: '',
20
+ tools: [],
21
+ approvals: [],
22
+ leases: [],
23
+ checkpoints: [],
24
+ budget: null,
25
+ toolDescriptors: [],
26
+ lastHeartbeatAt: null,
27
+ playLeaseFreshUntil: null,
28
+ lastError: null,
29
+ reconnectRequired: false,
30
+ };
31
+ this.listeners = new Set();
32
+ this.buffered = new Map();
33
+ this.appliedEventIds = new Map();
34
+ this.subscription = null;
35
+ this.generation = 0;
36
+ this.drainPromise = null;
37
+ this.acknowledgePromise = Promise.resolve();
38
+ this.reconnectTimer = null;
39
+ this.heartbeatTimer = null;
40
+ this.workspaceRenewTimer = null;
41
+ this.reconnectAttempts = 0;
42
+ this.keySequence = 0;
43
+ this.pageVisible = true;
44
+ this.destroyed = false;
45
+ if (!options.sessionId && !options.createSession) {
46
+ throw new CrowdyAgentError('AGENT_SESSION_NOT_FOUND', 'Provide sessionId or createSession to initialize the agent client');
47
+ }
48
+ this.clientInstanceId =
49
+ options.clientInstanceId ?? createClientInstanceId();
50
+ if (options.onStateChange)
51
+ this.listeners.add(options.onStateChange);
52
+ }
53
+ getState() {
54
+ return this.state;
55
+ }
56
+ subscribe(listener) {
57
+ this.listeners.add(listener);
58
+ listener(this.state);
59
+ return () => this.listeners.delete(listener);
60
+ }
61
+ setPageVisible(visible) {
62
+ if (this.pageVisible === visible)
63
+ return;
64
+ this.pageVisible = visible;
65
+ if (!visible &&
66
+ this.state.connection === 'CONNECTED' &&
67
+ this.state.session?.mode === 'PLAY') {
68
+ this.preemptLocal('DISCONNECTED');
69
+ this.update({ leases: [], playLeaseFreshUntil: null });
70
+ }
71
+ this.refreshHeartbeat(visible);
72
+ }
73
+ async initialize() {
74
+ this.ensureAlive();
75
+ const binding = await this.options.resolveProjectBinding?.();
76
+ let session;
77
+ if (this.options.sessionId) {
78
+ session = await this.options.transport.getSession(this.options.sessionId);
79
+ this.assertProjectBinding(session, binding?.projectId, Boolean(this.options.resolveProjectBinding));
80
+ }
81
+ else {
82
+ const configured = typeof this.options.createSession === 'function'
83
+ ? await this.options.createSession()
84
+ : this.options.createSession;
85
+ const createInput = {
86
+ ...configured,
87
+ ...(this.options.resolveProjectBinding
88
+ ? binding?.projectId
89
+ ? { projectId: binding.projectId }
90
+ : { projectId: undefined }
91
+ : {}),
92
+ ...(binding?.gridId ? { gridId: binding.gridId } : {}),
93
+ };
94
+ if (createInput.mode === 'BUILD' && !createInput.projectId) {
95
+ throw new CrowdyAgentError('AGENT_CONTEXT_CHANGED', 'BUILD requires the currently selected saved Crowdy Studio project');
96
+ }
97
+ session = await this.options.transport.createSession(createInput);
98
+ this.assertProjectBinding(session, binding?.projectId, Boolean(this.options.resolveProjectBinding));
99
+ }
100
+ this.update({ session, lastError: null });
101
+ await this.attach('ATTACHING');
102
+ }
103
+ /**
104
+ * Fail closed when the human selects another project. The v1 Game API has no
105
+ * set-project mutation, so callers must create/remount a session for it.
106
+ */
107
+ projectSelectionChanged(projectId) {
108
+ const session = this.state.session;
109
+ if (!session || session.projectId === projectId)
110
+ return;
111
+ const clientEpoch = this.state.clientEpoch;
112
+ if (clientEpoch) {
113
+ for (const lease of this.state.leases.filter((entry) => entry.status === 'ACTIVE')) {
114
+ void this.options.transport
115
+ .revokeLease({
116
+ sessionId: session.sessionId,
117
+ clientEpoch,
118
+ leaseId: lease.leaseId,
119
+ reason: 'CONTEXT_CHANGED',
120
+ idempotencyKey: this.nextKey(`project-switch-revoke-${lease.leaseId}`),
121
+ })
122
+ .catch(() => { });
123
+ }
124
+ if (session.currentRun?.runId) {
125
+ void this.options.transport
126
+ .cancelRun({
127
+ sessionId: session.sessionId,
128
+ clientEpoch,
129
+ runId: session.currentRun.runId,
130
+ idempotencyKey: this.nextKey('project-switch-cancel'),
131
+ })
132
+ .catch(() => { });
133
+ }
134
+ void this.options.transport
135
+ .pause({
136
+ sessionId: session.sessionId,
137
+ clientEpoch,
138
+ idempotencyKey: this.nextKey('project-switch-pause'),
139
+ })
140
+ .catch(() => { });
141
+ }
142
+ this.preemptLocal('CONTEXT_CHANGED');
143
+ this.stopHeartbeat();
144
+ this.generation++;
145
+ this.disconnectSubscription();
146
+ const error = new CrowdyAgentError('AGENT_CONTEXT_CHANGED', 'Selected project changed; create a new agent session for this project');
147
+ this.update({
148
+ connection: 'ERROR',
149
+ clientEpoch: null,
150
+ leases: [],
151
+ approvals: [],
152
+ reconnectRequired: true,
153
+ lastError: error.toJSON(),
154
+ });
155
+ }
156
+ async reconnect() {
157
+ this.ensureAlive();
158
+ if (!this.state.session) {
159
+ await this.initialize();
160
+ return;
161
+ }
162
+ await this.attach('RECONNECTING');
163
+ }
164
+ async sendMessage(content) {
165
+ const normalized = content.trim();
166
+ if (normalized.length < 1 ||
167
+ new TextEncoder().encode(normalized).byteLength > 16384) {
168
+ throw new CrowdyAgentError('AGENT_TOOL_INPUT_INVALID', 'Agent message must be 1 to 16384 UTF-8 bytes');
169
+ }
170
+ const session = this.requireSession();
171
+ await this.options.beforeAgentWork?.(session.mode);
172
+ return this.options.transport.sendMessage({
173
+ ...this.mutationContext('send-message'),
174
+ message: normalized,
175
+ });
176
+ }
177
+ async setMode(mode) {
178
+ if (!['ASK', 'BUILD', 'PLAY'].includes(mode)) {
179
+ throw new CrowdyAgentError('AGENT_TOOL_INPUT_INVALID', `Unknown agent mode ${String(mode)}`);
180
+ }
181
+ if (mode === 'BUILD' && !this.requireSession().projectId) {
182
+ throw new CrowdyAgentError('AGENT_CONTEXT_CHANGED', 'Create a project-bound agent session before selecting BUILD');
183
+ }
184
+ this.preemptLocal('CONTEXT_CHANGED');
185
+ const session = await this.options.transport.setMode({
186
+ ...this.mutationContext('set-mode'),
187
+ mode,
188
+ });
189
+ const context = await this.loadRemoteContext(session);
190
+ this.update({
191
+ session,
192
+ ...context,
193
+ leases: [],
194
+ approvals: [],
195
+ reconnectRequired: false,
196
+ });
197
+ this.refreshHeartbeat(true);
198
+ this.refreshWorkspaceRenewal();
199
+ }
200
+ async approveTool(toolCallId, expectedArgumentHash) {
201
+ const approval = this.state.approvals.find((entry) => entry.toolCallId === toolCallId && entry.status === 'PENDING');
202
+ if (!approval) {
203
+ throw new CrowdyAgentError('AGENT_APPROVAL_MISMATCH', `No pending exact approval exists for ${toolCallId}`);
204
+ }
205
+ if (expectedArgumentHash &&
206
+ expectedArgumentHash !== approval.argumentHash) {
207
+ throw new CrowdyAgentError('AGENT_APPROVAL_MISMATCH', 'Displayed approval hash changed');
208
+ }
209
+ if (Date.parse(approval.expiresAt) <= Date.now()) {
210
+ throw new CrowdyAgentError('AGENT_APPROVAL_EXPIRED', 'Exact tool approval expired');
211
+ }
212
+ await this.options.transport.approveTool({
213
+ ...this.mutationContext('approve-tool'),
214
+ toolCallId,
215
+ argumentHash: approval.argumentHash,
216
+ });
217
+ }
218
+ async rejectTool(toolCallId, reason) {
219
+ const approval = this.state.approvals.find((entry) => entry.toolCallId === toolCallId && entry.status === 'PENDING');
220
+ if (!approval) {
221
+ throw new CrowdyAgentError('AGENT_APPROVAL_MISMATCH', `No pending exact approval exists for ${toolCallId}`);
222
+ }
223
+ await this.options.transport.rejectTool({
224
+ ...this.mutationContext('reject-tool'),
225
+ toolCallId,
226
+ argumentHash: approval.argumentHash,
227
+ ...(reason ? { reason: reason.slice(0, 512) } : {}),
228
+ });
229
+ }
230
+ async grantPlayLease(input) {
231
+ if (!Number.isInteger(input.durationSeconds) ||
232
+ input.durationSeconds < 1 ||
233
+ input.durationSeconds > 600) {
234
+ throw new CrowdyAgentError('AGENT_LEASE_EXPIRED', 'Play lease duration must be 1 to 600 seconds');
235
+ }
236
+ if (input.scopes.length === 0 || input.scopes.length > 10) {
237
+ throw new CrowdyAgentError('AGENT_LEASE_SCOPE_MISSING', 'Select 1 to 10 explicit Play scopes');
238
+ }
239
+ const allowed = new Set([
240
+ 'observe',
241
+ 'locomotion',
242
+ 'interact',
243
+ 'craft',
244
+ 'combat',
245
+ 'communicate',
246
+ 'travel',
247
+ 'grid',
248
+ 'trust_consent',
249
+ 'commerce',
250
+ ]);
251
+ const invalid = input.scopes.find((scope) => !allowed.has(scope));
252
+ if (invalid) {
253
+ throw new CrowdyAgentError('AGENT_LEASE_SCOPE_MISSING', `Unknown Play lease scope ${invalid}`);
254
+ }
255
+ const lease = await this.options.transport.grantLease({
256
+ ...this.mutationContext('grant-play-lease'),
257
+ scopes: [...new Set(input.scopes)],
258
+ durationSeconds: input.durationSeconds,
259
+ controlledEntityId: input.controlledEntityId,
260
+ hostCapabilityRevision: input.hostCapabilityRevision,
261
+ });
262
+ this.upsertLease(lease);
263
+ this.options.onLeaseChanged?.(lease);
264
+ return lease;
265
+ }
266
+ async revokeLease(leaseId, reason = 'HUMAN_STOP') {
267
+ const lease = await this.options.transport.revokeLease({
268
+ ...this.mutationContext('revoke-lease'),
269
+ leaseId,
270
+ reason,
271
+ });
272
+ this.upsertLease(lease);
273
+ this.options.onLeaseChanged?.(lease);
274
+ }
275
+ async pause() {
276
+ this.preemptLocal('HUMAN_STOP');
277
+ this.stopHeartbeat();
278
+ this.stopWorkspaceRenewal();
279
+ const session = await this.options.transport.pause(this.mutationContext('pause'));
280
+ this.update({ session, playLeaseFreshUntil: null });
281
+ }
282
+ async resume() {
283
+ const session = await this.options.transport.resume(this.mutationContext('resume'));
284
+ const context = await this.loadRemoteContext(session);
285
+ this.update({
286
+ session,
287
+ ...context,
288
+ reconnectRequired: false,
289
+ // A Play lease is never restored by resume.
290
+ leases: session.mode === 'PLAY'
291
+ ? this.state.leases.filter((lease) => lease.kind !== 'PLAY')
292
+ : this.state.leases,
293
+ });
294
+ this.refreshHeartbeat(true);
295
+ this.refreshWorkspaceRenewal();
296
+ }
297
+ async cancelRun(runId = this.state.session?.currentRun?.runId) {
298
+ if (!runId) {
299
+ throw new CrowdyAgentError('AGENT_RUN_NOT_ACTIVE', 'No current run exists to cancel');
300
+ }
301
+ await this.options.transport.cancelRun({
302
+ ...this.mutationContext('cancel-run'),
303
+ runId,
304
+ });
305
+ }
306
+ /** Immediate human Stop: local preemption happens before transport calls. */
307
+ async stop() {
308
+ this.preemptLocal('HUMAN_STOP');
309
+ const activeLeases = this.state.leases.filter((lease) => lease.status === 'ACTIVE');
310
+ await Promise.allSettled(activeLeases.map((lease) => this.options.transport.revokeLease({
311
+ ...this.mutationContext(`stop-revoke-${lease.leaseId}`),
312
+ leaseId: lease.leaseId,
313
+ reason: 'HUMAN_STOP',
314
+ })));
315
+ await Promise.allSettled([
316
+ this.cancelRun(),
317
+ this.options.transport.pause(this.mutationContext('stop-pause')),
318
+ ]);
319
+ }
320
+ /** Editor changes synchronously preempt Build before best-effort cleanup. */
321
+ preemptForHumanEdit() {
322
+ this.preemptLocal('HUMAN_EDIT');
323
+ this.update({ reconnectRequired: true });
324
+ const workspace = this.state.leases.find((lease) => lease.kind === 'WORKSPACE' && lease.status === 'ACTIVE');
325
+ if (workspace) {
326
+ void this.revokeLease(workspace.leaseId, 'HUMAN_EDIT').catch(() => { });
327
+ }
328
+ if (this.state.session?.currentRun) {
329
+ void this.cancelRun().catch(() => { });
330
+ }
331
+ }
332
+ async restoreCheckpoint(checkpointId) {
333
+ const checkpoint = this.state.checkpoints.find((entry) => entry.checkpointId === checkpointId);
334
+ if (!checkpoint) {
335
+ throw new CrowdyAgentError('AGENT_CHECKPOINT_NOT_FOUND', `Checkpoint ${checkpointId} is not loaded`);
336
+ }
337
+ // This is user intent, not approval. The server/model must still propose
338
+ // project.checkpoint.restore and issue an exact approval card.
339
+ await this.sendMessage(`Request checkpoint restore for ${checkpoint.checkpointId} at project revision ${checkpoint.projectRevision} with content hash ${checkpoint.contentHash}. Do not execute without exact human approval.`);
340
+ }
341
+ async close() {
342
+ this.preemptLocal('SESSION_CLOSED');
343
+ this.stopHeartbeat();
344
+ this.stopWorkspaceRenewal();
345
+ const session = await this.options.transport.closeSession(this.mutationContext('close-session'));
346
+ this.generation++;
347
+ this.disconnectSubscription();
348
+ this.update({
349
+ connection: 'DISCONNECTED',
350
+ session,
351
+ clientEpoch: null,
352
+ leases: [],
353
+ approvals: [],
354
+ });
355
+ this.options.browserDispatcher?.clearClosedSession();
356
+ }
357
+ destroy() {
358
+ if (this.destroyed)
359
+ return;
360
+ this.destroyed = true;
361
+ this.generation++;
362
+ this.disconnectSubscription();
363
+ if (this.reconnectTimer)
364
+ clearTimeout(this.reconnectTimer);
365
+ this.reconnectTimer = null;
366
+ this.stopHeartbeat();
367
+ this.stopWorkspaceRenewal();
368
+ this.preemptLocal('DISCONNECTED');
369
+ this.listeners.clear();
370
+ }
371
+ async attach(connection) {
372
+ const session = this.requireSession();
373
+ const generation = ++this.generation;
374
+ this.stopHeartbeat();
375
+ this.disconnectSubscription();
376
+ if (connection === 'RECONNECTING') {
377
+ this.preemptLocal('DISCONNECTED');
378
+ }
379
+ this.update({
380
+ connection,
381
+ lastError: null,
382
+ reconnectRequired: connection === 'RECONNECTING',
383
+ });
384
+ try {
385
+ const attached = await this.options.transport.attachClient({
386
+ sessionId: session.sessionId,
387
+ clientInstanceId: this.clientInstanceId,
388
+ idempotencyKey: this.nextKey('attach-client'),
389
+ });
390
+ if (generation !== this.generation || this.destroyed)
391
+ return;
392
+ assertSequence(attached.clientEpoch, 'clientEpoch');
393
+ assertSequence(attached.replayAfterSeq, 'replayAfterSeq');
394
+ assertSequence(attached.session.lastEventSeq, 'lastEventSeq');
395
+ if (attached.session.sessionId !== session.sessionId ||
396
+ (attached.session.clientEpoch &&
397
+ attached.session.clientEpoch !== attached.clientEpoch)) {
398
+ throw new CrowdyAgentError('AGENT_CLIENT_EPOCH_STALE', 'Attach response session or epoch is inconsistent');
399
+ }
400
+ if (BigInt(attached.replayAfterSeq) > BigInt(attached.session.lastEventSeq)) {
401
+ throw new CrowdyAgentError('AGENT_EVENT_CURSOR_INVALID', 'Attach replay cursor exceeds committed session history');
402
+ }
403
+ if (this.state.clientEpoch &&
404
+ BigInt(attached.clientEpoch) <= BigInt(this.state.clientEpoch)) {
405
+ throw new CrowdyAgentError('AGENT_CLIENT_EPOCH_STALE', 'Server attach did not advance the client epoch');
406
+ }
407
+ this.options.onEpochAttached?.(attached.clientEpoch);
408
+ this.buffered.clear();
409
+ const replayAfterSeq = BigInt(attached.replayAfterSeq) >
410
+ BigInt(this.state.lastContiguousSeq)
411
+ ? attached.replayAfterSeq
412
+ : this.state.lastContiguousSeq;
413
+ this.update({
414
+ connection: 'REPLAYING',
415
+ session: attached.session,
416
+ clientEpoch: attached.clientEpoch,
417
+ lastContiguousSeq: replayAfterSeq,
418
+ lastAcknowledgedSeq: BigInt(replayAfterSeq) > BigInt(this.state.lastAcknowledgedSeq)
419
+ ? replayAfterSeq
420
+ : this.state.lastAcknowledgedSeq,
421
+ leases: attached.session.activeLeases,
422
+ approvals: attached.session.pendingApproval
423
+ ? [attached.session.pendingApproval]
424
+ : [],
425
+ });
426
+ for (const lease of attached.session.activeLeases) {
427
+ this.options.onLeaseChanged?.(lease);
428
+ }
429
+ const context = await this.loadRemoteContext(attached.session);
430
+ if (generation !== this.generation || this.destroyed)
431
+ return;
432
+ this.update(context);
433
+ await this.replayHistory(generation);
434
+ if (generation !== this.generation || this.destroyed)
435
+ return;
436
+ this.subscription = await this.options.transport.subscribeEvents({
437
+ sessionId: session.sessionId,
438
+ afterSeq: this.state.lastContiguousSeq,
439
+ clientEpoch: attached.clientEpoch,
440
+ }, {
441
+ next: (event) => this.enqueueEvent(event, generation),
442
+ error: (error) => this.handleDisconnect(error, generation),
443
+ complete: () => this.handleDisconnect(new CrowdyAgentError('AGENT_DISCONNECTED', 'Agent event stream closed', { retryable: true }), generation),
444
+ });
445
+ if (generation !== this.generation || this.destroyed) {
446
+ this.subscription.close();
447
+ this.subscription = null;
448
+ return;
449
+ }
450
+ this.reconnectAttempts = 0;
451
+ this.update({
452
+ connection: 'CONNECTED',
453
+ reconnectRequired: connection === 'RECONNECTING',
454
+ });
455
+ this.refreshHeartbeat(true);
456
+ this.refreshWorkspaceRenewal();
457
+ }
458
+ catch (error) {
459
+ if (generation !== this.generation || this.destroyed)
460
+ return;
461
+ this.stopHeartbeat();
462
+ this.fail(error);
463
+ throw error;
464
+ }
465
+ }
466
+ async replayHistory(generation) {
467
+ let hasMore = true;
468
+ while (hasMore && generation === this.generation && !this.destroyed) {
469
+ const page = await this.options.transport.history({
470
+ sessionId: this.requireSession().sessionId,
471
+ afterSeq: this.state.lastContiguousSeq,
472
+ first: this.options.historyPageSize ?? 100,
473
+ });
474
+ for (const event of page.events)
475
+ this.bufferEvent(event);
476
+ await this.drainBuffered(generation, true);
477
+ hasMore = page.hasMore;
478
+ if (page.hasMore && page.events.length === 0) {
479
+ throw new CrowdyAgentError('AGENT_EVENT_CURSOR_INVALID', 'History page claimed more events without advancing the cursor');
480
+ }
481
+ }
482
+ }
483
+ enqueueEvent(event, generation) {
484
+ if (generation !== this.generation || this.destroyed)
485
+ return;
486
+ try {
487
+ this.bufferEvent(event);
488
+ }
489
+ catch (error) {
490
+ this.fail(error);
491
+ return;
492
+ }
493
+ this.startDrain(generation);
494
+ }
495
+ startDrain(generation) {
496
+ if (this.drainPromise)
497
+ return;
498
+ this.drainPromise = this.drainBuffered(generation, true)
499
+ .catch((error) => this.fail(error))
500
+ .finally(() => {
501
+ this.drainPromise = null;
502
+ if (this.buffered.size > 0 &&
503
+ generation === this.generation &&
504
+ !this.destroyed) {
505
+ this.startDrain(generation);
506
+ }
507
+ });
508
+ }
509
+ bufferEvent(event) {
510
+ const session = this.requireSession();
511
+ if (event === null ||
512
+ typeof event !== 'object' ||
513
+ event.protocolVersion !== 'crowdy.agent-event/1' ||
514
+ !CROWDY_AGENT_EVENT_TYPES.includes(event.type) ||
515
+ typeof event.eventId !== 'string' ||
516
+ event.eventId.length < 1 ||
517
+ event.eventId.length > 128) {
518
+ throw new CrowdyAgentError('AGENT_EVENT_CURSOR_INVALID', 'Event envelope or type is invalid');
519
+ }
520
+ if (event.sessionId !== session.sessionId) {
521
+ throw new CrowdyAgentError('AGENT_EVENT_CURSOR_INVALID', 'Event belongs to a different session');
522
+ }
523
+ validateEventPayload(event);
524
+ assertSequence(event.seq, 'event seq');
525
+ const appliedId = this.appliedEventIds.get(event.seq);
526
+ if (appliedId) {
527
+ if (appliedId !== event.eventId) {
528
+ throw new CrowdyAgentError('AGENT_EVENT_CURSOR_INVALID', `Sequence ${event.seq} was reused by a different event`);
529
+ }
530
+ return;
531
+ }
532
+ const buffered = this.buffered.get(event.seq);
533
+ if (buffered && buffered.eventId !== event.eventId) {
534
+ throw new CrowdyAgentError('AGENT_EVENT_CURSOR_INVALID', `Buffered sequence ${event.seq} conflicts with another event`);
535
+ }
536
+ if (BigInt(event.seq) <= BigInt(this.state.lastContiguousSeq))
537
+ return;
538
+ this.buffered.set(event.seq, event);
539
+ }
540
+ async drainBuffered(generation, recoverGaps) {
541
+ let recoveredWithoutProgress = false;
542
+ while (generation === this.generation && !this.destroyed) {
543
+ const nextSeq = (BigInt(this.state.lastContiguousSeq) + 1n).toString();
544
+ const next = this.buffered.get(nextSeq);
545
+ if (next) {
546
+ this.buffered.delete(nextSeq);
547
+ this.applyEvent(next, generation);
548
+ recoveredWithoutProgress = false;
549
+ continue;
550
+ }
551
+ if (this.buffered.size === 0 || !recoverGaps)
552
+ break;
553
+ const firstBuffered = [...this.buffered.keys()].reduce((left, right) => BigInt(left) < BigInt(right) ? left : right);
554
+ if (BigInt(firstBuffered) <= BigInt(this.state.lastContiguousSeq)) {
555
+ this.buffered.delete(firstBuffered);
556
+ continue;
557
+ }
558
+ const page = await this.options.transport.history({
559
+ sessionId: this.requireSession().sessionId,
560
+ afterSeq: this.state.lastContiguousSeq,
561
+ first: this.options.historyPageSize ?? 100,
562
+ });
563
+ const before = this.buffered.size;
564
+ for (const event of page.events)
565
+ this.bufferEvent(event);
566
+ if (page.events.length === 0 ||
567
+ (this.buffered.size === before && recoveredWithoutProgress)) {
568
+ throw new CrowdyAgentError('AGENT_EVENT_GAP', `Durable history could not fill missing sequence ${nextSeq}`, { retryable: true });
569
+ }
570
+ recoveredWithoutProgress = this.buffered.size === before;
571
+ }
572
+ this.scheduleAcknowledge(generation);
573
+ }
574
+ applyEvent(event, generation) {
575
+ const maxEvents = this.options.maxRetainedEvents ?? 1000;
576
+ const events = [...this.state.events, event].slice(-maxEvents);
577
+ let patch = {
578
+ events,
579
+ lastContiguousSeq: event.seq,
580
+ };
581
+ switch (event.type) {
582
+ case 'MODE_SELECTED':
583
+ patch = {
584
+ ...patch,
585
+ session: this.patchSession({ mode: event.payload.mode }),
586
+ };
587
+ break;
588
+ case 'SESSION_CREATED':
589
+ case 'SESSION_PAUSED':
590
+ case 'SESSION_RESUMED':
591
+ case 'SESSION_CLOSED':
592
+ patch = {
593
+ ...patch,
594
+ session: this.patchSession({
595
+ ...(event.payload.status
596
+ ? { status: event.payload.status }
597
+ : event.type === 'SESSION_PAUSED'
598
+ ? { status: 'PAUSED' }
599
+ : event.type === 'SESSION_RESUMED'
600
+ ? { status: 'ACTIVE' }
601
+ : event.type === 'SESSION_CLOSED'
602
+ ? { status: 'CLOSED' }
603
+ : {}),
604
+ }),
605
+ };
606
+ break;
607
+ case 'CLIENT_ATTACHED':
608
+ case 'CLIENT_DETACHED':
609
+ break;
610
+ case 'USER_MESSAGE':
611
+ case 'ASSISTANT_MESSAGE':
612
+ patch = {
613
+ ...patch,
614
+ messages: upsertMessage(this.state.messages, event.payload.message),
615
+ ...(event.type === 'ASSISTANT_MESSAGE'
616
+ ? { streamingText: '' }
617
+ : {}),
618
+ };
619
+ break;
620
+ case 'ASSISTANT_CHUNK':
621
+ patch = {
622
+ ...patch,
623
+ streamingText: `${this.state.streamingText}${event.payload.content}`.slice(-65536),
624
+ };
625
+ break;
626
+ case 'RUN_STARTED':
627
+ case 'RUN_PAUSED':
628
+ case 'RUN_SUCCEEDED':
629
+ case 'RUN_FAILED':
630
+ case 'RUN_CANCELLED':
631
+ case 'RUN_PREEMPTED':
632
+ patch = {
633
+ ...patch,
634
+ session: this.patchSession({
635
+ currentRun: {
636
+ runId: event.payload.runId,
637
+ status: event.payload.status,
638
+ ...(event.payload.code
639
+ ? { errorCode: event.payload.code }
640
+ : {}),
641
+ ...(event.payload.reason
642
+ ? { reason: event.payload.reason }
643
+ : {}),
644
+ ...(event.payload.error
645
+ ? { error: event.payload.error }
646
+ : {}),
647
+ },
648
+ }),
649
+ };
650
+ break;
651
+ case 'TOOL_PROPOSED':
652
+ case 'TOOL_DISPATCHED':
653
+ case 'TOOL_SUCCEEDED':
654
+ case 'TOOL_FAILED':
655
+ case 'TOOL_DENIED':
656
+ case 'TOOL_TIMED_OUT':
657
+ case 'TOOL_OUTCOME_UNKNOWN':
658
+ patch = {
659
+ ...patch,
660
+ tools: upsertTool(this.state.tools, event),
661
+ };
662
+ break;
663
+ case 'APPROVAL_REQUESTED':
664
+ case 'APPROVAL_GRANTED':
665
+ case 'APPROVAL_DENIED':
666
+ case 'APPROVAL_CONSUMED':
667
+ case 'APPROVAL_EXPIRED':
668
+ patch = {
669
+ ...patch,
670
+ approvals: upsertBy(this.state.approvals, event.payload.approval, (entry) => entry.approvalId),
671
+ };
672
+ break;
673
+ case 'CHECKPOINT_CREATED':
674
+ case 'CHECKPOINT_RESTORED':
675
+ patch = {
676
+ ...patch,
677
+ checkpoints: upsertBy(this.state.checkpoints, event.payload.checkpoint, (entry) => entry.checkpointId),
678
+ };
679
+ break;
680
+ case 'LEASE_GRANTED':
681
+ case 'LEASE_REVOKED':
682
+ case 'LEASE_EXPIRED':
683
+ patch = {
684
+ ...patch,
685
+ leases: upsertBy(this.state.leases, event.payload.lease, (entry) => entry.leaseId),
686
+ };
687
+ this.options.onLeaseChanged?.(event.payload.lease);
688
+ if (event.type !== 'LEASE_GRANTED') {
689
+ this.preemptLocal(event.payload.lease.revokedReason ??
690
+ (event.type === 'LEASE_EXPIRED'
691
+ ? 'LEASE_EXPIRED'
692
+ : 'CONTEXT_CHANGED'));
693
+ }
694
+ break;
695
+ case 'BUDGET_UPDATED':
696
+ patch = { ...patch, budget: event.payload.budget };
697
+ break;
698
+ case 'CONTEXT_CHANGED':
699
+ patch = {
700
+ ...patch,
701
+ session: this.patchSession({
702
+ contextVersion: event.payload.contextVersion,
703
+ }),
704
+ leases: [],
705
+ approvals: [],
706
+ reconnectRequired: true,
707
+ };
708
+ this.preemptLocal('CONTEXT_CHANGED');
709
+ break;
710
+ }
711
+ this.appliedEventIds.set(event.seq, event.eventId);
712
+ this.update(patch);
713
+ this.refreshHeartbeat();
714
+ this.refreshWorkspaceRenewal();
715
+ if (event.type === 'TOOL_DISPATCHED' &&
716
+ event.payload.invocation &&
717
+ this.options.browserDispatcher) {
718
+ void this.dispatchBrowserTool(event.payload.invocation, generation).catch((error) => this.fail(error));
719
+ }
720
+ }
721
+ async dispatchBrowserTool(invocation, generation) {
722
+ if (generation !== this.generation ||
723
+ invocation.clientEpoch !== this.state.clientEpoch) {
724
+ return;
725
+ }
726
+ const result = await this.options.browserDispatcher.dispatch(invocation);
727
+ const currentContextVersion = this.state.session?.contextVersion;
728
+ const leaseStillActive = !invocation.leaseId ||
729
+ this.state.leases.some((lease) => lease.leaseId === invocation.leaseId &&
730
+ lease.status === 'ACTIVE');
731
+ if (generation !== this.generation ||
732
+ invocation.clientEpoch !== this.state.clientEpoch ||
733
+ result.error?.code === 'AGENT_CLIENT_EPOCH_STALE' ||
734
+ invocation.contextVersion !== currentContextVersion ||
735
+ result.observedContextVersion !== currentContextVersion ||
736
+ !leaseStillActive) {
737
+ return;
738
+ }
739
+ await this.options.transport.toolResult({
740
+ ...this.mutationContext(`tool-result-${invocation.toolCallId}`),
741
+ result,
742
+ });
743
+ }
744
+ scheduleAcknowledge(generation) {
745
+ const throughSeq = this.state.lastContiguousSeq;
746
+ if (generation !== this.generation ||
747
+ BigInt(throughSeq) <= BigInt(this.state.lastAcknowledgedSeq)) {
748
+ return;
749
+ }
750
+ this.acknowledgePromise = this.acknowledgePromise
751
+ .then(async () => {
752
+ if (generation !== this.generation ||
753
+ BigInt(throughSeq) <= BigInt(this.state.lastAcknowledgedSeq)) {
754
+ return;
755
+ }
756
+ const acknowledged = await this.options.transport.acknowledgeEvents({
757
+ ...this.mutationContext(`ack-${throughSeq}`),
758
+ throughSeq,
759
+ });
760
+ if (generation === this.generation &&
761
+ BigInt(acknowledged.throughSeq) >=
762
+ BigInt(this.state.lastAcknowledgedSeq)) {
763
+ this.update({
764
+ lastAcknowledgedSeq: acknowledged.throughSeq,
765
+ });
766
+ }
767
+ })
768
+ .catch((error) => {
769
+ if (generation === this.generation)
770
+ this.handleDisconnect(error, generation);
771
+ });
772
+ }
773
+ handleDisconnect(error, generation) {
774
+ if (generation !== this.generation || this.destroyed)
775
+ return;
776
+ this.generation++;
777
+ this.stopHeartbeat();
778
+ this.stopWorkspaceRenewal();
779
+ this.disconnectSubscription();
780
+ const reason = error instanceof CrowdyAgentError &&
781
+ error.code === 'AGENT_OPERATOR_KILLED'
782
+ ? 'OPERATOR_KILL'
783
+ : error instanceof CrowdyAgentError &&
784
+ error.code === 'AGENT_CLIENT_EPOCH_STALE'
785
+ ? 'CLIENT_REATTACHED'
786
+ : 'DISCONNECTED';
787
+ this.preemptLocal(reason);
788
+ this.update({
789
+ connection: 'DISCONNECTED',
790
+ clientEpoch: null,
791
+ leases: [],
792
+ approvals: [],
793
+ playLeaseFreshUntil: null,
794
+ reconnectRequired: true,
795
+ lastError: toAgentError(error, 'AGENT_DISCONNECTED'),
796
+ });
797
+ const terminalPolicyError = error instanceof CrowdyAgentError &&
798
+ [
799
+ 'AGENT_DISABLED',
800
+ 'AGENT_OPERATOR_KILLED',
801
+ 'AGENT_SESSION_CLOSED',
802
+ 'AGENT_PERMISSION_DENIED',
803
+ ].includes(error.code);
804
+ if (this.options.autoReconnect && !terminalPolicyError) {
805
+ this.scheduleReconnect();
806
+ }
807
+ }
808
+ refreshHeartbeat(immediate = false) {
809
+ const shouldRun = !this.destroyed &&
810
+ this.pageVisible &&
811
+ this.state.connection === 'CONNECTED' &&
812
+ this.state.session?.status === 'ACTIVE' &&
813
+ this.state.session.mode === 'PLAY' &&
814
+ this.state.clientEpoch !== null;
815
+ if (!shouldRun) {
816
+ this.stopHeartbeat();
817
+ if (this.state.playLeaseFreshUntil !== null) {
818
+ this.update({ playLeaseFreshUntil: null });
819
+ }
820
+ return;
821
+ }
822
+ if (this.heartbeatTimer)
823
+ return;
824
+ const generation = this.generation;
825
+ this.heartbeatTimer = setTimeout(() => {
826
+ this.heartbeatTimer = null;
827
+ void this.sendHeartbeat(generation);
828
+ }, immediate ? 0 : (this.options.heartbeatIntervalMs ?? 2000));
829
+ }
830
+ async sendHeartbeat(generation) {
831
+ if (generation !== this.generation || this.destroyed)
832
+ return;
833
+ try {
834
+ const heartbeat = await withHeartbeatDeadline(this.options.transport.heartbeat(this.mutationContext('heartbeat')), this.options.heartbeatStaleMs ?? 5000);
835
+ if (generation !== this.generation || this.destroyed)
836
+ return;
837
+ this.update({
838
+ lastHeartbeatAt: heartbeat.serverTime,
839
+ playLeaseFreshUntil: heartbeat.playLeaseFreshUntil ?? null,
840
+ });
841
+ this.refreshHeartbeat();
842
+ }
843
+ catch (error) {
844
+ if (generation === this.generation) {
845
+ this.handleDisconnect(error, generation);
846
+ }
847
+ }
848
+ }
849
+ stopHeartbeat() {
850
+ if (this.heartbeatTimer)
851
+ clearTimeout(this.heartbeatTimer);
852
+ this.heartbeatTimer = null;
853
+ }
854
+ refreshWorkspaceRenewal() {
855
+ const lease = this.state.leases.find((entry) => entry.kind === 'WORKSPACE' && entry.status === 'ACTIVE');
856
+ const shouldRun = !this.destroyed &&
857
+ this.state.connection === 'CONNECTED' &&
858
+ this.state.session?.status === 'ACTIVE' &&
859
+ this.state.session.mode === 'BUILD' &&
860
+ this.state.clientEpoch !== null &&
861
+ lease !== undefined;
862
+ if (!shouldRun) {
863
+ this.stopWorkspaceRenewal();
864
+ return;
865
+ }
866
+ if (this.workspaceRenewTimer)
867
+ return;
868
+ const generation = this.generation;
869
+ this.workspaceRenewTimer = setTimeout(() => {
870
+ this.workspaceRenewTimer = null;
871
+ void this.renewWorkspaceLease(lease, generation);
872
+ }, this.options.workspaceRenewIntervalMs ?? 10000);
873
+ }
874
+ async renewWorkspaceLease(lease, generation) {
875
+ if (generation !== this.generation || this.destroyed)
876
+ return;
877
+ try {
878
+ const heartbeat = await withHeartbeatDeadline(this.options.transport.heartbeat({
879
+ ...this.mutationContext(`renew-workspace-${lease.leaseId}`),
880
+ }), this.options.heartbeatStaleMs ?? 5000);
881
+ if (!heartbeat.workspaceLeaseExpiresAt) {
882
+ throw new CrowdyAgentError('AGENT_LEASE_REVOKED', 'Server did not renew the active workspace lease');
883
+ }
884
+ const renewed = {
885
+ ...lease,
886
+ status: 'ACTIVE',
887
+ expiresAt: heartbeat.workspaceLeaseExpiresAt,
888
+ };
889
+ if (generation !== this.generation ||
890
+ this.destroyed ||
891
+ this.state.session?.mode !== 'BUILD' ||
892
+ !this.state.leases.some((entry) => entry.leaseId === lease.leaseId && entry.status === 'ACTIVE')) {
893
+ return;
894
+ }
895
+ if (renewed.kind !== 'WORKSPACE' ||
896
+ renewed.status !== 'ACTIVE' ||
897
+ renewed.contextVersion !== this.state.session.contextVersion) {
898
+ throw new CrowdyAgentError('AGENT_LEASE_REVOKED', 'Workspace renewal returned stale lease context');
899
+ }
900
+ this.upsertLease(renewed);
901
+ this.options.onLeaseChanged?.(renewed);
902
+ this.refreshWorkspaceRenewal();
903
+ }
904
+ catch (error) {
905
+ if (generation !== this.generation || this.destroyed)
906
+ return;
907
+ if (error instanceof CrowdyAgentError &&
908
+ error.code === 'AGENT_CLIENT_EPOCH_STALE') {
909
+ this.handleDisconnect(error, generation);
910
+ return;
911
+ }
912
+ this.preemptLocal('CONTEXT_CHANGED');
913
+ const revoked = {
914
+ ...lease,
915
+ status: 'REVOKED',
916
+ revokedReason: 'CONTEXT_CHANGED',
917
+ };
918
+ this.upsertLease(revoked);
919
+ this.options.onLeaseChanged?.(revoked);
920
+ this.update({
921
+ lastError: toAgentError(error, 'AGENT_LEASE_REVOKED'),
922
+ reconnectRequired: true,
923
+ });
924
+ }
925
+ }
926
+ stopWorkspaceRenewal() {
927
+ if (this.workspaceRenewTimer)
928
+ clearTimeout(this.workspaceRenewTimer);
929
+ this.workspaceRenewTimer = null;
930
+ }
931
+ scheduleReconnect() {
932
+ if (this.reconnectTimer || this.destroyed)
933
+ return;
934
+ const max = this.options.maxReconnectAttempts ?? 5;
935
+ if (this.reconnectAttempts >= max)
936
+ return;
937
+ const delay = (this.options.reconnectDelayMs ?? 500) *
938
+ Math.min(8, 2 ** this.reconnectAttempts);
939
+ this.reconnectAttempts++;
940
+ this.reconnectTimer = setTimeout(() => {
941
+ this.reconnectTimer = null;
942
+ void this.reconnect().catch(() => this.scheduleReconnect());
943
+ }, delay);
944
+ }
945
+ async loadRemoteContext(session) {
946
+ const [descriptorSet, budget] = await Promise.all([
947
+ this.options.transport.toolDescriptors(session.sessionId),
948
+ this.options.transport.budget(session.sessionId),
949
+ ]);
950
+ if (descriptorSet.registryDigest !== session.registryDigest) {
951
+ throw new CrowdyAgentError('AGENT_CONTEXT_STALE', 'Session registry digest does not match the effective descriptor set');
952
+ }
953
+ return {
954
+ toolDescriptors: descriptorSet.tools,
955
+ budget,
956
+ };
957
+ }
958
+ assertProjectBinding(session, expectedProjectId, enforceSelection) {
959
+ if (session.mode === 'BUILD' && !session.projectId) {
960
+ throw new CrowdyAgentError('AGENT_CONTEXT_CHANGED', 'BUILD session is not bound to a Crowdy Studio project');
961
+ }
962
+ if (enforceSelection &&
963
+ session.projectId !== expectedProjectId) {
964
+ throw new CrowdyAgentError('AGENT_CONTEXT_CHANGED', 'Agent session project does not match the selected Crowdy Studio project');
965
+ }
966
+ }
967
+ mutationContext(operation) {
968
+ const session = this.requireSession();
969
+ const clientEpoch = this.state.clientEpoch;
970
+ if (!clientEpoch) {
971
+ throw new CrowdyAgentError('AGENT_DISCONNECTED', 'An attached client epoch is required');
972
+ }
973
+ return {
974
+ sessionId: session.sessionId,
975
+ clientEpoch,
976
+ idempotencyKey: this.nextKey(operation),
977
+ };
978
+ }
979
+ nextKey(operation) {
980
+ if (this.options.createIdempotencyKey) {
981
+ return this.options.createIdempotencyKey(operation);
982
+ }
983
+ const random = globalThis.crypto?.randomUUID?.() ??
984
+ `${Date.now().toString(36)}-${(++this.keySequence).toString(36)}`;
985
+ return `crowdy-agent:${operation}:${random}`.slice(0, 240);
986
+ }
987
+ patchSession(patch) {
988
+ return this.state.session ? { ...this.state.session, ...patch } : null;
989
+ }
990
+ upsertLease(lease) {
991
+ this.update({
992
+ leases: upsertBy(this.state.leases, lease, (entry) => entry.leaseId),
993
+ });
994
+ }
995
+ disconnectSubscription() {
996
+ this.subscription?.close();
997
+ this.subscription = null;
998
+ }
999
+ requireSession() {
1000
+ if (!this.state.session) {
1001
+ throw new CrowdyAgentError('AGENT_SESSION_NOT_FOUND', 'Agent session is not initialized');
1002
+ }
1003
+ return this.state.session;
1004
+ }
1005
+ preemptLocal(reason) {
1006
+ this.stopWorkspaceRenewal();
1007
+ this.options.browserDispatcher?.cancelActive();
1008
+ this.options.onPreempt?.(reason);
1009
+ }
1010
+ update(patch) {
1011
+ this.state = { ...this.state, ...patch };
1012
+ for (const listener of this.listeners)
1013
+ listener(this.state);
1014
+ }
1015
+ fail(error) {
1016
+ this.stopHeartbeat();
1017
+ this.update({
1018
+ connection: 'ERROR',
1019
+ lastError: toAgentError(error, 'AGENT_EVENT_CURSOR_INVALID'),
1020
+ reconnectRequired: true,
1021
+ });
1022
+ }
1023
+ ensureAlive() {
1024
+ if (this.destroyed) {
1025
+ throw new CrowdyAgentError('AGENT_SESSION_CLOSED', 'CrowdyStudioAgentController is destroyed');
1026
+ }
1027
+ }
1028
+ }
1029
+ function createClientInstanceId() {
1030
+ if (globalThis.crypto?.randomUUID)
1031
+ return globalThis.crypto.randomUUID();
1032
+ const bytes = new Uint8Array(16);
1033
+ if (globalThis.crypto?.getRandomValues) {
1034
+ globalThis.crypto.getRandomValues(bytes);
1035
+ }
1036
+ else {
1037
+ for (let index = 0; index < bytes.length; index++) {
1038
+ bytes[index] = Math.floor(Math.random() * 256);
1039
+ }
1040
+ }
1041
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
1042
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
1043
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
1044
+ return [
1045
+ hex.slice(0, 8),
1046
+ hex.slice(8, 12),
1047
+ hex.slice(12, 16),
1048
+ hex.slice(16, 20),
1049
+ hex.slice(20),
1050
+ ].join('-');
1051
+ }
1052
+ function withHeartbeatDeadline(operation, timeoutMs) {
1053
+ return new Promise((resolve, reject) => {
1054
+ const timer = setTimeout(() => reject(new CrowdyAgentError('AGENT_DISCONNECTED', 'Agent heartbeat exceeded the server freshness window', { retryable: true })), timeoutMs);
1055
+ operation.then((value) => {
1056
+ clearTimeout(timer);
1057
+ resolve(value);
1058
+ }, (error) => {
1059
+ clearTimeout(timer);
1060
+ reject(error);
1061
+ });
1062
+ });
1063
+ }
1064
+ function validateEventPayload(event) {
1065
+ const payload = asEventRecord(event.payload, `${event.type} payload`);
1066
+ if (event.type === 'MODE_SELECTED') {
1067
+ assertEventEnum(payload.mode, ['ASK', 'BUILD', 'PLAY'], 'mode');
1068
+ return;
1069
+ }
1070
+ if (event.type === 'SESSION_CREATED' ||
1071
+ event.type === 'SESSION_PAUSED' ||
1072
+ event.type === 'SESSION_RESUMED' ||
1073
+ event.type === 'SESSION_CLOSED') {
1074
+ if (payload.status !== undefined) {
1075
+ assertEventEnum(payload.status, ['ACTIVE', 'PAUSED', 'CLOSED', 'REVOKED'], 'session status');
1076
+ }
1077
+ return;
1078
+ }
1079
+ if (event.type === 'RUN_STARTED' ||
1080
+ event.type === 'RUN_PAUSED' ||
1081
+ event.type === 'RUN_SUCCEEDED' ||
1082
+ event.type === 'RUN_FAILED' ||
1083
+ event.type === 'RUN_CANCELLED' ||
1084
+ event.type === 'RUN_PREEMPTED') {
1085
+ assertEventEnum(payload.status, [
1086
+ 'QUEUED',
1087
+ 'RUNNING',
1088
+ 'WAITING_FOR_TOOL',
1089
+ 'WAITING_FOR_APPROVAL',
1090
+ 'PAUSED',
1091
+ 'SUCCEEDED',
1092
+ 'FAILED',
1093
+ 'CANCELLED',
1094
+ 'PREEMPTED',
1095
+ ], 'run status');
1096
+ assertBoundedEventString(payload.runId, 'runId', 128);
1097
+ if (payload.code !== undefined && payload.code !== null) {
1098
+ assertBoundedEventString(payload.code, 'run code', 128);
1099
+ }
1100
+ if (payload.error !== undefined && payload.error !== null) {
1101
+ const error = asEventRecord(payload.error, 'run error');
1102
+ assertBoundedEventString(error.code, 'run error code', 128);
1103
+ assertBoundedEventString(error.message, 'run error message', 512);
1104
+ if (typeof error.retryable !== 'boolean') {
1105
+ throw invalidEvent('run error retryable must be boolean');
1106
+ }
1107
+ }
1108
+ return;
1109
+ }
1110
+ if (event.type === 'TOOL_PROPOSED' ||
1111
+ event.type === 'TOOL_DISPATCHED' ||
1112
+ event.type === 'TOOL_SUCCEEDED' ||
1113
+ event.type === 'TOOL_FAILED' ||
1114
+ event.type === 'TOOL_DENIED' ||
1115
+ event.type === 'TOOL_TIMED_OUT' ||
1116
+ event.type === 'TOOL_OUTCOME_UNKNOWN') {
1117
+ assertEventEnum(payload.status, [
1118
+ 'PROPOSED',
1119
+ 'WAITING_FOR_APPROVAL',
1120
+ 'DISPATCHED',
1121
+ 'RUNNING',
1122
+ 'SUCCEEDED',
1123
+ 'FAILED',
1124
+ 'DENIED',
1125
+ 'TIMED_OUT',
1126
+ 'CANCELLED',
1127
+ 'STALE',
1128
+ 'OUTCOME_UNKNOWN',
1129
+ ], 'tool status');
1130
+ assertBoundedEventString(payload.toolCallId, 'toolCallId', 128);
1131
+ assertBoundedEventString(payload.name, 'tool name', 160);
1132
+ assertBoundedEventString(payload.version, 'tool version', 32);
1133
+ return;
1134
+ }
1135
+ if (event.type === 'APPROVAL_REQUESTED' ||
1136
+ event.type === 'APPROVAL_GRANTED' ||
1137
+ event.type === 'APPROVAL_DENIED' ||
1138
+ event.type === 'APPROVAL_CONSUMED' ||
1139
+ event.type === 'APPROVAL_EXPIRED') {
1140
+ const approval = asEventRecord(payload.approval, 'approval');
1141
+ assertEventEnum(approval.status, ['PENDING', 'GRANTED', 'DENIED', 'CONSUMED', 'REVOKED', 'EXPIRED'], 'approval status');
1142
+ if (typeof approval.argumentHash !== 'string' ||
1143
+ !/^sha256:[0-9a-f]{64}$/u.test(approval.argumentHash)) {
1144
+ throw invalidEvent('approval argument hash is invalid');
1145
+ }
1146
+ assertBoundedEventString(approval.safeSummary, 'approval summary', 2048);
1147
+ return;
1148
+ }
1149
+ if (event.type === 'LEASE_GRANTED' ||
1150
+ event.type === 'LEASE_REVOKED' ||
1151
+ event.type === 'LEASE_EXPIRED') {
1152
+ const lease = asEventRecord(payload.lease, 'lease');
1153
+ assertEventEnum(lease.kind, ['WORKSPACE', 'PLAY'], 'lease kind');
1154
+ assertEventEnum(lease.status, ['ACTIVE', 'REVOKED', 'EXPIRED'], 'lease status');
1155
+ assertBoundedEventString(lease.leaseId, 'leaseId', 128);
1156
+ return;
1157
+ }
1158
+ if (event.type === 'ASSISTANT_CHUNK') {
1159
+ assertBoundedEventString(payload.content, 'assistant chunk', 4096, true);
1160
+ return;
1161
+ }
1162
+ if (event.type === 'USER_MESSAGE' || event.type === 'ASSISTANT_MESSAGE') {
1163
+ const message = asEventRecord(payload.message, 'message');
1164
+ assertEventEnum(message.role, ['USER', 'ASSISTANT'], 'message role');
1165
+ assertBoundedEventString(message.messageId, 'messageId', 128);
1166
+ assertBoundedEventString(message.content, 'message content', 65536, true);
1167
+ return;
1168
+ }
1169
+ if (event.type === 'CONTEXT_CHANGED') {
1170
+ assertBoundedEventString(payload.contextVersion, 'contextVersion', 128);
1171
+ }
1172
+ }
1173
+ function asEventRecord(value, field) {
1174
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
1175
+ throw invalidEvent(`${field} must be an object`);
1176
+ }
1177
+ return value;
1178
+ }
1179
+ function assertEventEnum(value, allowed, field) {
1180
+ if (typeof value !== 'string' || !allowed.includes(value)) {
1181
+ throw invalidEvent(`${field} contains an unknown enum value`);
1182
+ }
1183
+ }
1184
+ function assertBoundedEventString(value, field, maxLength, allowEmpty = false) {
1185
+ if (typeof value !== 'string' ||
1186
+ (!allowEmpty && value.length === 0) ||
1187
+ value.length > maxLength) {
1188
+ throw invalidEvent(`${field} is outside protocol bounds`);
1189
+ }
1190
+ }
1191
+ function invalidEvent(message) {
1192
+ return new CrowdyAgentError('AGENT_EVENT_CURSOR_INVALID', message);
1193
+ }
1194
+ function assertSequence(value, field) {
1195
+ if (typeof value !== 'string' ||
1196
+ value.length < 1 ||
1197
+ value.length > 40 ||
1198
+ !isDecimalString(value) ||
1199
+ value.startsWith('-')) {
1200
+ throw new CrowdyAgentError('AGENT_EVENT_CURSOR_INVALID', `${field} must be a non-negative decimal string`);
1201
+ }
1202
+ }
1203
+ function upsertMessage(messages, next) {
1204
+ return [
1205
+ ...messages.filter((message) => message.messageId !== next.messageId),
1206
+ next,
1207
+ ].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
1208
+ }
1209
+ function upsertTool(tools, event) {
1210
+ const current = tools.find((entry) => entry.toolCallId === event.payload.toolCallId);
1211
+ const next = {
1212
+ toolCallId: event.payload.toolCallId,
1213
+ name: event.payload.name,
1214
+ version: event.payload.version,
1215
+ status: event.payload.status,
1216
+ ...(current?.risk ? { risk: current.risk } : {}),
1217
+ ...(event.payload.safeSummary
1218
+ ? { safeSummary: event.payload.safeSummary }
1219
+ : current?.safeSummary
1220
+ ? { safeSummary: current.safeSummary }
1221
+ : {}),
1222
+ ...(event.payload.argumentHash
1223
+ ? { argumentHash: event.payload.argumentHash }
1224
+ : current?.argumentHash
1225
+ ? { argumentHash: current.argumentHash }
1226
+ : {}),
1227
+ ...(event.payload.result ? { result: event.payload.result } : {}),
1228
+ ...(event.payload.error ? { error: event.payload.error } : {}),
1229
+ updatedAt: event.createdAt,
1230
+ };
1231
+ return [
1232
+ ...tools.filter((entry) => entry.toolCallId !== next.toolCallId),
1233
+ next,
1234
+ ];
1235
+ }
1236
+ function upsertBy(values, next, key) {
1237
+ const nextKey = key(next);
1238
+ return [...values.filter((value) => key(value) !== nextKey), next];
1239
+ }