oomi-ai 0.2.4 → 0.2.5

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.
Files changed (2) hide show
  1. package/bin/oomi-ai.js +511 -39
  2. package/package.json +1 -1
package/bin/oomi-ai.js CHANGED
@@ -4,6 +4,8 @@ import os from 'os';
4
4
  import path from 'path';
5
5
  import { spawn } from 'child_process';
6
6
  import { randomUUID } from 'crypto';
7
+ import net from 'net';
8
+ import { lookup as dnsLookup } from 'dns/promises';
7
9
  import WebSocket from 'ws';
8
10
  import { ensureSessionBridge, forwardFrameToSession, flushSessionQueue } from './sessionBridgeState.js';
9
11
 
@@ -14,6 +16,8 @@ const PACKAGE_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname
14
16
  const UPDATE_STATE_FILE = path.join(os.homedir(), '.openclaw', 'oomi-ai-update-check.json');
15
17
  const DEFAULT_UPDATE_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
16
18
  const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1200;
19
+ const BRIDGE_RECONNECT_BASE_MS = 2000;
20
+ const BRIDGE_RECONNECT_MAX_MS = 60000;
17
21
 
18
22
  function parsePositiveInteger(value, fallback) {
19
23
  const num = Number(value);
@@ -149,6 +153,9 @@ Commands:
149
153
  openclaw plugin
150
154
  Print OpenClaw extension install/config guidance for Oomi channel plugin.
151
155
 
156
+ openclaw status
157
+ Show bridge state + runtime health from local status files.
158
+
152
159
  personas sync
153
160
  Sync personas from the repo into the Oomi backend registry.
154
161
 
@@ -162,7 +169,7 @@ Common flags:
162
169
  --broker-http URL Managed broker HTTPS URL (for pair claim)
163
170
  --broker-ws URL Managed broker device WS URL (wss://.../cable)
164
171
  --pair-code CODE One-time pairing code from Oomi
165
- --app-url URL Oomi app URL used for pairing APIs (default: http://127.0.0.1:3456)
172
+ --app-url URL Oomi app URL used for pairing APIs; bridge can also refresh managed broker URLs from it
166
173
  --label TEXT Pairing label shown in broker logs
167
174
  --session-key KEY Session key used in generated connect URL
168
175
  --detach Start bridge in background and exit
@@ -537,6 +544,10 @@ function resolveBridgeStatePath() {
537
544
  return path.join(os.homedir(), '.openclaw', 'oomi-bridge.json');
538
545
  }
539
546
 
547
+ function resolveBridgeStatusPath() {
548
+ return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
549
+ }
550
+
540
551
  function defaultDeviceId() {
541
552
  const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
542
553
  return `oomi-${host}-${randomUUID().slice(0, 8)}`;
@@ -566,6 +577,27 @@ function writeBridgeState(nextState) {
566
577
  writeFile(statePath, JSON.stringify(nextState, null, 2) + '\n');
567
578
  }
568
579
 
580
+ function readBridgeStatus() {
581
+ return readJsonSafe(resolveBridgeStatusPath()) || {};
582
+ }
583
+
584
+ function writeBridgeStatus(nextStatus) {
585
+ const statusPath = resolveBridgeStatusPath();
586
+ ensureDir(path.dirname(statusPath));
587
+ writeFile(statusPath, JSON.stringify(nextStatus, null, 2) + '\n');
588
+ }
589
+
590
+ function updateBridgeStatus(partial) {
591
+ const current = readBridgeStatus();
592
+ const next = {
593
+ ...current,
594
+ ...partial,
595
+ updatedAt: new Date().toISOString(),
596
+ };
597
+ writeBridgeStatus(next);
598
+ return next;
599
+ }
600
+
569
601
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
570
602
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
571
603
  method: 'POST',
@@ -674,29 +706,219 @@ function parseJsonPayload(raw) {
674
706
  }
675
707
  }
676
708
 
709
+ function bridgeNowIso() {
710
+ return new Date().toISOString();
711
+ }
712
+
713
+ function extractErrorCode(err) {
714
+ if (!err || typeof err !== 'object') return '';
715
+ const value = err.code;
716
+ return typeof value === 'string' ? value.trim().toUpperCase() : '';
717
+ }
718
+
719
+ function extractErrorMessage(err) {
720
+ if (!err) return '';
721
+ if (typeof err === 'string') return err.trim();
722
+ if (err instanceof Error) return err.message.trim();
723
+ return String(err).trim();
724
+ }
725
+
726
+ function classifyBridgeFailure({ err, reason = '', code = '', forceClass = '' } = {}) {
727
+ const errorCode = (code || extractErrorCode(err)).toUpperCase();
728
+ const message = [extractErrorMessage(err), String(reason || '').trim()].filter(Boolean).join(' | ');
729
+ const text = `${errorCode} ${message}`.toLowerCase();
730
+
731
+ let failureClass = forceClass || 'unknown';
732
+ let retryable = true;
733
+ let hint = 'Check broker URL, network path, and bridge token.';
734
+ let baseDelayMs = BRIDGE_RECONNECT_BASE_MS;
735
+
736
+ if (
737
+ errorCode === 'ENOTFOUND' ||
738
+ errorCode === 'EAI_AGAIN' ||
739
+ text.includes('enotfound') ||
740
+ text.includes('name resolution') ||
741
+ text.includes('dns')
742
+ ) {
743
+ failureClass = 'dns_resolution';
744
+ hint = 'Host resolution failed. Verify DNS/network access to the broker host.';
745
+ baseDelayMs = 5000;
746
+ } else if (
747
+ text.includes('unauthorized') ||
748
+ text.includes('forbidden') ||
749
+ text.includes('invalid token') ||
750
+ text.includes('token expired') ||
751
+ text.includes('broker rejected')
752
+ ) {
753
+ failureClass = 'auth_rejected';
754
+ retryable = false;
755
+ hint = 'Bridge token is invalid/expired. Re-run: oomi openclaw pair --app-url <url>.';
756
+ baseDelayMs = BRIDGE_RECONNECT_MAX_MS;
757
+ } else if (
758
+ errorCode === 'ECONNREFUSED' ||
759
+ errorCode === 'ENETUNREACH' ||
760
+ errorCode === 'EHOSTUNREACH' ||
761
+ errorCode === 'ETIMEDOUT' ||
762
+ text.includes('socket hang up') ||
763
+ text.includes('abnormal closure')
764
+ ) {
765
+ failureClass = 'network';
766
+ hint = 'Broker network path is unavailable. Check connectivity/firewall/proxy settings.';
767
+ baseDelayMs = 3000;
768
+ }
769
+
770
+ return {
771
+ errorCode: errorCode || 'UNKNOWN',
772
+ message: message || 'unknown bridge error',
773
+ failureClass,
774
+ retryable,
775
+ hint,
776
+ baseDelayMs,
777
+ };
778
+ }
779
+
780
+ function computeReconnectDelayMs(attempt, baseDelayMs) {
781
+ const growth = Math.min(BRIDGE_RECONNECT_MAX_MS, Math.round(baseDelayMs * (2 ** Math.max(0, attempt - 1))));
782
+ const jitter = Math.floor(growth * (Math.random() * 0.25));
783
+ return Math.min(BRIDGE_RECONNECT_MAX_MS, growth + jitter);
784
+ }
785
+
786
+ async function assertTcpReachable(urlValue, timeoutMs = 1500) {
787
+ const url = new URL(urlValue);
788
+ const host = url.hostname || '127.0.0.1';
789
+ const port = Number(url.port || (url.protocol === 'wss:' || url.protocol === 'https:' ? 443 : 80));
790
+ if (!Number.isFinite(port) || port <= 0) {
791
+ throw new Error(`Invalid port in URL: ${urlValue}`);
792
+ }
793
+
794
+ await new Promise((resolve, reject) => {
795
+ const socket = net.connect({ host, port });
796
+ let finished = false;
797
+ const done = (fn) => (value) => {
798
+ if (finished) return;
799
+ finished = true;
800
+ clearTimeout(timer);
801
+ socket.destroy();
802
+ fn(value);
803
+ };
804
+ const timer = setTimeout(done(reject), timeoutMs, new Error(`TCP connect timeout (${host}:${port})`));
805
+ socket.once('connect', done(resolve));
806
+ socket.once('error', done(reject));
807
+ });
808
+ }
809
+
810
+ async function runBridgePreflight({ brokerWs, gatewayUrl, gatewayConfigPath }) {
811
+ let brokerUrl;
812
+ try {
813
+ brokerUrl = new URL(brokerWs);
814
+ } catch {
815
+ throw new Error(`Invalid broker WS URL: ${brokerWs}`);
816
+ }
817
+ if (brokerUrl.protocol !== 'ws:' && brokerUrl.protocol !== 'wss:') {
818
+ throw new Error(`Broker WS URL must use ws:// or wss:// (received ${brokerUrl.protocol})`);
819
+ }
820
+
821
+ const brokerHost = brokerUrl.hostname;
822
+ if (!brokerHost) {
823
+ throw new Error('Broker WS URL is missing hostname.');
824
+ }
825
+ await dnsLookup(brokerHost);
826
+
827
+ let parsedGatewayUrl;
828
+ try {
829
+ parsedGatewayUrl = new URL(gatewayUrl);
830
+ } catch {
831
+ throw new Error(`Invalid local gateway URL (${gatewayUrl}) from ${gatewayConfigPath}`);
832
+ }
833
+ if (parsedGatewayUrl.protocol !== 'ws:') {
834
+ throw new Error(`Local gateway URL must use ws:// (received ${parsedGatewayUrl.protocol})`);
835
+ }
836
+ await assertTcpReachable(parsedGatewayUrl.toString());
837
+ }
838
+
839
+ function buildBridgeDetachArgs(rawFlags = {}) {
840
+ const orderedKeys = [
841
+ 'broker-http',
842
+ 'broker-ws',
843
+ 'pair-code',
844
+ 'app-url',
845
+ 'device-id',
846
+ 'device-token',
847
+ ];
848
+ const args = [process.argv[1], 'openclaw', 'bridge'];
849
+
850
+ for (const key of orderedKeys) {
851
+ const value = rawFlags[key];
852
+ if (value === undefined || value === null || value === false) continue;
853
+ if (value === true) {
854
+ args.push(`--${key}`);
855
+ continue;
856
+ }
857
+ const text = String(value).trim();
858
+ if (!text) continue;
859
+ args.push(`--${key}`, text);
860
+ }
861
+
862
+ return args;
863
+ }
864
+
865
+ function startBridgeDetachedProcess(rawFlags = {}) {
866
+ const args = buildBridgeDetachArgs(rawFlags);
867
+ const child = spawn(process.execPath, args, {
868
+ detached: true,
869
+ stdio: 'ignore',
870
+ });
871
+ child.unref();
872
+ return child.pid;
873
+ }
874
+
875
+ async function resolveBridgeRuntimeConfig(flags, bridgeState) {
876
+ const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
877
+ const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
878
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || '').trim();
879
+
880
+ let brokerHttp = String(explicitBrokerHttp || process.env.OOMI_CHAT_BROKER_HTTP_URL || bridgeState.brokerHttp || '').trim();
881
+ let brokerWs = String(explicitBrokerWs || process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL || bridgeState.brokerWs || '').trim();
882
+ let managedConfigUsed = false;
883
+ let managedConfigError = '';
884
+
885
+ if (appUrl && (!explicitBrokerHttp || !explicitBrokerWs)) {
886
+ try {
887
+ const managedConfig = await fetchManagedGatewayConfig({ appUrl });
888
+ managedConfigUsed = true;
889
+ if (!explicitBrokerHttp) {
890
+ brokerHttp = String(managedConfig.brokerHttpUrl || '').trim();
891
+ }
892
+ if (!explicitBrokerWs) {
893
+ brokerWs = String(managedConfig.brokerDeviceWsUrl || '').trim();
894
+ }
895
+ } catch (err) {
896
+ managedConfigError = extractErrorMessage(err);
897
+ if (!brokerWs) {
898
+ throw err;
899
+ }
900
+ }
901
+ }
902
+
903
+ return {
904
+ appUrl,
905
+ brokerHttp,
906
+ brokerWs,
907
+ managedConfigUsed,
908
+ managedConfigError,
909
+ };
910
+ }
911
+
677
912
  async function startOpenclawBridge(flags) {
678
913
  const bridgeState = readBridgeState();
679
- const brokerHttp = String(
680
- flags['broker-http'] ||
681
- process.env.OOMI_CHAT_BROKER_HTTP_URL ||
682
- bridgeState.brokerHttp ||
683
- ''
684
- ).trim();
685
- const brokerWs = String(
686
- flags['broker-ws'] ||
687
- process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL ||
688
- bridgeState.brokerWs ||
689
- ''
690
- ).trim();
914
+ const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
915
+ const brokerHttp = runtimeConfig.brokerHttp;
916
+ const brokerWs = runtimeConfig.brokerWs;
691
917
  const deviceId = resolveDeviceId(flags, bridgeState);
692
918
  const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
693
919
  const explicitDeviceToken = String(flags['device-token'] || '').trim();
694
- const canReuseStateToken =
695
- String(bridgeState.deviceId || '').trim() === deviceId &&
696
- String(bridgeState.brokerWs || '').trim() === brokerWs &&
697
- String(bridgeState.brokerHttp || '').trim() === brokerHttp;
698
920
  let deviceToken = explicitDeviceToken;
699
- if (!deviceToken && canReuseStateToken) {
921
+ if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
700
922
  deviceToken = String(bridgeState.deviceToken || '').trim();
701
923
  }
702
924
 
@@ -731,11 +953,64 @@ async function startOpenclawBridge(flags) {
731
953
  throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
732
954
  }
733
955
 
956
+ if (runtimeConfig.managedConfigUsed && runtimeConfig.appUrl) {
957
+ console.log(`[bridge] refreshed broker URLs from ${runtimeConfig.appUrl}`);
958
+ } else if (runtimeConfig.managedConfigError) {
959
+ console.warn(
960
+ `[bridge] failed to refresh broker URLs from app URL; using local/state broker config (${runtimeConfig.managedConfigError})`
961
+ );
962
+ }
963
+
964
+ try {
965
+ await runBridgePreflight({
966
+ brokerWs,
967
+ gatewayUrl: gateway.gatewayUrl,
968
+ gatewayConfigPath: gateway.configPath,
969
+ });
970
+ } catch (err) {
971
+ const failure = classifyBridgeFailure({ err });
972
+ updateBridgeStatus({
973
+ status: 'error',
974
+ deviceId,
975
+ brokerWs,
976
+ brokerHttp,
977
+ gatewayUrl: gateway.gatewayUrl,
978
+ lastDisconnectAt: bridgeNowIso(),
979
+ lastErrorCode: failure.errorCode,
980
+ lastErrorClass: failure.failureClass,
981
+ lastErrorMessage: failure.message,
982
+ hint: failure.hint,
983
+ consecutiveFailures: 0,
984
+ pid: process.pid,
985
+ });
986
+ throw new Error(`Bridge preflight failed (${failure.failureClass}): ${failure.message}. ${failure.hint}`);
987
+ }
988
+
989
+ updateBridgeStatus({
990
+ status: 'starting',
991
+ deviceId,
992
+ brokerWs,
993
+ brokerHttp,
994
+ gatewayUrl: gateway.gatewayUrl,
995
+ lastErrorCode: '',
996
+ lastErrorClass: '',
997
+ lastErrorMessage: '',
998
+ consecutiveFailures: 0,
999
+ pid: process.pid,
1000
+ startedAt: bridgeNowIso(),
1001
+ });
1002
+
734
1003
  console.log(`Starting OpenClaw bridge: device=${deviceId}`);
735
1004
  console.log(`Local gateway: ${gateway.gatewayUrl}`);
736
1005
  console.log(`Broker WS: ${brokerWs}`);
737
1006
 
738
1007
  const activeGatewaySockets = new Map();
1008
+ const reconnectState = {
1009
+ attempt: 0,
1010
+ timer: null,
1011
+ stopped: false,
1012
+ lastFailure: null,
1013
+ };
739
1014
  const brokerPath = (() => {
740
1015
  try {
741
1016
  return new URL(brokerWs).pathname || '';
@@ -785,6 +1060,39 @@ async function startOpenclawBridge(flags) {
785
1060
  return null;
786
1061
  };
787
1062
 
1063
+ const scheduleReconnect = () => {
1064
+ if (reconnectState.stopped || reconnectState.timer) return;
1065
+ reconnectState.attempt += 1;
1066
+ const failure =
1067
+ reconnectState.lastFailure ||
1068
+ classifyBridgeFailure({ reason: 'connection closed without classified error' });
1069
+ const delayMs = computeReconnectDelayMs(reconnectState.attempt, failure.baseDelayMs);
1070
+
1071
+ console.warn(
1072
+ `[bridge] reconnect scheduled in ${delayMs}ms (attempt ${reconnectState.attempt}, class=${failure.failureClass}, code=${failure.errorCode})`
1073
+ );
1074
+
1075
+ updateBridgeStatus({
1076
+ status: 'reconnecting',
1077
+ deviceId,
1078
+ brokerWs,
1079
+ brokerHttp,
1080
+ gatewayUrl: gateway.gatewayUrl,
1081
+ lastDisconnectAt: bridgeNowIso(),
1082
+ lastErrorCode: failure.errorCode,
1083
+ lastErrorClass: failure.failureClass,
1084
+ lastErrorMessage: failure.message,
1085
+ hint: failure.hint,
1086
+ consecutiveFailures: reconnectState.attempt,
1087
+ pid: process.pid,
1088
+ });
1089
+
1090
+ reconnectState.timer = setTimeout(() => {
1091
+ reconnectState.timer = null;
1092
+ connectBroker();
1093
+ }, delayMs);
1094
+ };
1095
+
788
1096
  const connectBroker = () => {
789
1097
  const wsUrl = new URL(brokerWs);
790
1098
  wsUrl.searchParams.set('token', deviceToken);
@@ -846,6 +1154,8 @@ async function startOpenclawBridge(flags) {
846
1154
 
847
1155
  brokerSocket.on('open', () => {
848
1156
  console.log('[bridge] Connected to managed broker.');
1157
+ reconnectState.attempt = 0;
1158
+ reconnectState.lastFailure = null;
849
1159
  if (!actionCableMode) return;
850
1160
  brokerSocket.send(
851
1161
  JSON.stringify({
@@ -856,6 +1166,20 @@ async function startOpenclawBridge(flags) {
856
1166
  actionCableHeartbeat = setInterval(() => {
857
1167
  sendBrokerPayload(brokerSocket, { action: 'heartbeat' });
858
1168
  }, 15000);
1169
+ updateBridgeStatus({
1170
+ status: 'connected',
1171
+ deviceId,
1172
+ brokerWs,
1173
+ brokerHttp,
1174
+ gatewayUrl: gateway.gatewayUrl,
1175
+ lastConnectedAt: bridgeNowIso(),
1176
+ lastErrorCode: '',
1177
+ lastErrorClass: '',
1178
+ lastErrorMessage: '',
1179
+ hint: '',
1180
+ consecutiveFailures: 0,
1181
+ pid: process.pid,
1182
+ });
859
1183
  });
860
1184
 
861
1185
  brokerSocket.on('message', (rawData) => {
@@ -868,12 +1192,61 @@ async function startOpenclawBridge(flags) {
868
1192
  }
869
1193
 
870
1194
  if (payload.type === 'broker.disconnect') {
1195
+ reconnectState.lastFailure = classifyBridgeFailure({
1196
+ reason: String(payload.reason || 'unauthorized'),
1197
+ forceClass: 'auth_rejected',
1198
+ });
1199
+ reconnectState.stopped = true;
871
1200
  console.error(`[bridge] Broker rejected connection: ${String(payload.reason || 'unauthorized')}`);
1201
+ console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
1202
+ updateBridgeStatus({
1203
+ status: 'error',
1204
+ deviceId,
1205
+ brokerWs,
1206
+ brokerHttp,
1207
+ gatewayUrl: gateway.gatewayUrl,
1208
+ lastDisconnectAt: bridgeNowIso(),
1209
+ lastErrorCode: reconnectState.lastFailure.errorCode,
1210
+ lastErrorClass: reconnectState.lastFailure.failureClass,
1211
+ lastErrorMessage: reconnectState.lastFailure.message,
1212
+ hint: reconnectState.lastFailure.hint,
1213
+ consecutiveFailures: reconnectState.attempt + 1,
1214
+ pid: process.pid,
1215
+ });
1216
+ try {
1217
+ brokerSocket.close(4001, 'auth_rejected');
1218
+ } catch {
1219
+ // no-op
1220
+ }
872
1221
  return;
873
1222
  }
874
1223
 
875
1224
  if (payload.type === 'broker.reject_subscription') {
876
1225
  console.error('[bridge] Broker rejected DeviceChannel subscription.');
1226
+ reconnectState.lastFailure = classifyBridgeFailure({
1227
+ reason: 'broker rejected DeviceChannel subscription',
1228
+ forceClass: 'auth_rejected',
1229
+ });
1230
+ reconnectState.stopped = true;
1231
+ updateBridgeStatus({
1232
+ status: 'error',
1233
+ deviceId,
1234
+ brokerWs,
1235
+ brokerHttp,
1236
+ gatewayUrl: gateway.gatewayUrl,
1237
+ lastDisconnectAt: bridgeNowIso(),
1238
+ lastErrorCode: reconnectState.lastFailure.errorCode,
1239
+ lastErrorClass: reconnectState.lastFailure.failureClass,
1240
+ lastErrorMessage: reconnectState.lastFailure.message,
1241
+ hint: reconnectState.lastFailure.hint,
1242
+ consecutiveFailures: reconnectState.attempt + 1,
1243
+ pid: process.pid,
1244
+ });
1245
+ try {
1246
+ brokerSocket.close(4002, 'subscription_rejected');
1247
+ } catch {
1248
+ // no-op
1249
+ }
877
1250
  return;
878
1251
  }
879
1252
 
@@ -924,7 +1297,8 @@ async function startOpenclawBridge(flags) {
924
1297
  clearInterval(actionCableHeartbeat);
925
1298
  actionCableHeartbeat = null;
926
1299
  }
927
- console.log(`[bridge] Broker disconnected (${code}) ${reason.toString()}`);
1300
+ const reasonText = reason ? reason.toString() : '';
1301
+ console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
928
1302
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
929
1303
  activeGatewaySockets.delete(sessionId);
930
1304
  try {
@@ -933,13 +1307,48 @@ async function startOpenclawBridge(flags) {
933
1307
  // no-op
934
1308
  }
935
1309
  }
936
- setTimeout(connectBroker, 2000);
1310
+
1311
+ if (reconnectState.stopped) {
1312
+ return;
1313
+ }
1314
+
1315
+ if (!reconnectState.lastFailure) {
1316
+ reconnectState.lastFailure = classifyBridgeFailure({
1317
+ reason: `socket closed code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`,
1318
+ });
1319
+ }
1320
+ scheduleReconnect();
937
1321
  });
938
1322
 
939
1323
  brokerSocket.on('error', (err) => {
940
- console.error('[bridge] Broker socket error:', err);
1324
+ reconnectState.lastFailure = classifyBridgeFailure({ err });
1325
+ console.error(
1326
+ `[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
1327
+ );
1328
+ console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
1329
+ });
1330
+ };
1331
+
1332
+ const markStopped = (signal) => {
1333
+ reconnectState.stopped = true;
1334
+ if (reconnectState.timer) {
1335
+ clearTimeout(reconnectState.timer);
1336
+ reconnectState.timer = null;
1337
+ }
1338
+ updateBridgeStatus({
1339
+ status: 'stopped',
1340
+ deviceId,
1341
+ brokerWs,
1342
+ brokerHttp,
1343
+ gatewayUrl: gateway.gatewayUrl,
1344
+ lastDisconnectAt: bridgeNowIso(),
1345
+ stopSignal: signal,
1346
+ pid: process.pid,
941
1347
  });
1348
+ process.exit(0);
942
1349
  };
1350
+ process.once('SIGINT', () => markStopped('SIGINT'));
1351
+ process.once('SIGTERM', () => markStopped('SIGTERM'));
943
1352
 
944
1353
  connectBroker();
945
1354
  await new Promise(() => {});
@@ -1030,25 +1439,13 @@ async function pairAndStartOpenclawBridge(flags) {
1030
1439
  }
1031
1440
 
1032
1441
  if (detach) {
1033
- const args = [
1034
- process.argv[1],
1035
- 'openclaw',
1036
- 'bridge',
1037
- '--broker-http',
1038
- managedConfig.brokerHttpUrl,
1039
- '--broker-ws',
1040
- brokerWs,
1041
- '--device-id',
1042
- deviceId,
1043
- '--device-token',
1044
- deviceToken,
1045
- ];
1046
- const child = spawn(process.execPath, args, {
1047
- detached: true,
1048
- stdio: 'ignore',
1442
+ const pid = startBridgeDetachedProcess({
1443
+ 'broker-http': managedConfig.brokerHttpUrl,
1444
+ 'broker-ws': brokerWs,
1445
+ 'device-id': deviceId,
1446
+ 'device-token': deviceToken,
1049
1447
  });
1050
- child.unref();
1051
- console.log(`Bridge started in background (pid: ${child.pid}).`);
1448
+ console.log(`Bridge started in background (pid: ${pid}).`);
1052
1449
  return;
1053
1450
  }
1054
1451
 
@@ -1116,6 +1513,69 @@ async function createOpenclawInviteLink(flags) {
1116
1513
  }
1117
1514
  }
1118
1515
 
1516
+ function printOpenclawBridgeStatus(flags) {
1517
+ const bridgeState = readBridgeState();
1518
+ const runtimeStatus = readBridgeStatus();
1519
+ const jsonOutput = isTruthyFlag(flags.json);
1520
+ const redactToken = (value) => {
1521
+ const text = String(value || '').trim();
1522
+ if (!text) return '';
1523
+ if (text.length <= 12) return '***';
1524
+ return `${text.slice(0, 6)}...${text.slice(-6)}`;
1525
+ };
1526
+
1527
+ const payload = {
1528
+ bridgeStatePath: resolveBridgeStatePath(),
1529
+ bridgeStatusPath: resolveBridgeStatusPath(),
1530
+ bridgeState: {
1531
+ brokerHttp: String(bridgeState.brokerHttp || ''),
1532
+ brokerWs: String(bridgeState.brokerWs || ''),
1533
+ deviceId: String(bridgeState.deviceId || ''),
1534
+ deviceToken: redactToken(bridgeState.deviceToken),
1535
+ claimedAt: bridgeState.claimedAt || null,
1536
+ expiresAt: bridgeState.expiresAt || null,
1537
+ },
1538
+ runtime: runtimeStatus,
1539
+ };
1540
+
1541
+ if (jsonOutput) {
1542
+ console.log(JSON.stringify(payload, null, 2));
1543
+ return;
1544
+ }
1545
+
1546
+ console.log('Oomi Bridge Status');
1547
+ console.log('------------------');
1548
+ console.log(`Bridge state: ${payload.bridgeStatePath}`);
1549
+ console.log(`Runtime status: ${payload.bridgeStatusPath}`);
1550
+ console.log(`Device: ${payload.bridgeState.deviceId || 'not paired'}`);
1551
+ console.log(`Broker HTTP: ${payload.bridgeState.brokerHttp || 'not configured'}`);
1552
+ console.log(`Broker WS: ${payload.bridgeState.brokerWs || 'not configured'}`);
1553
+ if (payload.bridgeState.deviceToken) {
1554
+ console.log(`Device token: ${payload.bridgeState.deviceToken}`);
1555
+ }
1556
+ if (payload.runtime && typeof payload.runtime === 'object' && Object.keys(payload.runtime).length > 0) {
1557
+ console.log(`Runtime state: ${String(payload.runtime.status || 'unknown')}`);
1558
+ if (payload.runtime.lastConnectedAt) {
1559
+ console.log(`Last connected: ${payload.runtime.lastConnectedAt}`);
1560
+ }
1561
+ if (payload.runtime.lastDisconnectAt) {
1562
+ console.log(`Last disconnected: ${payload.runtime.lastDisconnectAt}`);
1563
+ }
1564
+ if (payload.runtime.lastErrorClass || payload.runtime.lastErrorCode || payload.runtime.lastErrorMessage) {
1565
+ console.log(
1566
+ `Last error: ${String(payload.runtime.lastErrorClass || 'unknown')}/${String(payload.runtime.lastErrorCode || 'UNKNOWN')} ${String(payload.runtime.lastErrorMessage || '').trim()}`
1567
+ );
1568
+ }
1569
+ if (payload.runtime.hint) {
1570
+ console.log(`Hint: ${payload.runtime.hint}`);
1571
+ }
1572
+ return;
1573
+ }
1574
+
1575
+ console.log('Runtime state: no bridge runtime status recorded yet.');
1576
+ console.log('Run: oomi openclaw bridge --app-url https://www.oomi.ai');
1577
+ }
1578
+
1119
1579
  function printOpenclawPluginSetup(flags) {
1120
1580
  const bridgeState = readBridgeState();
1121
1581
  const backendUrl = String(
@@ -1215,6 +1675,13 @@ async function main() {
1215
1675
  }
1216
1676
 
1217
1677
  if (command === 'openclaw' && subcommand === 'bridge') {
1678
+ if (Boolean(args.flags.detach)) {
1679
+ const detachedFlags = { ...args.flags };
1680
+ delete detachedFlags.detach;
1681
+ const pid = startBridgeDetachedProcess(detachedFlags);
1682
+ console.log(`Bridge started in background (pid: ${pid}).`);
1683
+ return;
1684
+ }
1218
1685
  await startOpenclawBridge(args.flags);
1219
1686
  return;
1220
1687
  }
@@ -1229,6 +1696,11 @@ async function main() {
1229
1696
  return;
1230
1697
  }
1231
1698
 
1699
+ if (command === 'openclaw' && subcommand === 'status') {
1700
+ printOpenclawBridgeStatus(args.flags);
1701
+ return;
1702
+ }
1703
+
1232
1704
  if (command === 'openclaw' && subcommand === 'plugin') {
1233
1705
  printOpenclawPluginSetup(args.flags);
1234
1706
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"