@xfxstudio/claworld 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 (69) hide show
  1. package/README.md +60 -0
  2. package/bin/claworld.mjs +9 -0
  3. package/index.js +51 -0
  4. package/openclaw.plugin.json +470 -0
  5. package/package.json +76 -0
  6. package/setup-entry.js +6 -0
  7. package/src/lib/accepted-chat-kickoff.js +192 -0
  8. package/src/lib/agent-address.js +46 -0
  9. package/src/lib/agent-profile.js +69 -0
  10. package/src/lib/http-auth.js +151 -0
  11. package/src/lib/policy.js +118 -0
  12. package/src/lib/runtime-errors.js +149 -0
  13. package/src/lib/runtime-guidance.js +458 -0
  14. package/src/openclaw/index.js +53 -0
  15. package/src/openclaw/installer/cli.js +349 -0
  16. package/src/openclaw/installer/constants.js +6 -0
  17. package/src/openclaw/installer/core.js +1548 -0
  18. package/src/openclaw/installer/doctor.js +690 -0
  19. package/src/openclaw/installer/workspace-contract.js +403 -0
  20. package/src/openclaw/plugin/account-identity.js +66 -0
  21. package/src/openclaw/plugin/claworld-channel-plugin.js +3118 -0
  22. package/src/openclaw/plugin/config-schema.js +464 -0
  23. package/src/openclaw/plugin/lifecycle.js +114 -0
  24. package/src/openclaw/plugin/managed-config.js +648 -0
  25. package/src/openclaw/plugin/onboarding.js +291 -0
  26. package/src/openclaw/plugin/register.js +961 -0
  27. package/src/openclaw/plugin/relay-client.js +783 -0
  28. package/src/openclaw/plugin/runtime.js +12 -0
  29. package/src/openclaw/protocol/relay-event-protocol.js +31 -0
  30. package/src/openclaw/runtime/canonical-result-builder.js +116 -0
  31. package/src/openclaw/runtime/demo-session-bootstrap.js +37 -0
  32. package/src/openclaw/runtime/feedback-helper.js +145 -0
  33. package/src/openclaw/runtime/inbound-session-router.js +36 -0
  34. package/src/openclaw/runtime/outbound-session-bridge.js +17 -0
  35. package/src/openclaw/runtime/product-shell-helper.js +1712 -0
  36. package/src/openclaw/runtime/runtime-path.js +19 -0
  37. package/src/openclaw/runtime/system-message-orchestrator.js +1 -0
  38. package/src/openclaw/runtime/tool-contracts.js +714 -0
  39. package/src/openclaw/runtime/tool-inventory.js +92 -0
  40. package/src/openclaw/runtime/world-moderation-helper.js +415 -0
  41. package/src/openclaw/runtime/world-session-startup.js +1 -0
  42. package/src/product-shell/catalog/default-world-catalog.js +296 -0
  43. package/src/product-shell/contracts/candidate-feed.js +330 -0
  44. package/src/product-shell/contracts/chat-request-approval-policy.js +98 -0
  45. package/src/product-shell/contracts/world-manifest.js +435 -0
  46. package/src/product-shell/contracts/world-orchestration.js +1024 -0
  47. package/src/product-shell/feedback/feedback-contract.js +13 -0
  48. package/src/product-shell/feedback/feedback-routes.js +98 -0
  49. package/src/product-shell/feedback/feedback-service.js +254 -0
  50. package/src/product-shell/index.js +163 -0
  51. package/src/product-shell/matching/matchmaking-service.js +340 -0
  52. package/src/product-shell/membership/membership-service.js +277 -0
  53. package/src/product-shell/onboarding/onboarding-routes.js +37 -0
  54. package/src/product-shell/onboarding/onboarding-service.js +230 -0
  55. package/src/product-shell/orchestration/session-orchestrator.js +38 -0
  56. package/src/product-shell/results/result-service.js +15 -0
  57. package/src/product-shell/search/search-service.js +359 -0
  58. package/src/product-shell/social/chat-request-approval-policy.js +332 -0
  59. package/src/product-shell/social/chat-request-routes.js +108 -0
  60. package/src/product-shell/social/chat-request-service.js +632 -0
  61. package/src/product-shell/social/friend-routes.js +82 -0
  62. package/src/product-shell/social/friend-service.js +560 -0
  63. package/src/product-shell/social/social-routes.js +21 -0
  64. package/src/product-shell/social/social-service.js +140 -0
  65. package/src/product-shell/worlds/world-admin-service.js +705 -0
  66. package/src/product-shell/worlds/world-authorization.js +135 -0
  67. package/src/product-shell/worlds/world-broadcast-service.js +299 -0
  68. package/src/product-shell/worlds/world-routes.js +410 -0
  69. package/src/product-shell/worlds/world-service.js +89 -0
@@ -0,0 +1,783 @@
1
+ import { EventEmitter } from 'events';
2
+ import WebSocket from 'ws';
3
+ import { resolveClaworldRuntimeConfig } from './config-schema.js';
4
+ import { buildRuntimeAuthHeaders } from './account-identity.js';
5
+ import { createRelayEventProtocol } from '../protocol/relay-event-protocol.js';
6
+ import { createInboundSessionRouter } from '../runtime/inbound-session-router.js';
7
+ import { createOutboundSessionBridge } from '../runtime/outbound-session-bridge.js';
8
+ import {
9
+ buildPublicErrorPayload,
10
+ createRuntimeBoundaryError,
11
+ logRuntimeBoundary,
12
+ } from '../../lib/runtime-errors.js';
13
+
14
+ const DUPLICATE_CONNECTION_CLOSE_CODE = 4001;
15
+ const STALE_CONNECTION_CLOSE_CODE = 4002;
16
+ const TERMINAL_CLOSE_REASONS = new Set(['duplicate_connection_replaced', 'stale_connection']);
17
+
18
+ function normalizeRelayWebSocketUrl(serverUrl) {
19
+ const parsed = new URL(serverUrl);
20
+ if (parsed.protocol === 'http:') parsed.protocol = 'ws:';
21
+ if (parsed.protocol === 'https:') parsed.protocol = 'wss:';
22
+
23
+ const pathname = parsed.pathname || '/';
24
+ const normalizedPathname = pathname.replace(/\/+$/, '') || '/';
25
+ parsed.pathname = normalizedPathname === '/' || normalizedPathname === ''
26
+ ? '/ws'
27
+ : normalizedPathname.endsWith('/ws')
28
+ ? normalizedPathname
29
+ : normalizedPathname + '/ws';
30
+
31
+ return parsed.toString();
32
+ }
33
+
34
+ function buildInboundEnvelope(message = {}) {
35
+ const data = message.data || {};
36
+ const sessionId = data.sessionId || data.requestId || null;
37
+ const eventId = data.turnId || data.requestId || data.sessionId || null;
38
+
39
+ switch (message.event) {
40
+ case 'request.received':
41
+ return {
42
+ session_id: sessionId,
43
+ event_id: eventId,
44
+ from_agent_id: data.fromAgentId || data.fromAgent?.agentId || null,
45
+ type: 'system_message',
46
+ payload: { kind: 'request.received', ...data },
47
+ };
48
+ case 'session.created':
49
+ return {
50
+ session_id: sessionId,
51
+ event_id: eventId,
52
+ from_agent_id: data.fromAgentId || data.targetAgentId || null,
53
+ type: 'system_message',
54
+ payload: { kind: 'session.created', ...data },
55
+ };
56
+ case 'turn.deliver':
57
+ return {
58
+ session_id: sessionId,
59
+ event_id: eventId,
60
+ from_agent_id: data.fromAgentId || data.fromAgent?.agentId || null,
61
+ type: 'message',
62
+ payload: data.payload || {},
63
+ };
64
+ case 'turn.reply':
65
+ return {
66
+ session_id: sessionId,
67
+ event_id: eventId,
68
+ from_agent_id: data.fromAgentId || data.fromAgent?.agentId || null,
69
+ type: 'message',
70
+ payload: data.payload || {},
71
+ };
72
+ case 'turn.timeout':
73
+ return {
74
+ session_id: sessionId,
75
+ event_id: eventId,
76
+ from_agent_id: data.fromAgentId || null,
77
+ type: 'system_message',
78
+ payload: { kind: 'turn.timeout', ...data },
79
+ };
80
+ case 'session.terminated':
81
+ return {
82
+ session_id: sessionId,
83
+ event_id: eventId,
84
+ from_agent_id: data.actorAgentId || null,
85
+ type: 'session_end',
86
+ payload: { kind: 'session.terminated', ...data },
87
+ };
88
+ default:
89
+ return null;
90
+ }
91
+ }
92
+
93
+ export class ClaworldRelayClient extends EventEmitter {
94
+ constructor({
95
+ logger = console,
96
+ inbound = createInboundSessionRouter(),
97
+ outbound = createOutboundSessionBridge(),
98
+ protocol = createRelayEventProtocol(),
99
+ wsFactory = (url) => new WebSocket(url),
100
+ httpFetch = globalThis.fetch,
101
+ } = {}) {
102
+ super();
103
+ this.logger = logger;
104
+ this.inbound = inbound;
105
+ this.outbound = outbound;
106
+ this.protocol = protocol;
107
+ this.wsFactory = wsFactory;
108
+ this.httpFetch = httpFetch;
109
+ this.ws = null;
110
+ this.events = [];
111
+ this.heartbeatTimer = null;
112
+ this.connectionState = 'idle';
113
+ this.runtimeConfig = null;
114
+ this.boundAgentId = null;
115
+ this.serverUrl = null;
116
+ this.connectionParams = null;
117
+ this.reconnectTimer = null;
118
+ this.reconnectAttempts = 0;
119
+ this.manualClose = false;
120
+ this.lastDisconnectInfo = null;
121
+ }
122
+
123
+ buildBoundaryContext(extra = null) {
124
+ return {
125
+ accountId: this.runtimeConfig?.accountId || null,
126
+ agentId: this.boundAgentId,
127
+ ...(extra && typeof extra === 'object' ? extra : {}),
128
+ };
129
+ }
130
+
131
+ buildDisconnectInfo(code = null, reason = null, source = 'socket') {
132
+ const reasonText = String(reason || '').trim() || null;
133
+ return {
134
+ code: Number.isInteger(code) ? code : null,
135
+ reason: reasonText,
136
+ source,
137
+ recoverable: !(
138
+ code === DUPLICATE_CONNECTION_CLOSE_CODE
139
+ || code === STALE_CONNECTION_CLOSE_CODE
140
+ || TERMINAL_CLOSE_REASONS.has(reasonText)
141
+ ),
142
+ };
143
+ }
144
+
145
+ clearReconnectTimer() {
146
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
147
+ this.reconnectTimer = null;
148
+ }
149
+
150
+ resolveReconnectDelayMs() {
151
+ const heartbeatSeconds = Number(this.runtimeConfig?.heartbeatSeconds || 1);
152
+ return Math.min(5000, Math.max(500, Math.floor(heartbeatSeconds * 500)));
153
+ }
154
+
155
+ shouldAutoReconnect(disconnectInfo = null) {
156
+ if (this.manualClose) return false;
157
+ if (this.runtimeConfig?.reconnect === false) return false;
158
+ return disconnectInfo?.recoverable !== false;
159
+ }
160
+
161
+ emitRelayMessage(message, { sessionTarget, fallbackTarget }) {
162
+ this.events.push(message);
163
+ this.emit('event', message);
164
+ if (message.event === 'error') {
165
+ this.emit('relay_error', message);
166
+ this.emit('relay.error', message);
167
+ } else {
168
+ this.emit(message.event, message);
169
+ }
170
+
171
+ const inboundEnvelope = buildInboundEnvelope(message);
172
+ if (!inboundEnvelope) return;
173
+ const described = this.protocol.describeEvent(inboundEnvelope);
174
+ const route = this.inbound.routeInboundEvent(inboundEnvelope, {
175
+ sessionTarget: sessionTarget || this.runtimeConfig.routing.sessionTarget,
176
+ fallbackTarget: fallbackTarget || this.runtimeConfig.routing.fallbackTarget,
177
+ });
178
+ const runtimeEvent = {
179
+ relayEvent: message.event,
180
+ protocol: described,
181
+ inboundEnvelope,
182
+ route,
183
+ raw: message,
184
+ };
185
+ this.emit('runtime_event', runtimeEvent);
186
+ }
187
+
188
+ emitBoundaryError(
189
+ label,
190
+ error,
191
+ {
192
+ code = 'relay_runtime_error',
193
+ category = 'runtime',
194
+ publicMessage = 'relay runtime error',
195
+ recoverable = true,
196
+ context = null,
197
+ errorType = 'relay_runtime_error',
198
+ fallbackMessage = null,
199
+ includeStack = false,
200
+ } = {},
201
+ ) {
202
+ const normalized = logRuntimeBoundary(this.logger, label, error, this.buildBoundaryContext(context), {
203
+ includeStack,
204
+ fallback: {
205
+ code,
206
+ category,
207
+ publicMessage,
208
+ recoverable,
209
+ },
210
+ });
211
+ const payload = {
212
+ event: 'error',
213
+ data: buildPublicErrorPayload(normalized, {
214
+ errorType,
215
+ fallbackMessage: fallbackMessage || publicMessage,
216
+ exposeMessage: true,
217
+ }),
218
+ };
219
+ this.events.push(payload);
220
+ this.emit('event', payload);
221
+ this.emit('relay_error', payload);
222
+ this.emit('relay.error', payload);
223
+ return normalized;
224
+ }
225
+
226
+ buildDisconnectInfoFromError(error, source = 'reconnect') {
227
+ if (error?.close) return error.close;
228
+ return this.buildDisconnectInfo(null, error?.reason || error?.code || error?.message || 'reconnect_failed', source);
229
+ }
230
+
231
+ createClosedBeforeAuthError(disconnectInfo, cause = null) {
232
+ const normalized = createRuntimeBoundaryError({
233
+ code: 'relay_ws_closed_before_auth',
234
+ category: 'transport',
235
+ status: 502,
236
+ message: `relay websocket closed before authentication (code=${disconnectInfo?.code ?? 'unknown'}, reason=${disconnectInfo?.reason || ''})`,
237
+ publicMessage: 'relay websocket closed before authentication',
238
+ recoverable: disconnectInfo?.recoverable !== false,
239
+ context: this.buildBoundaryContext({
240
+ stage: 'pre_auth_close',
241
+ closeCode: disconnectInfo?.code ?? null,
242
+ closeReason: disconnectInfo?.reason || null,
243
+ }),
244
+ cause,
245
+ });
246
+ normalized.reason = disconnectInfo?.reason || normalized.code;
247
+ normalized.close = disconnectInfo;
248
+ normalized.fatal = disconnectInfo?.recoverable === false;
249
+ return normalized;
250
+ }
251
+
252
+ async requestJson(pathName, init = {}, fallback = {}) {
253
+ if (!this.serverUrl) {
254
+ throw createRuntimeBoundaryError({
255
+ code: 'relay_client_not_connected',
256
+ category: 'runtime',
257
+ status: 409,
258
+ message: 'client not connected',
259
+ publicMessage: 'relay client is not connected',
260
+ recoverable: true,
261
+ });
262
+ }
263
+
264
+ const url = `${this.serverUrl}${pathName}`;
265
+ let response;
266
+ try {
267
+ response = await this.httpFetch(url, init);
268
+ } catch (error) {
269
+ throw createRuntimeBoundaryError({
270
+ code: fallback.code || 'relay_fetch_failed',
271
+ category: 'transport',
272
+ status: 502,
273
+ message: `${fallback.message || 'relay request failed'}: ${error?.message || String(error)}`,
274
+ publicMessage: fallback.publicMessage || 'relay request failed',
275
+ recoverable: true,
276
+ context: {
277
+ url,
278
+ method: init?.method || 'GET',
279
+ },
280
+ cause: error,
281
+ });
282
+ }
283
+
284
+ let body = null;
285
+ try {
286
+ body = await response.json();
287
+ } catch (error) {
288
+ throw createRuntimeBoundaryError({
289
+ code: 'relay_response_invalid',
290
+ category: 'transport',
291
+ status: 502,
292
+ message: `relay response was not valid JSON: ${error?.message || String(error)}`,
293
+ publicMessage: 'relay response was not valid JSON',
294
+ recoverable: true,
295
+ context: {
296
+ url,
297
+ method: init?.method || 'GET',
298
+ status: response.status,
299
+ },
300
+ cause: error,
301
+ });
302
+ }
303
+
304
+ return { status: response.status, body };
305
+ }
306
+
307
+ async openSocket({
308
+ wsUrl,
309
+ agentId,
310
+ credential,
311
+ clientVersion,
312
+ sessionTarget,
313
+ fallbackTarget,
314
+ }) {
315
+ this.connectionState = this.connectionState === 'reconnecting' ? 'reconnecting' : 'connecting';
316
+
317
+ return await new Promise((resolve, reject) => {
318
+ let settled = false;
319
+ let suppressCloseHandler = false;
320
+ const ws = this.wsFactory(wsUrl);
321
+ this.ws = ws;
322
+
323
+ ws.on('open', () => {
324
+ this.logger.info?.('[claworld:relay-client] websocket open, sending auth', {
325
+ accountId: this.runtimeConfig.accountId,
326
+ agentId,
327
+ clientVersion,
328
+ });
329
+ this.send({
330
+ type: 'auth',
331
+ agentId,
332
+ credential,
333
+ clientVersion,
334
+ });
335
+ });
336
+
337
+ ws.on('message', (buf) => {
338
+ let message;
339
+ try {
340
+ message = JSON.parse(String(buf));
341
+ } catch (error) {
342
+ const normalized = this.emitBoundaryError('[claworld:relay-client] invalid relay message', error, {
343
+ code: 'relay_ws_message_invalid',
344
+ category: 'transport',
345
+ publicMessage: 'relay websocket delivered invalid JSON',
346
+ context: { stage: 'message_parse' },
347
+ });
348
+ if (!settled) {
349
+ settled = true;
350
+ this.connectionState = 'error';
351
+ reject(normalized);
352
+ }
353
+ return;
354
+ }
355
+
356
+ try {
357
+ this.emitRelayMessage(message, { sessionTarget, fallbackTarget });
358
+
359
+ if (message.event === 'auth.ok' && !settled) {
360
+ settled = true;
361
+ this.connectionState = 'authenticated';
362
+ this.reconnectAttempts = 0;
363
+ this.logger.info?.('[claworld:relay-client] auth ok', {
364
+ accountId: this.runtimeConfig.accountId,
365
+ agentId,
366
+ });
367
+ this.startHeartbeatLoop();
368
+ resolve(message);
369
+ }
370
+
371
+ if (message.event === 'error' && !settled && message.data?.code === 'unauthorized') {
372
+ settled = true;
373
+ suppressCloseHandler = true;
374
+ this.connectionState = 'error';
375
+ const authReason = message.data?.reason || message.data?.error || 'unauthorized';
376
+ const authError = createRuntimeBoundaryError({
377
+ code: message.data?.code || 'unauthorized',
378
+ category: 'auth',
379
+ status: 401,
380
+ message: authReason,
381
+ publicMessage: 'relay authentication failed',
382
+ recoverable: true,
383
+ context: this.buildBoundaryContext({
384
+ stage: 'auth',
385
+ reason: authReason,
386
+ }),
387
+ });
388
+ authError.reason = authReason;
389
+ authError.fatal = true;
390
+ authError.close = this.buildDisconnectInfo(null, authReason, 'auth');
391
+ this.logger.error?.('[claworld:relay-client] auth failed', {
392
+ accountId: this.runtimeConfig.accountId,
393
+ agentId,
394
+ error: authError.message,
395
+ code: authError.code,
396
+ });
397
+ reject(authError);
398
+ }
399
+ } catch (error) {
400
+ const normalized = this.emitBoundaryError('[claworld:relay-client] relay message handling failed', error, {
401
+ code: 'relay_ws_message_handling_failed',
402
+ category: 'runtime',
403
+ publicMessage: 'relay websocket message handling failed',
404
+ context: {
405
+ stage: 'message_handle',
406
+ relayEvent: message?.event || null,
407
+ },
408
+ });
409
+ if (!settled) {
410
+ settled = true;
411
+ suppressCloseHandler = true;
412
+ this.connectionState = 'error';
413
+ reject(normalized);
414
+ }
415
+ }
416
+ });
417
+
418
+ ws.on('close', (code, reason) => {
419
+ if (this.ws === ws) this.ws = null;
420
+ this.stopHeartbeatLoop();
421
+ const disconnectInfo = this.buildDisconnectInfo(code, reason);
422
+ this.lastDisconnectInfo = disconnectInfo;
423
+ this.logger.warn?.('[claworld:relay-client] websocket closed', {
424
+ accountId: this.runtimeConfig?.accountId || null,
425
+ agentId: this.boundAgentId,
426
+ code: disconnectInfo.code,
427
+ reason: disconnectInfo.reason || '',
428
+ recoverable: disconnectInfo.recoverable,
429
+ });
430
+ this.emit('disconnect', disconnectInfo);
431
+ if (suppressCloseHandler) return;
432
+
433
+ if (!settled) {
434
+ settled = true;
435
+ this.connectionState = disconnectInfo.reason === 'duplicate_connection_replaced' ? 'replaced' : 'error';
436
+ reject(this.createClosedBeforeAuthError(disconnectInfo));
437
+ return;
438
+ }
439
+
440
+ if (this.shouldAutoReconnect(disconnectInfo)) {
441
+ this.connectionState = 'reconnecting';
442
+ this.scheduleReconnect(disconnectInfo);
443
+ return;
444
+ }
445
+
446
+ this.connectionState = disconnectInfo.reason === 'duplicate_connection_replaced' ? 'replaced' : 'closed';
447
+ this.emit('close', disconnectInfo);
448
+ });
449
+
450
+ ws.on('error', (error) => {
451
+ const normalized = logRuntimeBoundary(this.logger, '[claworld:relay-client] websocket error', error, this.buildBoundaryContext({
452
+ stage: settled ? 'runtime_socket_error' : 'connect_socket_error',
453
+ }), {
454
+ includeStack: false,
455
+ fallback: {
456
+ code: settled ? 'relay_ws_runtime_error' : 'relay_ws_connect_failed',
457
+ category: 'transport',
458
+ publicMessage: settled ? 'relay websocket runtime error' : 'relay websocket connection failed',
459
+ recoverable: true,
460
+ },
461
+ });
462
+ if (!settled) {
463
+ settled = true;
464
+ suppressCloseHandler = true;
465
+ this.connectionState = 'error';
466
+ normalized.reason = normalized.reason || normalized.code || normalized.message;
467
+ reject(normalized);
468
+ }
469
+ });
470
+ });
471
+ }
472
+
473
+ scheduleReconnect(disconnectInfo = null) {
474
+ if (!this.shouldAutoReconnect(disconnectInfo)) {
475
+ this.connectionState = 'closed';
476
+ this.emit('close', disconnectInfo || this.buildDisconnectInfo(null, 'reconnect_disabled', 'reconnect'));
477
+ return;
478
+ }
479
+
480
+ this.clearReconnectTimer();
481
+ const delayMs = this.resolveReconnectDelayMs();
482
+ const attempt = this.reconnectAttempts + 1;
483
+ this.reconnectAttempts = attempt;
484
+ this.logger.warn?.('[claworld:relay-client] scheduling reconnect', {
485
+ accountId: this.runtimeConfig?.accountId || null,
486
+ agentId: this.boundAgentId,
487
+ attempt,
488
+ delayMs,
489
+ reason: disconnectInfo?.reason || null,
490
+ });
491
+ this.emit('reconnecting', { attempt, delayMs, disconnectInfo });
492
+
493
+ this.reconnectTimer = setTimeout(async () => {
494
+ this.reconnectTimer = null;
495
+ if (this.manualClose || !this.connectionParams) return;
496
+
497
+ try {
498
+ await this.openSocket(this.connectionParams);
499
+ this.emit('reconnected', { attempt, disconnectInfo });
500
+ } catch (error) {
501
+ const nextDisconnect = this.buildDisconnectInfoFromError(error);
502
+ this.lastDisconnectInfo = nextDisconnect;
503
+ if (error?.fatal || !this.shouldAutoReconnect(nextDisconnect)) {
504
+ this.connectionState = nextDisconnect.reason === 'duplicate_connection_replaced' ? 'replaced' : 'error';
505
+ this.emit('close', nextDisconnect);
506
+ return;
507
+ }
508
+ this.connectionState = 'reconnecting';
509
+ this.scheduleReconnect(nextDisconnect);
510
+ }
511
+ }, delayMs);
512
+
513
+ if (typeof this.reconnectTimer.unref === 'function') this.reconnectTimer.unref();
514
+ }
515
+
516
+ async connect({
517
+ config,
518
+ agentId,
519
+ credential = null,
520
+ clientVersion = 'claworld-plugin/0.1.0',
521
+ sessionTarget,
522
+ fallbackTarget,
523
+ } = {}) {
524
+ if (!agentId) {
525
+ throw createRuntimeBoundaryError({
526
+ code: 'relay_agent_id_required',
527
+ category: 'input',
528
+ status: 400,
529
+ message: 'agentId is required',
530
+ publicMessage: 'agentId is required',
531
+ recoverable: true,
532
+ });
533
+ }
534
+
535
+ this.runtimeConfig = resolveClaworldRuntimeConfig(config);
536
+ this.boundAgentId = agentId;
537
+ this.serverUrl = this.runtimeConfig.serverUrl;
538
+ this.manualClose = false;
539
+ this.clearReconnectTimer();
540
+
541
+ const wsUrl = normalizeRelayWebSocketUrl(this.runtimeConfig.serverUrl);
542
+ this.connectionParams = {
543
+ wsUrl,
544
+ agentId,
545
+ credential,
546
+ clientVersion,
547
+ sessionTarget,
548
+ fallbackTarget,
549
+ };
550
+ this.connectionState = 'connecting';
551
+
552
+ this.logger.info?.('[claworld:relay-client] connecting websocket', {
553
+ accountId: this.runtimeConfig.accountId,
554
+ agentId,
555
+ wsUrl,
556
+ reconnect: this.runtimeConfig.reconnect !== false,
557
+ });
558
+
559
+ return await this.openSocket(this.connectionParams);
560
+ }
561
+
562
+ startHeartbeatLoop() {
563
+ this.stopHeartbeatLoop();
564
+ const intervalMs = this.runtimeConfig.heartbeatSeconds * 1000;
565
+ this.heartbeatTimer = setInterval(() => {
566
+ try {
567
+ this.sendHeartbeat();
568
+ } catch (error) {
569
+ logRuntimeBoundary(this.logger, '[claworld:relay-client] heartbeat failed', error, this.buildBoundaryContext({
570
+ stage: 'heartbeat',
571
+ }), {
572
+ includeStack: false,
573
+ fallback: {
574
+ code: 'relay_heartbeat_failed',
575
+ category: 'transport',
576
+ publicMessage: 'relay heartbeat failed',
577
+ recoverable: true,
578
+ },
579
+ });
580
+ }
581
+ }, intervalMs);
582
+ if (typeof this.heartbeatTimer.unref === 'function') this.heartbeatTimer.unref();
583
+ }
584
+
585
+ stopHeartbeatLoop() {
586
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
587
+ this.heartbeatTimer = null;
588
+ }
589
+
590
+ send(payload) {
591
+ if (!this.ws || this.ws.readyState !== 1) {
592
+ throw createRuntimeBoundaryError({
593
+ code: 'relay_ws_not_connected',
594
+ category: 'transport',
595
+ status: 409,
596
+ message: 'relay websocket is not connected',
597
+ publicMessage: 'relay websocket is not connected',
598
+ recoverable: true,
599
+ context: this.buildBoundaryContext({
600
+ stage: 'send',
601
+ }),
602
+ });
603
+ }
604
+ this.ws.send(JSON.stringify(payload));
605
+ }
606
+
607
+ sendHeartbeat() {
608
+ this.send({ type: 'heartbeat' });
609
+ }
610
+
611
+ sendTurnReply({ sessionId, messageId = null, replyText, source = 'subagent', control = null } = {}) {
612
+ const envelope = this.outbound.createReplyEnvelope({ sessionId, messageId, replyText, source, control });
613
+ this.send({
614
+ type: 'turn.reply',
615
+ sessionId,
616
+ payload: {
617
+ text: envelope.reply_text,
618
+ source: envelope.source,
619
+ trace: envelope.trace,
620
+ ...(envelope.control ? { control: envelope.control } : {}),
621
+ },
622
+ });
623
+ return envelope;
624
+ }
625
+
626
+ terminateSession({ sessionId, reason = 'client_terminate' } = {}) {
627
+ this.send({ type: 'session.terminate', sessionId, reason });
628
+ }
629
+
630
+ async createSessionRequest({ fromAgentId, toAddress, requestContext = {} } = {}) {
631
+ return await this.requestJson('/v1/session-requests', {
632
+ method: 'POST',
633
+ headers: buildRuntimeAuthHeaders(this.runtimeConfig, { 'content-type': 'application/json' }),
634
+ body: JSON.stringify({ fromAgentId, toAddress, requestContext }),
635
+ }, {
636
+ code: 'relay_request_create_failed',
637
+ message: 'failed to create relay session request',
638
+ publicMessage: 'failed to create relay session request',
639
+ });
640
+ }
641
+
642
+ async acceptSessionRequest(requestId, { actorAgentId, ...options } = {}) {
643
+ return await this.requestJson(`/v1/session-requests/${requestId}/accept`, {
644
+ method: 'POST',
645
+ headers: buildRuntimeAuthHeaders(this.runtimeConfig, { 'content-type': 'application/json' }),
646
+ body: JSON.stringify({ actorAgentId, ...options }),
647
+ }, {
648
+ code: 'relay_request_accept_failed',
649
+ message: 'failed to accept relay session request',
650
+ publicMessage: 'failed to accept relay session request',
651
+ });
652
+ }
653
+
654
+ async deliverMessage({ fromAgentId, toAddress, payload = {}, conversation = {} } = {}) {
655
+ return await this.requestJson('/v1/messages', {
656
+ method: 'POST',
657
+ headers: buildRuntimeAuthHeaders(this.runtimeConfig, { 'content-type': 'application/json' }),
658
+ body: JSON.stringify({ fromAgentId, toAddress, payload, conversation }),
659
+ }, {
660
+ code: 'relay_message_delivery_failed',
661
+ message: 'failed to deliver relay message',
662
+ publicMessage: 'failed to deliver relay message',
663
+ });
664
+ }
665
+
666
+ async createTurn({ sessionId, fromAgentId, payload = {} } = {}) {
667
+ return await this.requestJson(`/v1/sessions/${sessionId}/turns`, {
668
+ method: 'POST',
669
+ headers: buildRuntimeAuthHeaders(this.runtimeConfig, { 'content-type': 'application/json' }),
670
+ body: JSON.stringify({ fromAgentId, payload }),
671
+ }, {
672
+ code: 'relay_turn_create_failed',
673
+ message: 'failed to create relay turn',
674
+ publicMessage: 'failed to create relay turn',
675
+ });
676
+ }
677
+
678
+ waitFor(eventNameOrPredicate, timeoutMs = 8000) {
679
+ const isPredicate = typeof eventNameOrPredicate === 'function';
680
+ const predicate = isPredicate
681
+ ? eventNameOrPredicate
682
+ : (event) => event.event === eventNameOrPredicate;
683
+
684
+ return new Promise((resolve, reject) => {
685
+ const existing = this.events.find(predicate);
686
+ if (existing) return resolve(existing);
687
+
688
+ const started = Date.now();
689
+ const timer = setInterval(() => {
690
+ const found = this.events.find(predicate);
691
+ if (found) {
692
+ clearInterval(timer);
693
+ resolve(found);
694
+ } else if (Date.now() - started > timeoutMs) {
695
+ clearInterval(timer);
696
+ const eventName = isPredicate ? 'predicate event' : eventNameOrPredicate;
697
+ reject(new Error(`timed out waiting for ${eventName}`));
698
+ }
699
+ }, 100);
700
+ });
701
+ }
702
+
703
+ async establishSession({ fromAgentId, toAddress, requestContext = {}, openingPayload = {} } = {}) {
704
+ const normalizedRequestContext = requestContext && typeof requestContext === 'object' && !Array.isArray(requestContext)
705
+ ? { ...requestContext }
706
+ : {};
707
+ const normalizedOpeningPayload = openingPayload && typeof openingPayload === 'object' && !Array.isArray(openingPayload)
708
+ ? { ...openingPayload }
709
+ : {};
710
+ if (Object.keys(normalizedOpeningPayload).length > 0) {
711
+ normalizedRequestContext.openingPayload = normalizedOpeningPayload;
712
+ if (!normalizedRequestContext.message && typeof normalizedOpeningPayload.text === 'string' && normalizedOpeningPayload.text.trim()) {
713
+ normalizedRequestContext.message = normalizedOpeningPayload.text.trim();
714
+ }
715
+ }
716
+
717
+ const requestResult = await this.createSessionRequest({
718
+ fromAgentId,
719
+ toAddress,
720
+ requestContext: normalizedRequestContext,
721
+ });
722
+ if (requestResult.status !== 201) {
723
+ throw new Error(`failed to create session request: ${JSON.stringify(requestResult.body)}`);
724
+ }
725
+ const requestId = requestResult.body.requestId;
726
+ return {
727
+ requestId,
728
+ sessionId: null,
729
+ openApprovedSession: async ({ timeoutMs = 15000 } = {}) => {
730
+ const acceptedEvent = await this.waitFor(
731
+ (event) => event.event === 'request.updated'
732
+ && event.data?.requestId === requestId
733
+ && event.data?.status === 'accepted',
734
+ timeoutMs,
735
+ );
736
+ const kickoff = acceptedEvent?.data?.kickoff && typeof acceptedEvent.data.kickoff === 'object'
737
+ ? acceptedEvent.data.kickoff
738
+ : null;
739
+ return {
740
+ requestId,
741
+ sessionId: kickoff?.sessionId || null,
742
+ kickoff,
743
+ };
744
+ },
745
+ };
746
+ }
747
+
748
+ snapshot() {
749
+ return {
750
+ connectionState: this.connectionState,
751
+ boundAgentId: this.boundAgentId,
752
+ eventCount: this.events.length,
753
+ heartbeatSeconds: this.runtimeConfig?.heartbeatSeconds || null,
754
+ hasActiveSocket: Boolean(this.ws && this.ws.readyState === 1),
755
+ reconnectEnabled: this.runtimeConfig?.reconnect !== false,
756
+ reconnectAttempts: this.reconnectAttempts,
757
+ lastDisconnectCode: this.lastDisconnectInfo?.code ?? null,
758
+ lastDisconnectReason: this.lastDisconnectInfo?.reason || null,
759
+ };
760
+ }
761
+
762
+ async close(reason = 'manual_close') {
763
+ this.manualClose = true;
764
+ this.clearReconnectTimer();
765
+ this.stopHeartbeatLoop();
766
+ if (!this.ws) return { closed: false, reason: 'not_connected' };
767
+ const ws = this.ws;
768
+ this.ws = null;
769
+ await new Promise((resolve) => {
770
+ if (ws.readyState === 3) return resolve();
771
+ ws.once('close', resolve);
772
+ ws.close(1000, reason);
773
+ });
774
+ this.connectionState = 'closed';
775
+ return { closed: true, reason };
776
+ }
777
+ }
778
+
779
+ export function createClaworldRelayClient(options = {}) {
780
+ return new ClaworldRelayClient(options);
781
+ }
782
+
783
+ export { normalizeRelayWebSocketUrl };