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/README.md +192 -116
- package/agent_instructions.md +175 -35
- package/bin/oomi-ai.js +337 -39
- package/bin/sessionBridgeState.js +29 -2
- package/openclaw.extension.js +41 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/skills/oomi/SKILL.md +127 -60
- package/skills/oomi/agent_instructions.md +30 -0
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,
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
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 (
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
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:
|
|
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 = {
|
|
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;
|
package/openclaw.extension.js
CHANGED
|
@@ -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
|
|
package/openclaw.plugin.json
CHANGED