oomi-ai 0.2.10 → 0.2.12

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;
@@ -902,11 +949,20 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
902
949
  return { frameText, waitForChallenge: false };
903
950
  }
904
951
 
905
- const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
952
+ const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
953
+ const params = {};
954
+
955
+ params.minProtocol = Number.isInteger(rawParams.minProtocol) && rawParams.minProtocol >= 1
956
+ ? rawParams.minProtocol
957
+ : 3;
958
+ params.maxProtocol = Number.isInteger(rawParams.maxProtocol) && rawParams.maxProtocol >= 1
959
+ ? rawParams.maxProtocol
960
+ : 3;
906
961
 
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() : '';
962
+ const clientInput = rawParams.client && typeof rawParams.client === 'object' ? rawParams.client : {};
963
+ const client = {};
964
+ const incomingClientId = typeof clientInput.id === 'string' ? clientInput.id.trim().toLowerCase() : '';
965
+ const incomingClientMode = typeof clientInput.mode === 'string' ? clientInput.mode.trim().toLowerCase() : '';
910
966
  const proxiedBrowserClient =
911
967
  incomingClientMode === 'webchat' ||
912
968
  incomingClientId === 'webchat-ui' ||
@@ -918,20 +974,32 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
918
974
  // so Control UI/webchat Origin checks don't reject proxied sessions.
919
975
  client.id = proxiedBrowserClient
920
976
  ? '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';
977
+ : (typeof clientInput.id === 'string' && clientInput.id.trim() ? clientInput.id.trim() : 'node-host');
978
+ client.version = typeof clientInput.version === 'string' && clientInput.version.trim() ? clientInput.version.trim() : '0.1.0';
923
979
  client.platform = proxiedBrowserClient
924
980
  ? process.platform
925
- : (typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform);
981
+ : (typeof clientInput.platform === 'string' && clientInput.platform.trim() ? clientInput.platform.trim() : process.platform);
926
982
  client.mode = proxiedBrowserClient
927
983
  ? 'backend'
928
- : (typeof client.mode === 'string' && client.mode.trim() ? client.mode.trim() : 'backend');
984
+ : (typeof clientInput.mode === 'string' && clientInput.mode.trim() ? clientInput.mode.trim() : 'backend');
985
+ if (typeof clientInput.displayName === 'string' && clientInput.displayName.trim()) {
986
+ client.displayName = clientInput.displayName.trim();
987
+ }
988
+ if (typeof clientInput.deviceFamily === 'string' && clientInput.deviceFamily.trim()) {
989
+ client.deviceFamily = clientInput.deviceFamily.trim();
990
+ }
991
+ if (typeof clientInput.modelIdentifier === 'string' && clientInput.modelIdentifier.trim()) {
992
+ client.modelIdentifier = clientInput.modelIdentifier.trim();
993
+ }
994
+ if (typeof clientInput.instanceId === 'string' && clientInput.instanceId.trim()) {
995
+ client.instanceId = clientInput.instanceId.trim();
996
+ }
929
997
  params.client = client;
930
998
 
931
- params.role = typeof params.role === 'string' && params.role.trim() ? params.role.trim() : 'operator';
999
+ params.role = typeof rawParams.role === 'string' && rawParams.role.trim() ? rawParams.role.trim() : 'operator';
932
1000
 
933
- const existingScopes = Array.isArray(params.scopes)
934
- ? params.scopes.filter((value) => typeof value === 'string' && value.trim())
1001
+ const existingScopes = Array.isArray(rawParams.scopes)
1002
+ ? rawParams.scopes.filter((value) => typeof value === 'string' && value.trim())
935
1003
  : [];
936
1004
  const requiredScopes = ['operator.read', 'operator.write'];
937
1005
  for (const scope of requiredScopes) {
@@ -941,14 +1009,28 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
941
1009
  }
942
1010
  params.scopes = existingScopes;
943
1011
 
944
- if (!Array.isArray(params.caps)) {
945
- params.caps = [];
946
- }
947
- if (!Array.isArray(params.commands)) {
948
- params.commands = [];
1012
+ params.caps = Array.isArray(rawParams.caps)
1013
+ ? rawParams.caps.filter((value) => typeof value === 'string' && value.trim())
1014
+ : [];
1015
+
1016
+ params.commands = Array.isArray(rawParams.commands)
1017
+ ? rawParams.commands.filter((value) => typeof value === 'string' && value.trim())
1018
+ : [];
1019
+
1020
+ if (rawParams.permissions && typeof rawParams.permissions === 'object') {
1021
+ const permissions = {};
1022
+ for (const [key, value] of Object.entries(rawParams.permissions)) {
1023
+ const normalizedKey = typeof key === 'string' ? key.trim() : '';
1024
+ if (!normalizedKey || typeof value !== 'boolean') continue;
1025
+ permissions[normalizedKey] = value;
1026
+ }
1027
+ if (Object.keys(permissions).length > 0) {
1028
+ params.permissions = permissions;
1029
+ }
949
1030
  }
950
- if (!params.permissions || typeof params.permissions !== 'object') {
951
- params.permissions = {};
1031
+
1032
+ if (typeof rawParams.pathEnv === 'string') {
1033
+ params.pathEnv = rawParams.pathEnv;
952
1034
  }
953
1035
 
954
1036
  const auth = {};
@@ -961,16 +1043,23 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
961
1043
  params.auth = auth;
962
1044
  }
963
1045
 
1046
+ if (typeof rawParams.locale === 'string' && rawParams.locale.trim()) {
1047
+ params.locale = rawParams.locale;
1048
+ }
1049
+ if (typeof rawParams.userAgent === 'string' && rawParams.userAgent.trim()) {
1050
+ params.userAgent = rawParams.userAgent;
1051
+ }
1052
+
964
1053
  if (deviceIdentity) {
965
1054
  if (!connectNonce) {
966
- return { frameText: null, waitForChallenge: true };
1055
+ return { frameText, waitForChallenge: true };
967
1056
  }
968
-
969
1057
  const signedAtMs = Date.now();
970
1058
  const tokenForSignature =
971
1059
  typeof auth.token === 'string' && auth.token.trim()
972
1060
  ? auth.token.trim()
973
1061
  : (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
1062
+ const nonceForSignature = connectNonce;
974
1063
 
975
1064
  const payload = buildDeviceAuthPayloadV3({
976
1065
  deviceId: deviceIdentity.deviceId,
@@ -980,7 +1069,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
980
1069
  scopes: existingScopes,
981
1070
  signedAtMs,
982
1071
  token: tokenForSignature,
983
- nonce: connectNonce,
1072
+ nonce: nonceForSignature,
984
1073
  platform: client.platform,
985
1074
  deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
986
1075
  });
@@ -990,7 +1079,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
990
1079
  publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
991
1080
  signature,
992
1081
  signedAt: signedAtMs,
993
- nonce: connectNonce,
1082
+ nonce: nonceForSignature,
994
1083
  };
995
1084
  }
996
1085
 
@@ -1009,6 +1098,53 @@ function parseJsonPayload(raw) {
1009
1098
  }
1010
1099
  }
1011
1100
 
1101
+ function extractCorrelationId(params) {
1102
+ if (!params || typeof params !== 'object') return '';
1103
+ if (typeof params.correlationId === 'string' && params.correlationId.trim()) {
1104
+ return params.correlationId.trim();
1105
+ }
1106
+ const metadata = params.metadata;
1107
+ if (metadata && typeof metadata === 'object' && typeof metadata.correlationId === 'string' && metadata.correlationId.trim()) {
1108
+ return metadata.correlationId.trim();
1109
+ }
1110
+ return '';
1111
+ }
1112
+
1113
+ function extractGatewayRequestMeta(frameText) {
1114
+ const payload = parseJsonPayload(frameText);
1115
+ if (!payload || typeof payload !== 'object') return null;
1116
+ if (payload.type !== 'req') return null;
1117
+ const requestId = typeof payload.id === 'string' ? payload.id.trim() : '';
1118
+ const method = typeof payload.method === 'string' ? payload.method.trim() : '';
1119
+ if (!requestId || !method) return null;
1120
+
1121
+ const params = payload.params && typeof payload.params === 'object' ? payload.params : {};
1122
+ const correlationId = extractCorrelationId(params);
1123
+ return { requestId, method, correlationId };
1124
+ }
1125
+
1126
+ function extractGatewayResponseMeta(frameText) {
1127
+ const payload = parseJsonPayload(frameText);
1128
+ if (!payload || typeof payload !== 'object') return null;
1129
+ if (payload.type !== 'res') return null;
1130
+ const requestId = typeof payload.id === 'string' ? payload.id.trim() : '';
1131
+ if (!requestId) return null;
1132
+ return {
1133
+ requestId,
1134
+ ok: payload.ok === true,
1135
+ };
1136
+ }
1137
+
1138
+ function isGatewayRunStartedFrame(frameText) {
1139
+ const payload = parseJsonPayload(frameText);
1140
+ if (!payload || typeof payload !== 'object') return false;
1141
+ if (payload.type !== 'event' || payload.event !== 'agent') return false;
1142
+ const body = payload.payload;
1143
+ if (!body || typeof body !== 'object') return false;
1144
+ if (body.stream !== 'lifecycle') return false;
1145
+ return body.data && typeof body.data === 'object' && body.data.phase === 'start';
1146
+ }
1147
+
1012
1148
  function bridgeNowIso() {
1013
1149
  return new Date().toISOString();
1014
1150
  }
@@ -1175,11 +1311,20 @@ function startBridgeDetachedProcess(rawFlags = {}) {
1175
1311
  }
1176
1312
 
1177
1313
  const args = buildBridgeDetachArgs(rawFlags);
1314
+ const logPath = resolveBridgeLiveLogPath();
1315
+ ensureDir(path.dirname(logPath));
1316
+ fs.appendFileSync(logPath, `[${new Date().toISOString()}] [bridge-supervisor] starting detached bridge\n`);
1317
+ const logFd = fs.openSync(logPath, 'a');
1178
1318
  const child = spawn(process.execPath, args, {
1179
1319
  detached: true,
1180
- stdio: 'ignore',
1320
+ stdio: ['ignore', logFd, logFd],
1181
1321
  });
1182
1322
  child.unref();
1323
+ try {
1324
+ fs.closeSync(logFd);
1325
+ } catch {
1326
+ // no-op
1327
+ }
1183
1328
  return {
1184
1329
  pid: child.pid,
1185
1330
  alreadyRunning: false,
@@ -1291,6 +1436,128 @@ async function stopBridgeProcesses() {
1291
1436
  };
1292
1437
  }
1293
1438
 
1439
+ function assertMacOSLaunchdAvailable() {
1440
+ if (process.platform !== 'darwin') {
1441
+ throw new Error('Bridge service manager is only supported on macOS (launchd).');
1442
+ }
1443
+ if (typeof process.getuid !== 'function') {
1444
+ throw new Error('Cannot resolve current UID for launchd domain.');
1445
+ }
1446
+ }
1447
+
1448
+ function launchctlDomain() {
1449
+ assertMacOSLaunchdAvailable();
1450
+ return `gui/${String(process.getuid())}`;
1451
+ }
1452
+
1453
+ function launchctlServiceTarget() {
1454
+ return `${launchctlDomain()}/${BRIDGE_LAUNCHD_LABEL}`;
1455
+ }
1456
+
1457
+ function runLaunchctl(args, { allowFailure = false } = {}) {
1458
+ const result = spawnSync('launchctl', args, {
1459
+ encoding: 'utf8',
1460
+ stdio: ['ignore', 'pipe', 'pipe'],
1461
+ });
1462
+ const status = Number.isInteger(result.status) ? result.status : 1;
1463
+ const stdout = String(result.stdout || '').trim();
1464
+ const stderr = String(result.stderr || '').trim();
1465
+ if (status !== 0 && !allowFailure) {
1466
+ throw new Error(
1467
+ `launchctl ${args.join(' ')} failed (${status}): ${stderr || stdout || 'unknown launchctl error'}`
1468
+ );
1469
+ }
1470
+ return { status, stdout, stderr };
1471
+ }
1472
+
1473
+ function buildBridgeLaunchAgentPlist() {
1474
+ const scriptPath = (() => {
1475
+ try {
1476
+ return fs.realpathSync(process.argv[1]);
1477
+ } catch {
1478
+ return process.argv[1];
1479
+ }
1480
+ })();
1481
+ const programArgs = [process.execPath, scriptPath, 'openclaw', 'bridge', 'start'];
1482
+ const bridgeLogPath = resolveBridgeLiveLogPath();
1483
+ const argsXml = programArgs.map((arg) => `<string>${xmlEscape(arg)}</string>`).join('\n ');
1484
+
1485
+ return `<?xml version="1.0" encoding="UTF-8"?>
1486
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1487
+ <plist version="1.0">
1488
+ <dict>
1489
+ <key>Label</key>
1490
+ <string>${xmlEscape(BRIDGE_LAUNCHD_LABEL)}</string>
1491
+ <key>ProgramArguments</key>
1492
+ <array>
1493
+ ${argsXml}
1494
+ </array>
1495
+ <key>WorkingDirectory</key>
1496
+ <string>${xmlEscape(os.homedir())}</string>
1497
+ <key>RunAtLoad</key>
1498
+ <true/>
1499
+ <key>KeepAlive</key>
1500
+ <true/>
1501
+ <key>ThrottleInterval</key>
1502
+ <integer>5</integer>
1503
+ <key>EnvironmentVariables</key>
1504
+ <dict>
1505
+ <key>OOMI_SKIP_UPDATE_CHECK</key>
1506
+ <string>1</string>
1507
+ </dict>
1508
+ <key>StandardOutPath</key>
1509
+ <string>${xmlEscape(bridgeLogPath)}</string>
1510
+ <key>StandardErrorPath</key>
1511
+ <string>${xmlEscape(bridgeLogPath)}</string>
1512
+ </dict>
1513
+ </plist>
1514
+ `;
1515
+ }
1516
+
1517
+ function readBridgeLaunchdStatus() {
1518
+ assertMacOSLaunchdAvailable();
1519
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
1520
+ const target = launchctlServiceTarget();
1521
+ const printResult = runLaunchctl(['print', target], { allowFailure: true });
1522
+ const loaded = printResult.status === 0;
1523
+ const output = [printResult.stdout, printResult.stderr].filter(Boolean).join('\n');
1524
+ const pidMatch = output.match(/\bpid\s*=\s*(\d+)/);
1525
+ const lastExitMatch = output.match(/\blast exit code\s*=\s*(-?\d+)/i);
1526
+
1527
+ return {
1528
+ plistPath,
1529
+ target,
1530
+ installed: fs.existsSync(plistPath),
1531
+ loaded,
1532
+ pid: pidMatch ? Number(pidMatch[1]) : null,
1533
+ running: Boolean(pidMatch && Number(pidMatch[1]) > 0),
1534
+ lastExitCode: lastExitMatch ? Number(lastExitMatch[1]) : null,
1535
+ printOutput: output,
1536
+ };
1537
+ }
1538
+
1539
+ function startBridgeLaunchdService() {
1540
+ assertMacOSLaunchdAvailable();
1541
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
1542
+ if (!fs.existsSync(plistPath)) {
1543
+ throw new Error('Bridge service is not installed. Run: oomi openclaw bridge service install');
1544
+ }
1545
+ const domain = launchctlDomain();
1546
+ const target = launchctlServiceTarget();
1547
+ runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
1548
+ runLaunchctl(['bootstrap', domain, plistPath]);
1549
+ runLaunchctl(['enable', target], { allowFailure: true });
1550
+ runLaunchctl(['kickstart', '-k', target], { allowFailure: true });
1551
+ }
1552
+
1553
+ async function stopBridgeLaunchdService() {
1554
+ assertMacOSLaunchdAvailable();
1555
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
1556
+ const domain = launchctlDomain();
1557
+ runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
1558
+ return stopBridgeProcesses();
1559
+ }
1560
+
1294
1561
  async function resolveBridgeRuntimeConfig(flags, bridgeState) {
1295
1562
  const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
1296
1563
  const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
@@ -1469,6 +1736,188 @@ async function startOpenclawBridge(flags) {
1469
1736
  );
1470
1737
  };
1471
1738
 
1739
+ const sendGatewayAck = (brokerSocket, {
1740
+ sessionId,
1741
+ requestId = '',
1742
+ method = '',
1743
+ correlationId = '',
1744
+ stage = 'unknown',
1745
+ }) => {
1746
+ if (!sessionId) return;
1747
+ if (requestId) {
1748
+ const sessionBridge = activeGatewaySockets.get(sessionId);
1749
+ if (sessionBridge && sessionBridge.pendingRequests instanceof Map) {
1750
+ const pending = sessionBridge.pendingRequests.get(requestId);
1751
+ if (pending) {
1752
+ pending.lastSuccessfulHop = stage;
1753
+ sessionBridge.pendingRequests.set(requestId, pending);
1754
+ }
1755
+ }
1756
+ }
1757
+ sendBrokerPayload(brokerSocket, {
1758
+ action: 'gateway_ack',
1759
+ type: 'gateway.ack',
1760
+ sessionId,
1761
+ requestId,
1762
+ method,
1763
+ correlationId,
1764
+ stage,
1765
+ ts: bridgeNowIso(),
1766
+ });
1767
+ };
1768
+
1769
+ const sendGatewayErrorResponse = (
1770
+ brokerSocket,
1771
+ {
1772
+ sessionId,
1773
+ requestMeta,
1774
+ code = 'gateway_error',
1775
+ message = 'Gateway request failed',
1776
+ lastSuccessfulHop = '',
1777
+ retryable = false,
1778
+ details = null,
1779
+ }
1780
+ ) => {
1781
+ if (!sessionId || !requestMeta || !requestMeta.requestId) return;
1782
+ const errorPayload = {
1783
+ code,
1784
+ message,
1785
+ correlationId: requestMeta.correlationId || '',
1786
+ };
1787
+ if (lastSuccessfulHop) {
1788
+ errorPayload.lastSuccessfulHop = lastSuccessfulHop;
1789
+ }
1790
+ if (retryable === true) {
1791
+ errorPayload.retryable = true;
1792
+ }
1793
+ if (details && typeof details === 'object') {
1794
+ errorPayload.details = details;
1795
+ }
1796
+ const responseFrame = {
1797
+ type: 'res',
1798
+ id: requestMeta.requestId,
1799
+ ok: false,
1800
+ error: errorPayload,
1801
+ };
1802
+ sendBrokerPayload(brokerSocket, {
1803
+ action: 'gateway_frame',
1804
+ type: 'gateway.frame',
1805
+ sessionId,
1806
+ frame: JSON.stringify(responseFrame),
1807
+ });
1808
+ };
1809
+
1810
+ const classifyGatewayClose = (code, reasonText) => {
1811
+ const reasonLower = String(reasonText || '').toLowerCase();
1812
+ if (code === 1008 && reasonLower.includes('invalid connect params')) {
1813
+ return {
1814
+ errorCode: 'gateway_invalid_connect_params',
1815
+ retryable: false,
1816
+ };
1817
+ }
1818
+ if (code === 1008) {
1819
+ return {
1820
+ errorCode: 'gateway_policy_violation',
1821
+ retryable: false,
1822
+ };
1823
+ }
1824
+ if (code === 1003 || code === 1002) {
1825
+ return {
1826
+ errorCode: 'gateway_protocol_error',
1827
+ retryable: false,
1828
+ };
1829
+ }
1830
+ if (code === 1006) {
1831
+ return {
1832
+ errorCode: 'gateway_abnormal_close',
1833
+ retryable: true,
1834
+ };
1835
+ }
1836
+ return {
1837
+ errorCode: 'gateway_closed',
1838
+ retryable: true,
1839
+ };
1840
+ };
1841
+
1842
+ const clearPendingRequestTimeout = (sessionBridge, requestId) => {
1843
+ if (!sessionBridge || !(sessionBridge.pendingRequestTimers instanceof Map)) return;
1844
+ const existingTimer = sessionBridge.pendingRequestTimers.get(requestId);
1845
+ if (existingTimer) {
1846
+ clearTimeout(existingTimer);
1847
+ sessionBridge.pendingRequestTimers.delete(requestId);
1848
+ }
1849
+ };
1850
+
1851
+ const clearAllPendingRequestTimeouts = (sessionBridge) => {
1852
+ if (!sessionBridge || !(sessionBridge.pendingRequestTimers instanceof Map)) return;
1853
+ for (const timer of sessionBridge.pendingRequestTimers.values()) {
1854
+ clearTimeout(timer);
1855
+ }
1856
+ sessionBridge.pendingRequestTimers.clear();
1857
+ };
1858
+
1859
+ const startPendingRequestTimeout = (brokerSocket, sessionId, sessionBridge, requestMeta) => {
1860
+ if (!sessionBridge || !requestMeta || !requestMeta.requestId) return;
1861
+ if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
1862
+ sessionBridge.pendingRequestTimers = new Map();
1863
+ }
1864
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
1865
+ const timer = setTimeout(() => {
1866
+ const pending = sessionBridge.pendingRequests instanceof Map
1867
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
1868
+ : null;
1869
+ if (!pending) {
1870
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
1871
+ return;
1872
+ }
1873
+
1874
+ if (requestMeta.method === 'connect') {
1875
+ incrementBridgeMetric('connect_timeout_count');
1876
+ } else if (requestMeta.method === 'chat.send') {
1877
+ incrementBridgeMetric('chat_send_timeout_count');
1878
+ } else {
1879
+ incrementBridgeMetric('gateway_request_timeout_count');
1880
+ }
1881
+
1882
+ const lastSuccessfulHop = typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
1883
+ ? pending.lastSuccessfulHop
1884
+ : 'bridge.forwarded';
1885
+ sendGatewayAck(brokerSocket, {
1886
+ sessionId,
1887
+ requestId: pending.requestId,
1888
+ method: pending.method,
1889
+ correlationId: pending.correlationId,
1890
+ stage: 'gateway.timeout',
1891
+ });
1892
+ sendBrokerPayload(brokerSocket, {
1893
+ action: 'log',
1894
+ type: 'log',
1895
+ sessionId,
1896
+ level: 'warn',
1897
+ message: `Gateway request timeout (${pending.method} ${pending.requestId}) after ${String(BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS)}ms`,
1898
+ });
1899
+ sendGatewayErrorResponse(brokerSocket, {
1900
+ sessionId,
1901
+ requestMeta: pending,
1902
+ code: 'gateway_timeout',
1903
+ message: `Gateway request timeout (${pending.method}) after ${String(BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS)}ms`,
1904
+ lastSuccessfulHop,
1905
+ retryable: true,
1906
+ details: {
1907
+ method: pending.method,
1908
+ timeoutMs: BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS,
1909
+ },
1910
+ });
1911
+
1912
+ if (sessionBridge.pendingRequests instanceof Map) {
1913
+ sessionBridge.pendingRequests.delete(pending.requestId);
1914
+ }
1915
+ clearPendingRequestTimeout(sessionBridge, pending.requestId);
1916
+ }, BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS);
1917
+
1918
+ sessionBridge.pendingRequestTimers.set(requestMeta.requestId, timer);
1919
+ };
1920
+
1472
1921
  const parseBrokerEnvelope = (raw) => {
1473
1922
  const payload = parseJsonPayload(raw);
1474
1923
  if (!payload) return null;
@@ -1496,6 +1945,7 @@ async function startOpenclawBridge(flags) {
1496
1945
  const scheduleReconnect = () => {
1497
1946
  if (reconnectState.stopped || reconnectState.timer) return;
1498
1947
  reconnectState.attempt += 1;
1948
+ incrementBridgeMetric('bridge_reconnect_scheduled_count');
1499
1949
  const failure =
1500
1950
  reconnectState.lastFailure ||
1501
1951
  classifyBridgeFailure({ reason: 'connection closed without classified error' });
@@ -1592,20 +2042,102 @@ async function startOpenclawBridge(flags) {
1592
2042
 
1593
2043
  clearChallengeTimer(sessionBridge);
1594
2044
  for (const pendingFrame of pending) {
2045
+ const requestMeta = extractGatewayRequestMeta(pendingFrame);
1595
2046
  const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
1596
2047
  connectNonce: sessionBridge.connectNonce,
1597
2048
  deviceIdentity: gatewayDeviceIdentity,
1598
2049
  });
1599
2050
  if (!prepared.frameText || prepared.waitForChallenge) {
2051
+ if (requestMeta) {
2052
+ const pending = sessionBridge.pendingRequests instanceof Map
2053
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
2054
+ : null;
2055
+ const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2056
+ ? pending.lastSuccessfulHop
2057
+ : 'bridge.waiting_for_challenge';
2058
+ sendGatewayAck(brokerSocket, {
2059
+ sessionId,
2060
+ requestId: requestMeta.requestId,
2061
+ method: requestMeta.method,
2062
+ correlationId: requestMeta.correlationId,
2063
+ stage: 'bridge.dropped',
2064
+ });
2065
+ sendGatewayErrorResponse(brokerSocket, {
2066
+ sessionId,
2067
+ requestMeta,
2068
+ code: 'bridge_dropped',
2069
+ message: 'Bridge dropped connect request after challenge handling.',
2070
+ lastSuccessfulHop,
2071
+ retryable: true,
2072
+ });
2073
+ if (sessionBridge.pendingRequests instanceof Map) {
2074
+ sessionBridge.pendingRequests.delete(requestMeta.requestId);
2075
+ }
2076
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
2077
+ }
1600
2078
  continue;
1601
2079
  }
1602
2080
  const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1603
2081
  if (result === 'queued') {
1604
2082
  console.log(`[bridge] client.frame queued after challenge ${sessionId}`);
2083
+ if (requestMeta) {
2084
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
2085
+ }
2086
+ if (requestMeta) {
2087
+ sendGatewayAck(brokerSocket, {
2088
+ sessionId,
2089
+ requestId: requestMeta.requestId,
2090
+ method: requestMeta.method,
2091
+ correlationId: requestMeta.correlationId,
2092
+ stage: 'bridge.queued',
2093
+ });
2094
+ }
1605
2095
  } else if (result === 'dropped') {
1606
2096
  console.log(`[bridge] client.frame dropped after challenge ${sessionId}`);
2097
+ incrementBridgeMetric('bridge_drop_count');
2098
+ if (requestMeta) {
2099
+ const pending = sessionBridge.pendingRequests instanceof Map
2100
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
2101
+ : null;
2102
+ const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2103
+ ? pending.lastSuccessfulHop
2104
+ : 'bridge.waiting_for_challenge';
2105
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
2106
+ if (sessionBridge.pendingRequests instanceof Map) {
2107
+ sessionBridge.pendingRequests.delete(requestMeta.requestId);
2108
+ }
2109
+ sendGatewayErrorResponse(brokerSocket, {
2110
+ sessionId,
2111
+ requestMeta,
2112
+ code: 'bridge_dropped',
2113
+ message: 'Bridge dropped request because gateway socket is not open.',
2114
+ lastSuccessfulHop,
2115
+ retryable: true,
2116
+ });
2117
+ }
2118
+ if (requestMeta) {
2119
+ sendGatewayAck(brokerSocket, {
2120
+ sessionId,
2121
+ requestId: requestMeta.requestId,
2122
+ method: requestMeta.method,
2123
+ correlationId: requestMeta.correlationId,
2124
+ stage: 'bridge.dropped',
2125
+ });
2126
+ }
1607
2127
  } else {
1608
2128
  console.log(`[bridge] client.frame sent after challenge ${sessionId}`);
2129
+ if (requestMeta) {
2130
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
2131
+ }
2132
+ if (requestMeta) {
2133
+ sendGatewayAck(brokerSocket, {
2134
+ sessionId,
2135
+ requestId: requestMeta.requestId,
2136
+ method: requestMeta.method,
2137
+ correlationId: requestMeta.correlationId,
2138
+ stage: 'bridge.forwarded',
2139
+ });
2140
+ }
1609
2141
  }
1610
2142
  }
1611
2143
  };
@@ -1619,11 +2151,21 @@ async function startOpenclawBridge(flags) {
1619
2151
  if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
1620
2152
  sessionBridge.pendingConnectFrames = [];
1621
2153
  }
2154
+ if (!(sessionBridge.pendingRequests instanceof Map)) {
2155
+ sessionBridge.pendingRequests = new Map();
2156
+ }
2157
+ if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
2158
+ sessionBridge.pendingRequestTimers = new Map();
2159
+ }
2160
+ if (typeof sessionBridge.lastChatCorrelationId !== 'string') {
2161
+ sessionBridge.lastChatCorrelationId = '';
2162
+ }
1622
2163
  let connectTimeout = setTimeout(() => {
1623
2164
  if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
1624
2165
  console.error(
1625
2166
  `[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
1626
2167
  );
2168
+ incrementBridgeMetric('gateway_connect_timeout_count');
1627
2169
  sendBrokerPayload(brokerSocket, {
1628
2170
  action: 'log',
1629
2171
  type: 'log',
@@ -1651,6 +2193,7 @@ async function startOpenclawBridge(flags) {
1651
2193
  const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
1652
2194
  const gatewayPayload = parseJsonPayload(frame);
1653
2195
  if (gatewayPayload?.event === 'connect.challenge') {
2196
+ console.log(`[bridge] gateway.connect.challenge ${sessionId}`);
1654
2197
  const nonce =
1655
2198
  gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
1656
2199
  ? gatewayPayload.payload.nonce.trim()
@@ -1674,6 +2217,35 @@ async function startOpenclawBridge(flags) {
1674
2217
  flushPendingConnectFrames(sessionId, sessionBridge);
1675
2218
  }
1676
2219
  }
2220
+
2221
+ const responseMeta = extractGatewayResponseMeta(frame);
2222
+ if (responseMeta && sessionBridge.pendingRequests instanceof Map) {
2223
+ const requestMeta = sessionBridge.pendingRequests.get(responseMeta.requestId);
2224
+ if (requestMeta) {
2225
+ clearPendingRequestTimeout(sessionBridge, responseMeta.requestId);
2226
+ sendGatewayAck(brokerSocket, {
2227
+ sessionId,
2228
+ requestId: requestMeta.requestId,
2229
+ method: requestMeta.method,
2230
+ correlationId: requestMeta.correlationId,
2231
+ stage: responseMeta.ok ? 'gateway.accepted' : 'gateway.rejected',
2232
+ });
2233
+ if (!responseMeta.ok) {
2234
+ incrementBridgeMetric('gateway_rejected_count');
2235
+ }
2236
+ sessionBridge.pendingRequests.delete(responseMeta.requestId);
2237
+ }
2238
+ }
2239
+
2240
+ if (isGatewayRunStartedFrame(frame)) {
2241
+ sendGatewayAck(brokerSocket, {
2242
+ sessionId,
2243
+ method: 'chat.send',
2244
+ correlationId: sessionBridge.lastChatCorrelationId || '',
2245
+ stage: 'run.started',
2246
+ });
2247
+ }
2248
+
1677
2249
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
1678
2250
  });
1679
2251
 
@@ -1684,9 +2256,41 @@ async function startOpenclawBridge(flags) {
1684
2256
  }
1685
2257
  clearChallengeTimer(sessionBridge);
1686
2258
  const reasonText = reason ? reason.toString() : '';
2259
+ const closeMeta = classifyGatewayClose(code, reasonText);
1687
2260
  console.log(
1688
2261
  `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
1689
2262
  );
2263
+ if (sessionBridge.pendingRequests instanceof Map) {
2264
+ for (const requestMeta of sessionBridge.pendingRequests.values()) {
2265
+ if (!requestMeta || typeof requestMeta !== 'object') continue;
2266
+ const lastSuccessfulHop = typeof requestMeta.lastSuccessfulHop === 'string' && requestMeta.lastSuccessfulHop
2267
+ ? requestMeta.lastSuccessfulHop
2268
+ : 'bridge.forwarded';
2269
+ sendGatewayAck(brokerSocket, {
2270
+ sessionId,
2271
+ requestId: requestMeta.requestId || '',
2272
+ method: requestMeta.method || '',
2273
+ correlationId: requestMeta.correlationId || '',
2274
+ stage: 'gateway.closed',
2275
+ });
2276
+ sendGatewayErrorResponse(brokerSocket, {
2277
+ sessionId,
2278
+ requestMeta,
2279
+ code: closeMeta.errorCode,
2280
+ message: reasonText
2281
+ ? `Gateway closed (${String(code)}): ${reasonText}`
2282
+ : `Gateway closed (${String(code)})`,
2283
+ lastSuccessfulHop,
2284
+ retryable: closeMeta.retryable,
2285
+ details: {
2286
+ closeCode: code,
2287
+ closeReason: reasonText,
2288
+ },
2289
+ });
2290
+ }
2291
+ sessionBridge.pendingRequests.clear();
2292
+ }
2293
+ clearAllPendingRequestTimeouts(sessionBridge);
1690
2294
  activeGatewaySockets.delete(sessionId);
1691
2295
  sendBrokerPayload(brokerSocket, {
1692
2296
  action: 'gateway_closed',
@@ -1843,6 +2447,22 @@ async function startOpenclawBridge(flags) {
1843
2447
  console.log(`[bridge] client.frame ${sessionId}`);
1844
2448
  const sessionBridge = getOrCreateGatewaySession(sessionId);
1845
2449
  if (!sessionBridge) return;
2450
+ const requestMeta = extractGatewayRequestMeta(frame);
2451
+ if (requestMeta) {
2452
+ if (!(sessionBridge.pendingRequests instanceof Map)) {
2453
+ sessionBridge.pendingRequests = new Map();
2454
+ }
2455
+ if (!(sessionBridge.pendingRequestTimers instanceof Map)) {
2456
+ sessionBridge.pendingRequestTimers = new Map();
2457
+ }
2458
+ sessionBridge.pendingRequests.set(requestMeta.requestId, {
2459
+ ...requestMeta,
2460
+ lastSuccessfulHop: 'broker.accepted',
2461
+ });
2462
+ if (requestMeta.method === 'chat.send' && requestMeta.correlationId) {
2463
+ sessionBridge.lastChatCorrelationId = requestMeta.correlationId;
2464
+ }
2465
+ }
1846
2466
  const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
1847
2467
  connectNonce: sessionBridge.connectNonce,
1848
2468
  deviceIdentity: gatewayDeviceIdentity,
@@ -1850,16 +2470,103 @@ async function startOpenclawBridge(flags) {
1850
2470
  if (prepared.waitForChallenge) {
1851
2471
  queueConnectUntilChallenge(sessionId, sessionBridge, frame);
1852
2472
  console.log(`[bridge] client.frame waiting for challenge ${sessionId}`);
2473
+ if (requestMeta) {
2474
+ sendGatewayAck(brokerSocket, {
2475
+ sessionId,
2476
+ requestId: requestMeta.requestId,
2477
+ method: requestMeta.method,
2478
+ correlationId: requestMeta.correlationId,
2479
+ stage: 'bridge.waiting_for_challenge',
2480
+ });
2481
+ }
1853
2482
  return;
1854
2483
  }
1855
2484
  if (!prepared.frameText) {
2485
+ if (requestMeta) {
2486
+ const pending = sessionBridge.pendingRequests instanceof Map
2487
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
2488
+ : null;
2489
+ const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2490
+ ? pending.lastSuccessfulHop
2491
+ : 'broker.accepted';
2492
+ sendGatewayAck(brokerSocket, {
2493
+ sessionId,
2494
+ requestId: requestMeta.requestId,
2495
+ method: requestMeta.method,
2496
+ correlationId: requestMeta.correlationId,
2497
+ stage: 'bridge.dropped',
2498
+ });
2499
+ sendGatewayErrorResponse(brokerSocket, {
2500
+ sessionId,
2501
+ requestMeta,
2502
+ code: 'bridge_dropped',
2503
+ message: 'Bridge dropped request before forwarding to gateway.',
2504
+ lastSuccessfulHop,
2505
+ retryable: true,
2506
+ });
2507
+ if (sessionBridge.pendingRequests instanceof Map) {
2508
+ sessionBridge.pendingRequests.delete(requestMeta.requestId);
2509
+ }
2510
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
2511
+ }
1856
2512
  return;
1857
2513
  }
1858
2514
  const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1859
2515
  if (result === 'queued') {
1860
2516
  console.log(`[bridge] client.frame queued ${sessionId}`);
2517
+ if (requestMeta) {
2518
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
2519
+ }
2520
+ if (requestMeta) {
2521
+ sendGatewayAck(brokerSocket, {
2522
+ sessionId,
2523
+ requestId: requestMeta.requestId,
2524
+ method: requestMeta.method,
2525
+ correlationId: requestMeta.correlationId,
2526
+ stage: 'bridge.queued',
2527
+ });
2528
+ }
1861
2529
  } else if (result === 'dropped') {
1862
2530
  console.log(`[bridge] client.frame dropped (socket not open) ${sessionId}`);
2531
+ incrementBridgeMetric('bridge_drop_count');
2532
+ if (requestMeta) {
2533
+ const pending = sessionBridge.pendingRequests instanceof Map
2534
+ ? sessionBridge.pendingRequests.get(requestMeta.requestId)
2535
+ : null;
2536
+ const lastSuccessfulHop = pending && typeof pending.lastSuccessfulHop === 'string' && pending.lastSuccessfulHop
2537
+ ? pending.lastSuccessfulHop
2538
+ : 'broker.accepted';
2539
+ clearPendingRequestTimeout(sessionBridge, requestMeta.requestId);
2540
+ if (sessionBridge.pendingRequests instanceof Map) {
2541
+ sessionBridge.pendingRequests.delete(requestMeta.requestId);
2542
+ }
2543
+ sendGatewayErrorResponse(brokerSocket, {
2544
+ sessionId,
2545
+ requestMeta,
2546
+ code: 'bridge_dropped',
2547
+ message: 'Bridge dropped request because gateway socket is not open.',
2548
+ lastSuccessfulHop,
2549
+ retryable: true,
2550
+ });
2551
+ }
2552
+ if (requestMeta) {
2553
+ sendGatewayAck(brokerSocket, {
2554
+ sessionId,
2555
+ requestId: requestMeta.requestId,
2556
+ method: requestMeta.method,
2557
+ correlationId: requestMeta.correlationId,
2558
+ stage: 'bridge.dropped',
2559
+ });
2560
+ }
2561
+ } else if (requestMeta) {
2562
+ startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
2563
+ sendGatewayAck(brokerSocket, {
2564
+ sessionId,
2565
+ requestId: requestMeta.requestId,
2566
+ method: requestMeta.method,
2567
+ correlationId: requestMeta.correlationId,
2568
+ stage: 'bridge.forwarded',
2569
+ });
1863
2570
  }
1864
2571
  return;
1865
2572
  }
@@ -1870,6 +2577,10 @@ async function startOpenclawBridge(flags) {
1870
2577
  const sessionBridge = activeGatewaySockets.get(sessionId);
1871
2578
  if (sessionBridge && sessionBridge.socket) {
1872
2579
  clearChallengeTimer(sessionBridge);
2580
+ if (sessionBridge.pendingRequests instanceof Map) {
2581
+ sessionBridge.pendingRequests.clear();
2582
+ }
2583
+ clearAllPendingRequestTimeouts(sessionBridge);
1873
2584
  activeGatewaySockets.delete(sessionId);
1874
2585
  sessionBridge.socket.close(1000, 'client_closed');
1875
2586
  }
@@ -1884,8 +2595,13 @@ async function startOpenclawBridge(flags) {
1884
2595
  }
1885
2596
  const reasonText = reason ? reason.toString() : '';
1886
2597
  console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
2598
+ incrementBridgeMetric('bridge_disconnect_count');
1887
2599
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
1888
2600
  clearChallengeTimer(sessionBridge);
2601
+ if (sessionBridge.pendingRequests instanceof Map) {
2602
+ sessionBridge.pendingRequests.clear();
2603
+ }
2604
+ clearAllPendingRequestTimeouts(sessionBridge);
1889
2605
  activeGatewaySockets.delete(sessionId);
1890
2606
  try {
1891
2607
  sessionBridge.socket.close(1001, 'broker_disconnected');
@@ -1907,6 +2623,7 @@ async function startOpenclawBridge(flags) {
1907
2623
  });
1908
2624
 
1909
2625
  brokerSocket.on('error', (err) => {
2626
+ incrementBridgeMetric('bridge_socket_error_count');
1910
2627
  reconnectState.lastFailure = classifyBridgeFailure({ err });
1911
2628
  console.error(
1912
2629
  `[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
@@ -2160,6 +2877,16 @@ function printOpenclawBridgeStatus(flags) {
2160
2877
  if (payload.runtime.hint) {
2161
2878
  console.log(`Hint: ${payload.runtime.hint}`);
2162
2879
  }
2880
+ if (payload.runtime.metrics && typeof payload.runtime.metrics === 'object') {
2881
+ const metrics = normalizeBridgeMetrics(payload.runtime.metrics);
2882
+ const metricPairs = Object.entries(metrics);
2883
+ if (metricPairs.length > 0) {
2884
+ console.log('Metrics:');
2885
+ for (const [name, value] of metricPairs) {
2886
+ console.log(` ${name}: ${value}`);
2887
+ }
2888
+ }
2889
+ }
2163
2890
  return;
2164
2891
  }
2165
2892
 
@@ -2234,27 +2961,109 @@ function printOpenclawPluginSetup(flags) {
2234
2961
  }
2235
2962
  }
2236
2963
 
2964
+ async function handleBridgeServiceCommand(actionRaw = '', flags = {}) {
2965
+ assertMacOSLaunchdAvailable();
2966
+ const action = String(actionRaw || 'status').trim().toLowerCase();
2967
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
2968
+
2969
+ if (action === 'install') {
2970
+ ensureDir(path.dirname(plistPath));
2971
+ writeFile(plistPath, buildBridgeLaunchAgentPlist());
2972
+ console.log(`Installed bridge launchd plist: ${plistPath}`);
2973
+ if (isTruthyFlag(flags['no-start'])) {
2974
+ console.log('Service install complete. Start with: oomi openclaw bridge service start');
2975
+ return;
2976
+ }
2977
+ startBridgeLaunchdService();
2978
+ incrementBridgeMetric('bridge_start_count');
2979
+ console.log(`Bridge service started: ${launchctlServiceTarget()}`);
2980
+ return;
2981
+ }
2982
+
2983
+ if (action === 'uninstall') {
2984
+ await stopBridgeLaunchdService();
2985
+ if (fs.existsSync(plistPath)) {
2986
+ fs.unlinkSync(plistPath);
2987
+ }
2988
+ console.log(`Removed bridge launchd plist: ${plistPath}`);
2989
+ return;
2990
+ }
2991
+
2992
+ if (action === 'start') {
2993
+ startBridgeLaunchdService();
2994
+ incrementBridgeMetric('bridge_start_count');
2995
+ console.log(`Bridge service started: ${launchctlServiceTarget()}`);
2996
+ return;
2997
+ }
2998
+
2999
+ if (action === 'stop') {
3000
+ const stopped = await stopBridgeLaunchdService();
3001
+ if (Array.isArray(stopped.found) && stopped.found.length > 0) {
3002
+ console.log(`Stopped bridge workers: ${stopped.stopped.join(', ') || 'none'}.`);
3003
+ } else {
3004
+ console.log('No bridge workers running.');
3005
+ }
3006
+ console.log(`Bridge service stopped: ${launchctlServiceTarget()}`);
3007
+ return;
3008
+ }
3009
+
3010
+ if (action === 'restart') {
3011
+ await stopBridgeLaunchdService();
3012
+ startBridgeLaunchdService();
3013
+ incrementBridgeMetric('bridge_restart_count');
3014
+ console.log(`Bridge service restarted: ${launchctlServiceTarget()}`);
3015
+ return;
3016
+ }
3017
+
3018
+ if (action === 'status') {
3019
+ const status = readBridgeLaunchdStatus();
3020
+ console.log('Bridge Service Status');
3021
+ console.log('---------------------');
3022
+ console.log(`Label: ${BRIDGE_LAUNCHD_LABEL}`);
3023
+ console.log(`Target: ${status.target}`);
3024
+ console.log(`Plist: ${status.plistPath}`);
3025
+ console.log(`Installed: ${status.installed ? 'yes' : 'no'}`);
3026
+ console.log(`Loaded: ${status.loaded ? 'yes' : 'no'}`);
3027
+ console.log(`Running: ${status.running ? 'yes' : 'no'}`);
3028
+ if (status.pid) {
3029
+ console.log(`PID: ${status.pid}`);
3030
+ }
3031
+ if (status.lastExitCode !== null) {
3032
+ console.log(`Last exit code: ${status.lastExitCode}`);
3033
+ }
3034
+ return;
3035
+ }
3036
+
3037
+ throw new Error(
3038
+ `Unknown bridge service action: ${action}. Use: oomi openclaw bridge service [install|start|stop|restart|status|uninstall]`
3039
+ );
3040
+ }
3041
+
2237
3042
  async function startBridgeLifecycle(flags = {}) {
2238
3043
  if (Boolean(flags.detach)) {
2239
3044
  const detachedFlags = { ...flags };
2240
3045
  delete detachedFlags.detach;
2241
3046
  const result = startBridgeDetachedProcess(detachedFlags);
2242
3047
  if (result.alreadyRunning) {
3048
+ incrementBridgeMetric('duplicate_start_attempt_count');
2243
3049
  console.log(`Bridge already running (pid: ${result.pid}).`);
2244
3050
  return;
2245
3051
  }
3052
+ incrementBridgeMetric('bridge_start_count');
2246
3053
  console.log(`Bridge started in background (pid: ${result.pid}).`);
2247
3054
  return;
2248
3055
  }
2249
3056
 
2250
3057
  const running = findRunningBridgeProcess();
2251
3058
  if (running) {
3059
+ incrementBridgeMetric('duplicate_start_attempt_count');
2252
3060
  console.log(
2253
3061
  `Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
2254
3062
  );
2255
3063
  return;
2256
3064
  }
2257
3065
 
3066
+ incrementBridgeMetric('bridge_start_count');
2258
3067
  await startOpenclawBridge(flags);
2259
3068
  }
2260
3069
 
@@ -2293,6 +3102,7 @@ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
2293
3102
  }
2294
3103
 
2295
3104
  if (action === 'restart') {
3105
+ incrementBridgeMetric('bridge_restart_count');
2296
3106
  const result = await stopBridgeProcesses();
2297
3107
  if (result.found.length > 0) {
2298
3108
  console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
@@ -2346,7 +3156,12 @@ async function main() {
2346
3156
  }
2347
3157
 
2348
3158
  if (command === 'openclaw' && subcommand === 'bridge') {
2349
- const bridgeAction = args.positionals[0] || 'start';
3159
+ const bridgeAction = String(args.positionals[0] || 'start').trim().toLowerCase();
3160
+ if (bridgeAction === 'service') {
3161
+ const serviceAction = args.positionals[1] || 'status';
3162
+ await handleBridgeServiceCommand(serviceAction, args.flags);
3163
+ return;
3164
+ }
2350
3165
  await handleBridgeLifecycleCommand(args.flags, bridgeAction);
2351
3166
  return;
2352
3167
  }
@@ -2390,7 +3205,24 @@ async function main() {
2390
3205
  process.exit(1);
2391
3206
  }
2392
3207
 
2393
- main().catch((err) => {
2394
- console.error(err instanceof Error ? err.message : err);
2395
- process.exit(1);
2396
- });
3208
+ const __currentFilePath = fileURLToPath(import.meta.url);
3209
+ const __invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
3210
+ const __isDirectExecution = Boolean(__invokedPath) && __invokedPath === path.resolve(__currentFilePath);
3211
+
3212
+ if (__isDirectExecution) {
3213
+ main().catch((err) => {
3214
+ console.error(err instanceof Error ? err.message : err);
3215
+ process.exit(1);
3216
+ });
3217
+ }
3218
+
3219
+ export {
3220
+ prepareGatewayFrameForLocalGateway,
3221
+ classifyBridgeFailure,
3222
+ computeReconnectDelayMs,
3223
+ extractGatewayRequestMeta,
3224
+ extractGatewayResponseMeta,
3225
+ isGatewayRunStartedFrame,
3226
+ isBridgeWorkerCommand,
3227
+ parsePositiveInteger,
3228
+ };
@@ -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.12",
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`