@xfxstudio/claworld 2026.7.1-testing.1 → 2026.7.2-testing.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "name": "Claworld Persona Relay",
19
19
  "description": "Claworld relay world channel plugin for OpenClaw.",
20
- "version": "2026.7.1-testing.1",
20
+ "version": "2026.7.2-testing.1",
21
21
  "configSchema": {
22
22
  "type": "object",
23
23
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfxstudio/claworld",
3
- "version": "2026.7.1-testing.1",
3
+ "version": "2026.7.2-testing.1",
4
4
  "description": "Claworld channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -122,13 +122,17 @@ Useful questions:
122
122
 
123
123
  Use `details` for the developer-facing summary: concise evidence, relevant observations, why this looks like product/runtime work, and anything the human specifically cares about. Use `reproductionSteps` for repeatable steps. Use `context` and `runtimeContext` for lookup metadata.
124
124
 
125
- Use a direct HTTP POST to the configured Claworld backend feedback URL:
125
+ Use a direct HTTP POST to the configured Claworld backend feedback URL. Read the
126
+ active Claworld channel/account configuration first and use its configured
127
+ backend when present:
126
128
 
127
129
  ```text
128
- https://staging.claworld.love/v1/feedback
130
+ <configured Claworld server URL>/v1/feedback
129
131
  ```
130
132
 
131
- Use `https://staging.claworld.love` as the default Claworld backend unless the active channel/account configuration explicitly points to another server.
133
+ For a fresh setup with no configured backend yet, use the package default:
134
+ testing packages default to `https://staging.claworld.love`, and stable packages
135
+ default to `https://claworld.love`.
132
136
 
133
137
  The `accountId`, `apiKey`, and app token come from the active Claworld channel/account configuration. Do not print secrets to the human. If an app token is configured, send it as `Authorization: Bearer <appToken>` and `x-claworld-app-token: <appToken>`. If an API key is configured, send `x-api-key: <apiKey>`.
134
138
 
@@ -137,7 +141,7 @@ The clean authenticated path is an app token that resolves to the account's back
137
141
  Example:
138
142
 
139
143
  ```bash
140
- CLAWORLD_SERVER_URL="${CLAWORLD_SERVER_URL:-https://staging.claworld.love}"
144
+ CLAWORLD_SERVER_URL="${CLAWORLD_SERVER_URL:-${CONFIGURED_CLAWORLD_SERVER_URL:-https://claworld.love}}"
141
145
 
142
146
  headers=(-H "content-type: application/json")
143
147
  if [ -n "${CLAWORLD_APP_TOKEN:-}" ]; then
@@ -1,5 +1,9 @@
1
1
  import os from 'os';
2
2
  import path from 'path';
3
+ import {
4
+ CLAWORLD_PLUGIN_CURRENT_VERSION,
5
+ normalizeClaworldPluginVersion,
6
+ } from '../plugin-version.js';
3
7
  import {
4
8
  CLAWORLD_MINIMAL_OPENCLAW_TOOL_NAMES,
5
9
  CLAWORLD_PUBLIC_TOOL_NAMES,
@@ -7,7 +11,18 @@ import {
7
11
  CLAWORLD_TOOL_PROFILES,
8
12
  } from '../runtime/tool-inventory.js';
9
13
 
10
- export const DEFAULT_CLAWORLD_SERVER_URL = 'https://staging.claworld.love';
14
+ export const CLAWORLD_STAGING_SERVER_URL = 'https://staging.claworld.love';
15
+ export const CLAWORLD_PRODUCTION_SERVER_URL = 'https://claworld.love';
16
+ export function isClaworldTestingPluginVersion(version = CLAWORLD_PLUGIN_CURRENT_VERSION) {
17
+ const normalized = normalizeClaworldPluginVersion(version, null);
18
+ return Boolean(normalized && /-testing(?:\.|$)/.test(normalized));
19
+ }
20
+ export function resolveDefaultClaworldServerUrl(version = CLAWORLD_PLUGIN_CURRENT_VERSION) {
21
+ return isClaworldTestingPluginVersion(version)
22
+ ? CLAWORLD_STAGING_SERVER_URL
23
+ : CLAWORLD_PRODUCTION_SERVER_URL;
24
+ }
25
+ export const DEFAULT_CLAWORLD_SERVER_URL = resolveDefaultClaworldServerUrl();
11
26
  export const DEFAULT_CLAWORLD_API_KEY = 'local-test';
12
27
  export const DEFAULT_CLAWORLD_AGENT_ID = 'main';
13
28
  export const DEFAULT_CLAWORLD_ACCOUNT_ID = 'claworld';
@@ -1128,11 +1128,25 @@ function createTerminalToolAdapters(api, plugin, internalTools) {
1128
1128
  async execute(toolCallId, params = {}) {
1129
1129
  const action = normalizeTerminalConversationAction(params.action, 'list_related', { throwOnInvalid: true });
1130
1130
  if (action === 'request') {
1131
- const result = await requireTerminalTool(internalTools, 'claworld_request_chat').execute(toolCallId, {
1132
- ...params,
1133
- action: 'request_chat',
1131
+ const context = await resolveToolContext(api, plugin, params, {
1132
+ requiredPublicIdentityCapability: 'request chat',
1133
+ });
1134
+ const payload = await plugin.helpers.social.requestChat({
1135
+ ...context,
1136
+ displayName: params.displayName,
1137
+ agentCode: params.agentCode,
1138
+ openingMessage: resolveConversationOpeningMessage(params),
1139
+ message: params.message || null,
1140
+ text: params.text || null,
1141
+ kickoffBrief: params.kickoffBrief || null,
1142
+ openingPayload: params.openingPayload || null,
1143
+ worldId: params.worldId || null,
1144
+ });
1145
+ return buildToolResult({
1146
+ ...projectToolChatRequestMutationResponse(payload, { accountId: context.accountId }),
1147
+ tool: manageConversationsTool,
1148
+ action,
1134
1149
  });
1135
- return rewriteToolResultName(result, manageConversationsTool, action);
1136
1150
  }
1137
1151
  if (action === 'list_related' || action === 'get_state') {
1138
1152
  const filters = normalizeManageConversationInboxQuery(params, action);
@@ -1749,119 +1763,6 @@ function buildRegisteredTools(api, plugin) {
1749
1763
  }));
1750
1764
  },
1751
1765
  },
1752
- {
1753
- name: 'claworld_request_chat',
1754
- label: 'Claworld Request Chat',
1755
- description: 'Use in the main session to create a new Claworld chat request or re-engage a selected public identity. Do not use for live conversation turns or current-session replies.',
1756
- metadata: buildToolMetadata({
1757
- category: 'chat_request',
1758
- usageNotes: [
1759
- 'Primary actor/session: main session only. Use this tool when the user wants to start a new request or re-engage someone after an earlier request or chat went silent or ended.',
1760
- 'If the user asks to contact the same person again, call this tool again to create a fresh request or re-engagement.',
1761
- 'For world-scoped chat or re-engagement, use the displayName and agentCode returned by world member search.',
1762
- 'The backend resolves the target by agentCode.',
1763
- 'If the current displayName for that agentCode no longer matches, the tool can still route by the current owner and return an explicit warning with the current displayName.',
1764
- 'openingMessage is required and must contain non-blank kickoff intent; missing or blank opener text fails with opening_message_required.',
1765
- 'Do not use this tool for replying inside an already-open Claworld chat or for runtime live turns.',
1766
- 'After creation, use claworld_chat_inbox to inspect pending, expired, rejected, opening, ending, active, silent, or ended status, or wait for the peer to accept.',
1767
- 'Once accepted, the runtime owns the live conversation loop.',
1768
- ],
1769
- examples: [
1770
- {
1771
- title: 'Request chat with a world member',
1772
- input: {
1773
- accountId: 'claworld',
1774
- worldId: 'dating-demo-world',
1775
- displayName: 'Runtime Peer',
1776
- agentCode: 'ZX82QP',
1777
- openingMessage: 'Hi, want to compare trail-running routes in Shanghai?',
1778
- },
1779
- outcome: 'Creates one pending world-scoped chat request or re-engagement request.',
1780
- },
1781
- {
1782
- title: 'Re-engage a known public identity',
1783
- input: {
1784
- accountId: 'claworld',
1785
- displayName: 'Runtime Peer',
1786
- agentCode: 'ZX82QP',
1787
- openingMessage: 'Hi, want to compare trail-running routes in Shanghai?',
1788
- },
1789
- outcome: 'Creates one pending direct chat request toward the known public identity, including re-engagement after silence or a prior ended request.',
1790
- },
1791
- ],
1792
- }),
1793
- parameters: objectParam({
1794
- description: 'In the main session, create a new direct or world-scoped chat request, or re-engage a previously silent or ended relationship, for one target agent. Use this for user requests to contact, PK, continue, or send peer-facing Claworld conversation content. Provide the target displayName, agentCode, and non-blank openingMessage. Do not use this payload for current live replies.',
1795
- required: ['accountId', 'displayName', 'agentCode', 'openingMessage'],
1796
- properties: {
1797
- accountId: accountIdProperty,
1798
- displayName: stringParam({
1799
- description: 'Target public displayName for the request or re-engagement target.',
1800
- minLength: 1,
1801
- examples: ['Runtime Peer'],
1802
- }),
1803
- agentCode: stringParam({
1804
- description: 'Target public agentCode. The backend resolves the target by this code and verifies the displayName still matches. Use public identity, not local session references.',
1805
- minLength: 1,
1806
- examples: ['ZX82QP'],
1807
- }),
1808
- openingMessage: stringParam({
1809
- description: 'Required non-blank request or re-engagement brief that the backend uses when the peer accepts. Missing or blank opener text fails with opening_message_required. This is kickoff intent, not a live runtime reply payload.',
1810
- minLength: 1,
1811
- examples: ['Hi, want to compare trail-running routes in Shanghai?'],
1812
- }),
1813
- message: stringParam({
1814
- description: 'Alias for openingMessage.',
1815
- minLength: 1,
1816
- }),
1817
- text: stringParam({
1818
- description: 'Alias for openingMessage.',
1819
- minLength: 1,
1820
- }),
1821
- kickoffBrief: objectParam({
1822
- description: 'Structured request kickoff brief. text/openingMessage/message are accepted as opener aliases.',
1823
- properties: {
1824
- text: stringParam({ description: 'Request/re-engagement kickoff message.', minLength: 1 }),
1825
- openingMessage: stringParam({ description: 'Alias for kickoff brief text.', minLength: 1 }),
1826
- message: stringParam({ description: 'Alias for kickoff brief text.', minLength: 1 }),
1827
- },
1828
- }),
1829
- openingPayload: objectParam({
1830
- description: 'Optional structured opening payload. text is accepted as opener alias.',
1831
- properties: {
1832
- text: stringParam({ description: 'Request/re-engagement kickoff message.', minLength: 1 }),
1833
- },
1834
- }),
1835
- worldId: worldIdProperty,
1836
- },
1837
- examples: [
1838
- {
1839
- accountId: 'claworld',
1840
- worldId: 'dating-demo-world',
1841
- displayName: 'Runtime Peer',
1842
- agentCode: 'ZX82QP',
1843
- openingMessage: 'Hi, want to compare trail-running routes in Shanghai?',
1844
- },
1845
- ],
1846
- }),
1847
- async execute(_toolCallId, params = {}) {
1848
- const context = await resolveToolContext(api, plugin, params, {
1849
- requiredPublicIdentityCapability: 'request chat',
1850
- });
1851
- const payload = await plugin.helpers.social.requestChat({
1852
- ...context,
1853
- displayName: params.displayName,
1854
- agentCode: params.agentCode,
1855
- openingMessage: resolveConversationOpeningMessage(params),
1856
- message: params.message || null,
1857
- text: params.text || null,
1858
- kickoffBrief: params.kickoffBrief || null,
1859
- openingPayload: params.openingPayload || null,
1860
- worldId: params.worldId || null,
1861
- });
1862
- return buildToolResult(projectToolChatRequestMutationResponse(payload, { accountId: context.accountId }));
1863
- },
1864
- },
1865
1766
  {
1866
1767
  name: 'claworld_chat_inbox',
1867
1768
  label: 'Claworld Chat Inbox',
@@ -32,6 +32,10 @@ import {
32
32
 
33
33
  const DELIVERY_VISIBILITY_RETRY_ATTEMPTS = 20;
34
34
  const DELIVERY_VISIBILITY_RETRY_DELAY_MS = 10;
35
+ const DEFAULT_RELAY_AUTH_TIMEOUT_MS = 30_000;
36
+ const DEFAULT_RECONNECT_BASE_DELAY_MS = 1_000;
37
+ const DEFAULT_RECONNECT_MAX_DELAY_MS = 60_000;
38
+ const DEFAULT_RECONNECT_JITTER_RATIO = 0.2;
35
39
 
36
40
  function isDeliveryVisibilityMiss(result = {}) {
37
41
  return Number(result?.status) === 404
@@ -53,6 +57,11 @@ export class ClaworldRelayClient extends EventEmitter {
53
57
  protocol = createRelayEventProtocol(),
54
58
  wsFactory = (url) => new WebSocket(url),
55
59
  httpFetch = globalThis.fetch,
60
+ authTimeoutMs = DEFAULT_RELAY_AUTH_TIMEOUT_MS,
61
+ reconnectBaseDelayMs = DEFAULT_RECONNECT_BASE_DELAY_MS,
62
+ reconnectMaxDelayMs = DEFAULT_RECONNECT_MAX_DELAY_MS,
63
+ reconnectJitterRatio = DEFAULT_RECONNECT_JITTER_RATIO,
64
+ random = Math.random,
56
65
  } = {}) {
57
66
  super();
58
67
  this.logger = logger;
@@ -61,6 +70,11 @@ export class ClaworldRelayClient extends EventEmitter {
61
70
  this.protocol = protocol;
62
71
  this.wsFactory = wsFactory;
63
72
  this.httpFetch = httpFetch;
73
+ this.authTimeoutMs = Math.max(1, Math.floor(Number(authTimeoutMs) || DEFAULT_RELAY_AUTH_TIMEOUT_MS));
74
+ this.reconnectBaseDelayMs = Math.max(1, Math.floor(Number(reconnectBaseDelayMs) || DEFAULT_RECONNECT_BASE_DELAY_MS));
75
+ this.reconnectMaxDelayMs = Math.max(this.reconnectBaseDelayMs, Math.floor(Number(reconnectMaxDelayMs) || DEFAULT_RECONNECT_MAX_DELAY_MS));
76
+ this.reconnectJitterRatio = Math.max(0, Math.min(1, Number(reconnectJitterRatio) || 0));
77
+ this.random = typeof random === 'function' ? random : Math.random;
64
78
  this.ws = null;
65
79
  this.events = [];
66
80
  this.heartbeatTimer = null;
@@ -103,9 +117,17 @@ export class ClaworldRelayClient extends EventEmitter {
103
117
  this.reconnectTimer = null;
104
118
  }
105
119
 
106
- resolveReconnectDelayMs() {
107
- const heartbeatSeconds = Number(this.runtimeConfig?.heartbeatSeconds || 1);
108
- return Math.min(5000, Math.max(500, Math.floor(heartbeatSeconds * 500)));
120
+ resolveReconnectDelayMs(attempt = this.reconnectAttempts + 1) {
121
+ const normalizedAttempt = Math.max(1, Math.floor(Number(attempt) || 1));
122
+ const exponent = Math.min(normalizedAttempt - 1, 10);
123
+ const baseDelayMs = Math.min(
124
+ this.reconnectMaxDelayMs,
125
+ this.reconnectBaseDelayMs * (2 ** exponent),
126
+ );
127
+ const jitterWindowMs = Math.floor(baseDelayMs * this.reconnectJitterRatio);
128
+ if (jitterWindowMs <= 0) return baseDelayMs;
129
+ const jitterMs = Math.floor(Math.max(0, Math.min(1, Number(this.random()) || 0)) * jitterWindowMs);
130
+ return Math.min(this.reconnectMaxDelayMs, baseDelayMs + jitterMs);
109
131
  }
110
132
 
111
133
  shouldAutoReconnect(disconnectInfo = null) {
@@ -285,9 +307,59 @@ export class ClaworldRelayClient extends EventEmitter {
285
307
  return await new Promise((resolve, reject) => {
286
308
  let settled = false;
287
309
  let suppressCloseHandler = false;
310
+ let authTimer = null;
288
311
  const ws = this.wsFactory(wsUrl);
289
312
  this.ws = ws;
290
313
 
314
+ const clearAuthTimer = () => {
315
+ if (authTimer) clearTimeout(authTimer);
316
+ authTimer = null;
317
+ };
318
+
319
+ const closeSocketAfterFailedAuth = (reason = 'auth_failed') => {
320
+ try {
321
+ if (this.ws === ws) this.ws = null;
322
+ if (ws.readyState !== 3) ws.close(1008, reason);
323
+ } catch {
324
+ // No-op.
325
+ }
326
+ };
327
+
328
+ const settleAuthResolve = (message) => {
329
+ if (settled) return;
330
+ settled = true;
331
+ clearAuthTimer();
332
+ resolve(message);
333
+ };
334
+
335
+ const settleAuthReject = (error, { suppressClose = false, closeReason = null } = {}) => {
336
+ if (settled) return;
337
+ settled = true;
338
+ clearAuthTimer();
339
+ if (suppressClose) suppressCloseHandler = true;
340
+ this.connectionState = 'error';
341
+ if (closeReason) closeSocketAfterFailedAuth(closeReason);
342
+ reject(error);
343
+ };
344
+
345
+ authTimer = setTimeout(() => {
346
+ const timeoutError = createRuntimeBoundaryError({
347
+ code: 'relay_auth_timeout',
348
+ category: 'transport',
349
+ status: 504,
350
+ message: `timed out waiting for relay authentication after ${this.authTimeoutMs}ms`,
351
+ publicMessage: 'relay authentication timed out',
352
+ recoverable: true,
353
+ context: this.buildBoundaryContext({
354
+ stage: 'auth',
355
+ timeoutMs: this.authTimeoutMs,
356
+ }),
357
+ });
358
+ timeoutError.reason = 'auth_timeout';
359
+ timeoutError.close = this.buildDisconnectInfo(null, 'auth_timeout', 'auth');
360
+ settleAuthReject(timeoutError, { suppressClose: true, closeReason: 'auth_timeout' });
361
+ }, this.authTimeoutMs);
362
+
291
363
  ws.on('open', () => {
292
364
  this.logger.info?.('[claworld:relay-client] websocket open, sending auth', {
293
365
  accountId: this.runtimeConfig.accountId,
@@ -295,13 +367,17 @@ export class ClaworldRelayClient extends EventEmitter {
295
367
  clientVersion,
296
368
  bridgeProtocol: this.protocol.version,
297
369
  });
298
- this.send({
299
- type: 'auth',
300
- agentId,
301
- credential,
302
- clientVersion,
303
- bridgeProtocol: this.protocol.version,
304
- });
370
+ try {
371
+ this.send({
372
+ type: 'auth',
373
+ agentId,
374
+ credential,
375
+ clientVersion,
376
+ bridgeProtocol: this.protocol.version,
377
+ });
378
+ } catch (error) {
379
+ settleAuthReject(error, { suppressClose: true, closeReason: 'auth_send_failed' });
380
+ }
305
381
  });
306
382
 
307
383
  ws.on('message', (buf) => {
@@ -316,9 +392,7 @@ export class ClaworldRelayClient extends EventEmitter {
316
392
  context: { stage: 'message_parse' },
317
393
  });
318
394
  if (!settled) {
319
- settled = true;
320
- this.connectionState = 'error';
321
- reject(normalized);
395
+ settleAuthReject(normalized, { suppressClose: true, closeReason: 'message_parse_failed' });
322
396
  }
323
397
  return;
324
398
  }
@@ -327,7 +401,6 @@ export class ClaworldRelayClient extends EventEmitter {
327
401
  this.emitRelayMessage(message, { sessionTarget, fallbackTarget });
328
402
 
329
403
  if (message.event === 'auth.ok' && !settled) {
330
- settled = true;
331
404
  this.connectionState = 'authenticated';
332
405
  this.reconnectAttempts = 0;
333
406
  this.logger.info?.('[claworld:relay-client] auth ok', {
@@ -335,13 +408,10 @@ export class ClaworldRelayClient extends EventEmitter {
335
408
  agentId,
336
409
  });
337
410
  this.startHeartbeatLoop();
338
- resolve(message);
411
+ settleAuthResolve(message);
339
412
  }
340
413
 
341
414
  if (message.event === 'error' && !settled && message.data?.code === 'unauthorized') {
342
- settled = true;
343
- suppressCloseHandler = true;
344
- this.connectionState = 'error';
345
415
  const authReason = message.data?.reason || message.data?.error || 'unauthorized';
346
416
  const authError = createRuntimeBoundaryError({
347
417
  code: message.data?.code || 'unauthorized',
@@ -364,7 +434,7 @@ export class ClaworldRelayClient extends EventEmitter {
364
434
  error: authError.message,
365
435
  code: authError.code,
366
436
  });
367
- reject(authError);
437
+ settleAuthReject(authError, { suppressClose: true, closeReason: authReason });
368
438
  }
369
439
  } catch (error) {
370
440
  const normalized = this.emitBoundaryError('[claworld:relay-client] relay message handling failed', error, {
@@ -377,10 +447,7 @@ export class ClaworldRelayClient extends EventEmitter {
377
447
  },
378
448
  });
379
449
  if (!settled) {
380
- settled = true;
381
- suppressCloseHandler = true;
382
- this.connectionState = 'error';
383
- reject(normalized);
450
+ settleAuthReject(normalized, { suppressClose: true, closeReason: 'message_handling_failed' });
384
451
  }
385
452
  }
386
453
  });
@@ -402,6 +469,7 @@ export class ClaworldRelayClient extends EventEmitter {
402
469
 
403
470
  if (!settled) {
404
471
  settled = true;
472
+ clearAuthTimer();
405
473
  this.connectionState = disconnectInfo.reason === 'duplicate_connection_replaced' ? 'replaced' : 'error';
406
474
  reject(this.createClosedBeforeAuthError(disconnectInfo));
407
475
  return;
@@ -430,11 +498,8 @@ export class ClaworldRelayClient extends EventEmitter {
430
498
  },
431
499
  });
432
500
  if (!settled) {
433
- settled = true;
434
- suppressCloseHandler = true;
435
- this.connectionState = 'error';
436
501
  normalized.reason = normalized.reason || normalized.code || normalized.message;
437
- reject(normalized);
502
+ settleAuthReject(normalized, { suppressClose: true, closeReason: 'connect_error' });
438
503
  }
439
504
  });
440
505
  });
@@ -448,8 +513,8 @@ export class ClaworldRelayClient extends EventEmitter {
448
513
  }
449
514
 
450
515
  this.clearReconnectTimer();
451
- const delayMs = this.resolveReconnectDelayMs();
452
516
  const attempt = this.reconnectAttempts + 1;
517
+ const delayMs = this.resolveReconnectDelayMs(attempt);
453
518
  this.reconnectAttempts = attempt;
454
519
  this.logger.warn?.('[claworld:relay-client] scheduling reconnect', {
455
520
  accountId: this.runtimeConfig?.accountId || null,
@@ -793,7 +858,7 @@ export class ClaworldRelayClient extends EventEmitter {
793
858
  });
794
859
  }
795
860
 
796
- waitForAcceptedAck({ deliveryId, timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS } = {}) {
861
+ waitForAcceptedAck({ deliveryId, timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS, signal = null } = {}) {
797
862
  const normalizedDeliveryId = normalizeOptionalText(deliveryId);
798
863
  if (!normalizedDeliveryId) {
799
864
  return Promise.reject(createRuntimeBoundaryError({
@@ -805,6 +870,20 @@ export class ClaworldRelayClient extends EventEmitter {
805
870
  recoverable: true,
806
871
  }));
807
872
  }
873
+ if (signal?.aborted) {
874
+ return Promise.reject(createRuntimeBoundaryError({
875
+ code: 'relay_delivery_accept_ack_wait_cancelled',
876
+ category: 'transport',
877
+ status: 499,
878
+ message: `relay delivery acceptance acknowledgement wait cancelled for ${normalizedDeliveryId}`,
879
+ publicMessage: 'relay delivery acceptance acknowledgement wait cancelled',
880
+ recoverable: true,
881
+ context: this.buildBoundaryContext({
882
+ stage: 'delivery_accept_ack_wait',
883
+ deliveryId: normalizedDeliveryId,
884
+ }),
885
+ }));
886
+ }
808
887
 
809
888
  return new Promise((resolve, reject) => {
810
889
  let settled = false;
@@ -815,6 +894,7 @@ export class ClaworldRelayClient extends EventEmitter {
815
894
  this.off('delivery.accepted', onAccepted);
816
895
  this.off('disconnect', onDisconnect);
817
896
  this.off('close', onDisconnect);
897
+ signal?.removeEventListener('abort', onAbort);
818
898
  };
819
899
 
820
900
  const settleResolve = (value) => {
@@ -854,9 +934,25 @@ export class ClaworldRelayClient extends EventEmitter {
854
934
  }));
855
935
  };
856
936
 
937
+ const onAbort = () => {
938
+ settleReject(createRuntimeBoundaryError({
939
+ code: 'relay_delivery_accept_ack_wait_cancelled',
940
+ category: 'transport',
941
+ status: 499,
942
+ message: `relay delivery acceptance acknowledgement wait cancelled for ${normalizedDeliveryId}`,
943
+ publicMessage: 'relay delivery acceptance acknowledgement wait cancelled',
944
+ recoverable: true,
945
+ context: this.buildBoundaryContext({
946
+ stage: 'delivery_accept_ack_wait',
947
+ deliveryId: normalizedDeliveryId,
948
+ }),
949
+ }));
950
+ };
951
+
857
952
  this.on('delivery.accepted', onAccepted);
858
953
  this.on('disconnect', onDisconnect);
859
954
  this.on('close', onDisconnect);
955
+ signal?.addEventListener('abort', onAbort, { once: true });
860
956
 
861
957
  timeout = setTimeout(() => {
862
958
  settleReject(buildAcceptedAckTimeoutError({
@@ -1097,6 +1193,52 @@ export class ClaworldRelayClient extends EventEmitter {
1097
1193
  };
1098
1194
  }
1099
1195
 
1196
+ async submitAcceptedHttpFallback({
1197
+ deliveryId,
1198
+ sessionKey,
1199
+ source = 'runtime_dispatch',
1200
+ error = null,
1201
+ } = {}) {
1202
+ this.logger.warn?.('[claworld:relay-client] delivery acceptance websocket transport failed; attempting HTTP fallback', {
1203
+ accountId: this.runtimeConfig?.accountId || null,
1204
+ agentId: this.boundAgentId,
1205
+ deliveryId: normalizeOptionalText(deliveryId),
1206
+ sessionKey: normalizeOptionalText(sessionKey) || null,
1207
+ error: error?.message || String(error),
1208
+ });
1209
+
1210
+ const fallbackResult = await this.acceptDeliveryHttp({
1211
+ deliveryId,
1212
+ sessionKey,
1213
+ source,
1214
+ });
1215
+
1216
+ if (fallbackResult.status >= 200 && fallbackResult.status < 300) {
1217
+ return {
1218
+ ok: true,
1219
+ envelope: fallbackResult.envelope,
1220
+ ack: {
1221
+ event: 'delivery.accepted',
1222
+ data: fallbackResult.body,
1223
+ },
1224
+ transport: 'http',
1225
+ fallbackUsed: true,
1226
+ };
1227
+ }
1228
+
1229
+ throw buildReplyFallbackError({
1230
+ deliveryId: fallbackResult.envelope.deliveryId,
1231
+ status: fallbackResult.status,
1232
+ body: fallbackResult.body,
1233
+ context: this.buildBoundaryContext({
1234
+ stage: 'delivery_accept_fallback',
1235
+ deliveryId: fallbackResult.envelope.deliveryId,
1236
+ sessionKey: normalizeOptionalText(sessionKey) || null,
1237
+ fallbackFrom: error?.code || error?.message || null,
1238
+ }),
1239
+ });
1240
+ }
1241
+
1100
1242
  async sendReplyAndWaitForAck({
1101
1243
  deliveryId,
1102
1244
  sessionKey,
@@ -1190,15 +1332,40 @@ export class ClaworldRelayClient extends EventEmitter {
1190
1332
  timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS,
1191
1333
  httpFallback = true,
1192
1334
  } = {}) {
1335
+ if (httpFallback && (!this.ws || this.ws.readyState !== 1)) {
1336
+ return await this.submitAcceptedHttpFallback({
1337
+ deliveryId,
1338
+ sessionKey,
1339
+ source,
1340
+ error: this.buildWsNotConnectedError('delivery_accept_send'),
1341
+ });
1342
+ }
1343
+
1344
+ const ackAbortController = new AbortController();
1193
1345
  const ackPromise = this.waitForAcceptedAck({
1194
1346
  deliveryId,
1195
1347
  timeoutMs,
1348
+ signal: ackAbortController.signal,
1196
1349
  });
1197
- const envelope = this.sendAccepted({
1198
- deliveryId,
1199
- sessionKey,
1200
- source,
1201
- });
1350
+ let envelope;
1351
+
1352
+ try {
1353
+ envelope = this.sendAccepted({
1354
+ deliveryId,
1355
+ sessionKey,
1356
+ source,
1357
+ });
1358
+ } catch (error) {
1359
+ ackAbortController.abort();
1360
+ void ackPromise.catch(() => {});
1361
+ if (!httpFallback) throw error;
1362
+ return await this.submitAcceptedHttpFallback({
1363
+ deliveryId,
1364
+ sessionKey,
1365
+ source,
1366
+ error,
1367
+ });
1368
+ }
1202
1369
 
1203
1370
  try {
1204
1371
  const ack = await ackPromise;
@@ -1212,43 +1379,11 @@ export class ClaworldRelayClient extends EventEmitter {
1212
1379
  } catch (error) {
1213
1380
  if (!httpFallback) throw error;
1214
1381
 
1215
- this.logger.warn?.('[claworld:relay-client] delivery acceptance websocket acknowledgement failed; attempting HTTP fallback', {
1216
- accountId: this.runtimeConfig?.accountId || null,
1217
- agentId: this.boundAgentId,
1218
- deliveryId: envelope.deliveryId,
1219
- sessionKey: envelope.sessionKey,
1220
- error: error?.message || String(error),
1221
- });
1222
-
1223
- const fallbackResult = await this.acceptDeliveryHttp({
1382
+ return await this.submitAcceptedHttpFallback({
1224
1383
  deliveryId: envelope.deliveryId,
1225
1384
  sessionKey: envelope.sessionKey,
1226
1385
  source: envelope.source,
1227
- });
1228
-
1229
- if (fallbackResult.status >= 200 && fallbackResult.status < 300) {
1230
- return {
1231
- ok: true,
1232
- envelope,
1233
- ack: {
1234
- event: 'delivery.accepted',
1235
- data: fallbackResult.body,
1236
- },
1237
- transport: 'http',
1238
- fallbackUsed: true,
1239
- };
1240
- }
1241
-
1242
- throw buildReplyFallbackError({
1243
- deliveryId: envelope.deliveryId,
1244
- status: fallbackResult.status,
1245
- body: fallbackResult.body,
1246
- context: this.buildBoundaryContext({
1247
- stage: 'delivery_accept_fallback',
1248
- deliveryId: envelope.deliveryId,
1249
- sessionKey: envelope.sessionKey,
1250
- fallbackFrom: error?.code || error?.message || null,
1251
- }),
1386
+ error,
1252
1387
  });
1253
1388
  }
1254
1389
  }
@@ -1402,7 +1402,6 @@ export function buildClaworldToolMaintenanceEvent({
1402
1402
  const conversationToolNames = new Set([
1403
1403
  'claworld_manage_conversations',
1404
1404
  'claworld_chat_inbox',
1405
- 'claworld_request_chat',
1406
1405
  ]);
1407
1406
  const localSessionKey = firstText(
1408
1407
  conversationToolNames.has(normalizedToolName) ? resultConversationSessionKey : null,