oomi-ai 0.2.7 → 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/bin/oomi-ai.js CHANGED
@@ -2,10 +2,11 @@
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
- import { spawn } from 'child_process';
5
+ 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
 
@@ -151,8 +157,10 @@ Commands:
151
157
  openclaw install
152
158
  Install agent instructions and the Oomi skill into OpenClaw.
153
159
 
154
- openclaw bridge
155
- Start local OpenClaw-to-Oomi managed broker bridge.
160
+ openclaw bridge [start|ensure|stop|restart|ps]
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).
@@ -204,8 +212,17 @@ function readFile(filePath) {
204
212
  return fs.readFileSync(filePath, 'utf-8');
205
213
  }
206
214
 
207
- function writeFile(filePath, content) {
208
- fs.writeFileSync(filePath, content);
215
+ function writeFile(filePath, content, options = undefined) {
216
+ fs.writeFileSync(filePath, content, options);
217
+ }
218
+
219
+ function xmlEscape(value) {
220
+ return String(value)
221
+ .replaceAll('&', '&')
222
+ .replaceAll('<', '&lt;')
223
+ .replaceAll('>', '&gt;')
224
+ .replaceAll('"', '&quot;')
225
+ .replaceAll("'", '&apos;');
209
226
  }
210
227
 
211
228
  function resolveWorkspace() {
@@ -558,6 +575,18 @@ function resolveBridgeStatusPath() {
558
575
  return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
559
576
  }
560
577
 
578
+ function resolveBridgeLockPath() {
579
+ return path.join(os.homedir(), '.openclaw', 'oomi-bridge.lock');
580
+ }
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
+
561
590
  function defaultDeviceId() {
562
591
  const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
563
592
  return `oomi-${host}-${randomUUID().slice(0, 8)}`;
@@ -608,6 +637,158 @@ function updateBridgeStatus(partial) {
608
637
  return next;
609
638
  }
610
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
+
662
+ function normalizePid(value) {
663
+ const pid = Number(value);
664
+ if (!Number.isInteger(pid) || pid <= 0) return null;
665
+ return pid;
666
+ }
667
+
668
+ function isPidAlive(pid) {
669
+ const normalized = normalizePid(pid);
670
+ if (!normalized) return false;
671
+ try {
672
+ process.kill(normalized, 0);
673
+ return true;
674
+ } catch {
675
+ return false;
676
+ }
677
+ }
678
+
679
+ function isBridgeWorkerCommand(command) {
680
+ const text = String(command || '').trim().toLowerCase();
681
+ if (!text.includes('openclaw bridge')) return false;
682
+ if (/\bopenclaw\s+bridge\s+(ps|stop|restart|ensure)\b/.test(text)) return false;
683
+ if (/\bopenclaw\s+bridge\s+start\b/.test(text)) return true;
684
+ if (/\bopenclaw\s+bridge(\s+--|$)/.test(text)) return true;
685
+ return false;
686
+ }
687
+
688
+ function isBridgeProcess(pid) {
689
+ const normalized = normalizePid(pid);
690
+ if (!normalized) return false;
691
+ if (!isPidAlive(normalized)) return false;
692
+
693
+ try {
694
+ const result = spawnSync('ps', ['-p', String(normalized), '-o', 'command='], {
695
+ encoding: 'utf8',
696
+ stdio: ['ignore', 'pipe', 'ignore'],
697
+ });
698
+ const command = String(result.stdout || '').trim();
699
+ if (!command) return true;
700
+ return isBridgeWorkerCommand(command);
701
+ } catch {
702
+ return true;
703
+ }
704
+ }
705
+
706
+ function readBridgeLock() {
707
+ return readJsonSafe(resolveBridgeLockPath()) || {};
708
+ }
709
+
710
+ function clearStaleBridgeLock() {
711
+ const lockPath = resolveBridgeLockPath();
712
+ if (!fs.existsSync(lockPath)) return;
713
+ const lock = readBridgeLock();
714
+ const lockPid = normalizePid(lock.pid);
715
+ if (lockPid && isBridgeProcess(lockPid)) return;
716
+ try {
717
+ fs.unlinkSync(lockPath);
718
+ } catch {
719
+ // no-op
720
+ }
721
+ }
722
+
723
+ function findRunningBridgeProcess() {
724
+ clearStaleBridgeLock();
725
+
726
+ const lock = readBridgeLock();
727
+ const lockPid = normalizePid(lock.pid);
728
+ if (lockPid && isBridgeProcess(lockPid)) {
729
+ return {
730
+ pid: lockPid,
731
+ source: 'lock',
732
+ deviceId: typeof lock.deviceId === 'string' ? lock.deviceId : '',
733
+ };
734
+ }
735
+
736
+ const status = readBridgeStatus();
737
+ const statusPid = normalizePid(status.pid);
738
+ if (statusPid && isBridgeProcess(statusPid)) {
739
+ return {
740
+ pid: statusPid,
741
+ source: 'status',
742
+ deviceId: typeof status.deviceId === 'string' ? status.deviceId : '',
743
+ };
744
+ }
745
+
746
+ return null;
747
+ }
748
+
749
+ function acquireBridgeLock(deviceId) {
750
+ const lockPath = resolveBridgeLockPath();
751
+ ensureDir(path.dirname(lockPath));
752
+ const payload = {
753
+ pid: process.pid,
754
+ deviceId,
755
+ acquiredAt: bridgeNowIso(),
756
+ };
757
+
758
+ const writeLock = () => writeFile(lockPath, JSON.stringify(payload, null, 2) + '\n', { flag: 'wx' });
759
+
760
+ try {
761
+ writeLock();
762
+ } catch (err) {
763
+ const code = err && typeof err === 'object' ? err.code : '';
764
+ if (code !== 'EEXIST') {
765
+ throw err;
766
+ }
767
+ clearStaleBridgeLock();
768
+ const existing = findRunningBridgeProcess();
769
+ if (existing && existing.pid !== process.pid) {
770
+ throw new Error(
771
+ `Bridge already running (pid ${existing.pid})${existing.deviceId ? ` for device ${existing.deviceId}` : ''}.`
772
+ );
773
+ }
774
+ writeLock();
775
+ }
776
+
777
+ const release = () => {
778
+ const current = readBridgeLock();
779
+ const currentPid = normalizePid(current.pid);
780
+ if (currentPid && currentPid !== process.pid) return;
781
+ try {
782
+ fs.unlinkSync(lockPath);
783
+ } catch {
784
+ // no-op
785
+ }
786
+ };
787
+
788
+ process.once('exit', release);
789
+ return release;
790
+ }
791
+
611
792
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
612
793
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
613
794
  method: 'POST',
@@ -768,11 +949,20 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
768
949
  return { frameText, waitForChallenge: false };
769
950
  }
770
951
 
771
- const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
952
+ const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
953
+ const params = {};
772
954
 
773
- const client = params.client && typeof params.client === 'object' ? params.client : {};
774
- const incomingClientId = typeof client.id === 'string' ? client.id.trim().toLowerCase() : '';
775
- const incomingClientMode = typeof client.mode === 'string' ? client.mode.trim().toLowerCase() : '';
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;
961
+
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() : '';
776
966
  const proxiedBrowserClient =
777
967
  incomingClientMode === 'webchat' ||
778
968
  incomingClientId === 'webchat-ui' ||
@@ -784,18 +974,32 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
784
974
  // so Control UI/webchat Origin checks don't reject proxied sessions.
785
975
  client.id = proxiedBrowserClient
786
976
  ? 'node-host'
787
- : (typeof client.id === 'string' && client.id.trim() ? client.id.trim() : 'node-host');
788
- client.version = typeof client.version === 'string' && client.version.trim() ? client.version.trim() : '0.1.0';
789
- client.platform = typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform;
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';
979
+ client.platform = proxiedBrowserClient
980
+ ? process.platform
981
+ : (typeof clientInput.platform === 'string' && clientInput.platform.trim() ? clientInput.platform.trim() : process.platform);
790
982
  client.mode = proxiedBrowserClient
791
983
  ? 'backend'
792
- : (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
+ }
793
997
  params.client = client;
794
998
 
795
- 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';
796
1000
 
797
- const existingScopes = Array.isArray(params.scopes)
798
- ? 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())
799
1003
  : [];
800
1004
  const requiredScopes = ['operator.read', 'operator.write'];
801
1005
  for (const scope of requiredScopes) {
@@ -805,14 +1009,28 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
805
1009
  }
806
1010
  params.scopes = existingScopes;
807
1011
 
808
- if (!Array.isArray(params.caps)) {
809
- params.caps = [];
810
- }
811
- if (!Array.isArray(params.commands)) {
812
- 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
+ }
813
1030
  }
814
- if (!params.permissions || typeof params.permissions !== 'object') {
815
- params.permissions = {};
1031
+
1032
+ if (typeof rawParams.pathEnv === 'string') {
1033
+ params.pathEnv = rawParams.pathEnv;
816
1034
  }
817
1035
 
818
1036
  const auth = {};
@@ -825,16 +1043,23 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
825
1043
  params.auth = auth;
826
1044
  }
827
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
+
828
1053
  if (deviceIdentity) {
829
1054
  if (!connectNonce) {
830
- return { frameText: null, waitForChallenge: true };
1055
+ return { frameText, waitForChallenge: true };
831
1056
  }
832
-
833
1057
  const signedAtMs = Date.now();
834
1058
  const tokenForSignature =
835
1059
  typeof auth.token === 'string' && auth.token.trim()
836
1060
  ? auth.token.trim()
837
1061
  : (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
1062
+ const nonceForSignature = connectNonce;
838
1063
 
839
1064
  const payload = buildDeviceAuthPayloadV3({
840
1065
  deviceId: deviceIdentity.deviceId,
@@ -844,7 +1069,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
844
1069
  scopes: existingScopes,
845
1070
  signedAtMs,
846
1071
  token: tokenForSignature,
847
- nonce: connectNonce,
1072
+ nonce: nonceForSignature,
848
1073
  platform: client.platform,
849
1074
  deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
850
1075
  });
@@ -854,7 +1079,7 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
854
1079
  publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
855
1080
  signature,
856
1081
  signedAt: signedAtMs,
857
- nonce: connectNonce,
1082
+ nonce: nonceForSignature,
858
1083
  };
859
1084
  }
860
1085
 
@@ -873,6 +1098,53 @@ function parseJsonPayload(raw) {
873
1098
  }
874
1099
  }
875
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
+
876
1148
  function bridgeNowIso() {
877
1149
  return new Date().toISOString();
878
1150
  }
@@ -1030,13 +1302,260 @@ function buildBridgeDetachArgs(rawFlags = {}) {
1030
1302
  }
1031
1303
 
1032
1304
  function startBridgeDetachedProcess(rawFlags = {}) {
1305
+ const existing = findRunningBridgeProcess();
1306
+ if (existing) {
1307
+ return {
1308
+ pid: existing.pid,
1309
+ alreadyRunning: true,
1310
+ };
1311
+ }
1312
+
1033
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');
1034
1318
  const child = spawn(process.execPath, args, {
1035
1319
  detached: true,
1036
- stdio: 'ignore',
1320
+ stdio: ['ignore', logFd, logFd],
1037
1321
  });
1038
1322
  child.unref();
1039
- return child.pid;
1323
+ try {
1324
+ fs.closeSync(logFd);
1325
+ } catch {
1326
+ // no-op
1327
+ }
1328
+ return {
1329
+ pid: child.pid,
1330
+ alreadyRunning: false,
1331
+ };
1332
+ }
1333
+
1334
+ function listBridgeProcessPids() {
1335
+ const pids = new Set();
1336
+ const addPid = (value) => {
1337
+ const pid = normalizePid(value);
1338
+ if (!pid || pid === process.pid) return;
1339
+ if (!isBridgeProcess(pid)) return;
1340
+ pids.add(pid);
1341
+ };
1342
+
1343
+ const lock = readBridgeLock();
1344
+ addPid(lock.pid);
1345
+
1346
+ const status = readBridgeStatus();
1347
+ addPid(status.pid);
1348
+
1349
+ try {
1350
+ const result = spawnSync('ps', ['-Ao', 'pid=,command='], {
1351
+ encoding: 'utf8',
1352
+ stdio: ['ignore', 'pipe', 'ignore'],
1353
+ });
1354
+ const output = String(result.stdout || '');
1355
+ for (const rawLine of output.split('\n')) {
1356
+ const line = rawLine.trim();
1357
+ if (!line) continue;
1358
+ const match = line.match(/^(\d+)\s+(.+)$/);
1359
+ if (!match) continue;
1360
+ const pid = Number(match[1]);
1361
+ const command = String(match[2] || '');
1362
+ if (!isBridgeWorkerCommand(command)) continue;
1363
+ addPid(pid);
1364
+ }
1365
+ } catch {
1366
+ // best-effort process scan
1367
+ }
1368
+
1369
+ return Array.from(pids).sort((a, b) => a - b);
1370
+ }
1371
+
1372
+ async function waitForBridgePidsToExit(pids, timeoutMs) {
1373
+ const deadline = Date.now() + timeoutMs;
1374
+ while (Date.now() < deadline) {
1375
+ const alive = pids.filter((pid) => isBridgeProcess(pid));
1376
+ if (alive.length === 0) return [];
1377
+ await new Promise((resolve) => setTimeout(resolve, 100));
1378
+ }
1379
+ return pids.filter((pid) => isBridgeProcess(pid));
1380
+ }
1381
+
1382
+ async function stopBridgeProcesses() {
1383
+ const targets = listBridgeProcessPids();
1384
+ if (targets.length === 0) {
1385
+ clearStaleBridgeLock();
1386
+ updateBridgeStatus({
1387
+ status: 'stopped',
1388
+ stopSignal: 'none',
1389
+ lastDisconnectAt: bridgeNowIso(),
1390
+ pid: null,
1391
+ });
1392
+ return {
1393
+ stopped: [],
1394
+ forceKilled: [],
1395
+ found: [],
1396
+ };
1397
+ }
1398
+
1399
+ for (const pid of targets) {
1400
+ try {
1401
+ process.kill(pid, 'SIGTERM');
1402
+ } catch {
1403
+ // no-op
1404
+ }
1405
+ }
1406
+
1407
+ let remaining = await waitForBridgePidsToExit(targets, 2500);
1408
+ const forceKilled = [];
1409
+ if (remaining.length > 0) {
1410
+ for (const pid of remaining) {
1411
+ try {
1412
+ process.kill(pid, 'SIGKILL');
1413
+ forceKilled.push(pid);
1414
+ } catch {
1415
+ // no-op
1416
+ }
1417
+ }
1418
+ remaining = await waitForBridgePidsToExit(remaining, 1000);
1419
+ }
1420
+
1421
+ clearStaleBridgeLock();
1422
+
1423
+ const stopped = targets.filter((pid) => !remaining.includes(pid));
1424
+ updateBridgeStatus({
1425
+ status: 'stopped',
1426
+ stopSignal: forceKilled.length > 0 ? 'SIGKILL' : 'SIGTERM',
1427
+ lastDisconnectAt: bridgeNowIso(),
1428
+ pid: null,
1429
+ });
1430
+
1431
+ return {
1432
+ stopped,
1433
+ forceKilled,
1434
+ found: targets,
1435
+ stillAlive: remaining,
1436
+ };
1437
+ }
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();
1040
1559
  }
1041
1560
 
1042
1561
  async function resolveBridgeRuntimeConfig(flags, bridgeState) {
@@ -1077,6 +1596,13 @@ async function resolveBridgeRuntimeConfig(flags, bridgeState) {
1077
1596
  }
1078
1597
 
1079
1598
  async function startOpenclawBridge(flags) {
1599
+ const runningBridge = findRunningBridgeProcess();
1600
+ if (runningBridge && runningBridge.pid !== process.pid) {
1601
+ throw new Error(
1602
+ `Bridge already running (pid ${runningBridge.pid})${runningBridge.deviceId ? ` for device ${runningBridge.deviceId}` : ''}.`
1603
+ );
1604
+ }
1605
+
1080
1606
  const bridgeState = readBridgeState();
1081
1607
  const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
1082
1608
  const brokerHttp = runtimeConfig.brokerHttp;
@@ -1084,6 +1610,7 @@ async function startOpenclawBridge(flags) {
1084
1610
  const deviceId = resolveDeviceId(flags, bridgeState);
1085
1611
  const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
1086
1612
  const explicitDeviceToken = String(flags['device-token'] || '').trim();
1613
+ const releaseBridgeLock = acquireBridgeLock(deviceId);
1087
1614
  let deviceToken = explicitDeviceToken;
1088
1615
  if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
1089
1616
  deviceToken = String(bridgeState.deviceToken || '').trim();
@@ -1209,6 +1736,188 @@ async function startOpenclawBridge(flags) {
1209
1736
  );
1210
1737
  };
1211
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
+
1212
1921
  const parseBrokerEnvelope = (raw) => {
1213
1922
  const payload = parseJsonPayload(raw);
1214
1923
  if (!payload) return null;
@@ -1236,6 +1945,7 @@ async function startOpenclawBridge(flags) {
1236
1945
  const scheduleReconnect = () => {
1237
1946
  if (reconnectState.stopped || reconnectState.timer) return;
1238
1947
  reconnectState.attempt += 1;
1948
+ incrementBridgeMetric('bridge_reconnect_scheduled_count');
1239
1949
  const failure =
1240
1950
  reconnectState.lastFailure ||
1241
1951
  classifyBridgeFailure({ reason: 'connection closed without classified error' });
@@ -1332,20 +2042,102 @@ async function startOpenclawBridge(flags) {
1332
2042
 
1333
2043
  clearChallengeTimer(sessionBridge);
1334
2044
  for (const pendingFrame of pending) {
2045
+ const requestMeta = extractGatewayRequestMeta(pendingFrame);
1335
2046
  const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
1336
2047
  connectNonce: sessionBridge.connectNonce,
1337
2048
  deviceIdentity: gatewayDeviceIdentity,
1338
2049
  });
1339
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
+ }
1340
2078
  continue;
1341
2079
  }
1342
2080
  const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1343
2081
  if (result === 'queued') {
1344
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
+ }
1345
2095
  } else if (result === 'dropped') {
1346
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
+ }
1347
2127
  } else {
1348
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
+ }
1349
2141
  }
1350
2142
  }
1351
2143
  };
@@ -1359,11 +2151,21 @@ async function startOpenclawBridge(flags) {
1359
2151
  if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
1360
2152
  sessionBridge.pendingConnectFrames = [];
1361
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
+ }
1362
2163
  let connectTimeout = setTimeout(() => {
1363
2164
  if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
1364
2165
  console.error(
1365
2166
  `[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
1366
2167
  );
2168
+ incrementBridgeMetric('gateway_connect_timeout_count');
1367
2169
  sendBrokerPayload(brokerSocket, {
1368
2170
  action: 'log',
1369
2171
  type: 'log',
@@ -1391,6 +2193,7 @@ async function startOpenclawBridge(flags) {
1391
2193
  const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
1392
2194
  const gatewayPayload = parseJsonPayload(frame);
1393
2195
  if (gatewayPayload?.event === 'connect.challenge') {
2196
+ console.log(`[bridge] gateway.connect.challenge ${sessionId}`);
1394
2197
  const nonce =
1395
2198
  gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
1396
2199
  ? gatewayPayload.payload.nonce.trim()
@@ -1414,6 +2217,35 @@ async function startOpenclawBridge(flags) {
1414
2217
  flushPendingConnectFrames(sessionId, sessionBridge);
1415
2218
  }
1416
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
+
1417
2249
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
1418
2250
  });
1419
2251
 
@@ -1424,9 +2256,41 @@ async function startOpenclawBridge(flags) {
1424
2256
  }
1425
2257
  clearChallengeTimer(sessionBridge);
1426
2258
  const reasonText = reason ? reason.toString() : '';
2259
+ const closeMeta = classifyGatewayClose(code, reasonText);
1427
2260
  console.log(
1428
2261
  `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
1429
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);
1430
2294
  activeGatewaySockets.delete(sessionId);
1431
2295
  sendBrokerPayload(brokerSocket, {
1432
2296
  action: 'gateway_closed',
@@ -1583,6 +2447,22 @@ async function startOpenclawBridge(flags) {
1583
2447
  console.log(`[bridge] client.frame ${sessionId}`);
1584
2448
  const sessionBridge = getOrCreateGatewaySession(sessionId);
1585
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
+ }
1586
2466
  const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
1587
2467
  connectNonce: sessionBridge.connectNonce,
1588
2468
  deviceIdentity: gatewayDeviceIdentity,
@@ -1590,16 +2470,103 @@ async function startOpenclawBridge(flags) {
1590
2470
  if (prepared.waitForChallenge) {
1591
2471
  queueConnectUntilChallenge(sessionId, sessionBridge, frame);
1592
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
+ }
1593
2482
  return;
1594
2483
  }
1595
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
+ }
1596
2512
  return;
1597
2513
  }
1598
2514
  const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1599
2515
  if (result === 'queued') {
1600
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
+ }
1601
2529
  } else if (result === 'dropped') {
1602
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
+ });
1603
2570
  }
1604
2571
  return;
1605
2572
  }
@@ -1610,6 +2577,10 @@ async function startOpenclawBridge(flags) {
1610
2577
  const sessionBridge = activeGatewaySockets.get(sessionId);
1611
2578
  if (sessionBridge && sessionBridge.socket) {
1612
2579
  clearChallengeTimer(sessionBridge);
2580
+ if (sessionBridge.pendingRequests instanceof Map) {
2581
+ sessionBridge.pendingRequests.clear();
2582
+ }
2583
+ clearAllPendingRequestTimeouts(sessionBridge);
1613
2584
  activeGatewaySockets.delete(sessionId);
1614
2585
  sessionBridge.socket.close(1000, 'client_closed');
1615
2586
  }
@@ -1624,8 +2595,13 @@ async function startOpenclawBridge(flags) {
1624
2595
  }
1625
2596
  const reasonText = reason ? reason.toString() : '';
1626
2597
  console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
2598
+ incrementBridgeMetric('bridge_disconnect_count');
1627
2599
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
1628
2600
  clearChallengeTimer(sessionBridge);
2601
+ if (sessionBridge.pendingRequests instanceof Map) {
2602
+ sessionBridge.pendingRequests.clear();
2603
+ }
2604
+ clearAllPendingRequestTimeouts(sessionBridge);
1629
2605
  activeGatewaySockets.delete(sessionId);
1630
2606
  try {
1631
2607
  sessionBridge.socket.close(1001, 'broker_disconnected');
@@ -1647,6 +2623,7 @@ async function startOpenclawBridge(flags) {
1647
2623
  });
1648
2624
 
1649
2625
  brokerSocket.on('error', (err) => {
2626
+ incrementBridgeMetric('bridge_socket_error_count');
1650
2627
  reconnectState.lastFailure = classifyBridgeFailure({ err });
1651
2628
  console.error(
1652
2629
  `[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
@@ -1671,6 +2648,7 @@ async function startOpenclawBridge(flags) {
1671
2648
  stopSignal: signal,
1672
2649
  pid: process.pid,
1673
2650
  });
2651
+ releaseBridgeLock();
1674
2652
  process.exit(0);
1675
2653
  };
1676
2654
  process.once('SIGINT', () => markStopped('SIGINT'));
@@ -1765,13 +2743,17 @@ async function pairAndStartOpenclawBridge(flags) {
1765
2743
  }
1766
2744
 
1767
2745
  if (detach) {
1768
- const pid = startBridgeDetachedProcess({
2746
+ const result = startBridgeDetachedProcess({
1769
2747
  'broker-http': managedConfig.brokerHttpUrl,
1770
2748
  'broker-ws': brokerWs,
1771
2749
  'device-id': deviceId,
1772
2750
  'device-token': deviceToken,
1773
2751
  });
1774
- console.log(`Bridge started in background (pid: ${pid}).`);
2752
+ if (result.alreadyRunning) {
2753
+ console.log(`Bridge already running (pid: ${result.pid}).`);
2754
+ return;
2755
+ }
2756
+ console.log(`Bridge started in background (pid: ${result.pid}).`);
1775
2757
  return;
1776
2758
  }
1777
2759
 
@@ -1895,6 +2877,16 @@ function printOpenclawBridgeStatus(flags) {
1895
2877
  if (payload.runtime.hint) {
1896
2878
  console.log(`Hint: ${payload.runtime.hint}`);
1897
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
+ }
1898
2890
  return;
1899
2891
  }
1900
2892
 
@@ -1969,6 +2961,169 @@ function printOpenclawPluginSetup(flags) {
1969
2961
  }
1970
2962
  }
1971
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
+
3042
+ async function startBridgeLifecycle(flags = {}) {
3043
+ if (Boolean(flags.detach)) {
3044
+ const detachedFlags = { ...flags };
3045
+ delete detachedFlags.detach;
3046
+ const result = startBridgeDetachedProcess(detachedFlags);
3047
+ if (result.alreadyRunning) {
3048
+ incrementBridgeMetric('duplicate_start_attempt_count');
3049
+ console.log(`Bridge already running (pid: ${result.pid}).`);
3050
+ return;
3051
+ }
3052
+ incrementBridgeMetric('bridge_start_count');
3053
+ console.log(`Bridge started in background (pid: ${result.pid}).`);
3054
+ return;
3055
+ }
3056
+
3057
+ const running = findRunningBridgeProcess();
3058
+ if (running) {
3059
+ incrementBridgeMetric('duplicate_start_attempt_count');
3060
+ console.log(
3061
+ `Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
3062
+ );
3063
+ return;
3064
+ }
3065
+
3066
+ incrementBridgeMetric('bridge_start_count');
3067
+ await startOpenclawBridge(flags);
3068
+ }
3069
+
3070
+ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
3071
+ const action = String(actionRaw || 'start').trim().toLowerCase();
3072
+
3073
+ if (action === 'start' || action === 'ensure') {
3074
+ await startBridgeLifecycle(flags);
3075
+ return;
3076
+ }
3077
+
3078
+ if (action === 'ps') {
3079
+ const pids = listBridgeProcessPids();
3080
+ if (pids.length === 0) {
3081
+ console.log('No bridge processes running.');
3082
+ return;
3083
+ }
3084
+ console.log(`Bridge processes: ${pids.join(', ')}`);
3085
+ return;
3086
+ }
3087
+
3088
+ if (action === 'stop') {
3089
+ const result = await stopBridgeProcesses();
3090
+ if (result.found.length === 0) {
3091
+ console.log('No bridge processes running.');
3092
+ return;
3093
+ }
3094
+ console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
3095
+ if (result.forceKilled.length > 0) {
3096
+ console.log(`Force-killed bridge processes: ${result.forceKilled.join(', ')}.`);
3097
+ }
3098
+ if (Array.isArray(result.stillAlive) && result.stillAlive.length > 0) {
3099
+ throw new Error(`Failed to stop bridge processes: ${result.stillAlive.join(', ')}`);
3100
+ }
3101
+ return;
3102
+ }
3103
+
3104
+ if (action === 'restart') {
3105
+ incrementBridgeMetric('bridge_restart_count');
3106
+ const result = await stopBridgeProcesses();
3107
+ if (result.found.length > 0) {
3108
+ console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
3109
+ if (result.forceKilled.length > 0) {
3110
+ console.log(`Force-killed bridge processes: ${result.forceKilled.join(', ')}.`);
3111
+ }
3112
+ } else {
3113
+ console.log('No existing bridge process found; starting fresh bridge.');
3114
+ }
3115
+ if (Array.isArray(result.stillAlive) && result.stillAlive.length > 0) {
3116
+ throw new Error(`Failed to stop bridge processes: ${result.stillAlive.join(', ')}`);
3117
+ }
3118
+ await startBridgeLifecycle(flags);
3119
+ return;
3120
+ }
3121
+
3122
+ throw new Error(
3123
+ `Unknown bridge action: ${action}. Use: oomi openclaw bridge [start|ensure|stop|restart|ps]`
3124
+ );
3125
+ }
3126
+
1972
3127
  async function main() {
1973
3128
  const args = parseArgs(process.argv);
1974
3129
  const command = args.command;
@@ -2001,14 +3156,13 @@ async function main() {
2001
3156
  }
2002
3157
 
2003
3158
  if (command === 'openclaw' && subcommand === 'bridge') {
2004
- if (Boolean(args.flags.detach)) {
2005
- const detachedFlags = { ...args.flags };
2006
- delete detachedFlags.detach;
2007
- const pid = startBridgeDetachedProcess(detachedFlags);
2008
- console.log(`Bridge started in background (pid: ${pid}).`);
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);
2009
3163
  return;
2010
3164
  }
2011
- await startOpenclawBridge(args.flags);
3165
+ await handleBridgeLifecycleCommand(args.flags, bridgeAction);
2012
3166
  return;
2013
3167
  }
2014
3168
 
@@ -2051,7 +3205,24 @@ async function main() {
2051
3205
  process.exit(1);
2052
3206
  }
2053
3207
 
2054
- main().catch((err) => {
2055
- console.error(err instanceof Error ? err.message : err);
2056
- process.exit(1);
2057
- });
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
+ };