oomi-ai 0.2.3 → 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.
package/bin/oomi-ai.js CHANGED
@@ -4,7 +4,10 @@ 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';
10
+ import { ensureSessionBridge, forwardFrameToSession, flushSessionQueue } from './sessionBridgeState.js';
8
11
 
9
12
  const MARKER_START = '<oomi-agent-instructions>';
10
13
  const MARKER_END = '</oomi-agent-instructions>';
@@ -13,6 +16,8 @@ const PACKAGE_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname
13
16
  const UPDATE_STATE_FILE = path.join(os.homedir(), '.openclaw', 'oomi-ai-update-check.json');
14
17
  const DEFAULT_UPDATE_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
15
18
  const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1200;
19
+ const BRIDGE_RECONNECT_BASE_MS = 2000;
20
+ const BRIDGE_RECONNECT_MAX_MS = 60000;
16
21
 
17
22
  function parsePositiveInteger(value, fallback) {
18
23
  const num = Number(value);
@@ -148,6 +153,9 @@ Commands:
148
153
  openclaw plugin
149
154
  Print OpenClaw extension install/config guidance for Oomi channel plugin.
150
155
 
156
+ openclaw status
157
+ Show bridge state + runtime health from local status files.
158
+
151
159
  personas sync
152
160
  Sync personas from the repo into the Oomi backend registry.
153
161
 
@@ -161,7 +169,7 @@ Common flags:
161
169
  --broker-http URL Managed broker HTTPS URL (for pair claim)
162
170
  --broker-ws URL Managed broker device WS URL (wss://.../cable)
163
171
  --pair-code CODE One-time pairing code from Oomi
164
- --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
165
173
  --label TEXT Pairing label shown in broker logs
166
174
  --session-key KEY Session key used in generated connect URL
167
175
  --detach Start bridge in background and exit
@@ -536,6 +544,10 @@ function resolveBridgeStatePath() {
536
544
  return path.join(os.homedir(), '.openclaw', 'oomi-bridge.json');
537
545
  }
538
546
 
547
+ function resolveBridgeStatusPath() {
548
+ return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
549
+ }
550
+
539
551
  function defaultDeviceId() {
540
552
  const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
541
553
  return `oomi-${host}-${randomUUID().slice(0, 8)}`;
@@ -565,6 +577,27 @@ function writeBridgeState(nextState) {
565
577
  writeFile(statePath, JSON.stringify(nextState, null, 2) + '\n');
566
578
  }
567
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
+
568
601
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
569
602
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
570
603
  method: 'POST',
@@ -673,29 +706,219 @@ function parseJsonPayload(raw) {
673
706
  }
674
707
  }
675
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
+
676
912
  async function startOpenclawBridge(flags) {
677
913
  const bridgeState = readBridgeState();
678
- const brokerHttp = String(
679
- flags['broker-http'] ||
680
- process.env.OOMI_CHAT_BROKER_HTTP_URL ||
681
- bridgeState.brokerHttp ||
682
- ''
683
- ).trim();
684
- const brokerWs = String(
685
- flags['broker-ws'] ||
686
- process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL ||
687
- bridgeState.brokerWs ||
688
- ''
689
- ).trim();
914
+ const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
915
+ const brokerHttp = runtimeConfig.brokerHttp;
916
+ const brokerWs = runtimeConfig.brokerWs;
690
917
  const deviceId = resolveDeviceId(flags, bridgeState);
691
918
  const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
692
919
  const explicitDeviceToken = String(flags['device-token'] || '').trim();
693
- const canReuseStateToken =
694
- String(bridgeState.deviceId || '').trim() === deviceId &&
695
- String(bridgeState.brokerWs || '').trim() === brokerWs &&
696
- String(bridgeState.brokerHttp || '').trim() === brokerHttp;
697
920
  let deviceToken = explicitDeviceToken;
698
- if (!deviceToken && canReuseStateToken) {
921
+ if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
699
922
  deviceToken = String(bridgeState.deviceToken || '').trim();
700
923
  }
701
924
 
@@ -730,11 +953,64 @@ async function startOpenclawBridge(flags) {
730
953
  throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
731
954
  }
732
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
+
733
1003
  console.log(`Starting OpenClaw bridge: device=${deviceId}`);
734
1004
  console.log(`Local gateway: ${gateway.gatewayUrl}`);
735
1005
  console.log(`Broker WS: ${brokerWs}`);
736
1006
 
737
1007
  const activeGatewaySockets = new Map();
1008
+ const reconnectState = {
1009
+ attempt: 0,
1010
+ timer: null,
1011
+ stopped: false,
1012
+ lastFailure: null,
1013
+ };
738
1014
  const brokerPath = (() => {
739
1015
  try {
740
1016
  return new URL(brokerWs).pathname || '';
@@ -784,15 +1060,102 @@ async function startOpenclawBridge(flags) {
784
1060
  return null;
785
1061
  };
786
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
+
787
1096
  const connectBroker = () => {
788
1097
  const wsUrl = new URL(brokerWs);
789
1098
  wsUrl.searchParams.set('token', deviceToken);
790
1099
 
791
1100
  const brokerSocket = new WebSocket(wsUrl.toString());
792
1101
  let actionCableHeartbeat = null;
1102
+ const setupGatewaySession = (sessionId, sessionBridge) => {
1103
+ if (!sessionBridge || !sessionBridge.socket) return;
1104
+ const gatewaySocket = sessionBridge.socket;
1105
+
1106
+ gatewaySocket.on('open', () => {
1107
+ console.log(`[bridge] gateway.open ${sessionId}`);
1108
+ flushSessionQueue(sessionBridge);
1109
+ });
1110
+
1111
+ gatewaySocket.on('message', (gatewayRaw) => {
1112
+ const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
1113
+ sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
1114
+ });
1115
+
1116
+ gatewaySocket.on('close', (code, reason) => {
1117
+ const reasonText = reason ? reason.toString() : '';
1118
+ console.log(
1119
+ `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
1120
+ );
1121
+ activeGatewaySockets.delete(sessionId);
1122
+ sendBrokerPayload(brokerSocket, {
1123
+ action: 'gateway_closed',
1124
+ type: 'gateway.closed',
1125
+ sessionId,
1126
+ code,
1127
+ reason: reasonText,
1128
+ });
1129
+ });
1130
+
1131
+ gatewaySocket.on('error', (err) => {
1132
+ console.error(`[bridge] gateway.error ${sessionId}: ${String(err)}`);
1133
+ sendBrokerPayload(brokerSocket, {
1134
+ action: 'log',
1135
+ type: 'log',
1136
+ sessionId,
1137
+ level: 'error',
1138
+ message: `Gateway socket error (${sessionId}): ${String(err)}`,
1139
+ });
1140
+ });
1141
+ };
1142
+
1143
+ const getOrCreateGatewaySession = (sessionId) => {
1144
+ const existing = activeGatewaySockets.get(sessionId);
1145
+ if (existing) return existing;
1146
+ const sessionBridge = ensureSessionBridge({
1147
+ sessions: activeGatewaySockets,
1148
+ sessionId,
1149
+ createSocket: () => new WebSocket(gateway.gatewayUrl),
1150
+ });
1151
+ if (sessionBridge) setupGatewaySession(sessionId, sessionBridge);
1152
+ return sessionBridge;
1153
+ };
793
1154
 
794
1155
  brokerSocket.on('open', () => {
795
1156
  console.log('[bridge] Connected to managed broker.');
1157
+ reconnectState.attempt = 0;
1158
+ reconnectState.lastFailure = null;
796
1159
  if (!actionCableMode) return;
797
1160
  brokerSocket.send(
798
1161
  JSON.stringify({
@@ -803,6 +1166,20 @@ async function startOpenclawBridge(flags) {
803
1166
  actionCableHeartbeat = setInterval(() => {
804
1167
  sendBrokerPayload(brokerSocket, { action: 'heartbeat' });
805
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
+ });
806
1183
  });
807
1184
 
808
1185
  brokerSocket.on('message', (rawData) => {
@@ -815,12 +1192,61 @@ async function startOpenclawBridge(flags) {
815
1192
  }
816
1193
 
817
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;
818
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
+ }
819
1221
  return;
820
1222
  }
821
1223
 
822
1224
  if (payload.type === 'broker.reject_subscription') {
823
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
+ }
824
1250
  return;
825
1251
  }
826
1252
 
@@ -831,55 +1257,9 @@ async function startOpenclawBridge(flags) {
831
1257
 
832
1258
  if (payload.type === 'client.open') {
833
1259
  const sessionId = String(payload.sessionId || '').trim();
834
- if (!sessionId || activeGatewaySockets.has(sessionId)) return;
1260
+ if (!sessionId) return;
835
1261
  console.log(`[bridge] client.open ${sessionId}`);
836
- const gatewaySocket = new WebSocket(gateway.gatewayUrl);
837
- const sessionBridge = {
838
- socket: gatewaySocket,
839
- queue: [],
840
- };
841
- activeGatewaySockets.set(sessionId, sessionBridge);
842
-
843
- gatewaySocket.on('open', () => {
844
- console.log(`[bridge] gateway.open ${sessionId}`);
845
- while (sessionBridge.queue.length > 0 && gatewaySocket.readyState === WebSocket.OPEN) {
846
- const nextFrame = sessionBridge.queue.shift();
847
- if (typeof nextFrame === 'string' && nextFrame) {
848
- gatewaySocket.send(nextFrame);
849
- }
850
- }
851
- });
852
-
853
- gatewaySocket.on('message', (gatewayRaw) => {
854
- const frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
855
- sendBrokerPayload(brokerSocket, { action: 'gateway_frame', type: 'gateway.frame', sessionId, frame });
856
- });
857
-
858
- gatewaySocket.on('close', (code, reason) => {
859
- const reasonText = reason ? reason.toString() : '';
860
- console.log(
861
- `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
862
- );
863
- activeGatewaySockets.delete(sessionId);
864
- sendBrokerPayload(brokerSocket, {
865
- action: 'gateway_closed',
866
- type: 'gateway.closed',
867
- sessionId,
868
- code,
869
- reason: reasonText,
870
- });
871
- });
872
-
873
- gatewaySocket.on('error', (err) => {
874
- console.error(`[bridge] gateway.error ${sessionId}: ${String(err)}`);
875
- sendBrokerPayload(brokerSocket, {
876
- action: 'log',
877
- type: 'log',
878
- sessionId,
879
- level: 'error',
880
- message: `Gateway socket error (${sessionId}): ${String(err)}`,
881
- });
882
- });
1262
+ getOrCreateGatewaySession(sessionId);
883
1263
  return;
884
1264
  }
885
1265
 
@@ -888,19 +1268,13 @@ async function startOpenclawBridge(flags) {
888
1268
  const frame = typeof payload.frame === 'string' ? payload.frame : '';
889
1269
  if (!sessionId || !frame) return;
890
1270
  console.log(`[bridge] client.frame ${sessionId}`);
891
- const sessionBridge = activeGatewaySockets.get(sessionId);
892
- if (!sessionBridge || !sessionBridge.socket) {
893
- console.log(`[bridge] client.frame dropped (no session) ${sessionId}`);
894
- return;
895
- }
896
- const gatewaySocket = sessionBridge.socket;
1271
+ const sessionBridge = getOrCreateGatewaySession(sessionId);
1272
+ if (!sessionBridge) return;
897
1273
  const frameWithAuth = injectGatewayAuth(frame, gateway);
898
- if (gatewaySocket.readyState === WebSocket.OPEN) {
899
- gatewaySocket.send(frameWithAuth);
900
- } else if (gatewaySocket.readyState === WebSocket.CONNECTING) {
1274
+ const result = forwardFrameToSession(sessionBridge, frameWithAuth);
1275
+ if (result === 'queued') {
901
1276
  console.log(`[bridge] client.frame queued ${sessionId}`);
902
- sessionBridge.queue.push(frameWithAuth);
903
- } else {
1277
+ } else if (result === 'dropped') {
904
1278
  console.log(`[bridge] client.frame dropped (socket not open) ${sessionId}`);
905
1279
  }
906
1280
  return;
@@ -923,7 +1297,8 @@ async function startOpenclawBridge(flags) {
923
1297
  clearInterval(actionCableHeartbeat);
924
1298
  actionCableHeartbeat = null;
925
1299
  }
926
- console.log(`[bridge] Broker disconnected (${code}) ${reason.toString()}`);
1300
+ const reasonText = reason ? reason.toString() : '';
1301
+ console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
927
1302
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
928
1303
  activeGatewaySockets.delete(sessionId);
929
1304
  try {
@@ -932,14 +1307,49 @@ async function startOpenclawBridge(flags) {
932
1307
  // no-op
933
1308
  }
934
1309
  }
935
- 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();
936
1321
  });
937
1322
 
938
1323
  brokerSocket.on('error', (err) => {
939
- 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}`);
940
1329
  });
941
1330
  };
942
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,
1347
+ });
1348
+ process.exit(0);
1349
+ };
1350
+ process.once('SIGINT', () => markStopped('SIGINT'));
1351
+ process.once('SIGTERM', () => markStopped('SIGTERM'));
1352
+
943
1353
  connectBroker();
944
1354
  await new Promise(() => {});
945
1355
  }
@@ -1029,25 +1439,13 @@ async function pairAndStartOpenclawBridge(flags) {
1029
1439
  }
1030
1440
 
1031
1441
  if (detach) {
1032
- const args = [
1033
- process.argv[1],
1034
- 'openclaw',
1035
- 'bridge',
1036
- '--broker-http',
1037
- managedConfig.brokerHttpUrl,
1038
- '--broker-ws',
1039
- brokerWs,
1040
- '--device-id',
1041
- deviceId,
1042
- '--device-token',
1043
- deviceToken,
1044
- ];
1045
- const child = spawn(process.execPath, args, {
1046
- detached: true,
1047
- stdio: 'ignore',
1442
+ const pid = startBridgeDetachedProcess({
1443
+ 'broker-http': managedConfig.brokerHttpUrl,
1444
+ 'broker-ws': brokerWs,
1445
+ 'device-id': deviceId,
1446
+ 'device-token': deviceToken,
1048
1447
  });
1049
- child.unref();
1050
- console.log(`Bridge started in background (pid: ${child.pid}).`);
1448
+ console.log(`Bridge started in background (pid: ${pid}).`);
1051
1449
  return;
1052
1450
  }
1053
1451
 
@@ -1115,6 +1513,69 @@ async function createOpenclawInviteLink(flags) {
1115
1513
  }
1116
1514
  }
1117
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
+
1118
1579
  function printOpenclawPluginSetup(flags) {
1119
1580
  const bridgeState = readBridgeState();
1120
1581
  const backendUrl = String(
@@ -1214,6 +1675,13 @@ async function main() {
1214
1675
  }
1215
1676
 
1216
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
+ }
1217
1685
  await startOpenclawBridge(args.flags);
1218
1686
  return;
1219
1687
  }
@@ -1228,6 +1696,11 @@ async function main() {
1228
1696
  return;
1229
1697
  }
1230
1698
 
1699
+ if (command === 'openclaw' && subcommand === 'status') {
1700
+ printOpenclawBridgeStatus(args.flags);
1701
+ return;
1702
+ }
1703
+
1231
1704
  if (command === 'openclaw' && subcommand === 'plugin') {
1232
1705
  printOpenclawPluginSetup(args.flags);
1233
1706
  return;
@@ -0,0 +1,51 @@
1
+ const WS_CONNECTING = 0;
2
+ const WS_OPEN = 1;
3
+
4
+ /**
5
+ * Ensure session state exists so client frames can be buffered before client.open arrives.
6
+ */
7
+ export function ensureSessionBridge({ sessions, sessionId, createSocket }) {
8
+ const id = String(sessionId || '').trim();
9
+ if (!id) return null;
10
+
11
+ const existing = sessions.get(id);
12
+ if (existing) return existing;
13
+
14
+ const socket = createSocket(id);
15
+ const next = { socket, queue: [] };
16
+ sessions.set(id, next);
17
+ return next;
18
+ }
19
+
20
+ /**
21
+ * Forward a frame to the gateway socket or queue it while connecting.
22
+ */
23
+ export function forwardFrameToSession(sessionBridge, frameText) {
24
+ if (!sessionBridge || !sessionBridge.socket || typeof frameText !== 'string' || !frameText) {
25
+ return 'dropped';
26
+ }
27
+
28
+ const { socket } = sessionBridge;
29
+ if (socket.readyState === WS_OPEN) {
30
+ socket.send(frameText);
31
+ return 'sent';
32
+ }
33
+
34
+ if (socket.readyState === WS_CONNECTING) {
35
+ sessionBridge.queue.push(frameText);
36
+ return 'queued';
37
+ }
38
+
39
+ return 'dropped';
40
+ }
41
+
42
+ export function flushSessionQueue(sessionBridge) {
43
+ if (!sessionBridge || !sessionBridge.socket) return;
44
+ const socket = sessionBridge.socket;
45
+ while (sessionBridge.queue.length > 0 && socket.readyState === WS_OPEN) {
46
+ const nextFrame = sessionBridge.queue.shift();
47
+ if (typeof nextFrame === 'string' && nextFrame) {
48
+ socket.send(nextFrame);
49
+ }
50
+ }
51
+ }
@@ -154,7 +154,12 @@ async function postJson({ url, token, body, timeoutMs }) {
154
154
  const oomiChannelPlugin = {
155
155
  id: CHANNEL_ID,
156
156
  meta: {
157
- name: 'Oomi',
157
+ label: 'Oomi',
158
+ selectionLabel: 'Oomi (Managed)',
159
+ docsPath: '/channels/oomi',
160
+ docsLabel: 'oomi',
161
+ blurb: 'Managed channel transport for Oomi chat.',
162
+ aliases: ['oomi-ai'],
158
163
  description: 'Managed Oomi channel plugin.',
159
164
  },
160
165
  capabilities: {
@@ -167,15 +172,22 @@ const oomiChannelPlugin = {
167
172
  threads: true,
168
173
  },
169
174
 
170
- config(cfg) {
171
- return normalizeConfig(cfg);
172
- },
175
+ config: {
176
+ listAccountIds(cfg) {
177
+ const normalized = normalizeConfig(cfg);
178
+ return Object.entries(normalized.accounts)
179
+ .filter(([, account]) => account.enabled !== false)
180
+ .map(([accountId]) => accountId);
181
+ },
173
182
 
174
- listAccountIds(cfg) {
175
- const normalized = normalizeConfig(cfg);
176
- return Object.entries(normalized.accounts)
177
- .filter(([, account]) => account.enabled !== false)
178
- .map(([accountId]) => accountId);
183
+ resolveAccount(cfg, accountId) {
184
+ const { accountId: resolvedAccountId, account } = resolveAccount(cfg, accountId);
185
+ if (!account) return null;
186
+ return {
187
+ id: resolvedAccountId,
188
+ ...account,
189
+ };
190
+ },
179
191
  },
180
192
 
181
193
  outbound: {
@@ -2,7 +2,7 @@
2
2
  "id": "oomi-ai",
3
3
  "name": "Oomi Channel Plugin",
4
4
  "description": "Managed Oomi channel integration for OpenClaw.",
5
- "version": "0.2.1",
5
+ "version": "0.2.4",
6
6
  "author": "Oomi",
7
7
  "license": "MIT",
8
8
  "openclawVersion": ">=0.5.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -46,7 +46,8 @@
46
46
  "url": "https://github.com/crispcode-io/oomi/issues"
47
47
  },
48
48
  "scripts": {
49
- "check": "node --check bin/oomi-ai.js"
49
+ "check": "node --check bin/oomi-ai.js",
50
+ "test": "node --test test/sessionBridgeState.test.mjs"
50
51
  },
51
52
  "dependencies": {
52
53
  "ws": "^8.19.0"
@@ -54,6 +55,7 @@
54
55
  "license": "MIT",
55
56
  "files": [
56
57
  "bin/oomi-ai.js",
58
+ "bin/sessionBridgeState.js",
57
59
  "openclaw.plugin.json",
58
60
  "openclaw.extension.js",
59
61
  "agent_instructions.md",