oomi-ai 0.2.10 → 0.2.13

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 CHANGED
@@ -65,9 +65,20 @@ oomi openclaw bridge ensure --detach # start if needed; no-op if already runni
65
65
  oomi openclaw bridge ps # list bridge pids
66
66
  oomi openclaw bridge stop # stop all bridge workers
67
67
  oomi openclaw bridge restart --detach # clean restart as background worker
68
+ tail -f ~/.openclaw/logs/oomi-bridge-live.log # detached bridge logs
68
69
  ```
69
70
  `oomi openclaw bridge --detach` is equivalent to `oomi openclaw bridge start --detach`.
70
71
 
72
+ macOS launchd supervision (recommended for durability):
73
+ ```
74
+ oomi openclaw bridge service install # install + start service
75
+ oomi openclaw bridge service status
76
+ oomi openclaw bridge service restart
77
+ oomi openclaw bridge service stop
78
+ oomi openclaw bridge service uninstall
79
+ ```
80
+ Optional: `oomi openclaw bridge service install --no-start` to install without starting.
81
+
71
82
  Agent-intent mapping (recommended):
72
83
  - If user says `Connect yourself to Oomi. Use app URL https://www.oomi.ai.`
73
84
  - Run:
@@ -121,6 +132,12 @@ Restart OpenClaw after running `oomi init` or `oomi openclaw install`.
121
132
  - `OOMI_UPDATE_CHECK_TIMEOUT_MS=<ms>` changes network timeout
122
133
  - `OOMI_BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS=<ms>` changes local gateway socket connect timeout
123
134
  - `OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS=<ms>` changes wait timeout for gateway `connect.challenge` nonce
135
+ - `OOMI_BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS=<ms>` changes timeout for forwarded gateway `connect`/`chat.send` request responses
136
+
137
+ Bridge alert helper (reads `~/.openclaw/oomi-bridge-status.json` counters):
138
+ ```
139
+ node <repo-root>/scripts/openclaw/bridge-alert-check.mjs
140
+ ```
124
141
 
125
142
  ## Package Audit + Publish (pnpm)
126
143
  ```
@@ -29,6 +29,7 @@ If the user asks to connect OpenClaw to Oomi voice/web:
29
29
  7. Ensure exactly one bridge worker is running (singleton handler):
30
30
  - `oomi openclaw bridge ensure --detach`
31
31
  - If stale/disconnected: `oomi openclaw bridge restart --detach`
32
+ - On macOS, prefer supervised mode: `oomi openclaw bridge service install`
32
33
  8. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
33
34
  - Use that URL directly in the pair command.
34
35
  - Example: `oomi openclaw pair --app-url <URL> --no-start`
package/bin/oomi-ai.js CHANGED
@@ -6,6 +6,7 @@ import { spawn, spawnSync } from 'child_process';
6
6
  import { createPrivateKey, createPublicKey, randomUUID, sign as cryptoSign } from 'crypto';
7
7
  import net from 'net';
8
8
  import { lookup as dnsLookup } from 'dns/promises';
9
+ import { fileURLToPath } from 'url';
9
10
  import WebSocket from 'ws';
10
11
  import { ensureSessionBridge, forwardFrameToSession, flushSessionQueue } from './sessionBridgeState.js';
11
12
 
@@ -26,6 +27,11 @@ const BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS = parsePositiveInteger(
26
27
  process.env.OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS,
27
28
  3000
28
29
  );
30
+ const BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS = parsePositiveInteger(
31
+ process.env.OOMI_BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS,
32
+ 30000
33
+ );
34
+ const BRIDGE_LAUNCHD_LABEL = 'ai.oomi.bridge';
29
35
  const DEVICE_IDENTITY_PATH = path.join(os.homedir(), '.openclaw', 'identity', 'device.json');
30
36
  const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
31
37
 
@@ -153,6 +159,8 @@ Commands:
153
159
 
154
160
  openclaw bridge [start|ensure|stop|restart|ps]
155
161
  Manage local OpenClaw-to-Oomi bridge lifecycle (singleton).
162
+ openclaw bridge service [install|start|stop|restart|status|uninstall]
163
+ Manage macOS launchd bridge supervision.
156
164
 
157
165
  openclaw pair
158
166
  Pair this OpenClaw host with Oomi and start bridge (single command).
@@ -208,6 +216,15 @@ function writeFile(filePath, content, options = undefined) {
208
216
  fs.writeFileSync(filePath, content, options);
209
217
  }
210
218
 
219
+ function xmlEscape(value) {
220
+ return String(value)
221
+ .replaceAll('&', '&amp;')
222
+ .replaceAll('<', '&lt;')
223
+ .replaceAll('>', '&gt;')
224
+ .replaceAll('"', '&quot;')
225
+ .replaceAll("'", '&apos;');
226
+ }
227
+
211
228
  function resolveWorkspace() {
212
229
  const envWorkspace = process.env.OPENCLAW_WORKSPACE || process.env.OPENCLAW_HOME;
213
230
  if (envWorkspace) return envWorkspace;
@@ -562,6 +579,14 @@ function resolveBridgeLockPath() {
562
579
  return path.join(os.homedir(), '.openclaw', 'oomi-bridge.lock');
563
580
  }
564
581
 
582
+ function resolveBridgeLiveLogPath() {
583
+ return path.join(os.homedir(), '.openclaw', 'logs', 'oomi-bridge-live.log');
584
+ }
585
+
586
+ function resolveBridgeLaunchAgentPlistPath() {
587
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${BRIDGE_LAUNCHD_LABEL}.plist`);
588
+ }
589
+
565
590
  function defaultDeviceId() {
566
591
  const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
567
592
  return `oomi-${host}-${randomUUID().slice(0, 8)}`;
@@ -612,6 +637,28 @@ function updateBridgeStatus(partial) {
612
637
  return next;
613
638
  }
614
639
 
640
+ function normalizeBridgeMetrics(value) {
641
+ if (!value || typeof value !== 'object') return {};
642
+ const next = {};
643
+ for (const [key, raw] of Object.entries(value)) {
644
+ const parsed = Number(raw);
645
+ next[key] = Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
646
+ }
647
+ return next;
648
+ }
649
+
650
+ function incrementBridgeMetric(metricKey, amount = 1) {
651
+ const normalizedKey = String(metricKey || '').trim();
652
+ if (!normalizedKey) return;
653
+ const delta = Number(amount);
654
+ if (!Number.isFinite(delta) || delta <= 0) return;
655
+
656
+ const current = readBridgeStatus();
657
+ const metrics = normalizeBridgeMetrics(current.metrics);
658
+ metrics[normalizedKey] = (metrics[normalizedKey] || 0) + Math.floor(delta);
659
+ updateBridgeStatus({ metrics });
660
+ }
661
+
615
662
  function normalizePid(value) {
616
663
  const pid = Number(value);
617
664
  if (!Number.isInteger(pid) || pid <= 0) return null;
@@ -898,15 +945,51 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
898
945
 
899
946
  try {
900
947
  const frame = JSON.parse(frameText);
901
- if (frame?.type !== 'req' || frame?.method !== 'connect') {
948
+ if (frame?.type !== 'req') {
949
+ return { frameText, waitForChallenge: false };
950
+ }
951
+ const method = typeof frame.method === 'string' ? frame.method.trim() : '';
952
+ if (!method) {
953
+ return { frameText, waitForChallenge: false };
954
+ }
955
+
956
+ if (method !== 'connect') {
957
+ const shouldStripBridgeOnlyParams = method === 'chat.send' || method === 'chat.history';
958
+ if (!shouldStripBridgeOnlyParams) {
959
+ return { frameText, waitForChallenge: false };
960
+ }
961
+ 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;
971
+ }
972
+ if (changed) {
973
+ frame.params = sanitized;
974
+ return { frameText: JSON.stringify(frame), waitForChallenge: false };
975
+ }
902
976
  return { frameText, waitForChallenge: false };
903
977
  }
904
978
 
905
- const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
979
+ const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
980
+ const params = {};
906
981
 
907
- const client = params.client && typeof params.client === 'object' ? params.client : {};
908
- const incomingClientId = typeof client.id === 'string' ? client.id.trim().toLowerCase() : '';
909
- const incomingClientMode = typeof client.mode === 'string' ? client.mode.trim().toLowerCase() : '';
982
+ params.minProtocol = Number.isInteger(rawParams.minProtocol) && rawParams.minProtocol >= 1
983
+ ? rawParams.minProtocol
984
+ : 3;
985
+ params.maxProtocol = Number.isInteger(rawParams.maxProtocol) && rawParams.maxProtocol >= 1
986
+ ? rawParams.maxProtocol
987
+ : 3;
988
+
989
+ const clientInput = rawParams.client && typeof rawParams.client === 'object' ? rawParams.client : {};
990
+ const client = {};
991
+ const incomingClientId = typeof clientInput.id === 'string' ? clientInput.id.trim().toLowerCase() : '';
992
+ const incomingClientMode = typeof clientInput.mode === 'string' ? clientInput.mode.trim().toLowerCase() : '';
910
993
  const proxiedBrowserClient =
911
994
  incomingClientMode === 'webchat' ||
912
995
  incomingClientId === 'webchat-ui' ||
@@ -918,20 +1001,32 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
918
1001
  // so Control UI/webchat Origin checks don't reject proxied sessions.
919
1002
  client.id = proxiedBrowserClient
920
1003
  ? 'node-host'
921
- : (typeof client.id === 'string' && client.id.trim() ? client.id.trim() : 'node-host');
922
- client.version = typeof client.version === 'string' && client.version.trim() ? client.version.trim() : '0.1.0';
1004
+ : (typeof clientInput.id === 'string' && clientInput.id.trim() ? clientInput.id.trim() : 'node-host');
1005
+ client.version = typeof clientInput.version === 'string' && clientInput.version.trim() ? clientInput.version.trim() : '0.1.0';
923
1006
  client.platform = proxiedBrowserClient
924
1007
  ? process.platform
925
- : (typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform);
1008
+ : (typeof clientInput.platform === 'string' && clientInput.platform.trim() ? clientInput.platform.trim() : process.platform);
926
1009
  client.mode = proxiedBrowserClient
927
1010
  ? 'backend'
928
- : (typeof client.mode === 'string' && client.mode.trim() ? client.mode.trim() : 'backend');
1011
+ : (typeof clientInput.mode === 'string' && clientInput.mode.trim() ? clientInput.mode.trim() : 'backend');
1012
+ if (typeof clientInput.displayName === 'string' && clientInput.displayName.trim()) {
1013
+ client.displayName = clientInput.displayName.trim();
1014
+ }
1015
+ if (typeof clientInput.deviceFamily === 'string' && clientInput.deviceFamily.trim()) {
1016
+ client.deviceFamily = clientInput.deviceFamily.trim();
1017
+ }
1018
+ if (typeof clientInput.modelIdentifier === 'string' && clientInput.modelIdentifier.trim()) {
1019
+ client.modelIdentifier = clientInput.modelIdentifier.trim();
1020
+ }
1021
+ if (typeof clientInput.instanceId === 'string' && clientInput.instanceId.trim()) {
1022
+ client.instanceId = clientInput.instanceId.trim();
1023
+ }
929
1024
  params.client = client;
930
1025
 
931
- params.role = typeof params.role === 'string' && params.role.trim() ? params.role.trim() : 'operator';
1026
+ params.role = typeof rawParams.role === 'string' && rawParams.role.trim() ? rawParams.role.trim() : 'operator';
932
1027
 
933
- const existingScopes = Array.isArray(params.scopes)
934
- ? params.scopes.filter((value) => typeof value === 'string' && value.trim())
1028
+ const existingScopes = Array.isArray(rawParams.scopes)
1029
+ ? rawParams.scopes.filter((value) => typeof value === 'string' && value.trim())
935
1030
  : [];
936
1031
  const requiredScopes = ['operator.read', 'operator.write'];
937
1032
  for (const scope of requiredScopes) {
@@ -941,14 +1036,28 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
941
1036
  }
942
1037
  params.scopes = existingScopes;
943
1038
 
944
- if (!Array.isArray(params.caps)) {
945
- params.caps = [];
946
- }
947
- if (!Array.isArray(params.commands)) {
948
- params.commands = [];
1039
+ params.caps = Array.isArray(rawParams.caps)
1040
+ ? rawParams.caps.filter((value) => typeof value === 'string' && value.trim())
1041
+ : [];
1042
+
1043
+ params.commands = Array.isArray(rawParams.commands)
1044
+ ? rawParams.commands.filter((value) => typeof value === 'string' && value.trim())
1045
+ : [];
1046
+
1047
+ if (rawParams.permissions && typeof rawParams.permissions === 'object') {
1048
+ const permissions = {};
1049
+ for (const [key, value] of Object.entries(rawParams.permissions)) {
1050
+ const normalizedKey = typeof key === 'string' ? key.trim() : '';
1051
+ if (!normalizedKey || typeof value !== 'boolean') continue;
1052
+ permissions[normalizedKey] = value;
1053
+ }
1054
+ if (Object.keys(permissions).length > 0) {
1055
+ params.permissions = permissions;
1056
+ }
949
1057
  }
950
- if (!params.permissions || typeof params.permissions !== 'object') {
951
- params.permissions = {};
1058
+
1059
+ if (typeof rawParams.pathEnv === 'string') {
1060
+ params.pathEnv = rawParams.pathEnv;
952
1061
  }
953
1062
 
954
1063
  const auth = {};
@@ -961,16 +1070,23 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
961
1070
  params.auth = auth;
962
1071
  }
963
1072
 
1073
+ if (typeof rawParams.locale === 'string' && rawParams.locale.trim()) {
1074
+ params.locale = rawParams.locale;
1075
+ }
1076
+ if (typeof rawParams.userAgent === 'string' && rawParams.userAgent.trim()) {
1077
+ params.userAgent = rawParams.userAgent;
1078
+ }
1079
+
964
1080
  if (deviceIdentity) {
965
1081
  if (!connectNonce) {
966
- return { frameText: null, waitForChallenge: true };
1082
+ return { frameText, waitForChallenge: true };
967
1083
  }
968
-
969
1084
  const signedAtMs = Date.now();
970
1085
  const tokenForSignature =
971
1086
  typeof auth.token === 'string' && auth.token.trim()
972
1087
  ? auth.token.trim()
973
1088
  : (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
1089
+ const nonceForSignature = connectNonce;
974
1090
 
975
1091
  const payload = buildDeviceAuthPayloadV3({
976
1092
  deviceId: deviceIdentity.deviceId,
@@ -980,7 +1096,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
980
1096
  scopes: existingScopes,
981
1097
  signedAtMs,
982
1098
  token: tokenForSignature,
983
- nonce: connectNonce,
1099
+ nonce: nonceForSignature,
984
1100
  platform: client.platform,
985
1101
  deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
986
1102
  });
@@ -990,7 +1106,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
990
1106
  publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
991
1107
  signature,
992
1108
  signedAt: signedAtMs,
993
- nonce: connectNonce,
1109
+ nonce: nonceForSignature,
994
1110
  };
995
1111
  }
996
1112
 
@@ -1009,6 +1125,53 @@ function parseJsonPayload(raw) {
1009
1125
  }
1010
1126
  }
1011
1127
 
1128
+ function extractCorrelationId(params) {
1129
+ if (!params || typeof params !== 'object') return '';
1130
+ if (typeof params.correlationId === 'string' && params.correlationId.trim()) {
1131
+ return params.correlationId.trim();
1132
+ }
1133
+ const metadata = params.metadata;
1134
+ if (metadata && typeof metadata === 'object' && typeof metadata.correlationId === 'string' && metadata.correlationId.trim()) {
1135
+ return metadata.correlationId.trim();
1136
+ }
1137
+ return '';
1138
+ }
1139
+
1140
+ function extractGatewayRequestMeta(frameText) {
1141
+ const payload = parseJsonPayload(frameText);
1142
+ if (!payload || typeof payload !== 'object') return null;
1143
+ if (payload.type !== 'req') return null;
1144
+ const requestId = typeof payload.id === 'string' ? payload.id.trim() : '';
1145
+ const method = typeof payload.method === 'string' ? payload.method.trim() : '';
1146
+ if (!requestId || !method) return null;
1147
+
1148
+ const params = payload.params && typeof payload.params === 'object' ? payload.params : {};
1149
+ const correlationId = extractCorrelationId(params);
1150
+ return { requestId, method, correlationId };
1151
+ }
1152
+
1153
+ function extractGatewayResponseMeta(frameText) {
1154
+ const payload = parseJsonPayload(frameText);
1155
+ if (!payload || typeof payload !== 'object') return null;
1156
+ if (payload.type !== 'res') return null;
1157
+ const requestId = typeof payload.id === 'string' ? payload.id.trim() : '';
1158
+ if (!requestId) return null;
1159
+ return {
1160
+ requestId,
1161
+ ok: payload.ok === true,
1162
+ };
1163
+ }
1164
+
1165
+ function isGatewayRunStartedFrame(frameText) {
1166
+ const payload = parseJsonPayload(frameText);
1167
+ if (!payload || typeof payload !== 'object') return false;
1168
+ if (payload.type !== 'event' || payload.event !== 'agent') return false;
1169
+ const body = payload.payload;
1170
+ if (!body || typeof body !== 'object') return false;
1171
+ if (body.stream !== 'lifecycle') return false;
1172
+ return body.data && typeof body.data === 'object' && body.data.phase === 'start';
1173
+ }
1174
+
1012
1175
  function bridgeNowIso() {
1013
1176
  return new Date().toISOString();
1014
1177
  }
@@ -1175,11 +1338,20 @@ function startBridgeDetachedProcess(rawFlags = {}) {
1175
1338
  }
1176
1339
 
1177
1340
  const args = buildBridgeDetachArgs(rawFlags);
1341
+ const logPath = resolveBridgeLiveLogPath();
1342
+ ensureDir(path.dirname(logPath));
1343
+ fs.appendFileSync(logPath, `[${new Date().toISOString()}] [bridge-supervisor] starting detached bridge\n`);
1344
+ const logFd = fs.openSync(logPath, 'a');
1178
1345
  const child = spawn(process.execPath, args, {
1179
1346
  detached: true,
1180
- stdio: 'ignore',
1347
+ stdio: ['ignore', logFd, logFd],
1181
1348
  });
1182
1349
  child.unref();
1350
+ try {
1351
+ fs.closeSync(logFd);
1352
+ } catch {
1353
+ // no-op
1354
+ }
1183
1355
  return {
1184
1356
  pid: child.pid,
1185
1357
  alreadyRunning: false,
@@ -1291,6 +1463,128 @@ async function stopBridgeProcesses() {
1291
1463
  };
1292
1464
  }
1293
1465
 
1466
+ function assertMacOSLaunchdAvailable() {
1467
+ if (process.platform !== 'darwin') {
1468
+ throw new Error('Bridge service manager is only supported on macOS (launchd).');
1469
+ }
1470
+ if (typeof process.getuid !== 'function') {
1471
+ throw new Error('Cannot resolve current UID for launchd domain.');
1472
+ }
1473
+ }
1474
+
1475
+ function launchctlDomain() {
1476
+ assertMacOSLaunchdAvailable();
1477
+ return `gui/${String(process.getuid())}`;
1478
+ }
1479
+
1480
+ function launchctlServiceTarget() {
1481
+ return `${launchctlDomain()}/${BRIDGE_LAUNCHD_LABEL}`;
1482
+ }
1483
+
1484
+ function runLaunchctl(args, { allowFailure = false } = {}) {
1485
+ const result = spawnSync('launchctl', args, {
1486
+ encoding: 'utf8',
1487
+ stdio: ['ignore', 'pipe', 'pipe'],
1488
+ });
1489
+ const status = Number.isInteger(result.status) ? result.status : 1;
1490
+ const stdout = String(result.stdout || '').trim();
1491
+ const stderr = String(result.stderr || '').trim();
1492
+ if (status !== 0 && !allowFailure) {
1493
+ throw new Error(
1494
+ `launchctl ${args.join(' ')} failed (${status}): ${stderr || stdout || 'unknown launchctl error'}`
1495
+ );
1496
+ }
1497
+ return { status, stdout, stderr };
1498
+ }
1499
+
1500
+ function buildBridgeLaunchAgentPlist() {
1501
+ const scriptPath = (() => {
1502
+ try {
1503
+ return fs.realpathSync(process.argv[1]);
1504
+ } catch {
1505
+ return process.argv[1];
1506
+ }
1507
+ })();
1508
+ const programArgs = [process.execPath, scriptPath, 'openclaw', 'bridge', 'start'];
1509
+ const bridgeLogPath = resolveBridgeLiveLogPath();
1510
+ const argsXml = programArgs.map((arg) => `<string>${xmlEscape(arg)}</string>`).join('\n ');
1511
+
1512
+ return `<?xml version="1.0" encoding="UTF-8"?>
1513
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1514
+ <plist version="1.0">
1515
+ <dict>
1516
+ <key>Label</key>
1517
+ <string>${xmlEscape(BRIDGE_LAUNCHD_LABEL)}</string>
1518
+ <key>ProgramArguments</key>
1519
+ <array>
1520
+ ${argsXml}
1521
+ </array>
1522
+ <key>WorkingDirectory</key>
1523
+ <string>${xmlEscape(os.homedir())}</string>
1524
+ <key>RunAtLoad</key>
1525
+ <true/>
1526
+ <key>KeepAlive</key>
1527
+ <true/>
1528
+ <key>ThrottleInterval</key>
1529
+ <integer>5</integer>
1530
+ <key>EnvironmentVariables</key>
1531
+ <dict>
1532
+ <key>OOMI_SKIP_UPDATE_CHECK</key>
1533
+ <string>1</string>
1534
+ </dict>
1535
+ <key>StandardOutPath</key>
1536
+ <string>${xmlEscape(bridgeLogPath)}</string>
1537
+ <key>StandardErrorPath</key>
1538
+ <string>${xmlEscape(bridgeLogPath)}</string>
1539
+ </dict>
1540
+ </plist>
1541
+ `;
1542
+ }
1543
+
1544
+ function readBridgeLaunchdStatus() {
1545
+ assertMacOSLaunchdAvailable();
1546
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
1547
+ const target = launchctlServiceTarget();
1548
+ const printResult = runLaunchctl(['print', target], { allowFailure: true });
1549
+ const loaded = printResult.status === 0;
1550
+ const output = [printResult.stdout, printResult.stderr].filter(Boolean).join('\n');
1551
+ const pidMatch = output.match(/\bpid\s*=\s*(\d+)/);
1552
+ const lastExitMatch = output.match(/\blast exit code\s*=\s*(-?\d+)/i);
1553
+
1554
+ return {
1555
+ plistPath,
1556
+ target,
1557
+ installed: fs.existsSync(plistPath),
1558
+ loaded,
1559
+ pid: pidMatch ? Number(pidMatch[1]) : null,
1560
+ running: Boolean(pidMatch && Number(pidMatch[1]) > 0),
1561
+ lastExitCode: lastExitMatch ? Number(lastExitMatch[1]) : null,
1562
+ printOutput: output,
1563
+ };
1564
+ }
1565
+
1566
+ function startBridgeLaunchdService() {
1567
+ assertMacOSLaunchdAvailable();
1568
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
1569
+ if (!fs.existsSync(plistPath)) {
1570
+ throw new Error('Bridge service is not installed. Run: oomi openclaw bridge service install');
1571
+ }
1572
+ const domain = launchctlDomain();
1573
+ const target = launchctlServiceTarget();
1574
+ runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
1575
+ runLaunchctl(['bootstrap', domain, plistPath]);
1576
+ runLaunchctl(['enable', target], { allowFailure: true });
1577
+ runLaunchctl(['kickstart', '-k', target], { allowFailure: true });
1578
+ }
1579
+
1580
+ async function stopBridgeLaunchdService() {
1581
+ assertMacOSLaunchdAvailable();
1582
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
1583
+ const domain = launchctlDomain();
1584
+ runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
1585
+ return stopBridgeProcesses();
1586
+ }
1587
+
1294
1588
  async function resolveBridgeRuntimeConfig(flags, bridgeState) {
1295
1589
  const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
1296
1590
  const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
@@ -1469,6 +1763,188 @@ async function startOpenclawBridge(flags) {
1469
1763
  );
1470
1764
  };
1471
1765
 
1766
+ const sendGatewayAck = (brokerSocket, {
1767
+ sessionId,
1768
+ requestId = '',
1769
+ method = '',
1770
+ correlationId = '',
1771
+ stage = 'unknown',
1772
+ }) => {
1773
+ if (!sessionId) return;
1774
+ if (requestId) {
1775
+ const sessionBridge = activeGatewaySockets.get(sessionId);
1776
+ if (sessionBridge && sessionBridge.pendingRequests instanceof Map) {
1777
+ const pending = sessionBridge.pendingRequests.get(requestId);
1778
+ if (pending) {
1779
+ pending.lastSuccessfulHop = stage;
1780
+ sessionBridge.pendingRequests.set(requestId, pending);
1781
+ }
1782
+ }
1783
+ }
1784
+ sendBrokerPayload(brokerSocket, {
1785
+ action: 'gateway_ack',
1786
+ type: 'gateway.ack',
1787
+ sessionId,
1788
+ requestId,
1789
+ method,
1790
+ correlationId,
1791
+ stage,
1792
+ ts: bridgeNowIso(),
1793
+ });
1794
+ };
1795
+
1796
+ const sendGatewayErrorResponse = (
1797
+ brokerSocket,
1798
+ {
1799
+ sessionId,
1800
+ requestMeta,
1801
+ code = 'gateway_error',
1802
+ message = 'Gateway request failed',
1803
+ lastSuccessfulHop = '',
1804
+ retryable = false,
1805
+ details = null,
1806
+ }
1807
+ ) => {
1808
+ if (!sessionId || !requestMeta || !requestMeta.requestId) return;
1809
+ const errorPayload = {
1810
+ code,
1811
+ message,
1812
+ correlationId: requestMeta.correlationId || '',
1813
+ };
1814
+ if (lastSuccessfulHop) {
1815
+ errorPayload.lastSuccessfulHop = lastSuccessfulHop;
1816
+ }
1817
+ if (retryable === true) {
1818
+ errorPayload.retryable = true;
1819
+ }
1820
+ if (details && typeof details === 'object') {
1821
+ errorPayload.details = details;
1822
+ }
1823
+ const responseFrame = {
1824
+ type: 'res',
1825
+ id: requestMeta.requestId,
1826
+ ok: false,
1827
+ error: errorPayload,
1828
+ };
1829
+ sendBrokerPayload(brokerSocket, {
1830
+ action: 'gateway_frame',
1831
+ type: 'gateway.frame',
1832
+ sessionId,
1833
+ frame: JSON.stringify(responseFrame),
1834
+ });
1835
+ };
1836
+
1837
+ const classifyGatewayClose = (code, reasonText) => {
1838
+ const reasonLower = String(reasonText || '').toLowerCase();
1839
+ if (code === 1008 && reasonLower.includes('invalid connect params')) {
1840
+ return {
1841
+ errorCode: 'gateway_invalid_connect_params',
1842
+ retryable: false,
1843
+ };
1844
+ }
1845
+ if (code === 1008) {
1846
+ return {
1847
+ errorCode: 'gateway_policy_violation',
1848
+ retryable: false,
1849
+ };
1850
+ }
1851
+ if (code === 1003 || code === 1002) {
1852
+ return {
1853
+ errorCode: 'gateway_protocol_error',
1854
+ retryable: false,
1855
+ };
1856
+ }
1857
+ if (code === 1006) {
1858
+ return {
1859
+ errorCode: 'gateway_abnormal_close',
1860
+ retryable: true,
1861
+ };
1862
+ }
1863
+ return {
1864
+ errorCode: 'gateway_closed',
1865
+ retryable: true,
1866
+ };
1867
+ };
1868
+
1869
+ const clearPendingRequestTimeout = (sessionBridge, requestId) => {
1870
+ if (!sessionBridge || !(sessionBridge.pendingRequestTimers instanceof Map)) return;
1871
+ const existingTimer = sessionBridge.pendingRequestTimers.get(requestId);
1872
+ if (existingTimer) {
1873
+ clearTimeout(existingTimer);
1874
+ sessionBridge.pendingRequestTimers.delete(requestId);
1875
+ }
1876
+ };
1877
+
1878
+ const clearAllPendingRequestTimeouts = (sessionBridge) => {
1879
+ if (!sessionBridge || !(sessionBridge.pendingRequestTimers instanceof Map)) return;
1880
+ for (const timer of sessionBridge.pendingRequestTimers.values()) {
1881
+ clearTimeout(timer);
1882
+ }
1883
+ sessionBridge.pendingRequestTimers.clear();
1884
+ };
1885
+
1886
+ const startPendingRequestTimeout = (brokerSocket, sessionId, sessionBridge, requestMeta) => {
1887
+ if (!sessionBridge || !requestMeta || !requestMeta.requestId) return;
1888
+ if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
1889
+ sessionBridge.pendingRequestTimers = new Map();
1890
+ }
1891
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
1892
+ const timer = setTimeout(() => {
1893
+ const pending = sessionBridge.pendingRequests instanceof Map
1894
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
1895
+ : null;
1896
+ if (!pending) {
1897
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
1898
+ return;
1899
+ }
1900
+
1901
+ if (requestMeta.method === 'connect') {
1902
+ incrementBridgeMetric('connect_timeout_count');
1903
+ } else if (requestMeta.method === 'chat.send') {
1904
+ incrementBridgeMetric('chat_send_timeout_count');
1905
+ } else {
1906
+ incrementBridgeMetric('gateway_request_timeout_count');
1907
+ }
1908
+
1909
+ const lastSuccessfulHop = typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
1910
+ ? pending.lastSuccessfulHop
1911
+ : 'bridge.forwarded';
1912
+ sendGatewayAck(brokerSocket, {
1913
+ sessionId,
1914
+ requestId: pending.requestId,
1915
+ method: pending.method,
1916
+ correlationId: pending.correlationId,
1917
+ stage: 'gateway.timeout',
1918
+ });
1919
+ sendBrokerPayload(brokerSocket, {
1920
+ action: 'log',
1921
+ type: 'log',
1922
+ sessionId,
1923
+ level: 'warn',
1924
+ message: `Gateway request timeout (${pending.method} ${pending.requestId}) after ${String(BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS)}ms`,
1925
+ });
1926
+ sendGatewayErrorResponse(brokerSocket, {
1927
+ sessionId,
1928
+ requestMeta: pending,
1929
+ code: 'gateway_timeout',
1930
+ message: `Gateway request timeout (${pending.method}) after ${String(BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS)}ms`,
1931
+ lastSuccessfulHop,
1932
+ retryable: true,
1933
+ details: {
1934
+ method: pending.method,
1935
+ timeoutMs: BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS,
1936
+ },
1937
+ });
1938
+
1939
+ if (sessionBridge.pendingRequests instanceof Map) {
1940
+ sessionBridge.pendingRequests.delete(pending.requestId);
1941
+ }
1942
+ clearPendingRequestTimeout(sessionBridge, pending.requestId);
1943
+ }, BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS);
1944
+
1945
+ sessionBridge.pendingRequestTimers.set(requestMeta.requestId, timer);
1946
+ };
1947
+
1472
1948
  const parseBrokerEnvelope = (raw) => {
1473
1949
  const payload = parseJsonPayload(raw);
1474
1950
  if (!payload) return null;
@@ -1496,6 +1972,7 @@ async function startOpenclawBridge(flags) {
1496
1972
  const scheduleReconnect = () => {
1497
1973
  if (reconnectState.stopped || reconnectState.timer) return;
1498
1974
  reconnectState.attempt += 1;
1975
+ incrementBridgeMetric('bridge_reconnect_scheduled_count');
1499
1976
  const failure =
1500
1977
  reconnectState.lastFailure ||
1501
1978
  classifyBridgeFailure({ reason: 'connection closed without classified error' });
@@ -1592,20 +2069,102 @@ async function startOpenclawBridge(flags) {
1592
2069
 
1593
2070
  clearChallengeTimer(sessionBridge);
1594
2071
  for (const pendingFrame of pending) {
2072
+ const requestMeta = extractGatewayRequestMeta(pendingFrame);
1595
2073
  const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
1596
2074
  connectNonce: sessionBridge.connectNonce,
1597
2075
  deviceIdentity: gatewayDeviceIdentity,
1598
2076
  });
1599
2077
  if (!prepared.frameText || prepared.waitForChallenge) {
2078
+ if (requestMeta) {
2079
+ const pending = sessionBridge.pendingRequests instanceof Map
2080
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
2081
+ : null;
2082
+ const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2083
+ ? pending.lastSuccessfulHop
2084
+ : 'bridge.waiting_for_challenge';
2085
+ sendGatewayAck(brokerSocket, {
2086
+ sessionId,
2087
+ requestId: requestMeta.requestId,
2088
+ method: requestMeta.method,
2089
+ correlationId: requestMeta.correlationId,
2090
+ stage: 'bridge.dropped',
2091
+ });
2092
+ sendGatewayErrorResponse(brokerSocket, {
2093
+ sessionId,
2094
+ requestMeta,
2095
+ code: 'bridge_dropped',
2096
+ message: 'Bridge dropped connect request after challenge handling.',
2097
+ lastSuccessfulHop,
2098
+ retryable: true,
2099
+ });
2100
+ if (sessionBridge.pendingRequests instanceof Map) {
2101
+ sessionBridge.pendingRequests.delete(requestMeta.requestId);
2102
+ }
2103
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
2104
+ }
1600
2105
  continue;
1601
2106
  }
1602
2107
  const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1603
2108
  if (result === 'queued') {
1604
2109
  console.log(`[bridge] client.frame queued after challenge ${sessionId}`);
2110
+ if (requestMeta) {
2111
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
2112
+ }
2113
+ if (requestMeta) {
2114
+ sendGatewayAck(brokerSocket, {
2115
+ sessionId,
2116
+ requestId: requestMeta.requestId,
2117
+ method: requestMeta.method,
2118
+ correlationId: requestMeta.correlationId,
2119
+ stage: 'bridge.queued',
2120
+ });
2121
+ }
1605
2122
  } else if (result === 'dropped') {
1606
2123
  console.log(`[bridge] client.frame dropped after challenge ${sessionId}`);
2124
+ incrementBridgeMetric('bridge_drop_count');
2125
+ if (requestMeta) {
2126
+ const pending = sessionBridge.pendingRequests instanceof Map
2127
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
2128
+ : null;
2129
+ const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2130
+ ? pending.lastSuccessfulHop
2131
+ : 'bridge.waiting_for_challenge';
2132
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
2133
+ if (sessionBridge.pendingRequests instanceof Map) {
2134
+ sessionBridge.pendingRequests.delete(requestMeta.requestId);
2135
+ }
2136
+ sendGatewayErrorResponse(brokerSocket, {
2137
+ sessionId,
2138
+ requestMeta,
2139
+ code: 'bridge_dropped',
2140
+ message: 'Bridge dropped request because gateway socket is not open.',
2141
+ lastSuccessfulHop,
2142
+ retryable: true,
2143
+ });
2144
+ }
2145
+ if (requestMeta) {
2146
+ sendGatewayAck(brokerSocket, {
2147
+ sessionId,
2148
+ requestId: requestMeta.requestId,
2149
+ method: requestMeta.method,
2150
+ correlationId: requestMeta.correlationId,
2151
+ stage: 'bridge.dropped',
2152
+ });
2153
+ }
1607
2154
  } else {
1608
2155
  console.log(`[bridge] client.frame sent after challenge ${sessionId}`);
2156
+ if (requestMeta) {
2157
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
2158
+ }
2159
+ if (requestMeta) {
2160
+ sendGatewayAck(brokerSocket, {
2161
+ sessionId,
2162
+ requestId: requestMeta.requestId,
2163
+ method: requestMeta.method,
2164
+ correlationId: requestMeta.correlationId,
2165
+ stage: 'bridge.forwarded',
2166
+ });
2167
+ }
1609
2168
  }
1610
2169
  }
1611
2170
  };
@@ -1619,11 +2178,21 @@ async function startOpenclawBridge(flags) {
1619
2178
  if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
1620
2179
  sessionBridge.pendingConnectFrames = [];
1621
2180
  }
2181
+ if (!(sessionBridge.pendingRequests instanceof Map)) {
2182
+ sessionBridge.pendingRequests = new Map();
2183
+ }
2184
+ if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
2185
+ sessionBridge.pendingRequestTimers = new Map();
2186
+ }
2187
+ if (typeof sessionBridge.lastChatCorrelationId !== 'string') {
2188
+ sessionBridge.lastChatCorrelationId = '';
2189
+ }
1622
2190
  let connectTimeout = setTimeout(() => {
1623
2191
  if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
1624
2192
  console.error(
1625
2193
  `[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
1626
2194
  );
2195
+ incrementBridgeMetric('gateway_connect_timeout_count');
1627
2196
  sendBrokerPayload(brokerSocket, {
1628
2197
  action: 'log',
1629
2198
  type: 'log',
@@ -1651,6 +2220,7 @@ async function startOpenclawBridge(flags) {
1651
2220
  const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
1652
2221
  const gatewayPayload = parseJsonPayload(frame);
1653
2222
  if (gatewayPayload?.event === 'connect.challenge') {
2223
+ console.log(`[bridge] gateway.connect.challenge ${sessionId}`);
1654
2224
  const nonce =
1655
2225
  gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
1656
2226
  ? gatewayPayload.payload.nonce.trim()
@@ -1674,6 +2244,35 @@ async function startOpenclawBridge(flags) {
1674
2244
  flushPendingConnectFrames(sessionId, sessionBridge);
1675
2245
  }
1676
2246
  }
2247
+
2248
+ const responseMeta = extractGatewayResponseMeta(frame);
2249
+ if (responseMeta && sessionBridge.pendingRequests instanceof Map) {
2250
+ const requestMeta = sessionBridge.pendingRequests.get(responseMeta.requestId);
2251
+ if (requestMeta) {
2252
+ clearPendingRequestTimeout(sessionBridge, responseMeta.requestId);
2253
+ sendGatewayAck(brokerSocket, {
2254
+ sessionId,
2255
+ requestId: requestMeta.requestId,
2256
+ method: requestMeta.method,
2257
+ correlationId: requestMeta.correlationId,
2258
+ stage: responseMeta.ok ? 'gateway.accepted' : 'gateway.rejected',
2259
+ });
2260
+ if (!responseMeta.ok) {
2261
+ incrementBridgeMetric('gateway_rejected_count');
2262
+ }
2263
+ sessionBridge.pendingRequests.delete(responseMeta.requestId);
2264
+ }
2265
+ }
2266
+
2267
+ if (isGatewayRunStartedFrame(frame)) {
2268
+ sendGatewayAck(brokerSocket, {
2269
+ sessionId,
2270
+ method: 'chat.send',
2271
+ correlationId: sessionBridge.lastChatCorrelationId || '',
2272
+ stage: 'run.started',
2273
+ });
2274
+ }
2275
+
1677
2276
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
1678
2277
  });
1679
2278
 
@@ -1684,9 +2283,41 @@ async function startOpenclawBridge(flags) {
1684
2283
  }
1685
2284
  clearChallengeTimer(sessionBridge);
1686
2285
  const reasonText = reason ? reason.toString() : '';
2286
+ const closeMeta = classifyGatewayClose(code, reasonText);
1687
2287
  console.log(
1688
2288
  `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
1689
2289
  );
2290
+ if (sessionBridge.pendingRequests instanceof Map) {
2291
+ for (const requestMeta of sessionBridge.pendingRequests.values()) {
2292
+ if (!requestMeta || typeof requestMeta !== 'object') continue;
2293
+ const lastSuccessfulHop = typeof requestMeta.lastSuccessfulHop === 'string' && requestMeta.lastSuccessfulHop
2294
+ ? requestMeta.lastSuccessfulHop
2295
+ : 'bridge.forwarded';
2296
+ sendGatewayAck(brokerSocket, {
2297
+ sessionId,
2298
+ requestId: requestMeta.requestId || '',
2299
+ method: requestMeta.method || '',
2300
+ correlationId: requestMeta.correlationId || '',
2301
+ stage: 'gateway.closed',
2302
+ });
2303
+ sendGatewayErrorResponse(brokerSocket, {
2304
+ sessionId,
2305
+ requestMeta,
2306
+ code: closeMeta.errorCode,
2307
+ message: reasonText
2308
+ ? `Gateway closed (${String(code)}): ${reasonText}`
2309
+ : `Gateway closed (${String(code)})`,
2310
+ lastSuccessfulHop,
2311
+ retryable: closeMeta.retryable,
2312
+ details: {
2313
+ closeCode: code,
2314
+ closeReason: reasonText,
2315
+ },
2316
+ });
2317
+ }
2318
+ sessionBridge.pendingRequests.clear();
2319
+ }
2320
+ clearAllPendingRequestTimeouts(sessionBridge);
1690
2321
  activeGatewaySockets.delete(sessionId);
1691
2322
  sendBrokerPayload(brokerSocket, {
1692
2323
  action: 'gateway_closed',
@@ -1843,6 +2474,22 @@ async function startOpenclawBridge(flags) {
1843
2474
  console.log(`[bridge] client.frame ${sessionId}`);
1844
2475
  const sessionBridge = getOrCreateGatewaySession(sessionId);
1845
2476
  if (!sessionBridge) return;
2477
+ const requestMeta = extractGatewayRequestMeta(frame);
2478
+ if (requestMeta) {
2479
+ if (!(sessionBridge.pendingRequests instanceof Map)) {
2480
+ sessionBridge.pendingRequests = new Map();
2481
+ }
2482
+ if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
2483
+ sessionBridge.pendingRequestTimers = new Map();
2484
+ }
2485
+ sessionBridge.pendingRequests.set(requestMeta.requestId, {
2486
+ ...requestMeta,
2487
+ lastSuccessfulHop: 'broker.accepted',
2488
+ });
2489
+ if (requestMeta.method === 'chat.send' && requestMeta.correlationId) {
2490
+ sessionBridge.lastChatCorrelationId = requestMeta.correlationId;
2491
+ }
2492
+ }
1846
2493
  const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
1847
2494
  connectNonce: sessionBridge.connectNonce,
1848
2495
  deviceIdentity: gatewayDeviceIdentity,
@@ -1850,16 +2497,103 @@ async function startOpenclawBridge(flags) {
1850
2497
  if (prepared.waitForChallenge) {
1851
2498
  queueConnectUntilChallenge(sessionId, sessionBridge, frame);
1852
2499
  console.log(`[bridge] client.frame waiting for challenge ${sessionId}`);
2500
+ if (requestMeta) {
2501
+ sendGatewayAck(brokerSocket, {
2502
+ sessionId,
2503
+ requestId: requestMeta.requestId,
2504
+ method: requestMeta.method,
2505
+ correlationId: requestMeta.correlationId,
2506
+ stage: 'bridge.waiting_for_challenge',
2507
+ });
2508
+ }
1853
2509
  return;
1854
2510
  }
1855
2511
  if (!prepared.frameText) {
2512
+ if (requestMeta) {
2513
+ const pending = sessionBridge.pendingRequests instanceof Map
2514
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
2515
+ : null;
2516
+ const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2517
+ ? pending.lastSuccessfulHop
2518
+ : 'broker.accepted';
2519
+ sendGatewayAck(brokerSocket, {
2520
+ sessionId,
2521
+ requestId: requestMeta.requestId,
2522
+ method: requestMeta.method,
2523
+ correlationId: requestMeta.correlationId,
2524
+ stage: 'bridge.dropped',
2525
+ });
2526
+ sendGatewayErrorResponse(brokerSocket, {
2527
+ sessionId,
2528
+ requestMeta,
2529
+ code: 'bridge_dropped',
2530
+ message: 'Bridge dropped request before forwarding to gateway.',
2531
+ lastSuccessfulHop,
2532
+ retryable: true,
2533
+ });
2534
+ if (sessionBridge.pendingRequests instanceof Map) {
2535
+ sessionBridge.pendingRequests.delete(requestMeta.requestId);
2536
+ }
2537
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
2538
+ }
1856
2539
  return;
1857
2540
  }
1858
2541
  const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1859
2542
  if (result === 'queued') {
1860
2543
  console.log(`[bridge] client.frame queued ${sessionId}`);
2544
+ if (requestMeta) {
2545
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
2546
+ }
2547
+ if (requestMeta) {
2548
+ sendGatewayAck(brokerSocket, {
2549
+ sessionId,
2550
+ requestId: requestMeta.requestId,
2551
+ method: requestMeta.method,
2552
+ correlationId: requestMeta.correlationId,
2553
+ stage: 'bridge.queued',
2554
+ });
2555
+ }
1861
2556
  } else if (result === 'dropped') {
1862
2557
  console.log(`[bridge] client.frame dropped (socket not open) ${sessionId}`);
2558
+ incrementBridgeMetric('bridge_drop_count');
2559
+ if (requestMeta) {
2560
+ const pending = sessionBridge.pendingRequests instanceof Map
2561
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
2562
+ : null;
2563
+ const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2564
+ ? pending.lastSuccessfulHop
2565
+ : 'broker.accepted';
2566
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
2567
+ if (sessionBridge.pendingRequests instanceof Map) {
2568
+ sessionBridge.pendingRequests.delete(requestMeta.requestId);
2569
+ }
2570
+ sendGatewayErrorResponse(brokerSocket, {
2571
+ sessionId,
2572
+ requestMeta,
2573
+ code: 'bridge_dropped',
2574
+ message: 'Bridge dropped request because gateway socket is not open.',
2575
+ lastSuccessfulHop,
2576
+ retryable: true,
2577
+ });
2578
+ }
2579
+ if (requestMeta) {
2580
+ sendGatewayAck(brokerSocket, {
2581
+ sessionId,
2582
+ requestId: requestMeta.requestId,
2583
+ method: requestMeta.method,
2584
+ correlationId: requestMeta.correlationId,
2585
+ stage: 'bridge.dropped',
2586
+ });
2587
+ }
2588
+ } else if (requestMeta) {
2589
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
2590
+ sendGatewayAck(brokerSocket, {
2591
+ sessionId,
2592
+ requestId: requestMeta.requestId,
2593
+ method: requestMeta.method,
2594
+ correlationId: requestMeta.correlationId,
2595
+ stage: 'bridge.forwarded',
2596
+ });
1863
2597
  }
1864
2598
  return;
1865
2599
  }
@@ -1870,6 +2604,10 @@ async function startOpenclawBridge(flags) {
1870
2604
  const sessionBridge = activeGatewaySockets.get(sessionId);
1871
2605
  if (sessionBridge && sessionBridge.socket) {
1872
2606
  clearChallengeTimer(sessionBridge);
2607
+ if (sessionBridge.pendingRequests instanceof Map) {
2608
+ sessionBridge.pendingRequests.clear();
2609
+ }
2610
+ clearAllPendingRequestTimeouts(sessionBridge);
1873
2611
  activeGatewaySockets.delete(sessionId);
1874
2612
  sessionBridge.socket.close(1000, 'client_closed');
1875
2613
  }
@@ -1884,8 +2622,13 @@ async function startOpenclawBridge(flags) {
1884
2622
  }
1885
2623
  const reasonText = reason ? reason.toString() : '';
1886
2624
  console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
2625
+ incrementBridgeMetric('bridge_disconnect_count');
1887
2626
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
1888
2627
  clearChallengeTimer(sessionBridge);
2628
+ if (sessionBridge.pendingRequests instanceof Map) {
2629
+ sessionBridge.pendingRequests.clear();
2630
+ }
2631
+ clearAllPendingRequestTimeouts(sessionBridge);
1889
2632
  activeGatewaySockets.delete(sessionId);
1890
2633
  try {
1891
2634
  sessionBridge.socket.close(1001, 'broker_disconnected');
@@ -1907,6 +2650,7 @@ async function startOpenclawBridge(flags) {
1907
2650
  });
1908
2651
 
1909
2652
  brokerSocket.on('error', (err) => {
2653
+ incrementBridgeMetric('bridge_socket_error_count');
1910
2654
  reconnectState.lastFailure = classifyBridgeFailure({ err });
1911
2655
  console.error(
1912
2656
  `[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
@@ -2160,6 +2904,16 @@ function printOpenclawBridgeStatus(flags) {
2160
2904
  if (payload.runtime.hint) {
2161
2905
  console.log(`Hint: ${payload.runtime.hint}`);
2162
2906
  }
2907
+ if (payload.runtime.metrics && typeof payload.runtime.metrics === 'object') {
2908
+ const metrics = normalizeBridgeMetrics(payload.runtime.metrics);
2909
+ const metricPairs = Object.entries(metrics);
2910
+ if (metricPairs.length > 0) {
2911
+ console.log('Metrics:');
2912
+ for (const [name, value] of metricPairs) {
2913
+ console.log(` ${name}: ${value}`);
2914
+ }
2915
+ }
2916
+ }
2163
2917
  return;
2164
2918
  }
2165
2919
 
@@ -2234,27 +2988,109 @@ function printOpenclawPluginSetup(flags) {
2234
2988
  }
2235
2989
  }
2236
2990
 
2991
+ async function handleBridgeServiceCommand(actionRaw = '', flags = {}) {
2992
+ assertMacOSLaunchdAvailable();
2993
+ const action = String(actionRaw || 'status').trim().toLowerCase();
2994
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
2995
+
2996
+ if (action === 'install') {
2997
+ ensureDir(path.dirname(plistPath));
2998
+ writeFile(plistPath, buildBridgeLaunchAgentPlist());
2999
+ console.log(`Installed bridge launchd plist: ${plistPath}`);
3000
+ if (isTruthyFlag(flags['no-start'])) {
3001
+ console.log('Service install complete. Start with: oomi openclaw bridge service start');
3002
+ return;
3003
+ }
3004
+ startBridgeLaunchdService();
3005
+ incrementBridgeMetric('bridge_start_count');
3006
+ console.log(`Bridge service started: ${launchctlServiceTarget()}`);
3007
+ return;
3008
+ }
3009
+
3010
+ if (action === 'uninstall') {
3011
+ await stopBridgeLaunchdService();
3012
+ if (fs.existsSync(plistPath)) {
3013
+ fs.unlinkSync(plistPath);
3014
+ }
3015
+ console.log(`Removed bridge launchd plist: ${plistPath}`);
3016
+ return;
3017
+ }
3018
+
3019
+ if (action === 'start') {
3020
+ startBridgeLaunchdService();
3021
+ incrementBridgeMetric('bridge_start_count');
3022
+ console.log(`Bridge service started: ${launchctlServiceTarget()}`);
3023
+ return;
3024
+ }
3025
+
3026
+ if (action === 'stop') {
3027
+ const stopped = await stopBridgeLaunchdService();
3028
+ if (Array.isArray(stopped.found) && stopped.found.length > 0) {
3029
+ console.log(`Stopped bridge workers: ${stopped.stopped.join(', ') || 'none'}.`);
3030
+ } else {
3031
+ console.log('No bridge workers running.');
3032
+ }
3033
+ console.log(`Bridge service stopped: ${launchctlServiceTarget()}`);
3034
+ return;
3035
+ }
3036
+
3037
+ if (action === 'restart') {
3038
+ await stopBridgeLaunchdService();
3039
+ startBridgeLaunchdService();
3040
+ incrementBridgeMetric('bridge_restart_count');
3041
+ console.log(`Bridge service restarted: ${launchctlServiceTarget()}`);
3042
+ return;
3043
+ }
3044
+
3045
+ if (action === 'status') {
3046
+ const status = readBridgeLaunchdStatus();
3047
+ console.log('Bridge Service Status');
3048
+ console.log('---------------------');
3049
+ console.log(`Label: ${BRIDGE_LAUNCHD_LABEL}`);
3050
+ console.log(`Target: ${status.target}`);
3051
+ console.log(`Plist: ${status.plistPath}`);
3052
+ console.log(`Installed: ${status.installed ? 'yes' : 'no'}`);
3053
+ console.log(`Loaded: ${status.loaded ? 'yes' : 'no'}`);
3054
+ console.log(`Running: ${status.running ? 'yes' : 'no'}`);
3055
+ if (status.pid) {
3056
+ console.log(`PID: ${status.pid}`);
3057
+ }
3058
+ if (status.lastExitCode !== null) {
3059
+ console.log(`Last exit code: ${status.lastExitCode}`);
3060
+ }
3061
+ return;
3062
+ }
3063
+
3064
+ throw new Error(
3065
+ `Unknown bridge service action: ${action}. Use: oomi openclaw bridge service [install|start|stop|restart|status|uninstall]`
3066
+ );
3067
+ }
3068
+
2237
3069
  async function startBridgeLifecycle(flags = {}) {
2238
3070
  if (Boolean(flags.detach)) {
2239
3071
  const detachedFlags = { ...flags };
2240
3072
  delete detachedFlags.detach;
2241
3073
  const result = startBridgeDetachedProcess(detachedFlags);
2242
3074
  if (result.alreadyRunning) {
3075
+ incrementBridgeMetric('duplicate_start_attempt_count');
2243
3076
  console.log(`Bridge already running (pid: ${result.pid}).`);
2244
3077
  return;
2245
3078
  }
3079
+ incrementBridgeMetric('bridge_start_count');
2246
3080
  console.log(`Bridge started in background (pid: ${result.pid}).`);
2247
3081
  return;
2248
3082
  }
2249
3083
 
2250
3084
  const running = findRunningBridgeProcess();
2251
3085
  if (running) {
3086
+ incrementBridgeMetric('duplicate_start_attempt_count');
2252
3087
  console.log(
2253
3088
  `Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
2254
3089
  );
2255
3090
  return;
2256
3091
  }
2257
3092
 
3093
+ incrementBridgeMetric('bridge_start_count');
2258
3094
  await startOpenclawBridge(flags);
2259
3095
  }
2260
3096
 
@@ -2293,6 +3129,7 @@ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
2293
3129
  }
2294
3130
 
2295
3131
  if (action === 'restart') {
3132
+ incrementBridgeMetric('bridge_restart_count');
2296
3133
  const result = await stopBridgeProcesses();
2297
3134
  if (result.found.length > 0) {
2298
3135
  console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
@@ -2346,7 +3183,12 @@ async function main() {
2346
3183
  }
2347
3184
 
2348
3185
  if (command === 'openclaw' && subcommand === 'bridge') {
2349
- const bridgeAction = args.positionals[0] || 'start';
3186
+ const bridgeAction = String(args.positionals[0] || 'start').trim().toLowerCase();
3187
+ if (bridgeAction === 'service') {
3188
+ const serviceAction = args.positionals[1] || 'status';
3189
+ await handleBridgeServiceCommand(serviceAction, args.flags);
3190
+ return;
3191
+ }
2350
3192
  await handleBridgeLifecycleCommand(args.flags, bridgeAction);
2351
3193
  return;
2352
3194
  }
@@ -2390,7 +3232,24 @@ async function main() {
2390
3232
  process.exit(1);
2391
3233
  }
2392
3234
 
2393
- main().catch((err) => {
2394
- console.error(err instanceof Error ? err.message : err);
2395
- process.exit(1);
2396
- });
3235
+ const __currentFilePath = fileURLToPath(import.meta.url);
3236
+ const __invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
3237
+ const __isDirectExecution = Boolean(__invokedPath) && __invokedPath === path.resolve(__currentFilePath);
3238
+
3239
+ if (__isDirectExecution) {
3240
+ main().catch((err) => {
3241
+ console.error(err instanceof Error ? err.message : err);
3242
+ process.exit(1);
3243
+ });
3244
+ }
3245
+
3246
+ export {
3247
+ prepareGatewayFrameForLocalGateway,
3248
+ classifyBridgeFailure,
3249
+ computeReconnectDelayMs,
3250
+ extractGatewayRequestMeta,
3251
+ extractGatewayResponseMeta,
3252
+ isGatewayRunStartedFrame,
3253
+ isBridgeWorkerCommand,
3254
+ parsePositiveInteger,
3255
+ };
@@ -32,8 +32,22 @@ function parseAccounts(rawAccounts) {
32
32
  return accounts;
33
33
  }
34
34
 
35
+ function extractChannelConfig(cfg = {}) {
36
+ if (!cfg || typeof cfg !== 'object') return {};
37
+ if (cfg.channels && typeof cfg.channels === 'object' && cfg.channels[CHANNEL_ID] && typeof cfg.channels[CHANNEL_ID] === 'object') {
38
+ return cfg.channels[CHANNEL_ID];
39
+ }
40
+ if (cfg[CHANNEL_ID] && typeof cfg[CHANNEL_ID] === 'object') {
41
+ return cfg[CHANNEL_ID];
42
+ }
43
+ if (cfg.accounts && typeof cfg.accounts === 'object') {
44
+ return cfg;
45
+ }
46
+ return {};
47
+ }
48
+
35
49
  function normalizeConfig(cfg = {}) {
36
- const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
50
+ const channelConfig = extractChannelConfig(cfg);
37
51
  const configuredAccounts = parseAccounts(channelConfig.accounts);
38
52
  const accountIds = Object.keys(configuredAccounts);
39
53
  const defaultAccountId = toString(channelConfig.defaultAccountId, accountIds[0] || 'default');
@@ -125,6 +139,45 @@ function extractUserId(payload) {
125
139
  return '';
126
140
  }
127
141
 
142
+ function nextMessageId() {
143
+ return `oomi_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
144
+ }
145
+
146
+ function extractMessageId(payload) {
147
+ const candidates = [
148
+ payload?.messageId,
149
+ payload?.id,
150
+ payload?.requestId,
151
+ payload?.idempotencyKey,
152
+ payload?.metadata?.messageId,
153
+ payload?.metadata?.idempotencyKey,
154
+ ];
155
+
156
+ for (const candidate of candidates) {
157
+ const value = toString(candidate);
158
+ if (value) return value;
159
+ }
160
+
161
+ return nextMessageId();
162
+ }
163
+
164
+ function extractCorrelationId(payload) {
165
+ const candidates = [
166
+ payload?.correlationId,
167
+ payload?.metadata?.correlationId,
168
+ payload?.requestId,
169
+ payload?.messageId,
170
+ payload?.id,
171
+ ];
172
+
173
+ for (const candidate of candidates) {
174
+ const value = toString(candidate);
175
+ if (value) return value;
176
+ }
177
+
178
+ return '';
179
+ }
180
+
128
181
  async function postJson({ url, token, body, timeoutMs }) {
129
182
  const controller = new AbortController();
130
183
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -221,12 +274,16 @@ const oomiChannelPlugin = {
221
274
  const conversationKey = extractConversationKey(payload);
222
275
  const userId = extractUserId(payload);
223
276
  const sessionKey = toString(payload?.sessionKey || payload?.metadata?.sessionKey, account.defaultSessionKey);
277
+ const messageId = extractMessageId(payload);
278
+ const correlationId = extractCorrelationId(payload);
224
279
 
225
280
  const response = await postJson({
226
281
  url: `${account.backendUrl}/v1/channel/plugin/messages`,
227
282
  token: account.deviceToken,
228
283
  timeoutMs: account.requestTimeoutMs,
229
284
  body: {
285
+ messageId,
286
+ correlationId,
230
287
  conversationKey,
231
288
  userId,
232
289
  sessionKey,
@@ -234,15 +291,18 @@ const oomiChannelPlugin = {
234
291
  source: 'openclaw.channel',
235
292
  metadata: {
236
293
  accountId: resolvedAccountId,
294
+ correlationId,
237
295
  },
238
296
  },
239
297
  });
240
298
 
241
299
  if (!response.ok) {
242
300
  const reason = toString(response.payload?.error, `status ${response.status}`);
301
+ const code = toString(response.payload?.errorCode);
243
302
  return {
244
303
  ok: false,
245
- error: `oomi plugin message publish failed: ${reason}`,
304
+ error: `oomi plugin message publish failed: ${reason}${code ? ` (code=${code})` : ''}`,
305
+ code,
246
306
  };
247
307
  }
248
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.10",
3
+ "version": "0.2.13",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "scripts": {
49
49
  "check": "node --check bin/oomi-ai.js",
50
- "test": "node --test test/sessionBridgeState.test.mjs"
50
+ "test": "node --test test/*.test.mjs"
51
51
  },
52
52
  "dependencies": {
53
53
  "ws": "^8.19.0"
@@ -29,6 +29,7 @@ If the user asks to connect OpenClaw to Oomi voice/web:
29
29
  7. Ensure exactly one bridge worker is running (singleton handler):
30
30
  - `oomi openclaw bridge ensure --detach`
31
31
  - If stale/disconnected: `oomi openclaw bridge restart --detach`
32
+ - On macOS, prefer supervised mode: `oomi openclaw bridge service install`
32
33
  8. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
33
34
  - Use that URL directly in the pair command.
34
35
  - Example: `oomi openclaw pair --app-url <URL> --no-start`