oomi-ai 0.2.13 → 0.2.15

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.
package/bin/oomi-ai.js CHANGED
@@ -8,7 +8,7 @@ import net from 'net';
8
8
  import { lookup as dnsLookup } from 'dns/promises';
9
9
  import { fileURLToPath } from 'url';
10
10
  import WebSocket from 'ws';
11
- import { ensureSessionBridge, forwardFrameToSession, flushSessionQueue } from './sessionBridgeState.js';
11
+ import { ensureSessionBridge, flushSessionQueue, flushWaitingForConnect, forwardFrameToSession } from './sessionBridgeState.js';
12
12
 
13
13
  const MARKER_START = '<oomi-agent-instructions>';
14
14
  const MARKER_END = '</oomi-agent-instructions>';
@@ -637,6 +637,59 @@ function updateBridgeStatus(partial) {
637
637
  return next;
638
638
  }
639
639
 
640
+ function resolveBridgeStatusForBrokerOpen({ actionCableMode, deviceSubscribed }) {
641
+ if (!actionCableMode) {
642
+ return 'connected';
643
+ }
644
+ return deviceSubscribed ? 'connected' : 'starting';
645
+ }
646
+
647
+ function classifyBridgeSessionScope(sessionId) {
648
+ const normalized = String(sessionId || '').trim();
649
+ return normalized.startsWith('voice_session_') ? 'voice' : 'default';
650
+ }
651
+
652
+ function resolveBridgeStatusForRuntimeFault({ currentStatus, sessionId }) {
653
+ if (classifyBridgeSessionScope(sessionId) === 'voice') {
654
+ return currentStatus === 'connected' ? 'connected' : currentStatus || 'starting';
655
+ }
656
+ if (currentStatus === 'connected' || currentStatus === 'reconnecting' || currentStatus === 'degraded') {
657
+ return 'degraded';
658
+ }
659
+ return 'error';
660
+ }
661
+
662
+ function runBridgeCallbackSafely(callback, onError) {
663
+ return (...args) => {
664
+ try {
665
+ return callback(...args);
666
+ } catch (err) {
667
+ onError(err instanceof Error ? err : new Error(String(err)));
668
+ return undefined;
669
+ }
670
+ };
671
+ }
672
+
673
+ function createBridgeProcessFaultHandler({ readStatus, onReport, onExit }) {
674
+ return ({ phase, error }) => {
675
+ const normalizedError = error instanceof Error ? error : new Error(String(error || 'unknown process fault'));
676
+ const currentStatus = String(readStatus?.()?.status || '').trim();
677
+ const nextStatus = resolveBridgeStatusForRuntimeFault({ currentStatus, sessionId: '' });
678
+
679
+ onReport?.({
680
+ phase,
681
+ status: nextStatus,
682
+ error: normalizedError,
683
+ currentStatus,
684
+ shouldExit: nextStatus === 'error',
685
+ });
686
+
687
+ if (nextStatus === 'error') {
688
+ onExit?.(1);
689
+ }
690
+ };
691
+ }
692
+
640
693
  function normalizeBridgeMetrics(value) {
641
694
  if (!value || typeof value !== 'object') return {};
642
695
  const next = {};
@@ -954,25 +1007,55 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
954
1007
  }
955
1008
 
956
1009
  if (method !== 'connect') {
957
- const shouldStripBridgeOnlyParams = method === 'chat.send' || method === 'chat.history';
958
- if (!shouldStripBridgeOnlyParams) {
959
- return { frameText, waitForChallenge: false };
960
- }
961
1010
  const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
962
- const sanitized = { ...rawParams };
963
- let changed = false;
964
- if (Object.prototype.hasOwnProperty.call(sanitized, 'correlationId')) {
965
- delete sanitized.correlationId;
966
- changed = true;
967
- }
968
- if (Object.prototype.hasOwnProperty.call(sanitized, 'metadata')) {
969
- delete sanitized.metadata;
970
- changed = true;
1011
+ if (method === 'chat.send') {
1012
+ const sanitized = {};
1013
+ if (typeof rawParams.sessionKey === 'string' && rawParams.sessionKey.trim()) {
1014
+ sanitized.sessionKey = rawParams.sessionKey.trim();
1015
+ }
1016
+ if (typeof rawParams.message === 'string') {
1017
+ sanitized.message = rawParams.message;
1018
+ }
1019
+ if (typeof rawParams.thinking === 'boolean') {
1020
+ sanitized.thinking = rawParams.thinking;
1021
+ }
1022
+ if (typeof rawParams.deliver === 'string' && rawParams.deliver.trim()) {
1023
+ sanitized.deliver = rawParams.deliver.trim();
1024
+ }
1025
+ if (Array.isArray(rawParams.attachments)) {
1026
+ sanitized.attachments = rawParams.attachments;
1027
+ }
1028
+ if (Number.isFinite(rawParams.timeoutMs) && rawParams.timeoutMs > 0) {
1029
+ sanitized.timeoutMs = Math.floor(rawParams.timeoutMs);
1030
+ }
1031
+
1032
+ const idempotencyKeyCandidates = [
1033
+ rawParams.idempotencyKey,
1034
+ rawParams.requestId,
1035
+ rawParams.correlationId,
1036
+ frame.id,
1037
+ ];
1038
+ const idempotencyKey = idempotencyKeyCandidates.find((value) => typeof value === 'string' && value.trim());
1039
+ if (typeof idempotencyKey === 'string' && idempotencyKey.trim()) {
1040
+ sanitized.idempotencyKey = idempotencyKey.trim();
1041
+ }
1042
+
1043
+ frame.params = sanitized;
1044
+ return { frameText: JSON.stringify(frame), waitForChallenge: false };
971
1045
  }
972
- if (changed) {
1046
+
1047
+ if (method === 'chat.history') {
1048
+ const sanitized = {};
1049
+ if (typeof rawParams.sessionKey === 'string' && rawParams.sessionKey.trim()) {
1050
+ sanitized.sessionKey = rawParams.sessionKey.trim();
1051
+ }
1052
+ if (Number.isFinite(rawParams.limit) && rawParams.limit > 0) {
1053
+ sanitized.limit = Math.floor(rawParams.limit);
1054
+ }
973
1055
  frame.params = sanitized;
974
1056
  return { frameText: JSON.stringify(frame), waitForChallenge: false };
975
1057
  }
1058
+
976
1059
  return { frameText, waitForChallenge: false };
977
1060
  }
978
1061
 
@@ -1747,6 +1830,82 @@ async function startOpenclawBridge(flags) {
1747
1830
  })();
1748
1831
  const actionCableMode = brokerPath.endsWith('/cable');
1749
1832
  const deviceChannelIdentifier = JSON.stringify({ channel: 'DeviceChannel' });
1833
+ let deviceChannelSubscribed = false;
1834
+
1835
+ const reportBridgeRuntimeFault = ({ phase, sessionId = '', error }) => {
1836
+ const message = error instanceof Error ? error.message : String(error || 'unknown bridge callback error');
1837
+ const currentStatus = String(readBridgeStatus().status || '').trim();
1838
+ const nextStatus = resolveBridgeStatusForRuntimeFault({ currentStatus, sessionId });
1839
+ incrementBridgeMetric('bridge_callback_error_count');
1840
+ console.error(
1841
+ `[bridge] callback.error phase=${phase}${sessionId ? ` session=${sessionId}` : ''}: ${message}`
1842
+ );
1843
+ updateBridgeStatus({
1844
+ status: nextStatus,
1845
+ deviceId,
1846
+ brokerWs,
1847
+ brokerHttp,
1848
+ gatewayUrl: gateway.gatewayUrl,
1849
+ lastDisconnectAt: bridgeNowIso(),
1850
+ lastErrorCode: 'bridge_callback_error',
1851
+ lastErrorClass: 'internal',
1852
+ lastErrorMessage: `${phase}: ${message}`,
1853
+ hint:
1854
+ classifyBridgeSessionScope(sessionId) === 'voice'
1855
+ ? 'A voice-session bridge callback failed, but provider health remains available for normal chat.'
1856
+ : 'The bridge caught an internal callback error and kept running.',
1857
+ pid: process.pid,
1858
+ });
1859
+ };
1860
+
1861
+ const reportBridgeProcessFault = ({ phase, status, error }) => {
1862
+ const message = error instanceof Error ? error.message : String(error || 'unknown bridge process fault');
1863
+ incrementBridgeMetric('bridge_process_fault_count');
1864
+ console.error(`[bridge] process.error phase=${phase}: ${message}`);
1865
+ updateBridgeStatus({
1866
+ status,
1867
+ deviceId,
1868
+ brokerWs,
1869
+ brokerHttp,
1870
+ gatewayUrl: gateway.gatewayUrl,
1871
+ lastDisconnectAt: bridgeNowIso(),
1872
+ lastErrorCode: 'bridge_process_fault',
1873
+ lastErrorClass: 'internal',
1874
+ lastErrorMessage: `${phase}: ${message}`,
1875
+ hint:
1876
+ status === 'error'
1877
+ ? 'The bridge hit a runtime fault before it was fully connected and will restart.'
1878
+ : 'The bridge caught a process-level runtime fault and stayed alive in degraded mode.',
1879
+ pid: process.pid,
1880
+ });
1881
+ };
1882
+
1883
+ const handleBridgeProcessFault = createBridgeProcessFaultHandler({
1884
+ readStatus: readBridgeStatus,
1885
+ onReport: ({ phase, status, error }) => {
1886
+ reportBridgeProcessFault({ phase, status, error });
1887
+ },
1888
+ onExit: (code) => {
1889
+ reconnectState.stopped = true;
1890
+ if (reconnectState.timer) {
1891
+ clearTimeout(reconnectState.timer);
1892
+ reconnectState.timer = null;
1893
+ }
1894
+ releaseBridgeLock();
1895
+ process.exit(code);
1896
+ },
1897
+ });
1898
+
1899
+ const uncaughtExceptionHandler = (error) => {
1900
+ handleBridgeProcessFault({ phase: 'process.uncaughtException', error });
1901
+ };
1902
+
1903
+ const unhandledRejectionHandler = (reason) => {
1904
+ handleBridgeProcessFault({ phase: 'process.unhandledRejection', error: reason });
1905
+ };
1906
+
1907
+ process.on('uncaughtException', uncaughtExceptionHandler);
1908
+ process.on('unhandledRejection', unhandledRejectionHandler);
1750
1909
 
1751
1910
  const sendBrokerPayload = (brokerSocket, payload) => {
1752
1911
  if (brokerSocket.readyState !== WebSocket.OPEN) return;
@@ -2184,6 +2343,12 @@ async function startOpenclawBridge(flags) {
2184
2343
  if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
2185
2344
  sessionBridge.pendingRequestTimers = new Map();
2186
2345
  }
2346
+ if (sessionBridge.connectAccepted !== true) {
2347
+ sessionBridge.connectAccepted = false;
2348
+ }
2349
+ if (!Array.isArray(sessionBridge.waitingForConnect)) {
2350
+ sessionBridge.waitingForConnect = [];
2351
+ }
2187
2352
  if (typeof sessionBridge.lastChatCorrelationId !== 'string') {
2188
2353
  sessionBridge.lastChatCorrelationId = '';
2189
2354
  }
@@ -2216,7 +2381,7 @@ async function startOpenclawBridge(flags) {
2216
2381
  flushSessionQueue(sessionBridge);
2217
2382
  });
2218
2383
 
2219
- gatewaySocket.on('message', (gatewayRaw) => {
2384
+ gatewaySocket.on('message', runBridgeCallbackSafely((gatewayRaw) => {
2220
2385
  const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
2221
2386
  const gatewayPayload = parseJsonPayload(frame);
2222
2387
  if (gatewayPayload?.event === 'connect.challenge') {
@@ -2257,6 +2422,86 @@ async function startOpenclawBridge(flags) {
2257
2422
  correlationId: requestMeta.correlationId,
2258
2423
  stage: responseMeta.ok ? 'gateway.accepted' : 'gateway.rejected',
2259
2424
  });
2425
+ if (requestMeta.method === 'connect' && responseMeta.ok) {
2426
+ const releasedFrames = flushWaitingForConnect(sessionBridge);
2427
+ for (const released of releasedFrames) {
2428
+ const releasedMeta = extractGatewayRequestMeta(released.frameText);
2429
+ if (!releasedMeta) continue;
2430
+
2431
+ if (released.result === 'queued') {
2432
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, releasedMeta);
2433
+ sendGatewayAck(brokerSocket, {
2434
+ sessionId,
2435
+ requestId: releasedMeta.requestId,
2436
+ method: releasedMeta.method,
2437
+ correlationId: releasedMeta.correlationId,
2438
+ stage: 'bridge.queued',
2439
+ });
2440
+ } else if (released.result === 'dropped') {
2441
+ const pending = sessionBridge.pendingRequests.get(releasedMeta.requestId);
2442
+ const lastSuccessfulHop =
2443
+ pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2444
+ ? pending.lastSuccessfulHop
2445
+ : 'bridge.waiting_for_connect';
2446
+ clearPendingRequestTimeout(sessionBridge, releasedMeta.requestId);
2447
+ sessionBridge.pendingRequests.delete(releasedMeta.requestId);
2448
+ sendGatewayErrorResponse(brokerSocket, {
2449
+ sessionId,
2450
+ requestMeta: releasedMeta,
2451
+ code: 'bridge_dropped',
2452
+ message: 'Bridge dropped deferred request because gateway socket is not open.',
2453
+ lastSuccessfulHop,
2454
+ retryable: true,
2455
+ });
2456
+ sendGatewayAck(brokerSocket, {
2457
+ sessionId,
2458
+ requestId: releasedMeta.requestId,
2459
+ method: releasedMeta.method,
2460
+ correlationId: releasedMeta.correlationId,
2461
+ stage: 'bridge.dropped',
2462
+ });
2463
+ } else {
2464
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, releasedMeta);
2465
+ sendGatewayAck(brokerSocket, {
2466
+ sessionId,
2467
+ requestId: releasedMeta.requestId,
2468
+ method: releasedMeta.method,
2469
+ correlationId: releasedMeta.correlationId,
2470
+ stage: 'bridge.forwarded',
2471
+ });
2472
+ }
2473
+ }
2474
+ } else if (requestMeta.method === 'connect' && !responseMeta.ok) {
2475
+ const deferredFrames = Array.isArray(sessionBridge.waitingForConnect)
2476
+ ? sessionBridge.waitingForConnect.splice(0, sessionBridge.waitingForConnect.length)
2477
+ : [];
2478
+ for (const deferredFrame of deferredFrames) {
2479
+ const deferredMeta = extractGatewayRequestMeta(deferredFrame);
2480
+ if (!deferredMeta) continue;
2481
+ const pending = sessionBridge.pendingRequests.get(deferredMeta.requestId);
2482
+ const lastSuccessfulHop =
2483
+ pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2484
+ ? pending.lastSuccessfulHop
2485
+ : 'bridge.waiting_for_connect';
2486
+ clearPendingRequestTimeout(sessionBridge, deferredMeta.requestId);
2487
+ sessionBridge.pendingRequests.delete(deferredMeta.requestId);
2488
+ sendGatewayErrorResponse(brokerSocket, {
2489
+ sessionId,
2490
+ requestMeta: deferredMeta,
2491
+ code: 'gateway_connect_failed',
2492
+ message: 'Bridge could not forward request because gateway connect did not complete.',
2493
+ lastSuccessfulHop,
2494
+ retryable: true,
2495
+ });
2496
+ sendGatewayAck(brokerSocket, {
2497
+ sessionId,
2498
+ requestId: deferredMeta.requestId,
2499
+ method: deferredMeta.method,
2500
+ correlationId: deferredMeta.correlationId,
2501
+ stage: 'gateway.rejected',
2502
+ });
2503
+ }
2504
+ }
2260
2505
  if (!responseMeta.ok) {
2261
2506
  incrementBridgeMetric('gateway_rejected_count');
2262
2507
  }
@@ -2274,9 +2519,11 @@ async function startOpenclawBridge(flags) {
2274
2519
  }
2275
2520
 
2276
2521
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
2277
- });
2522
+ }, (error) => {
2523
+ reportBridgeRuntimeFault({ phase: 'gateway.message', sessionId, error });
2524
+ }));
2278
2525
 
2279
- gatewaySocket.on('close', (code, reason) => {
2526
+ gatewaySocket.on('close', runBridgeCallbackSafely((code, reason) => {
2280
2527
  if (connectTimeout) {
2281
2528
  clearTimeout(connectTimeout);
2282
2529
  connectTimeout = null;
@@ -2326,9 +2573,11 @@ async function startOpenclawBridge(flags) {
2326
2573
  code,
2327
2574
  reason: reasonText,
2328
2575
  });
2329
- });
2576
+ }, (error) => {
2577
+ reportBridgeRuntimeFault({ phase: 'gateway.close', sessionId, error });
2578
+ }));
2330
2579
 
2331
- gatewaySocket.on('error', (err) => {
2580
+ gatewaySocket.on('error', runBridgeCallbackSafely((err) => {
2332
2581
  if (connectTimeout && gatewaySocket.readyState !== WebSocket.CONNECTING) {
2333
2582
  clearTimeout(connectTimeout);
2334
2583
  connectTimeout = null;
@@ -2341,7 +2590,9 @@ async function startOpenclawBridge(flags) {
2341
2590
  level: 'error',
2342
2591
  message: `Gateway socket error (${sessionId}): ${String(err)}`,
2343
2592
  });
2344
- });
2593
+ }, (error) => {
2594
+ reportBridgeRuntimeFault({ phase: 'gateway.error', sessionId, error });
2595
+ }));
2345
2596
  };
2346
2597
 
2347
2598
  const getOrCreateGatewaySession = (sessionId) => {
@@ -2360,18 +2611,22 @@ async function startOpenclawBridge(flags) {
2360
2611
  console.log('[bridge] Connected to managed broker.');
2361
2612
  reconnectState.attempt = 0;
2362
2613
  reconnectState.lastFailure = null;
2363
- if (!actionCableMode) return;
2364
- brokerSocket.send(
2365
- JSON.stringify({
2366
- command: 'subscribe',
2367
- identifier: deviceChannelIdentifier,
2368
- })
2369
- );
2370
- actionCableHeartbeat = setInterval(() => {
2371
- sendBrokerPayload(brokerSocket, { action: 'heartbeat' });
2372
- }, 15000);
2614
+ if (actionCableMode) {
2615
+ brokerSocket.send(
2616
+ JSON.stringify({
2617
+ command: 'subscribe',
2618
+ identifier: deviceChannelIdentifier,
2619
+ })
2620
+ );
2621
+ actionCableHeartbeat = setInterval(() => {
2622
+ sendBrokerPayload(brokerSocket, { action: 'heartbeat' });
2623
+ }, 15000);
2624
+ }
2373
2625
  updateBridgeStatus({
2374
- status: 'connected',
2626
+ status: resolveBridgeStatusForBrokerOpen({
2627
+ actionCableMode,
2628
+ deviceSubscribed: deviceChannelSubscribed,
2629
+ }),
2375
2630
  deviceId,
2376
2631
  brokerWs,
2377
2632
  brokerHttp,
@@ -2386,12 +2641,27 @@ async function startOpenclawBridge(flags) {
2386
2641
  });
2387
2642
  });
2388
2643
 
2389
- brokerSocket.on('message', (rawData) => {
2644
+ brokerSocket.on('message', runBridgeCallbackSafely((rawData) => {
2390
2645
  const text = typeof rawData === 'string' ? rawData : rawData.toString();
2391
2646
  const payload = parseBrokerEnvelope(text);
2392
2647
  if (!payload || typeof payload.type !== 'string') return;
2393
2648
 
2394
2649
  if (payload.type === 'device.subscribed') {
2650
+ deviceChannelSubscribed = true;
2651
+ updateBridgeStatus({
2652
+ status: 'connected',
2653
+ deviceId,
2654
+ brokerWs,
2655
+ brokerHttp,
2656
+ gatewayUrl: gateway.gatewayUrl,
2657
+ lastConnectedAt: bridgeNowIso(),
2658
+ lastErrorCode: '',
2659
+ lastErrorClass: '',
2660
+ lastErrorMessage: '',
2661
+ hint: '',
2662
+ consecutiveFailures: 0,
2663
+ pid: process.pid,
2664
+ });
2395
2665
  return;
2396
2666
  }
2397
2667
 
@@ -2538,7 +2808,22 @@ async function startOpenclawBridge(flags) {
2538
2808
  }
2539
2809
  return;
2540
2810
  }
2541
- const result = forwardFrameToSession(sessionBridge, prepared.frameText);
2811
+ const result = forwardFrameToSession(sessionBridge, prepared.frameText, {
2812
+ requiresConnectAccepted: Boolean(requestMeta && requestMeta.method !== 'connect'),
2813
+ });
2814
+ if (result === 'waiting_for_connect') {
2815
+ console.log(`[bridge] client.frame waiting for connect ${sessionId}`);
2816
+ if (requestMeta) {
2817
+ sendGatewayAck(brokerSocket, {
2818
+ sessionId,
2819
+ requestId: requestMeta.requestId,
2820
+ method: requestMeta.method,
2821
+ correlationId: requestMeta.correlationId,
2822
+ stage: 'bridge.waiting_for_connect',
2823
+ });
2824
+ }
2825
+ return;
2826
+ }
2542
2827
  if (result === 'queued') {
2543
2828
  console.log(`[bridge] client.frame queued ${sessionId}`);
2544
2829
  if (requestMeta) {
@@ -2613,9 +2898,11 @@ async function startOpenclawBridge(flags) {
2613
2898
  }
2614
2899
  return;
2615
2900
  }
2616
- });
2901
+ }, (error) => {
2902
+ reportBridgeRuntimeFault({ phase: 'broker.message', error });
2903
+ }));
2617
2904
 
2618
- brokerSocket.on('close', (code, reason) => {
2905
+ brokerSocket.on('close', runBridgeCallbackSafely((code, reason) => {
2619
2906
  if (actionCableHeartbeat) {
2620
2907
  clearInterval(actionCableHeartbeat);
2621
2908
  actionCableHeartbeat = null;
@@ -2647,16 +2934,20 @@ async function startOpenclawBridge(flags) {
2647
2934
  });
2648
2935
  }
2649
2936
  scheduleReconnect();
2650
- });
2937
+ }, (error) => {
2938
+ reportBridgeRuntimeFault({ phase: 'broker.close', error });
2939
+ }));
2651
2940
 
2652
- brokerSocket.on('error', (err) => {
2941
+ brokerSocket.on('error', runBridgeCallbackSafely((err) => {
2653
2942
  incrementBridgeMetric('bridge_socket_error_count');
2654
2943
  reconnectState.lastFailure = classifyBridgeFailure({ err });
2655
2944
  console.error(
2656
2945
  `[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
2657
2946
  );
2658
2947
  console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
2659
- });
2948
+ }, (error) => {
2949
+ reportBridgeRuntimeFault({ phase: 'broker.error', error });
2950
+ }));
2660
2951
  };
2661
2952
 
2662
2953
  const markStopped = (signal) => {
@@ -2665,6 +2956,8 @@ async function startOpenclawBridge(flags) {
2665
2956
  clearTimeout(reconnectState.timer);
2666
2957
  reconnectState.timer = null;
2667
2958
  }
2959
+ process.off('uncaughtException', uncaughtExceptionHandler);
2960
+ process.off('unhandledRejection', unhandledRejectionHandler);
2668
2961
  updateBridgeStatus({
2669
2962
  status: 'stopped',
2670
2963
  deviceId,
@@ -3246,7 +3539,12 @@ if (__isDirectExecution) {
3246
3539
  export {
3247
3540
  prepareGatewayFrameForLocalGateway,
3248
3541
  classifyBridgeFailure,
3542
+ classifyBridgeSessionScope,
3543
+ createBridgeProcessFaultHandler,
3249
3544
  computeReconnectDelayMs,
3545
+ resolveBridgeStatusForBrokerOpen,
3546
+ resolveBridgeStatusForRuntimeFault,
3547
+ runBridgeCallbackSafely,
3250
3548
  extractGatewayRequestMeta,
3251
3549
  extractGatewayResponseMeta,
3252
3550
  isGatewayRunStartedFrame,
@@ -12,7 +12,12 @@ export function ensureSessionBridge({ sessions, sessionId, createSocket }) {
12
12
  if (existing) return existing;
13
13
 
14
14
  const socket = createSocket(id);
15
- const next = { socket, queue: [] };
15
+ const next = {
16
+ socket,
17
+ queue: [],
18
+ connectAccepted: false,
19
+ waitingForConnect: [],
20
+ };
16
21
  sessions.set(id, next);
17
22
  return next;
18
23
  }
@@ -20,11 +25,19 @@ export function ensureSessionBridge({ sessions, sessionId, createSocket }) {
20
25
  /**
21
26
  * Forward a frame to the gateway socket or queue it while connecting.
22
27
  */
23
- export function forwardFrameToSession(sessionBridge, frameText) {
28
+ export function forwardFrameToSession(sessionBridge, frameText, options = {}) {
24
29
  if (!sessionBridge || !sessionBridge.socket || typeof frameText !== 'string' || !frameText) {
25
30
  return 'dropped';
26
31
  }
27
32
 
33
+ if (options.requiresConnectAccepted === true && sessionBridge.connectAccepted !== true) {
34
+ if (!Array.isArray(sessionBridge.waitingForConnect)) {
35
+ sessionBridge.waitingForConnect = [];
36
+ }
37
+ sessionBridge.waitingForConnect.push(frameText);
38
+ return 'waiting_for_connect';
39
+ }
40
+
28
41
  const { socket } = sessionBridge;
29
42
  if (socket.readyState === WS_OPEN) {
30
43
  socket.send(frameText);
@@ -39,6 +52,20 @@ export function forwardFrameToSession(sessionBridge, frameText) {
39
52
  return 'dropped';
40
53
  }
41
54
 
55
+ export function flushWaitingForConnect(sessionBridge) {
56
+ if (!sessionBridge) return [];
57
+
58
+ sessionBridge.connectAccepted = true;
59
+ const pending = Array.isArray(sessionBridge.waitingForConnect)
60
+ ? sessionBridge.waitingForConnect.splice(0, sessionBridge.waitingForConnect.length)
61
+ : [];
62
+
63
+ return pending.map((frameText) => ({
64
+ frameText,
65
+ result: forwardFrameToSession(sessionBridge, frameText),
66
+ }));
67
+ }
68
+
42
69
  export function flushSessionQueue(sessionBridge) {
43
70
  if (!sessionBridge || !sessionBridge.socket) return;
44
71
  const socket = sessionBridge.socket;
@@ -178,6 +178,45 @@ function extractCorrelationId(payload) {
178
178
  return '';
179
179
  }
180
180
 
181
+ function normalizeSpokenMetadata(spoken) {
182
+ if (!spoken || typeof spoken !== 'object' || Array.isArray(spoken)) return null;
183
+
184
+ const text = toString(spoken.text);
185
+ if (!text) return null;
186
+
187
+ const normalized = { text };
188
+ const instructions = toString(spoken.instructions);
189
+ if (instructions) normalized.instructions = instructions;
190
+ if (spoken.style && typeof spoken.style === 'object' && !Array.isArray(spoken.style)) {
191
+ normalized.style = spoken.style;
192
+ }
193
+
194
+ return normalized;
195
+ }
196
+
197
+ function normalizeOutgoingMetadata(payloadMetadata, { accountId, correlationId }) {
198
+ const metadata =
199
+ payloadMetadata && typeof payloadMetadata === 'object' && !Array.isArray(payloadMetadata)
200
+ ? { ...payloadMetadata }
201
+ : {};
202
+
203
+ const spoken = normalizeSpokenMetadata(metadata.spoken);
204
+ if (spoken) {
205
+ metadata.spoken = spoken;
206
+ } else {
207
+ delete metadata.spoken;
208
+ }
209
+
210
+ metadata.accountId = accountId;
211
+ if (correlationId) {
212
+ metadata.correlationId = correlationId;
213
+ } else {
214
+ delete metadata.correlationId;
215
+ }
216
+
217
+ return metadata;
218
+ }
219
+
181
220
  async function postJson({ url, token, body, timeoutMs }) {
182
221
  const controller = new AbortController();
183
222
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -289,10 +328,10 @@ const oomiChannelPlugin = {
289
328
  sessionKey,
290
329
  content,
291
330
  source: 'openclaw.channel',
292
- metadata: {
331
+ metadata: normalizeOutgoingMetadata(payload?.metadata, {
293
332
  accountId: resolvedAccountId,
294
333
  correlationId,
295
- },
334
+ }),
296
335
  },
297
336
  });
298
337
 
@@ -2,7 +2,7 @@
2
2
  "id": "oomi-ai",
3
3
  "name": "Oomi Channel Plugin",
4
4
  "description": "Managed Oomi channel integration for OpenClaw.",
5
- "version": "0.2.6",
5
+ "version": "0.2.15",
6
6
  "author": "Oomi",
7
7
  "license": "MIT",
8
8
  "openclawVersion": ">=0.5.0",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.13",
4
- "description": "Oomi CLI for OpenClaw setup",
3
+ "version": "0.2.15",
4
+ "description": "Oomi OpenClaw channel plugin and bridge tooling",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
7
7
  },