@xfxstudio/claworld 2026.4.30-testing.1 → 2026.4.30-testing.3

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.
@@ -8,7 +8,7 @@
8
8
  ],
9
9
  "name": "Claworld Persona Relay",
10
10
  "description": "Claworld relay world channel plugin for OpenClaw.",
11
- "version": "2026.4.30-testing.1",
11
+ "version": "2026.4.30-testing.3",
12
12
  "configSchema": {
13
13
  "type": "object",
14
14
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfxstudio/claworld",
3
- "version": "2026.4.30-testing.1",
3
+ "version": "2026.4.30-testing.3",
4
4
  "description": "Claworld channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -132,6 +132,35 @@ function resolveNormalizedText(value, fallback = null) {
132
132
  return normalizeClaworldText(value, fallback);
133
133
  }
134
134
 
135
+ function resolveInboundMessageId({ delivery = {}, payload = {}, metadata = {} } = {}) {
136
+ const notification = payload.notification && typeof payload.notification === 'object' && !Array.isArray(payload.notification)
137
+ ? payload.notification
138
+ : delivery.notification && typeof delivery.notification === 'object' && !Array.isArray(delivery.notification)
139
+ ? delivery.notification
140
+ : {};
141
+ const candidates = [
142
+ delivery.deliveryId,
143
+ delivery.inboxItemId,
144
+ delivery.messageId,
145
+ delivery.eventId,
146
+ delivery.notificationId,
147
+ payload.deliveryId,
148
+ payload.inboxItemId,
149
+ payload.messageId,
150
+ payload.eventId,
151
+ payload.notificationId,
152
+ metadata.messageId,
153
+ metadata.eventId,
154
+ metadata.notificationId,
155
+ notification.notificationId,
156
+ ];
157
+ for (const candidate of candidates) {
158
+ const normalized = resolveNormalizedText(candidate, null);
159
+ if (normalized) return normalized;
160
+ }
161
+ return null;
162
+ }
163
+
135
164
  function isAgentScopedSessionKey(sessionKey) {
136
165
  return /^agent:[^:]+:/i.test(String(sessionKey || ''));
137
166
  }
@@ -274,6 +303,7 @@ function parseBridgeTimestampMs(value) {
274
303
 
275
304
  function resolveBridgeDeliveryTimestampMs({ delivery = {}, metadata = {} } = {}) {
276
305
  return parseBridgeTimestampMs(delivery?.createdAt)
306
+ || parseBridgeTimestampMs(delivery?.availableAt)
277
307
  || parseBridgeTimestampMs(delivery?.turnCreatedAt)
278
308
  || parseBridgeTimestampMs(metadata?.createdAt)
279
309
  || Date.now();
@@ -1752,6 +1782,7 @@ function buildDeliveryInboundEnvelope({
1752
1782
  commandText = null,
1753
1783
  timestamp = null,
1754
1784
  deliveryId,
1785
+ eventType = 'delivery',
1755
1786
  sessionKey,
1756
1787
  localSessionKey = null,
1757
1788
  worldId = null,
@@ -1770,13 +1801,16 @@ function buildDeliveryInboundEnvelope({
1770
1801
  const normalizedCommandText = String(commandText || '').trim();
1771
1802
  const commandBody = normalizedCommandText || rawBody;
1772
1803
  const bodyForAgent = bodyText || rawBody;
1804
+ const eventLabel = normalizePluginOptionalText(eventType) === 'delivery'
1805
+ ? 'delivery'
1806
+ : `event ${normalizePluginOptionalText(eventType)}`;
1773
1807
  const contextLines = mergeUntrustedContextLines([
1774
1808
  `[claworld peer ${remoteLabel}]`,
1775
1809
  ...(worldId ? [`[claworld world ${worldId}]`] : []),
1776
1810
  ...(conversationKey ? [`[claworld conversation ${conversationKey}]`] : []),
1777
1811
  ...(localSessionKey && localSessionKey !== sessionKey ? [`[claworld local session ${localSessionKey}]`] : []),
1778
1812
  `[claworld relay session ${sessionKey}]`,
1779
- `[claworld delivery ${deliveryId}]`,
1813
+ `[claworld ${eventLabel} ${deliveryId}]`,
1780
1814
  ], untrustedContext);
1781
1815
  const envelopeTimestamp = Number.isFinite(timestamp) ? new Date(timestamp) : new Date();
1782
1816
 
@@ -2173,7 +2207,7 @@ function resolveBoundLocalAgentId({ cfg = {}, runtimeConfig = {}, relayClient }
2173
2207
  || 'main';
2174
2208
  }
2175
2209
 
2176
- async function maybeBridgeRuntimeDelivery({
2210
+ async function maybeBridgeRuntimeInboundEvent({
2177
2211
  relayClient,
2178
2212
  runtimeConfig,
2179
2213
  runtimeAccountId,
@@ -2192,36 +2226,46 @@ async function maybeBridgeRuntimeDelivery({
2192
2226
  const payload = delivery.payload && typeof delivery.payload === 'object' && !Array.isArray(delivery.payload)
2193
2227
  ? delivery.payload
2194
2228
  : {};
2195
- const deliveryId = resolveNormalizedText(delivery.deliveryId, null);
2229
+ const eventType = resolveNormalizedText(delivery.eventType, resolveNormalizedText(event?.eventType, 'delivery'));
2230
+ const deliveryId = resolveInboundMessageId({ delivery, payload, metadata });
2196
2231
  const sessionKey = resolveNormalizedText(delivery.sessionKey, null);
2197
2232
  const contextText = resolveNormalizedText(payload.contextText, null);
2198
2233
  const incomingText = resolveNormalizedText(
2199
2234
  payload.commandText,
2200
- contextText ? null : resolveNormalizedText(payload.text, null),
2235
+ contextText ? null : resolveNormalizedText(payload.text, resolveNormalizedText(payload.body, null)),
2201
2236
  );
2202
2237
  const commandText = resolveNormalizedText(payload.commandText, incomingText);
2203
2238
  const fromAgentId = resolveNormalizedText(metadata.fromAgentId, null);
2204
- const remoteIdentity = fromAgentId || 'unknown-peer';
2239
+ const routeSessionKind = resolveNormalizedText(
2240
+ event?.route?.sessionKind,
2241
+ resolveNormalizedText(delivery.sessionKind, resolveNormalizedText(payload.sessionKind, null)),
2242
+ );
2243
+ const isRelayDelivery = eventType === 'delivery';
2244
+ const allowReply = metadata.allowReply === true || (isRelayDelivery && metadata.allowReply !== false);
2245
+ const remoteIdentity = fromAgentId
2246
+ || resolveNormalizedText(metadata.source, routeSessionKind === 'management' ? 'claworld-management' : 'unknown-peer');
2205
2247
 
2206
2248
  if (
2207
2249
  !runtime?.channel?.reply?.finalizeInboundContext
2208
2250
  || !runtime?.channel?.reply?.dispatchReplyFromConfig
2209
2251
  || !runtime?.channel?.reply?.createReplyDispatcherWithTyping
2210
2252
  ) {
2211
- logger.warn?.(`[claworld:${runtimeAccountId}] skipping delivery bridge: missing runtime bridge hooks`, {
2253
+ logger.warn?.(`[claworld:${runtimeAccountId}] skipping inbound bridge: missing runtime bridge hooks`, {
2254
+ eventType,
2212
2255
  deliveryId,
2213
2256
  sessionKey,
2214
2257
  });
2215
2258
  return { skipped: true, reason: 'missing_runtime_bridge_hooks' };
2216
2259
  }
2217
2260
  if (!deliveryId || !sessionKey || (!incomingText && !contextText)) {
2218
- logger.warn?.(`[claworld:${runtimeAccountId}] skipping delivery bridge: missing delivery payload`, {
2261
+ logger.warn?.(`[claworld:${runtimeAccountId}] skipping inbound bridge: missing payload`, {
2262
+ eventType,
2219
2263
  deliveryId,
2220
2264
  sessionKey,
2221
2265
  hasIncomingText: Boolean(incomingText),
2222
2266
  hasContextText: Boolean(contextText),
2223
2267
  });
2224
- return { skipped: true, reason: 'missing_delivery_payload' };
2268
+ return { skipped: true, reason: 'missing_inbound_payload' };
2225
2269
  }
2226
2270
 
2227
2271
  const loadedCfg = await runtime.config?.loadConfig?.() || {};
@@ -2247,7 +2291,7 @@ async function maybeBridgeRuntimeDelivery({
2247
2291
  fallbackTarget: runtimeConfig.routing?.fallbackTarget,
2248
2292
  }) || null;
2249
2293
  const worldId = resolveDeliveryWorldId(delivery);
2250
- const commandAuthorized = shouldAuthorizeBridgedCommand({
2294
+ const commandAuthorized = isRelayDelivery && shouldAuthorizeBridgedCommand({
2251
2295
  runtimeConfig,
2252
2296
  incomingText: commandText || incomingText,
2253
2297
  });
@@ -2261,6 +2305,7 @@ async function maybeBridgeRuntimeDelivery({
2261
2305
  commandText,
2262
2306
  timestamp: inboundTimestamp,
2263
2307
  deliveryId,
2308
+ eventType,
2264
2309
  sessionKey,
2265
2310
  localSessionKey,
2266
2311
  worldId,
@@ -2268,6 +2313,9 @@ async function maybeBridgeRuntimeDelivery({
2268
2313
  untrustedContext: payload.untrustedContext,
2269
2314
  });
2270
2315
  const localIdentity = normalizeClaworldText(runtimeConfig.relay?.agentId, runtimeConfig.accountId);
2316
+ const isManagementSession = routeSessionKind === 'management';
2317
+ const senderName = isManagementSession ? 'Claworld' : remoteIdentity;
2318
+ const conversationLabel = isManagementSession ? 'Claworld management' : remoteIdentity;
2271
2319
  const inboundCtx = runtime.channel.reply.finalizeInboundContext({
2272
2320
  Body,
2273
2321
  RawBody,
@@ -2282,18 +2330,18 @@ async function maybeBridgeRuntimeDelivery({
2282
2330
  OriginatingChannel: 'claworld',
2283
2331
  OriginatingFrom: remoteIdentity,
2284
2332
  OriginatingTo: remoteIdentity,
2285
- ChatType: 'direct',
2286
- SenderName: remoteIdentity,
2333
+ ChatType: isManagementSession ? 'management' : 'direct',
2334
+ SenderName: senderName,
2287
2335
  SenderId: remoteIdentity,
2288
2336
  MessageId: deliveryId,
2289
2337
  Provider: 'claworld',
2290
2338
  Surface: 'claworld',
2291
- ConversationLabel: remoteIdentity,
2339
+ ConversationLabel: conversationLabel,
2292
2340
  Timestamp: inboundTimestamp,
2293
2341
  MessageSid: deliveryId,
2294
2342
  WasMentioned: false,
2295
2343
  CommandAuthorized: commandAuthorized,
2296
- RelayDeliveryId: deliveryId,
2344
+ RelayDeliveryId: isRelayDelivery ? deliveryId : null,
2297
2345
  RelayFromAgentId: fromAgentId,
2298
2346
  UntrustedContext,
2299
2347
  });
@@ -2308,6 +2356,7 @@ async function maybeBridgeRuntimeDelivery({
2308
2356
  ctx: inboundCtx,
2309
2357
  onRecordError: (error) => {
2310
2358
  logger.error?.(`[claworld:${runtimeAccountId}] failed to record inbound session`, {
2359
+ eventType,
2311
2360
  deliveryId,
2312
2361
  sessionKey,
2313
2362
  localSessionKey,
@@ -2318,7 +2367,8 @@ async function maybeBridgeRuntimeDelivery({
2318
2367
  });
2319
2368
  }
2320
2369
 
2321
- logger.info?.(`[claworld:${runtimeAccountId}] routing delivery into runtime session`, {
2370
+ logger.info?.(`[claworld:${runtimeAccountId}] ${isRelayDelivery ? 'routing delivery into runtime session' : 'routing inbound event into runtime session'}`, {
2371
+ eventType,
2322
2372
  deliveryId,
2323
2373
  sessionKey,
2324
2374
  localSessionKey,
@@ -2327,27 +2377,29 @@ async function maybeBridgeRuntimeDelivery({
2327
2377
  routeStatus: routed?.status || null,
2328
2378
  bodyPreview: String(Body || '').slice(0, 240),
2329
2379
  rawBodyPreview: String(RawBody || '').slice(0, 240),
2330
- allowReply: metadata.allowReply !== false,
2380
+ allowReply,
2331
2381
  commandAuthorized,
2332
2382
  });
2333
2383
 
2334
- try {
2335
- const acceptedResult = await relayClient.acceptDeliveryHttp({
2336
- deliveryId,
2337
- sessionKey,
2338
- source: 'runtime_dispatch',
2339
- });
2340
- if (acceptedResult.status < 200 || acceptedResult.status >= 300) {
2341
- throw new Error(`failed to submit relay delivery acceptance: ${acceptedResult.status}`);
2384
+ if (isRelayDelivery && metadata.acceptanceRequired !== false) {
2385
+ try {
2386
+ const acceptedResult = await relayClient.acceptDeliveryHttp({
2387
+ deliveryId,
2388
+ sessionKey,
2389
+ source: 'runtime_dispatch',
2390
+ });
2391
+ if (acceptedResult.status < 200 || acceptedResult.status >= 300) {
2392
+ throw new Error(`failed to submit relay delivery acceptance: ${acceptedResult.status}`);
2393
+ }
2394
+ } catch (error) {
2395
+ logger.warn?.(`[claworld:${runtimeAccountId}] delivery acceptance acknowledgement failed`, {
2396
+ deliveryId,
2397
+ sessionKey,
2398
+ localSessionKey,
2399
+ localAgentId,
2400
+ error: error?.message || String(error),
2401
+ });
2342
2402
  }
2343
- } catch (error) {
2344
- logger.warn?.(`[claworld:${runtimeAccountId}] delivery acceptance acknowledgement failed`, {
2345
- deliveryId,
2346
- sessionKey,
2347
- localSessionKey,
2348
- localAgentId,
2349
- error: error?.message || String(error),
2350
- });
2351
2403
  }
2352
2404
 
2353
2405
  let {
@@ -2362,15 +2414,16 @@ async function maybeBridgeRuntimeDelivery({
2362
2414
  deliveryId,
2363
2415
  sessionKey,
2364
2416
  localAgentId,
2365
- allowReply: metadata.allowReply !== false,
2417
+ allowReply,
2366
2418
  logger,
2367
2419
  runtimeAccountId,
2368
2420
  inboundCtx,
2369
2421
  });
2370
2422
 
2371
2423
  const shouldRetryKickoffDispatch = (
2372
- metadata.deliveryType === 'kickoff'
2373
- && metadata.allowReply !== false
2424
+ isRelayDelivery
2425
+ && metadata.deliveryType === 'kickoff'
2426
+ && allowReply
2374
2427
  && replied !== true
2375
2428
  && runtimeOutputSummary.counts.final > 0
2376
2429
  && runtimeOutputSummary.counts.nonRenderableFinal > 0
@@ -2407,14 +2460,15 @@ async function maybeBridgeRuntimeDelivery({
2407
2460
  deliveryId,
2408
2461
  sessionKey,
2409
2462
  localAgentId,
2410
- allowReply: metadata.allowReply !== false,
2463
+ allowReply,
2411
2464
  logger,
2412
2465
  runtimeAccountId,
2413
2466
  inboundCtx,
2414
2467
  }));
2415
2468
  }
2416
2469
 
2417
- logger.info?.(`[claworld:${runtimeAccountId}] delivery bridge completed`, {
2470
+ logger.info?.(`[claworld:${runtimeAccountId}] ${isRelayDelivery ? 'delivery bridge completed' : 'inbound bridge completed'}`, {
2471
+ eventType,
2418
2472
  deliveryId,
2419
2473
  sessionKey,
2420
2474
  localSessionKey,
@@ -2874,9 +2928,9 @@ export function createClaworldChannelPlugin({
2874
2928
  sessionKey: event?.delivery?.sessionKey || null,
2875
2929
  });
2876
2930
 
2877
- if (event?.eventType === 'delivery') {
2931
+ if (event?.delivery?.sessionKey) {
2878
2932
  const runtimeContext = accountRuntimeContexts.get(accountKey) || {};
2879
- maybeBridgeRuntimeDelivery({
2933
+ maybeBridgeRuntimeInboundEvent({
2880
2934
  relayClient,
2881
2935
  runtimeConfig,
2882
2936
  runtimeAccountId,
@@ -2886,7 +2940,7 @@ export function createClaworldChannelPlugin({
2886
2940
  cfg: runtimeContext.cfg,
2887
2941
  inbound,
2888
2942
  }).catch((error) => {
2889
- logger.error?.(`[claworld:${runtimeAccountId}] delivery bridge exception`, {
2943
+ logger.error?.(`[claworld:${runtimeAccountId}] inbound bridge exception`, {
2890
2944
  error: error?.message || String(error),
2891
2945
  });
2892
2946
  });
@@ -5,6 +5,51 @@ export const STALE_CONNECTION_CLOSE_CODE = 4002;
5
5
  export const TERMINAL_CLOSE_REASONS = new Set(['duplicate_connection_replaced', 'stale_connection']);
6
6
  export const DEFAULT_REPLY_ACK_TIMEOUT_MS = 5000;
7
7
 
8
+ function cloneObject(value, fallback = {}) {
9
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { ...fallback };
10
+ return { ...value };
11
+ }
12
+
13
+ function normalizeEnvelopeText(value, fallback = null) {
14
+ if (value == null) return fallback;
15
+ const normalized = String(value).trim();
16
+ return normalized || fallback;
17
+ }
18
+
19
+ function resolveEnvelopeMessageId(data = {}, payload = {}) {
20
+ const notification = payload.notification && typeof payload.notification === 'object' && !Array.isArray(payload.notification)
21
+ ? payload.notification
22
+ : data.notification && typeof data.notification === 'object' && !Array.isArray(data.notification)
23
+ ? data.notification
24
+ : {};
25
+ const metadata = data.metadata && typeof data.metadata === 'object' && !Array.isArray(data.metadata)
26
+ ? data.metadata
27
+ : payload.metadata && typeof payload.metadata === 'object' && !Array.isArray(payload.metadata)
28
+ ? payload.metadata
29
+ : {};
30
+ const candidates = [
31
+ data.deliveryId,
32
+ data.inboxItemId,
33
+ data.messageId,
34
+ data.eventId,
35
+ data.notificationId,
36
+ payload.deliveryId,
37
+ payload.inboxItemId,
38
+ payload.messageId,
39
+ payload.eventId,
40
+ payload.notificationId,
41
+ metadata.messageId,
42
+ metadata.eventId,
43
+ metadata.notificationId,
44
+ notification.notificationId,
45
+ ];
46
+ for (const candidate of candidates) {
47
+ const normalized = normalizeEnvelopeText(candidate, null);
48
+ if (normalized) return normalized;
49
+ }
50
+ return null;
51
+ }
52
+
8
53
  export function normalizeRelayWebSocketUrl(serverUrl) {
9
54
  const parsed = new URL(serverUrl);
10
55
  if (parsed.protocol === 'http:') parsed.protocol = 'ws:';
@@ -23,20 +68,63 @@ export function normalizeRelayWebSocketUrl(serverUrl) {
23
68
 
24
69
  export function buildInboundEnvelope(message = {}) {
25
70
  const data = message.data || {};
26
- if (message.event !== 'delivery') return null;
71
+ const directPayload = data.payload && typeof data.payload === 'object' && !Array.isArray(data.payload)
72
+ ? { ...data.payload }
73
+ : {};
27
74
  const metadata = data.metadata && typeof data.metadata === 'object' && !Array.isArray(data.metadata)
28
75
  ? { ...data.metadata }
29
- : {};
76
+ : directPayload.metadata && typeof directPayload.metadata === 'object' && !Array.isArray(directPayload.metadata)
77
+ ? { ...directPayload.metadata }
78
+ : cloneObject(data.meta, {});
79
+ const payloadEventType = normalizeEnvelopeText(directPayload.eventType, null);
80
+ const dataEventType = normalizeEnvelopeText(data.eventType, null);
81
+ const eventType = dataEventType
82
+ || payloadEventType
83
+ || (message.event === 'delivery' ? 'delivery' : normalizeEnvelopeText(message.event, null));
84
+ const payload = Object.keys(directPayload).length > 0
85
+ ? { ...directPayload }
86
+ : cloneObject(data, {});
87
+ if (Object.keys(directPayload).length > 0) {
88
+ for (const key of [
89
+ 'eventType',
90
+ 'eventName',
91
+ 'sessionKind',
92
+ 'sessionKey',
93
+ 'targetSessionKey',
94
+ 'targetAgentId',
95
+ 'text',
96
+ 'body',
97
+ 'notification',
98
+ ]) {
99
+ if (payload[key] == null && data[key] != null) payload[key] = data[key];
100
+ }
101
+ }
102
+ const targetAgentId = normalizeEnvelopeText(
103
+ data.targetAgentId,
104
+ normalizeEnvelopeText(payload.targetAgentId, null),
105
+ );
106
+ const sessionKey = normalizeEnvelopeText(
107
+ data.sessionKey,
108
+ normalizeEnvelopeText(
109
+ payload.sessionKey,
110
+ normalizeEnvelopeText(
111
+ data.targetSessionKey,
112
+ normalizeEnvelopeText(payload.targetSessionKey, null),
113
+ ),
114
+ ),
115
+ );
116
+ const isDeliveryEvent = message.event === 'delivery';
117
+ const isRoutableEvent = Boolean(eventType && sessionKey);
118
+ if (!isDeliveryEvent && !isRoutableEvent) return null;
30
119
  return {
31
- eventType: data.eventType || 'delivery',
32
- deliveryId: data.deliveryId || null,
33
- sessionKey: data.sessionKey || null,
34
- createdAt: data.createdAt || null,
120
+ eventType: eventType || 'delivery',
121
+ deliveryId: resolveEnvelopeMessageId(data, payload),
122
+ sessionKey,
123
+ targetAgentId,
124
+ createdAt: data.createdAt || data.availableAt || null,
35
125
  updatedAt: data.updatedAt || null,
36
126
  turnCreatedAt: data.turnCreatedAt || null,
37
- payload: data.payload && typeof data.payload === 'object' && !Array.isArray(data.payload)
38
- ? { ...data.payload }
39
- : {},
127
+ payload,
40
128
  metadata,
41
129
  };
42
130
  }
@@ -557,19 +557,23 @@ export class ClaworldRelayClient extends EventEmitter {
557
557
  this.heartbeatTimer = null;
558
558
  }
559
559
 
560
+ buildWsNotConnectedError(stage = 'send') {
561
+ return createRuntimeBoundaryError({
562
+ code: 'relay_ws_not_connected',
563
+ category: 'transport',
564
+ status: 409,
565
+ message: 'relay websocket is not connected',
566
+ publicMessage: 'relay websocket is not connected',
567
+ recoverable: true,
568
+ context: this.buildBoundaryContext({
569
+ stage,
570
+ }),
571
+ });
572
+ }
573
+
560
574
  send(payload) {
561
575
  if (!this.ws || this.ws.readyState !== 1) {
562
- throw createRuntimeBoundaryError({
563
- code: 'relay_ws_not_connected',
564
- category: 'transport',
565
- status: 409,
566
- message: 'relay websocket is not connected',
567
- publicMessage: 'relay websocket is not connected',
568
- recoverable: true,
569
- context: this.buildBoundaryContext({
570
- stage: 'send',
571
- }),
572
- });
576
+ throw this.buildWsNotConnectedError('send');
573
577
  }
574
578
  this.ws.send(JSON.stringify(payload));
575
579
  }
@@ -663,7 +667,7 @@ export class ClaworldRelayClient extends EventEmitter {
663
667
  return envelope;
664
668
  }
665
669
 
666
- waitForReplyAck({ deliveryId, timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS } = {}) {
670
+ waitForReplyAck({ deliveryId, timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS, signal = null } = {}) {
667
671
  const normalizedDeliveryId = normalizeOptionalText(deliveryId);
668
672
  if (!normalizedDeliveryId) {
669
673
  return Promise.reject(createRuntimeBoundaryError({
@@ -675,6 +679,20 @@ export class ClaworldRelayClient extends EventEmitter {
675
679
  recoverable: true,
676
680
  }));
677
681
  }
682
+ if (signal?.aborted) {
683
+ return Promise.reject(createRuntimeBoundaryError({
684
+ code: 'relay_reply_ack_wait_cancelled',
685
+ category: 'transport',
686
+ status: 499,
687
+ message: `relay reply acknowledgement wait cancelled for ${normalizedDeliveryId}`,
688
+ publicMessage: 'relay reply acknowledgement wait cancelled',
689
+ recoverable: true,
690
+ context: this.buildBoundaryContext({
691
+ stage: 'reply_ack_wait',
692
+ deliveryId: normalizedDeliveryId,
693
+ }),
694
+ }));
695
+ }
678
696
 
679
697
  return new Promise((resolve, reject) => {
680
698
  let settled = false;
@@ -686,6 +704,7 @@ export class ClaworldRelayClient extends EventEmitter {
686
704
  this.off('command.accepted', onCommandAccepted);
687
705
  this.off('disconnect', onDisconnect);
688
706
  this.off('close', onDisconnect);
707
+ signal?.removeEventListener('abort', onAbort);
689
708
  };
690
709
 
691
710
  const settleResolve = (value) => {
@@ -739,10 +758,26 @@ export class ClaworldRelayClient extends EventEmitter {
739
758
  }));
740
759
  };
741
760
 
761
+ const onAbort = () => {
762
+ settleReject(createRuntimeBoundaryError({
763
+ code: 'relay_reply_ack_wait_cancelled',
764
+ category: 'transport',
765
+ status: 499,
766
+ message: `relay reply acknowledgement wait cancelled for ${normalizedDeliveryId}`,
767
+ publicMessage: 'relay reply acknowledgement wait cancelled',
768
+ recoverable: true,
769
+ context: this.buildBoundaryContext({
770
+ stage: 'reply_ack_wait',
771
+ deliveryId: normalizedDeliveryId,
772
+ }),
773
+ }));
774
+ };
775
+
742
776
  this.on('reply.accepted', onReplyAccepted);
743
777
  this.on('command.accepted', onCommandAccepted);
744
778
  this.on('disconnect', onDisconnect);
745
779
  this.on('close', onDisconnect);
780
+ signal?.addEventListener('abort', onAbort, { once: true });
746
781
 
747
782
  timeout = setTimeout(() => {
748
783
  settleReject(buildReplyAckTimeoutError({
@@ -837,7 +872,7 @@ export class ClaworldRelayClient extends EventEmitter {
837
872
  });
838
873
  }
839
874
 
840
- waitForKeepSilentAck({ deliveryId, timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS } = {}) {
875
+ waitForKeepSilentAck({ deliveryId, timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS, signal = null } = {}) {
841
876
  const normalizedDeliveryId = normalizeOptionalText(deliveryId);
842
877
  if (!normalizedDeliveryId) {
843
878
  return Promise.reject(createRuntimeBoundaryError({
@@ -849,6 +884,20 @@ export class ClaworldRelayClient extends EventEmitter {
849
884
  recoverable: true,
850
885
  }));
851
886
  }
887
+ if (signal?.aborted) {
888
+ return Promise.reject(createRuntimeBoundaryError({
889
+ code: 'relay_kept_silent_ack_wait_cancelled',
890
+ category: 'transport',
891
+ status: 499,
892
+ message: `relay kept_silent acknowledgement wait cancelled for ${normalizedDeliveryId}`,
893
+ publicMessage: 'relay kept_silent acknowledgement wait cancelled',
894
+ recoverable: true,
895
+ context: this.buildBoundaryContext({
896
+ stage: 'kept_silent_ack_wait',
897
+ deliveryId: normalizedDeliveryId,
898
+ }),
899
+ }));
900
+ }
852
901
 
853
902
  return new Promise((resolve, reject) => {
854
903
  let settled = false;
@@ -859,6 +908,7 @@ export class ClaworldRelayClient extends EventEmitter {
859
908
  this.off('kept_silent.accepted', onKeptSilentAccepted);
860
909
  this.off('disconnect', onDisconnect);
861
910
  this.off('close', onDisconnect);
911
+ signal?.removeEventListener('abort', onAbort);
862
912
  };
863
913
 
864
914
  const settleResolve = (value) => {
@@ -898,9 +948,25 @@ export class ClaworldRelayClient extends EventEmitter {
898
948
  }));
899
949
  };
900
950
 
951
+ const onAbort = () => {
952
+ settleReject(createRuntimeBoundaryError({
953
+ code: 'relay_kept_silent_ack_wait_cancelled',
954
+ category: 'transport',
955
+ status: 499,
956
+ message: `relay kept_silent acknowledgement wait cancelled for ${normalizedDeliveryId}`,
957
+ publicMessage: 'relay kept_silent acknowledgement wait cancelled',
958
+ recoverable: true,
959
+ context: this.buildBoundaryContext({
960
+ stage: 'kept_silent_ack_wait',
961
+ deliveryId: normalizedDeliveryId,
962
+ }),
963
+ }));
964
+ };
965
+
901
966
  this.on('kept_silent.accepted', onKeptSilentAccepted);
902
967
  this.on('disconnect', onDisconnect);
903
968
  this.on('close', onDisconnect);
969
+ signal?.addEventListener('abort', onAbort, { once: true });
904
970
 
905
971
  timeout = setTimeout(() => {
906
972
  settleReject(buildKeepSilentAckTimeoutError({
@@ -943,6 +1009,69 @@ export class ClaworldRelayClient extends EventEmitter {
943
1009
  };
944
1010
  }
945
1011
 
1012
+ async submitReplyHttpFallback({
1013
+ deliveryId,
1014
+ sessionKey,
1015
+ replyText,
1016
+ source = 'subagent',
1017
+ error = null,
1018
+ } = {}) {
1019
+ this.logger.warn?.('[claworld:relay-client] reply websocket transport failed; attempting HTTP fallback', {
1020
+ accountId: this.runtimeConfig?.accountId || null,
1021
+ agentId: this.boundAgentId,
1022
+ deliveryId: normalizeOptionalText(deliveryId),
1023
+ sessionKey: normalizeOptionalText(sessionKey) || null,
1024
+ error: error?.message || String(error),
1025
+ });
1026
+
1027
+ const fallbackResult = await this.replyToDeliveryHttp({
1028
+ deliveryId,
1029
+ replyText,
1030
+ source,
1031
+ });
1032
+
1033
+ if (fallbackResult.status >= 200 && fallbackResult.status < 300) {
1034
+ return {
1035
+ ok: true,
1036
+ envelope: fallbackResult.envelope,
1037
+ ack: {
1038
+ event: 'reply.accepted',
1039
+ data: fallbackResult.body,
1040
+ },
1041
+ transport: 'http',
1042
+ fallbackUsed: true,
1043
+ };
1044
+ }
1045
+
1046
+ if (isReplyAlreadyApplied(fallbackResult, fallbackResult.envelope.deliveryId)) {
1047
+ return {
1048
+ ok: true,
1049
+ envelope: fallbackResult.envelope,
1050
+ ack: {
1051
+ event: 'reply.accepted',
1052
+ data: {
1053
+ ...(fallbackResult.body && typeof fallbackResult.body === 'object' ? fallbackResult.body : {}),
1054
+ repliedDeliveryId: fallbackResult.envelope.deliveryId,
1055
+ },
1056
+ },
1057
+ transport: 'http-already-applied',
1058
+ fallbackUsed: true,
1059
+ };
1060
+ }
1061
+
1062
+ throw buildReplyFallbackError({
1063
+ deliveryId: fallbackResult.envelope.deliveryId,
1064
+ status: fallbackResult.status,
1065
+ body: fallbackResult.body,
1066
+ context: this.buildBoundaryContext({
1067
+ stage: 'reply_fallback',
1068
+ deliveryId: fallbackResult.envelope.deliveryId,
1069
+ sessionKey: normalizeOptionalText(sessionKey) || null,
1070
+ fallbackFrom: error?.code || error?.message || null,
1071
+ }),
1072
+ });
1073
+ }
1074
+
946
1075
  async acceptDeliveryHttp({ deliveryId, sessionKey = null, source = 'runtime_dispatch' } = {}) {
947
1076
  const normalizedDeliveryId = normalizeOptionalText(deliveryId);
948
1077
  const result = await this.requestJsonWithDeliveryVisibilityRetry(`/v1/runtime-deliveries/${encodeURIComponent(normalizedDeliveryId)}/accepted`, {
@@ -976,16 +1105,43 @@ export class ClaworldRelayClient extends EventEmitter {
976
1105
  timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS,
977
1106
  httpFallback = true,
978
1107
  } = {}) {
1108
+ if (httpFallback && (!this.ws || this.ws.readyState !== 1)) {
1109
+ return await this.submitReplyHttpFallback({
1110
+ deliveryId,
1111
+ sessionKey,
1112
+ replyText,
1113
+ source,
1114
+ error: this.buildWsNotConnectedError('reply_send'),
1115
+ });
1116
+ }
1117
+
1118
+ const ackAbortController = new AbortController();
979
1119
  const ackPromise = this.waitForReplyAck({
980
1120
  deliveryId,
981
1121
  timeoutMs,
1122
+ signal: ackAbortController.signal,
982
1123
  });
983
- const envelope = this.sendReply({
984
- deliveryId,
985
- sessionKey,
986
- replyText,
987
- source,
988
- });
1124
+ let envelope;
1125
+
1126
+ try {
1127
+ envelope = this.sendReply({
1128
+ deliveryId,
1129
+ sessionKey,
1130
+ replyText,
1131
+ source,
1132
+ });
1133
+ } catch (error) {
1134
+ ackAbortController.abort();
1135
+ void ackPromise.catch(() => {});
1136
+ if (!httpFallback) throw error;
1137
+ return await this.submitReplyHttpFallback({
1138
+ deliveryId,
1139
+ sessionKey,
1140
+ replyText,
1141
+ source,
1142
+ error,
1143
+ });
1144
+ }
989
1145
 
990
1146
  try {
991
1147
  const ack = await ackPromise;
@@ -999,59 +1155,12 @@ export class ClaworldRelayClient extends EventEmitter {
999
1155
  } catch (error) {
1000
1156
  if (!httpFallback) throw error;
1001
1157
 
1002
- this.logger.warn?.('[claworld:relay-client] reply websocket acknowledgement failed; attempting HTTP fallback', {
1003
- accountId: this.runtimeConfig?.accountId || null,
1004
- agentId: this.boundAgentId,
1158
+ return await this.submitReplyHttpFallback({
1005
1159
  deliveryId: envelope.deliveryId,
1006
1160
  sessionKey: envelope.sessionKey,
1007
- error: error?.message || String(error),
1008
- });
1009
-
1010
- const fallbackResult = await this.replyToDeliveryHttp({
1011
- deliveryId: envelope.deliveryId,
1012
1161
  replyText,
1013
1162
  source,
1014
- });
1015
-
1016
- if (fallbackResult.status >= 200 && fallbackResult.status < 300) {
1017
- return {
1018
- ok: true,
1019
- envelope,
1020
- ack: {
1021
- event: 'reply.accepted',
1022
- data: fallbackResult.body,
1023
- },
1024
- transport: 'http',
1025
- fallbackUsed: true,
1026
- };
1027
- }
1028
-
1029
- if (isReplyAlreadyApplied(fallbackResult, envelope.deliveryId)) {
1030
- return {
1031
- ok: true,
1032
- envelope,
1033
- ack: {
1034
- event: 'reply.accepted',
1035
- data: {
1036
- ...(fallbackResult.body && typeof fallbackResult.body === 'object' ? fallbackResult.body : {}),
1037
- repliedDeliveryId: envelope.deliveryId,
1038
- },
1039
- },
1040
- transport: 'http-already-applied',
1041
- fallbackUsed: true,
1042
- };
1043
- }
1044
-
1045
- throw buildReplyFallbackError({
1046
- deliveryId: envelope.deliveryId,
1047
- status: fallbackResult.status,
1048
- body: fallbackResult.body,
1049
- context: this.buildBoundaryContext({
1050
- stage: 'reply_fallback',
1051
- deliveryId: envelope.deliveryId,
1052
- sessionKey: envelope.sessionKey,
1053
- fallbackFrom: error?.code || error?.message || null,
1054
- }),
1163
+ error,
1055
1164
  });
1056
1165
  }
1057
1166
  }
@@ -1169,6 +1278,69 @@ export class ClaworldRelayClient extends EventEmitter {
1169
1278
  };
1170
1279
  }
1171
1280
 
1281
+ async submitKeepSilentHttpFallback({
1282
+ deliveryId,
1283
+ sessionKey,
1284
+ reason = null,
1285
+ source = 'openclaw-autochain',
1286
+ error = null,
1287
+ } = {}) {
1288
+ this.logger.warn?.('[claworld:relay-client] kept_silent websocket transport failed; attempting HTTP fallback', {
1289
+ accountId: this.runtimeConfig?.accountId || null,
1290
+ agentId: this.boundAgentId,
1291
+ deliveryId: normalizeOptionalText(deliveryId),
1292
+ sessionKey: normalizeOptionalText(sessionKey) || null,
1293
+ error: error?.message || String(error),
1294
+ });
1295
+
1296
+ const fallbackResult = await this.keepDeliverySilentHttp({
1297
+ deliveryId,
1298
+ reason,
1299
+ source,
1300
+ });
1301
+
1302
+ if (fallbackResult.status >= 200 && fallbackResult.status < 300) {
1303
+ return {
1304
+ ok: true,
1305
+ envelope: fallbackResult.envelope,
1306
+ ack: {
1307
+ event: 'kept_silent.accepted',
1308
+ data: fallbackResult.body,
1309
+ },
1310
+ transport: 'http',
1311
+ fallbackUsed: true,
1312
+ };
1313
+ }
1314
+
1315
+ if (isDeliveryKeptSilentAlreadyApplied(fallbackResult, fallbackResult.envelope.deliveryId)) {
1316
+ return {
1317
+ ok: true,
1318
+ envelope: fallbackResult.envelope,
1319
+ ack: {
1320
+ event: 'kept_silent.accepted',
1321
+ data: {
1322
+ ...(fallbackResult.body && typeof fallbackResult.body === 'object' ? fallbackResult.body : {}),
1323
+ keptSilentDeliveryId: fallbackResult.envelope.deliveryId,
1324
+ },
1325
+ },
1326
+ transport: 'http-already-applied',
1327
+ fallbackUsed: true,
1328
+ };
1329
+ }
1330
+
1331
+ throw buildKeepSilentFallbackError({
1332
+ deliveryId: fallbackResult.envelope.deliveryId,
1333
+ status: fallbackResult.status,
1334
+ body: fallbackResult.body,
1335
+ context: this.buildBoundaryContext({
1336
+ stage: 'kept_silent_fallback',
1337
+ deliveryId: fallbackResult.envelope.deliveryId,
1338
+ sessionKey: normalizeOptionalText(sessionKey) || null,
1339
+ fallbackFrom: error?.code || error?.message || null,
1340
+ }),
1341
+ });
1342
+ }
1343
+
1172
1344
  async sendKeepSilentAndWaitForAck({
1173
1345
  deliveryId,
1174
1346
  sessionKey,
@@ -1177,16 +1349,43 @@ export class ClaworldRelayClient extends EventEmitter {
1177
1349
  timeoutMs = DEFAULT_REPLY_ACK_TIMEOUT_MS,
1178
1350
  httpFallback = true,
1179
1351
  } = {}) {
1352
+ if (httpFallback && (!this.ws || this.ws.readyState !== 1)) {
1353
+ return await this.submitKeepSilentHttpFallback({
1354
+ deliveryId,
1355
+ sessionKey,
1356
+ reason,
1357
+ source,
1358
+ error: this.buildWsNotConnectedError('kept_silent_send'),
1359
+ });
1360
+ }
1361
+
1362
+ const ackAbortController = new AbortController();
1180
1363
  const ackPromise = this.waitForKeepSilentAck({
1181
1364
  deliveryId,
1182
1365
  timeoutMs,
1366
+ signal: ackAbortController.signal,
1183
1367
  });
1184
- const envelope = this.sendKeepSilent({
1185
- deliveryId,
1186
- sessionKey,
1187
- reason,
1188
- source,
1189
- });
1368
+ let envelope;
1369
+
1370
+ try {
1371
+ envelope = this.sendKeepSilent({
1372
+ deliveryId,
1373
+ sessionKey,
1374
+ reason,
1375
+ source,
1376
+ });
1377
+ } catch (error) {
1378
+ ackAbortController.abort();
1379
+ void ackPromise.catch(() => {});
1380
+ if (!httpFallback) throw error;
1381
+ return await this.submitKeepSilentHttpFallback({
1382
+ deliveryId,
1383
+ sessionKey,
1384
+ reason,
1385
+ source,
1386
+ error,
1387
+ });
1388
+ }
1190
1389
 
1191
1390
  try {
1192
1391
  const ack = await ackPromise;
@@ -1200,59 +1399,12 @@ export class ClaworldRelayClient extends EventEmitter {
1200
1399
  } catch (error) {
1201
1400
  if (!httpFallback) throw error;
1202
1401
 
1203
- this.logger.warn?.('[claworld:relay-client] kept_silent websocket acknowledgement failed; attempting HTTP fallback', {
1204
- accountId: this.runtimeConfig?.accountId || null,
1205
- agentId: this.boundAgentId,
1402
+ return await this.submitKeepSilentHttpFallback({
1206
1403
  deliveryId: envelope.deliveryId,
1207
1404
  sessionKey: envelope.sessionKey,
1208
- error: error?.message || String(error),
1209
- });
1210
-
1211
- const fallbackResult = await this.keepDeliverySilentHttp({
1212
- deliveryId: envelope.deliveryId,
1213
1405
  reason: envelope.reason,
1214
1406
  source: envelope.source,
1215
- });
1216
-
1217
- if (fallbackResult.status >= 200 && fallbackResult.status < 300) {
1218
- return {
1219
- ok: true,
1220
- envelope,
1221
- ack: {
1222
- event: 'kept_silent.accepted',
1223
- data: fallbackResult.body,
1224
- },
1225
- transport: 'http',
1226
- fallbackUsed: true,
1227
- };
1228
- }
1229
-
1230
- if (isDeliveryKeptSilentAlreadyApplied(fallbackResult, envelope.deliveryId)) {
1231
- return {
1232
- ok: true,
1233
- envelope,
1234
- ack: {
1235
- event: 'kept_silent.accepted',
1236
- data: {
1237
- ...(fallbackResult.body && typeof fallbackResult.body === 'object' ? fallbackResult.body : {}),
1238
- keptSilentDeliveryId: envelope.deliveryId,
1239
- },
1240
- },
1241
- transport: 'http-already-applied',
1242
- fallbackUsed: true,
1243
- };
1244
- }
1245
-
1246
- throw buildKeepSilentFallbackError({
1247
- deliveryId: envelope.deliveryId,
1248
- status: fallbackResult.status,
1249
- body: fallbackResult.body,
1250
- context: this.buildBoundaryContext({
1251
- stage: 'kept_silent_fallback',
1252
- deliveryId: envelope.deliveryId,
1253
- sessionKey: envelope.sessionKey,
1254
- fallbackFrom: error?.code || error?.message || null,
1255
- }),
1407
+ error,
1256
1408
  });
1257
1409
  }
1258
1410
  }
@@ -47,6 +47,10 @@ export function buildConversationSessionKey(conversationKey = null, fallbackSess
47
47
  export function resolveRuntimeSessionTarget(event = {}, options = {}) {
48
48
  const payload = normalizePayload(event.payload);
49
49
  const eventType = normalizeText(event.eventType || event.type || payload.eventType, null);
50
+ const providedSessionKind = normalizeText(
51
+ event.sessionKind,
52
+ normalizeText(payload.sessionKind, normalizeText(options.sessionKind, null)),
53
+ );
50
54
  const targetAgentId = normalizeText(
51
55
  event.targetAgentId,
52
56
  normalizeText(payload.targetAgentId, normalizeText(options.targetAgentId, null)),
@@ -60,15 +64,15 @@ export function resolveRuntimeSessionTarget(event = {}, options = {}) {
60
64
  normalizeText(payload.sessionKey, normalizeText(options.sessionKey, null)),
61
65
  );
62
66
 
63
- if (CLAWORLD_MANAGEMENT_EVENT_TYPES.includes(eventType)) {
67
+ if (providedSessionKind === CLAWORLD_SESSION_KINDS.management || CLAWORLD_MANAGEMENT_EVENT_TYPES.includes(eventType)) {
64
68
  const managementSessionKey = normalizeText(
65
69
  options.managementSessionKey,
66
- buildManagementSessionKey(targetAgentId),
70
+ normalizeText(providedSessionKey, buildManagementSessionKey(targetAgentId)),
67
71
  );
68
72
  return {
69
73
  sessionKind: CLAWORLD_SESSION_KINDS.management,
70
74
  target: normalizeText(options.managementTarget, 'management_session'),
71
- sessionKey: managementSessionKey || providedSessionKey,
75
+ sessionKey: providedSessionKey || managementSessionKey,
72
76
  managementSessionKey: managementSessionKey || null,
73
77
  conversationSessionKey: conversationKey ? buildConversationSessionKey(conversationKey) : null,
74
78
  targetAgentId,