oomi-ai 0.2.4 → 0.2.6

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 (3) hide show
  1. package/README.md +3 -0
  2. package/bin/oomi-ai.js +833 -50
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -72,6 +72,7 @@ Agent-intent mapping (recommended):
72
72
  Important distinction:
73
73
  - `pairCode` is one-time and used internally by the pair/bootstrap flow.
74
74
  - Invite auth links are the required user flow.
75
+ - Managed chat connect now uses OpenClaw challenge auth (`connect.challenge` nonce + signed device payload) in the local bridge path.
75
76
 
76
77
  Sync personas from the repo into the backend registry:
77
78
  ```
@@ -109,6 +110,8 @@ Restart OpenClaw after running `oomi init` or `oomi openclaw install`.
109
110
  - `OOMI_SKIP_UPDATE_CHECK=1` disables checks
110
111
  - `OOMI_UPDATE_CHECK_INTERVAL_MS=<ms>` changes check interval
111
112
  - `OOMI_UPDATE_CHECK_TIMEOUT_MS=<ms>` changes network timeout
113
+ - `OOMI_BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS=<ms>` changes local gateway socket connect timeout
114
+ - `OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS=<ms>` changes wait timeout for gateway `connect.challenge` nonce
112
115
 
113
116
  ## Package Audit + Publish (pnpm)
114
117
  ```
package/bin/oomi-ai.js CHANGED
@@ -3,7 +3,9 @@ import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
5
  import { spawn } from 'child_process';
6
- import { randomUUID } from 'crypto';
6
+ import { createPrivateKey, createPublicKey, randomUUID, sign as cryptoSign } 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,18 @@ 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;
21
+ const BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS = parsePositiveInteger(
22
+ process.env.OOMI_BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS,
23
+ 10000
24
+ );
25
+ const BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS = parsePositiveInteger(
26
+ process.env.OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS,
27
+ 3000
28
+ );
29
+ const DEVICE_IDENTITY_PATH = path.join(os.homedir(), '.openclaw', 'identity', 'device.json');
30
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
17
31
 
18
32
  function parsePositiveInteger(value, fallback) {
19
33
  const num = Number(value);
@@ -149,6 +163,9 @@ Commands:
149
163
  openclaw plugin
150
164
  Print OpenClaw extension install/config guidance for Oomi channel plugin.
151
165
 
166
+ openclaw status
167
+ Show bridge state + runtime health from local status files.
168
+
152
169
  personas sync
153
170
  Sync personas from the repo into the Oomi backend registry.
154
171
 
@@ -162,7 +179,7 @@ Common flags:
162
179
  --broker-http URL Managed broker HTTPS URL (for pair claim)
163
180
  --broker-ws URL Managed broker device WS URL (wss://.../cable)
164
181
  --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)
182
+ --app-url URL Oomi app URL used for pairing APIs; bridge can also refresh managed broker URLs from it
166
183
  --label TEXT Pairing label shown in broker logs
167
184
  --session-key KEY Session key used in generated connect URL
168
185
  --detach Start bridge in background and exit
@@ -537,6 +554,10 @@ function resolveBridgeStatePath() {
537
554
  return path.join(os.homedir(), '.openclaw', 'oomi-bridge.json');
538
555
  }
539
556
 
557
+ function resolveBridgeStatusPath() {
558
+ return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
559
+ }
560
+
540
561
  function defaultDeviceId() {
541
562
  const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
542
563
  return `oomi-${host}-${randomUUID().slice(0, 8)}`;
@@ -566,6 +587,27 @@ function writeBridgeState(nextState) {
566
587
  writeFile(statePath, JSON.stringify(nextState, null, 2) + '\n');
567
588
  }
568
589
 
590
+ function readBridgeStatus() {
591
+ return readJsonSafe(resolveBridgeStatusPath()) || {};
592
+ }
593
+
594
+ function writeBridgeStatus(nextStatus) {
595
+ const statusPath = resolveBridgeStatusPath();
596
+ ensureDir(path.dirname(statusPath));
597
+ writeFile(statusPath, JSON.stringify(nextStatus, null, 2) + '\n');
598
+ }
599
+
600
+ function updateBridgeStatus(partial) {
601
+ const current = readBridgeStatus();
602
+ const next = {
603
+ ...current,
604
+ ...partial,
605
+ updatedAt: new Date().toISOString(),
606
+ };
607
+ writeBridgeStatus(next);
608
+ return next;
609
+ }
610
+
569
611
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
570
612
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
571
613
  method: 'POST',
@@ -634,13 +676,109 @@ async function fetchManagedGatewayConfig({ appUrl }) {
634
676
  return payload;
635
677
  }
636
678
 
637
- function injectGatewayAuth(frameText, gatewayAuth) {
679
+ function base64UrlEncode(value) {
680
+ return Buffer.from(value)
681
+ .toString('base64')
682
+ .replaceAll('+', '-')
683
+ .replaceAll('/', '_')
684
+ .replace(/=+$/g, '');
685
+ }
686
+
687
+ function normalizeDeviceMetadataForAuth(value) {
688
+ if (typeof value !== 'string') return '';
689
+ const trimmed = value.trim();
690
+ if (!trimmed) return '';
691
+ return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
692
+ }
693
+
694
+ function publicKeyRawBase64UrlFromPem(publicKeyPem) {
695
+ const der = createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
696
+ const raw =
697
+ der.length === ED25519_SPKI_PREFIX.length + 32 &&
698
+ der.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
699
+ ? der.subarray(ED25519_SPKI_PREFIX.length)
700
+ : der;
701
+ return base64UrlEncode(raw);
702
+ }
703
+
704
+ function signDevicePayload(privateKeyPem, payload) {
705
+ const key = createPrivateKey(privateKeyPem);
706
+ return base64UrlEncode(cryptoSign(null, Buffer.from(payload, 'utf8'), key));
707
+ }
708
+
709
+ function buildDeviceAuthPayloadV3({
710
+ deviceId,
711
+ clientId,
712
+ clientMode,
713
+ role,
714
+ scopes,
715
+ signedAtMs,
716
+ token,
717
+ nonce,
718
+ platform,
719
+ deviceFamily,
720
+ }) {
721
+ return [
722
+ 'v3',
723
+ deviceId,
724
+ clientId,
725
+ clientMode,
726
+ role,
727
+ scopes.join(','),
728
+ String(signedAtMs),
729
+ token || '',
730
+ nonce,
731
+ normalizeDeviceMetadataForAuth(platform),
732
+ normalizeDeviceMetadataForAuth(deviceFamily),
733
+ ].join('|');
734
+ }
735
+
736
+ function loadGatewayDeviceIdentity() {
737
+ if (!fs.existsSync(DEVICE_IDENTITY_PATH)) {
738
+ return null;
739
+ }
740
+ try {
741
+ const parsed = JSON.parse(readFile(DEVICE_IDENTITY_PATH));
742
+ if (
743
+ parsed &&
744
+ parsed.version === 1 &&
745
+ typeof parsed.deviceId === 'string' &&
746
+ typeof parsed.publicKeyPem === 'string' &&
747
+ typeof parsed.privateKeyPem === 'string'
748
+ ) {
749
+ return {
750
+ deviceId: parsed.deviceId.trim(),
751
+ publicKeyPem: parsed.publicKeyPem,
752
+ privateKeyPem: parsed.privateKeyPem,
753
+ };
754
+ }
755
+ } catch {
756
+ // no-op
757
+ }
758
+ return null;
759
+ }
760
+
761
+ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}) {
762
+ const connectNonce = typeof options.connectNonce === 'string' ? options.connectNonce.trim() : '';
763
+ const deviceIdentity = options.deviceIdentity || null;
764
+
638
765
  try {
639
766
  const frame = JSON.parse(frameText);
640
767
  if (frame?.type !== 'req' || frame?.method !== 'connect') {
641
- return frameText;
768
+ return { frameText, waitForChallenge: false };
642
769
  }
770
+
643
771
  const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
772
+
773
+ const client = params.client && typeof params.client === 'object' ? params.client : {};
774
+ client.id = typeof client.id === 'string' && client.id.trim() ? client.id.trim() : 'webchat-ui';
775
+ client.version = typeof client.version === 'string' && client.version.trim() ? client.version.trim() : '0.1.0';
776
+ client.platform = typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform;
777
+ client.mode = typeof client.mode === 'string' && client.mode.trim() ? client.mode.trim() : 'webchat';
778
+ params.client = client;
779
+
780
+ params.role = typeof params.role === 'string' && params.role.trim() ? params.role.trim() : 'operator';
781
+
644
782
  const existingScopes = Array.isArray(params.scopes)
645
783
  ? params.scopes.filter((value) => typeof value === 'string' && value.trim())
646
784
  : [];
@@ -652,17 +790,63 @@ function injectGatewayAuth(frameText, gatewayAuth) {
652
790
  }
653
791
  params.scopes = existingScopes;
654
792
 
793
+ if (!Array.isArray(params.caps)) {
794
+ params.caps = [];
795
+ }
796
+ if (!Array.isArray(params.commands)) {
797
+ params.commands = [];
798
+ }
799
+ if (!params.permissions || typeof params.permissions !== 'object') {
800
+ params.permissions = {};
801
+ }
802
+
655
803
  const auth = {};
656
- if (gatewayAuth.token) auth.token = gatewayAuth.token;
657
- else if (gatewayAuth.password) auth.password = gatewayAuth.password;
804
+ if (gatewayAuth.token) {
805
+ auth.token = gatewayAuth.token;
806
+ } else if (gatewayAuth.password) {
807
+ auth.password = gatewayAuth.password;
808
+ }
658
809
  if (Object.keys(auth).length > 0) {
659
810
  params.auth = auth;
660
- frame.params = params;
661
- return JSON.stringify(frame);
662
811
  }
663
- return frameText;
812
+
813
+ if (deviceIdentity) {
814
+ if (!connectNonce) {
815
+ return { frameText: null, waitForChallenge: true };
816
+ }
817
+
818
+ const signedAtMs = Date.now();
819
+ const tokenForSignature =
820
+ typeof auth.token === 'string' && auth.token.trim()
821
+ ? auth.token.trim()
822
+ : (typeof auth.deviceToken === 'string' && auth.deviceToken.trim() ? auth.deviceToken.trim() : '');
823
+
824
+ const payload = buildDeviceAuthPayloadV3({
825
+ deviceId: deviceIdentity.deviceId,
826
+ clientId: client.id,
827
+ clientMode: client.mode,
828
+ role: params.role,
829
+ scopes: existingScopes,
830
+ signedAtMs,
831
+ token: tokenForSignature,
832
+ nonce: connectNonce,
833
+ platform: client.platform,
834
+ deviceFamily: typeof client.deviceFamily === 'string' ? client.deviceFamily : '',
835
+ });
836
+ const signature = signDevicePayload(deviceIdentity.privateKeyPem, payload);
837
+ params.device = {
838
+ id: deviceIdentity.deviceId,
839
+ publicKey: publicKeyRawBase64UrlFromPem(deviceIdentity.publicKeyPem),
840
+ signature,
841
+ signedAt: signedAtMs,
842
+ nonce: connectNonce,
843
+ };
844
+ }
845
+
846
+ frame.params = params;
847
+ return { frameText: JSON.stringify(frame), waitForChallenge: false };
664
848
  } catch {
665
- return frameText;
849
+ return { frameText, waitForChallenge: false };
666
850
  }
667
851
  }
668
852
 
@@ -674,29 +858,219 @@ function parseJsonPayload(raw) {
674
858
  }
675
859
  }
676
860
 
861
+ function bridgeNowIso() {
862
+ return new Date().toISOString();
863
+ }
864
+
865
+ function extractErrorCode(err) {
866
+ if (!err || typeof err !== 'object') return '';
867
+ const value = err.code;
868
+ return typeof value === 'string' ? value.trim().toUpperCase() : '';
869
+ }
870
+
871
+ function extractErrorMessage(err) {
872
+ if (!err) return '';
873
+ if (typeof err === 'string') return err.trim();
874
+ if (err instanceof Error) return err.message.trim();
875
+ return String(err).trim();
876
+ }
877
+
878
+ function classifyBridgeFailure({ err, reason = '', code = '', forceClass = '' } = {}) {
879
+ const errorCode = (code || extractErrorCode(err)).toUpperCase();
880
+ const message = [extractErrorMessage(err), String(reason || '').trim()].filter(Boolean).join(' | ');
881
+ const text = `${errorCode} ${message}`.toLowerCase();
882
+
883
+ let failureClass = forceClass || 'unknown';
884
+ let retryable = true;
885
+ let hint = 'Check broker URL, network path, and bridge token.';
886
+ let baseDelayMs = BRIDGE_RECONNECT_BASE_MS;
887
+
888
+ if (
889
+ errorCode === 'ENOTFOUND' ||
890
+ errorCode === 'EAI_AGAIN' ||
891
+ text.includes('enotfound') ||
892
+ text.includes('name resolution') ||
893
+ text.includes('dns')
894
+ ) {
895
+ failureClass = 'dns_resolution';
896
+ hint = 'Host resolution failed. Verify DNS/network access to the broker host.';
897
+ baseDelayMs = 5000;
898
+ } else if (
899
+ text.includes('unauthorized') ||
900
+ text.includes('forbidden') ||
901
+ text.includes('invalid token') ||
902
+ text.includes('token expired') ||
903
+ text.includes('broker rejected')
904
+ ) {
905
+ failureClass = 'auth_rejected';
906
+ retryable = false;
907
+ hint = 'Bridge token is invalid/expired. Re-run: oomi openclaw pair --app-url <url>.';
908
+ baseDelayMs = BRIDGE_RECONNECT_MAX_MS;
909
+ } else if (
910
+ errorCode === 'ECONNREFUSED' ||
911
+ errorCode === 'ENETUNREACH' ||
912
+ errorCode === 'EHOSTUNREACH' ||
913
+ errorCode === 'ETIMEDOUT' ||
914
+ text.includes('socket hang up') ||
915
+ text.includes('abnormal closure')
916
+ ) {
917
+ failureClass = 'network';
918
+ hint = 'Broker network path is unavailable. Check connectivity/firewall/proxy settings.';
919
+ baseDelayMs = 3000;
920
+ }
921
+
922
+ return {
923
+ errorCode: errorCode || 'UNKNOWN',
924
+ message: message || 'unknown bridge error',
925
+ failureClass,
926
+ retryable,
927
+ hint,
928
+ baseDelayMs,
929
+ };
930
+ }
931
+
932
+ function computeReconnectDelayMs(attempt, baseDelayMs) {
933
+ const growth = Math.min(BRIDGE_RECONNECT_MAX_MS, Math.round(baseDelayMs * (2 ** Math.max(0, attempt - 1))));
934
+ const jitter = Math.floor(growth * (Math.random() * 0.25));
935
+ return Math.min(BRIDGE_RECONNECT_MAX_MS, growth + jitter);
936
+ }
937
+
938
+ async function assertTcpReachable(urlValue, timeoutMs = 1500) {
939
+ const url = new URL(urlValue);
940
+ const host = url.hostname || '127.0.0.1';
941
+ const port = Number(url.port || (url.protocol === 'wss:' || url.protocol === 'https:' ? 443 : 80));
942
+ if (!Number.isFinite(port) || port <= 0) {
943
+ throw new Error(`Invalid port in URL: ${urlValue}`);
944
+ }
945
+
946
+ await new Promise((resolve, reject) => {
947
+ const socket = net.connect({ host, port });
948
+ let finished = false;
949
+ const done = (fn) => (value) => {
950
+ if (finished) return;
951
+ finished = true;
952
+ clearTimeout(timer);
953
+ socket.destroy();
954
+ fn(value);
955
+ };
956
+ const timer = setTimeout(done(reject), timeoutMs, new Error(`TCP connect timeout (${host}:${port})`));
957
+ socket.once('connect', done(resolve));
958
+ socket.once('error', done(reject));
959
+ });
960
+ }
961
+
962
+ async function runBridgePreflight({ brokerWs, gatewayUrl, gatewayConfigPath }) {
963
+ let brokerUrl;
964
+ try {
965
+ brokerUrl = new URL(brokerWs);
966
+ } catch {
967
+ throw new Error(`Invalid broker WS URL: ${brokerWs}`);
968
+ }
969
+ if (brokerUrl.protocol !== 'ws:' && brokerUrl.protocol !== 'wss:') {
970
+ throw new Error(`Broker WS URL must use ws:// or wss:// (received ${brokerUrl.protocol})`);
971
+ }
972
+
973
+ const brokerHost = brokerUrl.hostname;
974
+ if (!brokerHost) {
975
+ throw new Error('Broker WS URL is missing hostname.');
976
+ }
977
+ await dnsLookup(brokerHost);
978
+
979
+ let parsedGatewayUrl;
980
+ try {
981
+ parsedGatewayUrl = new URL(gatewayUrl);
982
+ } catch {
983
+ throw new Error(`Invalid local gateway URL (${gatewayUrl}) from ${gatewayConfigPath}`);
984
+ }
985
+ if (parsedGatewayUrl.protocol !== 'ws:') {
986
+ throw new Error(`Local gateway URL must use ws:// (received ${parsedGatewayUrl.protocol})`);
987
+ }
988
+ await assertTcpReachable(parsedGatewayUrl.toString());
989
+ }
990
+
991
+ function buildBridgeDetachArgs(rawFlags = {}) {
992
+ const orderedKeys = [
993
+ 'broker-http',
994
+ 'broker-ws',
995
+ 'pair-code',
996
+ 'app-url',
997
+ 'device-id',
998
+ 'device-token',
999
+ ];
1000
+ const args = [process.argv[1], 'openclaw', 'bridge'];
1001
+
1002
+ for (const key of orderedKeys) {
1003
+ const value = rawFlags[key];
1004
+ if (value === undefined || value === null || value === false) continue;
1005
+ if (value === true) {
1006
+ args.push(`--${key}`);
1007
+ continue;
1008
+ }
1009
+ const text = String(value).trim();
1010
+ if (!text) continue;
1011
+ args.push(`--${key}`, text);
1012
+ }
1013
+
1014
+ return args;
1015
+ }
1016
+
1017
+ function startBridgeDetachedProcess(rawFlags = {}) {
1018
+ const args = buildBridgeDetachArgs(rawFlags);
1019
+ const child = spawn(process.execPath, args, {
1020
+ detached: true,
1021
+ stdio: 'ignore',
1022
+ });
1023
+ child.unref();
1024
+ return child.pid;
1025
+ }
1026
+
1027
+ async function resolveBridgeRuntimeConfig(flags, bridgeState) {
1028
+ const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
1029
+ const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
1030
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || '').trim();
1031
+
1032
+ let brokerHttp = String(explicitBrokerHttp || process.env.OOMI_CHAT_BROKER_HTTP_URL || bridgeState.brokerHttp || '').trim();
1033
+ let brokerWs = String(explicitBrokerWs || process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL || bridgeState.brokerWs || '').trim();
1034
+ let managedConfigUsed = false;
1035
+ let managedConfigError = '';
1036
+
1037
+ if (appUrl && (!explicitBrokerHttp || !explicitBrokerWs)) {
1038
+ try {
1039
+ const managedConfig = await fetchManagedGatewayConfig({ appUrl });
1040
+ managedConfigUsed = true;
1041
+ if (!explicitBrokerHttp) {
1042
+ brokerHttp = String(managedConfig.brokerHttpUrl || '').trim();
1043
+ }
1044
+ if (!explicitBrokerWs) {
1045
+ brokerWs = String(managedConfig.brokerDeviceWsUrl || '').trim();
1046
+ }
1047
+ } catch (err) {
1048
+ managedConfigError = extractErrorMessage(err);
1049
+ if (!brokerWs) {
1050
+ throw err;
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ return {
1056
+ appUrl,
1057
+ brokerHttp,
1058
+ brokerWs,
1059
+ managedConfigUsed,
1060
+ managedConfigError,
1061
+ };
1062
+ }
1063
+
677
1064
  async function startOpenclawBridge(flags) {
678
1065
  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();
1066
+ const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
1067
+ const brokerHttp = runtimeConfig.brokerHttp;
1068
+ const brokerWs = runtimeConfig.brokerWs;
691
1069
  const deviceId = resolveDeviceId(flags, bridgeState);
692
1070
  const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
693
1071
  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
1072
  let deviceToken = explicitDeviceToken;
699
- if (!deviceToken && canReuseStateToken) {
1073
+ if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
700
1074
  deviceToken = String(bridgeState.deviceToken || '').trim();
701
1075
  }
702
1076
 
@@ -730,12 +1104,71 @@ async function startOpenclawBridge(flags) {
730
1104
  if (!gateway.token && !gateway.password) {
731
1105
  throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
732
1106
  }
1107
+ const gatewayDeviceIdentity = loadGatewayDeviceIdentity();
1108
+ if (!gatewayDeviceIdentity) {
1109
+ console.warn(
1110
+ `[bridge] OpenClaw device identity not found at ${DEVICE_IDENTITY_PATH}; device-signed connect may fail on newer gateways.`
1111
+ );
1112
+ }
1113
+
1114
+ if (runtimeConfig.managedConfigUsed && runtimeConfig.appUrl) {
1115
+ console.log(`[bridge] refreshed broker URLs from ${runtimeConfig.appUrl}`);
1116
+ } else if (runtimeConfig.managedConfigError) {
1117
+ console.warn(
1118
+ `[bridge] failed to refresh broker URLs from app URL; using local/state broker config (${runtimeConfig.managedConfigError})`
1119
+ );
1120
+ }
1121
+
1122
+ try {
1123
+ await runBridgePreflight({
1124
+ brokerWs,
1125
+ gatewayUrl: gateway.gatewayUrl,
1126
+ gatewayConfigPath: gateway.configPath,
1127
+ });
1128
+ } catch (err) {
1129
+ const failure = classifyBridgeFailure({ err });
1130
+ updateBridgeStatus({
1131
+ status: 'error',
1132
+ deviceId,
1133
+ brokerWs,
1134
+ brokerHttp,
1135
+ gatewayUrl: gateway.gatewayUrl,
1136
+ lastDisconnectAt: bridgeNowIso(),
1137
+ lastErrorCode: failure.errorCode,
1138
+ lastErrorClass: failure.failureClass,
1139
+ lastErrorMessage: failure.message,
1140
+ hint: failure.hint,
1141
+ consecutiveFailures: 0,
1142
+ pid: process.pid,
1143
+ });
1144
+ throw new Error(`Bridge preflight failed (${failure.failureClass}): ${failure.message}. ${failure.hint}`);
1145
+ }
1146
+
1147
+ updateBridgeStatus({
1148
+ status: 'starting',
1149
+ deviceId,
1150
+ brokerWs,
1151
+ brokerHttp,
1152
+ gatewayUrl: gateway.gatewayUrl,
1153
+ lastErrorCode: '',
1154
+ lastErrorClass: '',
1155
+ lastErrorMessage: '',
1156
+ consecutiveFailures: 0,
1157
+ pid: process.pid,
1158
+ startedAt: bridgeNowIso(),
1159
+ });
733
1160
 
734
1161
  console.log(`Starting OpenClaw bridge: device=${deviceId}`);
735
1162
  console.log(`Local gateway: ${gateway.gatewayUrl}`);
736
1163
  console.log(`Broker WS: ${brokerWs}`);
737
1164
 
738
1165
  const activeGatewaySockets = new Map();
1166
+ const reconnectState = {
1167
+ attempt: 0,
1168
+ timer: null,
1169
+ stopped: false,
1170
+ lastFailure: null,
1171
+ };
739
1172
  const brokerPath = (() => {
740
1173
  try {
741
1174
  return new URL(brokerWs).pathname || '';
@@ -785,27 +1218,196 @@ async function startOpenclawBridge(flags) {
785
1218
  return null;
786
1219
  };
787
1220
 
1221
+ const scheduleReconnect = () => {
1222
+ if (reconnectState.stopped || reconnectState.timer) return;
1223
+ reconnectState.attempt += 1;
1224
+ const failure =
1225
+ reconnectState.lastFailure ||
1226
+ classifyBridgeFailure({ reason: 'connection closed without classified error' });
1227
+ const delayMs = computeReconnectDelayMs(reconnectState.attempt, failure.baseDelayMs);
1228
+
1229
+ console.warn(
1230
+ `[bridge] reconnect scheduled in ${delayMs}ms (attempt ${reconnectState.attempt}, class=${failure.failureClass}, code=${failure.errorCode})`
1231
+ );
1232
+
1233
+ updateBridgeStatus({
1234
+ status: 'reconnecting',
1235
+ deviceId,
1236
+ brokerWs,
1237
+ brokerHttp,
1238
+ gatewayUrl: gateway.gatewayUrl,
1239
+ lastDisconnectAt: bridgeNowIso(),
1240
+ lastErrorCode: failure.errorCode,
1241
+ lastErrorClass: failure.failureClass,
1242
+ lastErrorMessage: failure.message,
1243
+ hint: failure.hint,
1244
+ consecutiveFailures: reconnectState.attempt,
1245
+ pid: process.pid,
1246
+ });
1247
+
1248
+ reconnectState.timer = setTimeout(() => {
1249
+ reconnectState.timer = null;
1250
+ connectBroker();
1251
+ }, delayMs);
1252
+ };
1253
+
788
1254
  const connectBroker = () => {
789
1255
  const wsUrl = new URL(brokerWs);
790
1256
  wsUrl.searchParams.set('token', deviceToken);
791
1257
 
792
1258
  const brokerSocket = new WebSocket(wsUrl.toString());
793
1259
  let actionCableHeartbeat = null;
1260
+
1261
+ const clearChallengeTimer = (sessionBridge) => {
1262
+ if (sessionBridge && sessionBridge.connectChallengeTimer) {
1263
+ clearTimeout(sessionBridge.connectChallengeTimer);
1264
+ sessionBridge.connectChallengeTimer = null;
1265
+ }
1266
+ };
1267
+
1268
+ const queueConnectUntilChallenge = (sessionId, sessionBridge, frame) => {
1269
+ if (!sessionBridge || typeof frame !== 'string' || !frame) return;
1270
+ if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
1271
+ sessionBridge.pendingConnectFrames = [];
1272
+ }
1273
+ if (sessionBridge.pendingConnectFrames.includes(frame)) {
1274
+ return;
1275
+ }
1276
+ sessionBridge.pendingConnectFrames.push(frame);
1277
+
1278
+ if (sessionBridge.connectChallengeTimer) {
1279
+ return;
1280
+ }
1281
+
1282
+ sessionBridge.connectChallengeTimer = setTimeout(() => {
1283
+ sessionBridge.connectChallengeTimer = null;
1284
+ const hasPending = Array.isArray(sessionBridge.pendingConnectFrames)
1285
+ ? sessionBridge.pendingConnectFrames.length > 0
1286
+ : false;
1287
+ if (!hasPending || sessionBridge.connectNonce) {
1288
+ return;
1289
+ }
1290
+ console.error(
1291
+ `[bridge] gateway.connect_challenge_timeout ${sessionId} (${String(BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS)}ms)`
1292
+ );
1293
+ sendBrokerPayload(brokerSocket, {
1294
+ action: 'log',
1295
+ type: 'log',
1296
+ sessionId,
1297
+ level: 'error',
1298
+ message: `Gateway challenge timeout (${String(BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS)}ms) for session ${sessionId}`,
1299
+ });
1300
+ try {
1301
+ sessionBridge.socket?.close(4009, 'connect_challenge_timeout');
1302
+ } catch {
1303
+ // no-op
1304
+ }
1305
+ }, BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS);
1306
+ };
1307
+
1308
+ const flushPendingConnectFrames = (sessionId, sessionBridge) => {
1309
+ if (!sessionBridge || !sessionBridge.connectNonce) return;
1310
+ const pending = Array.isArray(sessionBridge.pendingConnectFrames)
1311
+ ? sessionBridge.pendingConnectFrames.splice(0, sessionBridge.pendingConnectFrames.length)
1312
+ : [];
1313
+ if (pending.length === 0) {
1314
+ clearChallengeTimer(sessionBridge);
1315
+ return;
1316
+ }
1317
+
1318
+ clearChallengeTimer(sessionBridge);
1319
+ for (const pendingFrame of pending) {
1320
+ const prepared = prepareGatewayFrameForLocalGateway(pendingFrame, gateway, {
1321
+ connectNonce: sessionBridge.connectNonce,
1322
+ deviceIdentity: gatewayDeviceIdentity,
1323
+ });
1324
+ if (!prepared.frameText || prepared.waitForChallenge) {
1325
+ continue;
1326
+ }
1327
+ const result = forwardFrameToSession(sessionBridge, prepared.frameText);
1328
+ if (result === 'queued') {
1329
+ console.log(`[bridge] client.frame queued after challenge ${sessionId}`);
1330
+ } else if (result === 'dropped') {
1331
+ console.log(`[bridge] client.frame dropped after challenge ${sessionId}`);
1332
+ } else {
1333
+ console.log(`[bridge] client.frame sent after challenge ${sessionId}`);
1334
+ }
1335
+ }
1336
+ };
1337
+
794
1338
  const setupGatewaySession = (sessionId, sessionBridge) => {
795
1339
  if (!sessionBridge || !sessionBridge.socket) return;
796
1340
  const gatewaySocket = sessionBridge.socket;
1341
+ if (typeof sessionBridge.connectNonce !== 'string') {
1342
+ sessionBridge.connectNonce = '';
1343
+ }
1344
+ if (!Array.isArray(sessionBridge.pendingConnectFrames)) {
1345
+ sessionBridge.pendingConnectFrames = [];
1346
+ }
1347
+ let connectTimeout = setTimeout(() => {
1348
+ if (gatewaySocket.readyState !== WebSocket.CONNECTING) return;
1349
+ console.error(
1350
+ `[bridge] gateway.connect_timeout ${sessionId} (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms)`
1351
+ );
1352
+ sendBrokerPayload(brokerSocket, {
1353
+ action: 'log',
1354
+ type: 'log',
1355
+ sessionId,
1356
+ level: 'error',
1357
+ message: `Gateway connect timeout (${String(BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS)}ms) for session ${sessionId}`,
1358
+ });
1359
+ try {
1360
+ gatewaySocket.close(4008, 'gateway_connect_timeout');
1361
+ } catch {
1362
+ // no-op
1363
+ }
1364
+ }, BRIDGE_GATEWAY_CONNECT_TIMEOUT_MS);
797
1365
 
798
1366
  gatewaySocket.on('open', () => {
1367
+ if (connectTimeout) {
1368
+ clearTimeout(connectTimeout);
1369
+ connectTimeout = null;
1370
+ }
799
1371
  console.log(`[bridge] gateway.open ${sessionId}`);
800
1372
  flushSessionQueue(sessionBridge);
801
1373
  });
802
1374
 
803
1375
  gatewaySocket.on('message', (gatewayRaw) => {
804
1376
  const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
1377
+ const gatewayPayload = parseJsonPayload(frame);
1378
+ if (gatewayPayload?.event === 'connect.challenge') {
1379
+ const nonce =
1380
+ gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
1381
+ ? gatewayPayload.payload.nonce.trim()
1382
+ : '';
1383
+ if (!nonce) {
1384
+ console.error(`[bridge] gateway.connect.challenge missing nonce for ${sessionId}`);
1385
+ sendBrokerPayload(brokerSocket, {
1386
+ action: 'log',
1387
+ type: 'log',
1388
+ sessionId,
1389
+ level: 'error',
1390
+ message: `Gateway connect challenge missing nonce for session ${sessionId}`,
1391
+ });
1392
+ try {
1393
+ gatewaySocket.close(1008, 'connect_challenge_missing_nonce');
1394
+ } catch {
1395
+ // no-op
1396
+ }
1397
+ } else {
1398
+ sessionBridge.connectNonce = nonce;
1399
+ flushPendingConnectFrames(sessionId, sessionBridge);
1400
+ }
1401
+ }
805
1402
  sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
806
1403
  });
807
1404
 
808
1405
  gatewaySocket.on('close', (code, reason) => {
1406
+ if (connectTimeout) {
1407
+ clearTimeout(connectTimeout);
1408
+ connectTimeout = null;
1409
+ }
1410
+ clearChallengeTimer(sessionBridge);
809
1411
  const reasonText = reason ? reason.toString() : '';
810
1412
  console.log(
811
1413
  `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
@@ -821,6 +1423,10 @@ async function startOpenclawBridge(flags) {
821
1423
  });
822
1424
 
823
1425
  gatewaySocket.on('error', (err) => {
1426
+ if (connectTimeout && gatewaySocket.readyState !== WebSocket.CONNECTING) {
1427
+ clearTimeout(connectTimeout);
1428
+ connectTimeout = null;
1429
+ }
824
1430
  console.error(`[bridge] gateway.error ${sessionId}: ${String(err)}`);
825
1431
  sendBrokerPayload(brokerSocket, {
826
1432
  action: 'log',
@@ -846,6 +1452,8 @@ async function startOpenclawBridge(flags) {
846
1452
 
847
1453
  brokerSocket.on('open', () => {
848
1454
  console.log('[bridge] Connected to managed broker.');
1455
+ reconnectState.attempt = 0;
1456
+ reconnectState.lastFailure = null;
849
1457
  if (!actionCableMode) return;
850
1458
  brokerSocket.send(
851
1459
  JSON.stringify({
@@ -856,6 +1464,20 @@ async function startOpenclawBridge(flags) {
856
1464
  actionCableHeartbeat = setInterval(() => {
857
1465
  sendBrokerPayload(brokerSocket, { action: 'heartbeat' });
858
1466
  }, 15000);
1467
+ updateBridgeStatus({
1468
+ status: 'connected',
1469
+ deviceId,
1470
+ brokerWs,
1471
+ brokerHttp,
1472
+ gatewayUrl: gateway.gatewayUrl,
1473
+ lastConnectedAt: bridgeNowIso(),
1474
+ lastErrorCode: '',
1475
+ lastErrorClass: '',
1476
+ lastErrorMessage: '',
1477
+ hint: '',
1478
+ consecutiveFailures: 0,
1479
+ pid: process.pid,
1480
+ });
859
1481
  });
860
1482
 
861
1483
  brokerSocket.on('message', (rawData) => {
@@ -868,12 +1490,61 @@ async function startOpenclawBridge(flags) {
868
1490
  }
869
1491
 
870
1492
  if (payload.type === 'broker.disconnect') {
1493
+ reconnectState.lastFailure = classifyBridgeFailure({
1494
+ reason: String(payload.reason || 'unauthorized'),
1495
+ forceClass: 'auth_rejected',
1496
+ });
1497
+ reconnectState.stopped = true;
871
1498
  console.error(`[bridge] Broker rejected connection: ${String(payload.reason || 'unauthorized')}`);
1499
+ console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
1500
+ updateBridgeStatus({
1501
+ status: 'error',
1502
+ deviceId,
1503
+ brokerWs,
1504
+ brokerHttp,
1505
+ gatewayUrl: gateway.gatewayUrl,
1506
+ lastDisconnectAt: bridgeNowIso(),
1507
+ lastErrorCode: reconnectState.lastFailure.errorCode,
1508
+ lastErrorClass: reconnectState.lastFailure.failureClass,
1509
+ lastErrorMessage: reconnectState.lastFailure.message,
1510
+ hint: reconnectState.lastFailure.hint,
1511
+ consecutiveFailures: reconnectState.attempt + 1,
1512
+ pid: process.pid,
1513
+ });
1514
+ try {
1515
+ brokerSocket.close(4001, 'auth_rejected');
1516
+ } catch {
1517
+ // no-op
1518
+ }
872
1519
  return;
873
1520
  }
874
1521
 
875
1522
  if (payload.type === 'broker.reject_subscription') {
876
1523
  console.error('[bridge] Broker rejected DeviceChannel subscription.');
1524
+ reconnectState.lastFailure = classifyBridgeFailure({
1525
+ reason: 'broker rejected DeviceChannel subscription',
1526
+ forceClass: 'auth_rejected',
1527
+ });
1528
+ reconnectState.stopped = true;
1529
+ updateBridgeStatus({
1530
+ status: 'error',
1531
+ deviceId,
1532
+ brokerWs,
1533
+ brokerHttp,
1534
+ gatewayUrl: gateway.gatewayUrl,
1535
+ lastDisconnectAt: bridgeNowIso(),
1536
+ lastErrorCode: reconnectState.lastFailure.errorCode,
1537
+ lastErrorClass: reconnectState.lastFailure.failureClass,
1538
+ lastErrorMessage: reconnectState.lastFailure.message,
1539
+ hint: reconnectState.lastFailure.hint,
1540
+ consecutiveFailures: reconnectState.attempt + 1,
1541
+ pid: process.pid,
1542
+ });
1543
+ try {
1544
+ brokerSocket.close(4002, 'subscription_rejected');
1545
+ } catch {
1546
+ // no-op
1547
+ }
877
1548
  return;
878
1549
  }
879
1550
 
@@ -897,8 +1568,19 @@ async function startOpenclawBridge(flags) {
897
1568
  console.log(`[bridge] client.frame ${sessionId}`);
898
1569
  const sessionBridge = getOrCreateGatewaySession(sessionId);
899
1570
  if (!sessionBridge) return;
900
- const frameWithAuth = injectGatewayAuth(frame, gateway);
901
- const result = forwardFrameToSession(sessionBridge, frameWithAuth);
1571
+ const prepared = prepareGatewayFrameForLocalGateway(frame, gateway, {
1572
+ connectNonce: sessionBridge.connectNonce,
1573
+ deviceIdentity: gatewayDeviceIdentity,
1574
+ });
1575
+ if (prepared.waitForChallenge) {
1576
+ queueConnectUntilChallenge(sessionId, sessionBridge, frame);
1577
+ console.log(`[bridge] client.frame waiting for challenge ${sessionId}`);
1578
+ return;
1579
+ }
1580
+ if (!prepared.frameText) {
1581
+ return;
1582
+ }
1583
+ const result = forwardFrameToSession(sessionBridge, prepared.frameText);
902
1584
  if (result === 'queued') {
903
1585
  console.log(`[bridge] client.frame queued ${sessionId}`);
904
1586
  } else if (result === 'dropped') {
@@ -912,6 +1594,7 @@ async function startOpenclawBridge(flags) {
912
1594
  console.log(`[bridge] client.close ${sessionId}`);
913
1595
  const sessionBridge = activeGatewaySockets.get(sessionId);
914
1596
  if (sessionBridge && sessionBridge.socket) {
1597
+ clearChallengeTimer(sessionBridge);
915
1598
  activeGatewaySockets.delete(sessionId);
916
1599
  sessionBridge.socket.close(1000, 'client_closed');
917
1600
  }
@@ -924,8 +1607,10 @@ async function startOpenclawBridge(flags) {
924
1607
  clearInterval(actionCableHeartbeat);
925
1608
  actionCableHeartbeat = null;
926
1609
  }
927
- console.log(`[bridge] Broker disconnected (${code}) ${reason.toString()}`);
1610
+ const reasonText = reason ? reason.toString() : '';
1611
+ console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
928
1612
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
1613
+ clearChallengeTimer(sessionBridge);
929
1614
  activeGatewaySockets.delete(sessionId);
930
1615
  try {
931
1616
  sessionBridge.socket.close(1001, 'broker_disconnected');
@@ -933,13 +1618,48 @@ async function startOpenclawBridge(flags) {
933
1618
  // no-op
934
1619
  }
935
1620
  }
936
- setTimeout(connectBroker, 2000);
1621
+
1622
+ if (reconnectState.stopped) {
1623
+ return;
1624
+ }
1625
+
1626
+ if (!reconnectState.lastFailure) {
1627
+ reconnectState.lastFailure = classifyBridgeFailure({
1628
+ reason: `socket closed code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`,
1629
+ });
1630
+ }
1631
+ scheduleReconnect();
937
1632
  });
938
1633
 
939
1634
  brokerSocket.on('error', (err) => {
940
- console.error('[bridge] Broker socket error:', err);
1635
+ reconnectState.lastFailure = classifyBridgeFailure({ err });
1636
+ console.error(
1637
+ `[bridge] Broker socket error [${reconnectState.lastFailure.failureClass}/${reconnectState.lastFailure.errorCode}]: ${reconnectState.lastFailure.message}`
1638
+ );
1639
+ console.error(`[bridge] ${reconnectState.lastFailure.hint}`);
1640
+ });
1641
+ };
1642
+
1643
+ const markStopped = (signal) => {
1644
+ reconnectState.stopped = true;
1645
+ if (reconnectState.timer) {
1646
+ clearTimeout(reconnectState.timer);
1647
+ reconnectState.timer = null;
1648
+ }
1649
+ updateBridgeStatus({
1650
+ status: 'stopped',
1651
+ deviceId,
1652
+ brokerWs,
1653
+ brokerHttp,
1654
+ gatewayUrl: gateway.gatewayUrl,
1655
+ lastDisconnectAt: bridgeNowIso(),
1656
+ stopSignal: signal,
1657
+ pid: process.pid,
941
1658
  });
1659
+ process.exit(0);
942
1660
  };
1661
+ process.once('SIGINT', () => markStopped('SIGINT'));
1662
+ process.once('SIGTERM', () => markStopped('SIGTERM'));
943
1663
 
944
1664
  connectBroker();
945
1665
  await new Promise(() => {});
@@ -1030,25 +1750,13 @@ async function pairAndStartOpenclawBridge(flags) {
1030
1750
  }
1031
1751
 
1032
1752
  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',
1753
+ const pid = startBridgeDetachedProcess({
1754
+ 'broker-http': managedConfig.brokerHttpUrl,
1755
+ 'broker-ws': brokerWs,
1756
+ 'device-id': deviceId,
1757
+ 'device-token': deviceToken,
1049
1758
  });
1050
- child.unref();
1051
- console.log(`Bridge started in background (pid: ${child.pid}).`);
1759
+ console.log(`Bridge started in background (pid: ${pid}).`);
1052
1760
  return;
1053
1761
  }
1054
1762
 
@@ -1116,6 +1824,69 @@ async function createOpenclawInviteLink(flags) {
1116
1824
  }
1117
1825
  }
1118
1826
 
1827
+ function printOpenclawBridgeStatus(flags) {
1828
+ const bridgeState = readBridgeState();
1829
+ const runtimeStatus = readBridgeStatus();
1830
+ const jsonOutput = isTruthyFlag(flags.json);
1831
+ const redactToken = (value) => {
1832
+ const text = String(value || '').trim();
1833
+ if (!text) return '';
1834
+ if (text.length <= 12) return '***';
1835
+ return `${text.slice(0, 6)}...${text.slice(-6)}`;
1836
+ };
1837
+
1838
+ const payload = {
1839
+ bridgeStatePath: resolveBridgeStatePath(),
1840
+ bridgeStatusPath: resolveBridgeStatusPath(),
1841
+ bridgeState: {
1842
+ brokerHttp: String(bridgeState.brokerHttp || ''),
1843
+ brokerWs: String(bridgeState.brokerWs || ''),
1844
+ deviceId: String(bridgeState.deviceId || ''),
1845
+ deviceToken: redactToken(bridgeState.deviceToken),
1846
+ claimedAt: bridgeState.claimedAt || null,
1847
+ expiresAt: bridgeState.expiresAt || null,
1848
+ },
1849
+ runtime: runtimeStatus,
1850
+ };
1851
+
1852
+ if (jsonOutput) {
1853
+ console.log(JSON.stringify(payload, null, 2));
1854
+ return;
1855
+ }
1856
+
1857
+ console.log('Oomi Bridge Status');
1858
+ console.log('------------------');
1859
+ console.log(`Bridge state: ${payload.bridgeStatePath}`);
1860
+ console.log(`Runtime status: ${payload.bridgeStatusPath}`);
1861
+ console.log(`Device: ${payload.bridgeState.deviceId || 'not paired'}`);
1862
+ console.log(`Broker HTTP: ${payload.bridgeState.brokerHttp || 'not configured'}`);
1863
+ console.log(`Broker WS: ${payload.bridgeState.brokerWs || 'not configured'}`);
1864
+ if (payload.bridgeState.deviceToken) {
1865
+ console.log(`Device token: ${payload.bridgeState.deviceToken}`);
1866
+ }
1867
+ if (payload.runtime && typeof payload.runtime === 'object' && Object.keys(payload.runtime).length > 0) {
1868
+ console.log(`Runtime state: ${String(payload.runtime.status || 'unknown')}`);
1869
+ if (payload.runtime.lastConnectedAt) {
1870
+ console.log(`Last connected: ${payload.runtime.lastConnectedAt}`);
1871
+ }
1872
+ if (payload.runtime.lastDisconnectAt) {
1873
+ console.log(`Last disconnected: ${payload.runtime.lastDisconnectAt}`);
1874
+ }
1875
+ if (payload.runtime.lastErrorClass || payload.runtime.lastErrorCode || payload.runtime.lastErrorMessage) {
1876
+ console.log(
1877
+ `Last error: ${String(payload.runtime.lastErrorClass || 'unknown')}/${String(payload.runtime.lastErrorCode || 'UNKNOWN')} ${String(payload.runtime.lastErrorMessage || '').trim()}`
1878
+ );
1879
+ }
1880
+ if (payload.runtime.hint) {
1881
+ console.log(`Hint: ${payload.runtime.hint}`);
1882
+ }
1883
+ return;
1884
+ }
1885
+
1886
+ console.log('Runtime state: no bridge runtime status recorded yet.');
1887
+ console.log('Run: oomi openclaw bridge --app-url https://www.oomi.ai');
1888
+ }
1889
+
1119
1890
  function printOpenclawPluginSetup(flags) {
1120
1891
  const bridgeState = readBridgeState();
1121
1892
  const backendUrl = String(
@@ -1215,6 +1986,13 @@ async function main() {
1215
1986
  }
1216
1987
 
1217
1988
  if (command === 'openclaw' && subcommand === 'bridge') {
1989
+ if (Boolean(args.flags.detach)) {
1990
+ const detachedFlags = { ...args.flags };
1991
+ delete detachedFlags.detach;
1992
+ const pid = startBridgeDetachedProcess(detachedFlags);
1993
+ console.log(`Bridge started in background (pid: ${pid}).`);
1994
+ return;
1995
+ }
1218
1996
  await startOpenclawBridge(args.flags);
1219
1997
  return;
1220
1998
  }
@@ -1229,6 +2007,11 @@ async function main() {
1229
2007
  return;
1230
2008
  }
1231
2009
 
2010
+ if (command === 'openclaw' && subcommand === 'status') {
2011
+ printOpenclawBridgeStatus(args.flags);
2012
+ return;
2013
+ }
2014
+
1232
2015
  if (command === 'openclaw' && subcommand === 'plugin') {
1233
2016
  printOpenclawPluginSetup(args.flags);
1234
2017
  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.6",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"