oomi-ai 0.3.8 → 0.4.0

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
@@ -8,6 +8,8 @@ import net from 'net';
8
8
  import { lookup as dnsLookup } from 'dns/promises';
9
9
  import { fileURLToPath } from 'url';
10
10
  import WebSocket from 'ws';
11
+ import { LOCAL_PROTOCOL_MIN, LOCAL_PROTOCOL_MAX, probeGateway } from '../lib/gatewayProbe.js';
12
+ import { LocalAgentRuntime, localAgentConfig, runLocalAgent, writePrivateJson, startChannelWorker } from '../lib/localAgent.js';
11
13
  import { createPersonaApiClient } from '../lib/personaApiClient.js';
12
14
  import { inferSpokenMetadataFromContent, normalizeSpokenMetadata } from '../lib/spokenMetadata.js';
13
15
  import {
@@ -31,6 +33,8 @@ import {
31
33
  } from '../lib/openclawProfile.js';
32
34
  import { ensureSessionBridge, flushSessionQueue, flushWaitingForConnect, forwardFrameToSession } from './sessionBridgeState.js';
33
35
 
36
+ let bridgeConnectionName = '';
37
+
34
38
  const MARKER_START = '<oomi-agent-instructions>';
35
39
  const MARKER_END = '</oomi-agent-instructions>';
36
40
 
@@ -53,7 +57,6 @@ const BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS = parsePositiveInteger(
53
57
  30000
54
58
  );
55
59
  const BRIDGE_LAUNCHD_LABEL = 'ai.oomi.bridge';
56
- const DEVICE_IDENTITY_PATH = resolveOpenclawIdentityPath();
57
60
  const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
58
61
  const BRIDGE_DEBUG_ENABLED = process.env.OOMI_BRIDGE_DEBUG === '1';
59
62
 
@@ -195,6 +198,20 @@ function usage() {
195
198
  console.log(`oomi <command>
196
199
 
197
200
  Commands:
201
+ connect --runtime openclaw|hermes|command [--name NAME] [--app-url URL]
202
+ Verify the selected local agent, pair once, start its bridge, and return an Auth Link.
203
+ Hermes: --profile PROFILE and --resume SESSION select the intended agent/session.
204
+ Other agents: --command '["/absolute/path/to/adapter"]' (JSON stdin / JSON stdout).
205
+ --cwd PATH selects the agent working directory; --name isolates multiple agents.
206
+ status [--name NAME]
207
+ Show broker connection and verified local agent health separately.
208
+ verify [--name NAME]
209
+ Send a harmless local test message and require a real agent reply.
210
+ invite [--name NAME] [--app-url URL]
211
+ Generate a fresh Auth Link from saved pairing without changing agents.
212
+ bridge [start|ensure|stop|restart|ps] [--name NAME] [--detach]
213
+ Manage only the named agent's bridge.
214
+
198
215
  init
199
216
  Install Oomi agent instructions into OpenClaw AGENTS.md.
200
217
 
@@ -233,7 +250,9 @@ Commands:
233
250
  persona-apps apply-action <slug>
234
251
  Apply an approved persona app action through the Oomi backend.
235
252
 
236
- context health
253
+ context health [--window day|week|month|year|all|range] [--date YYYY-MM-DD]
254
+ [--from YYYY-MM-DD --to YYYY-MM-DD] [--shape summary|series|both]
255
+ [--group-by day|week|month|year] [--timezone IANA-zone] [--cursor YYYY-MM-DD]
237
256
  Show the latest account-linked health context available to this paired OpenClaw device.
238
257
  context health sync [--wait]
239
258
  Request the linked Oomi mobile app to sync fresh HealthKit context.
@@ -572,7 +591,10 @@ async function handlePersonaAppApplyActionCommand(slug, flags = {}) {
572
591
 
573
592
  async function handleContextHealthCommand(flags = {}) {
574
593
  const client = createCliPersonaApiClient(flags);
575
- const payload = await client.getHealthContext();
594
+ const payload = await client.getHealthContext({
595
+ window: flags.window, date: flags.date, from: flags.from, to: flags.to,
596
+ shape: flags.shape, groupBy: flags['group-by'], timezone: flags.timezone, cursor: flags.cursor,
597
+ });
576
598
  printStructuredResult(payload, isTruthyFlag(flags.json));
577
599
  }
578
600
 
@@ -770,19 +792,19 @@ function readOpenclawDefaultSessionKey() {
770
792
  }
771
793
 
772
794
  function resolveBridgeStatePath() {
773
- return resolveOpenclawBridgeStatePath();
795
+ return bridgeConnectionName ? path.join(connectionDirectory(), 'oomi-bridge.json') : resolveOpenclawBridgeStatePath();
774
796
  }
775
797
 
776
798
  function resolveBridgeStatusPath() {
777
- return resolveOpenclawBridgeStatusPath();
799
+ return bridgeConnectionName ? path.join(connectionDirectory(), 'oomi-bridge-status.json') : resolveOpenclawBridgeStatusPath();
778
800
  }
779
801
 
780
802
  function resolveBridgeLockPath() {
781
- return resolveOpenclawBridgeLockPath();
803
+ return bridgeConnectionName ? path.join(connectionDirectory(), 'oomi-bridge.lock') : resolveOpenclawBridgeLockPath();
782
804
  }
783
805
 
784
806
  function resolveBridgeLiveLogPath() {
785
- return resolveOpenclawBridgeLiveLogPath();
807
+ return bridgeConnectionName ? path.join(connectionDirectory(), 'logs', 'oomi-bridge-live.log') : resolveOpenclawBridgeLiveLogPath();
786
808
  }
787
809
 
788
810
  function resolveBridgeLaunchAgentPlistPath() {
@@ -815,7 +837,7 @@ function readBridgeState() {
815
837
  function writeBridgeState(nextState) {
816
838
  const statePath = resolveBridgeStatePath();
817
839
  ensureDir(path.dirname(statePath));
818
- writeFile(statePath, JSON.stringify(nextState, null, 2) + '\n');
840
+ writePrivateJson(statePath, nextState);
819
841
  }
820
842
 
821
843
  function readBridgeStatus() {
@@ -933,11 +955,11 @@ function isPidAlive(pid) {
933
955
 
934
956
  function isBridgeWorkerCommand(command) {
935
957
  const text = String(command || '').trim().toLowerCase();
936
- if (!text.includes('openclaw bridge')) return false;
937
- if (/\bopenclaw\s+bridge\s+(ps|stop|restart|ensure)\b/.test(text)) return false;
938
- if (/\bopenclaw\s+bridge\s+start\b/.test(text)) return true;
939
- if (/\bopenclaw\s+bridge(\s+--|$)/.test(text)) return true;
940
- return false;
958
+ if (!/(?:oomi(?:-ai\.js)?|openclaw)\s+bridge\b/.test(text)) return false;
959
+ const connection = text.match(/--(?:connection|name)[=\s]+([a-z0-9_-]+)/)?.[1] || '';
960
+ if (connection !== bridgeConnectionName) return false;
961
+ if (/\bbridge\s+(ps|stop|restart|ensure|service)\b/.test(text)) return false;
962
+ return /\bbridge(?:\s+start)?(?:\s+--|$)/.test(text);
941
963
  }
942
964
 
943
965
  function isBridgeProcess(pid) {
@@ -1047,6 +1069,7 @@ function acquireBridgeLock(deviceId) {
1047
1069
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
1048
1070
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
1049
1071
  method: 'POST',
1072
+ signal: AbortSignal.timeout(15000),
1050
1073
  headers: { 'Content-Type': 'application/json' },
1051
1074
  body: JSON.stringify({ pairCode, deviceId }),
1052
1075
  });
@@ -1064,6 +1087,7 @@ async function requestManagedPairCode({ appUrl, label }) {
1064
1087
  const baseUrl = appUrl.replace(/\/$/, '');
1065
1088
  const response = await fetch(`${baseUrl}/api/gateway/managed/pair/start`, {
1066
1089
  method: 'POST',
1090
+ signal: AbortSignal.timeout(15000),
1067
1091
  headers: { 'Content-Type': 'application/json' },
1068
1092
  body: JSON.stringify({ label }),
1069
1093
  });
@@ -1080,6 +1104,7 @@ async function requestManagedPairCode({ appUrl, label }) {
1080
1104
  async function requestBrokerPairCode({ brokerHttp, adminKey, label }) {
1081
1105
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/start`, {
1082
1106
  method: 'POST',
1107
+ signal: AbortSignal.timeout(15000),
1083
1108
  headers: {
1084
1109
  'Content-Type': 'application/json',
1085
1110
  Authorization: `Bearer ${adminKey}`,
@@ -1099,6 +1124,7 @@ async function requestBrokerPairCode({ brokerHttp, adminKey, label }) {
1099
1124
  async function requestConnectInviteLink({ backendHttp, appUrl, sessionKey, deviceToken }) {
1100
1125
  const response = await fetch(`${backendHttp.replace(/\/$/, '')}/v1/invite_links/start`, {
1101
1126
  method: 'POST',
1127
+ signal: AbortSignal.timeout(15000),
1102
1128
  headers: {
1103
1129
  'Content-Type': 'application/json',
1104
1130
  Authorization: `Bearer ${deviceToken}`,
@@ -1119,6 +1145,7 @@ async function fetchManagedGatewayConfig({ appUrl }) {
1119
1145
  const baseUrl = appUrl.replace(/\/$/, '');
1120
1146
  const response = await fetch(`${baseUrl}/api/gateway/managed/config`, {
1121
1147
  method: 'GET',
1148
+ signal: AbortSignal.timeout(15000),
1122
1149
  headers: { 'Content-Type': 'application/json' },
1123
1150
  });
1124
1151
  const payload = await response.json().catch(() => ({}));
@@ -1218,11 +1245,12 @@ function buildDeviceAuthPayloadV3({
1218
1245
  }
1219
1246
 
1220
1247
  function loadGatewayDeviceIdentity() {
1221
- if (!fs.existsSync(DEVICE_IDENTITY_PATH)) {
1248
+ const identityPath = resolveOpenclawIdentityPath();
1249
+ if (!fs.existsSync(identityPath)) {
1222
1250
  return null;
1223
1251
  }
1224
1252
  try {
1225
- const parsed = JSON.parse(readFile(DEVICE_IDENTITY_PATH));
1253
+ const parsed = JSON.parse(readFile(identityPath));
1226
1254
  if (
1227
1255
  parsed &&
1228
1256
  parsed.version === 1 &&
@@ -1312,12 +1340,8 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
1312
1340
  const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
1313
1341
  const params = {};
1314
1342
 
1315
- params.minProtocol = Number.isInteger(rawParams.minProtocol) && rawParams.minProtocol >= 1
1316
- ? rawParams.minProtocol
1317
- : 3;
1318
- params.maxProtocol = Number.isInteger(rawParams.maxProtocol) && rawParams.maxProtocol >= 1
1319
- ? rawParams.maxProtocol
1320
- : 3;
1343
+ params.minProtocol = gatewayAuth.protocol || LOCAL_PROTOCOL_MIN;
1344
+ params.maxProtocol = gatewayAuth.protocol || LOCAL_PROTOCOL_MAX;
1321
1345
 
1322
1346
  const clientInput = rawParams.client && typeof rawParams.client === 'object' ? rawParams.client : {};
1323
1347
  const client = {};
@@ -2174,6 +2198,7 @@ async function runBridgePreflight({ brokerWs, gatewayUrl, gatewayConfigPath }) {
2174
2198
  throw new Error('Broker WS URL is missing hostname.');
2175
2199
  }
2176
2200
  await dnsLookup(brokerHost);
2201
+ if (gatewayUrl === 'local-agent:') return;
2177
2202
 
2178
2203
  let parsedGatewayUrl;
2179
2204
  try {
@@ -2197,6 +2222,7 @@ function buildBridgeDetachArgs(rawFlags = {}) {
2197
2222
  'device-token',
2198
2223
  ];
2199
2224
  const args = [process.argv[1], 'openclaw', 'bridge'];
2225
+ if (bridgeConnectionName) args.push('--connection', bridgeConnectionName);
2200
2226
 
2201
2227
  for (const key of orderedKeys) {
2202
2228
  const value = rawFlags[key];
@@ -2262,6 +2288,10 @@ function listBridgeProcessPids() {
2262
2288
  const status = readBridgeStatus();
2263
2289
  addPid(status.pid);
2264
2290
 
2291
+ // Named connections are owned by their own lock and status, including when a
2292
+ // custom connections directory is used. Never sweep another installation.
2293
+ if (bridgeConnectionName) return Array.from(pids);
2294
+
2265
2295
  try {
2266
2296
  const result = spawnSync('ps', ['-Ao', 'pid=,command='], {
2267
2297
  encoding: 'utf8',
@@ -2491,7 +2521,7 @@ async function stopBridgeLaunchdService() {
2491
2521
  async function resolveBridgeRuntimeConfig(flags, bridgeState) {
2492
2522
  const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
2493
2523
  const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
2494
- const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || '').trim();
2524
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || bridgeState.appUrl || '').trim();
2495
2525
 
2496
2526
  let brokerHttp = String(explicitBrokerHttp || process.env.OOMI_CHAT_BROKER_HTTP_URL || bridgeState.brokerHttp || '').trim();
2497
2527
  let brokerWs = String(explicitBrokerWs || process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL || bridgeState.brokerWs || '').trim();
@@ -2534,6 +2564,7 @@ async function startOpenclawBridge(flags) {
2534
2564
  }
2535
2565
 
2536
2566
  const bridgeState = readBridgeState();
2567
+ if (bridgeState.openclawHome) process.env.OPENCLAW_HOME = bridgeState.openclawHome;
2537
2568
  const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
2538
2569
  const brokerHttp = runtimeConfig.brokerHttp;
2539
2570
  const brokerWs = runtimeConfig.brokerWs;
@@ -2574,14 +2605,17 @@ async function startOpenclawBridge(flags) {
2574
2605
  console.log(`Claimed bridge device token for ${deviceId}.`);
2575
2606
  }
2576
2607
 
2577
- const gateway = readOpenclawGatewayConfig();
2578
- if (!gateway.token && !gateway.password) {
2608
+ const localRuntime = bridgeState.localAgent
2609
+ ? new LocalAgentRuntime(bridgeState.localAgent, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
2610
+ : null;
2611
+ const gateway = localRuntime ? { gatewayUrl: 'local-agent:', configPath: resolveBridgeStatePath() } : readOpenclawGatewayConfig();
2612
+ if (!localRuntime && !gateway.token && !gateway.password) {
2579
2613
  throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
2580
2614
  }
2581
- const gatewayDeviceIdentity = loadGatewayDeviceIdentity();
2582
- if (!gatewayDeviceIdentity) {
2615
+ const gatewayDeviceIdentity = localRuntime ? null : loadGatewayDeviceIdentity();
2616
+ if (!localRuntime && !gatewayDeviceIdentity) {
2583
2617
  console.warn(
2584
- `[bridge] OpenClaw device identity not found at ${DEVICE_IDENTITY_PATH}; device-signed connect may fail on newer gateways.`
2618
+ `[bridge] OpenClaw device identity not found at ${resolveOpenclawIdentityPath()}; device-signed connect may fail on newer gateways.`
2585
2619
  );
2586
2620
  }
2587
2621
 
@@ -2599,10 +2633,16 @@ async function startOpenclawBridge(flags) {
2599
2633
  gatewayUrl: gateway.gatewayUrl,
2600
2634
  gatewayConfigPath: gateway.configPath,
2601
2635
  });
2636
+ if (!localRuntime) {
2637
+ const result = await probeGateway({ gateway, deviceIdentity: gatewayDeviceIdentity, prepareFrame: prepareGatewayFrameForLocalGateway });
2638
+ gateway.protocol = result.protocol;
2639
+ }
2640
+ updateBridgeStatus({ runtime: bridgeState.runtime || 'openclaw', runtimeReady: Boolean(bridgeState.verifiedAt), verifiedAt: bridgeState.verifiedAt || null, gatewayProtocol: gateway.protocol || null });
2602
2641
  } catch (err) {
2603
2642
  const failure = classifyBridgeFailure({ err });
2604
2643
  updateBridgeStatus({
2605
2644
  status: 'error',
2645
+ runtimeReady: false,
2606
2646
  deviceId,
2607
2647
  brokerWs,
2608
2648
  brokerHttp,
@@ -2632,7 +2672,8 @@ async function startOpenclawBridge(flags) {
2632
2672
  startedAt: bridgeNowIso(),
2633
2673
  });
2634
2674
 
2635
- console.log(`Starting OpenClaw bridge: device=${deviceId}`);
2675
+ console.log(`Starting ${bridgeState.runtime || 'openclaw'} bridge: device=${deviceId}`);
2676
+ const stopChannelWorker = localRuntime ? startChannelWorker(bridgeState, localRuntime) : () => {};
2636
2677
  console.log(`Local gateway: ${gateway.gatewayUrl}`);
2637
2678
  console.log(`Broker WS: ${brokerWs}`);
2638
2679
  bridgeDebugLog(
@@ -3316,6 +3357,7 @@ async function startOpenclawBridge(flags) {
3316
3357
  }
3317
3358
  }
3318
3359
  } else if (requestMeta.method === 'connect' && !responseMeta.ok) {
3360
+ updateBridgeStatus({ status: 'degraded', runtimeReady: false, lastErrorCode: 'gateway_connect_rejected', lastErrorMessage: gatewayPayload?.error?.message || 'Local agent rejected the gateway handshake.', hint: 'Run oomi verify for this connection, repair the local runtime, and restart its bridge.' });
3319
3361
  const deferredFrames = Array.isArray(sessionBridge.waitingForConnect)
3320
3362
  ? sessionBridge.waitingForConnect.splice(0, sessionBridge.waitingForConnect.length)
3321
3363
  : [];
@@ -3353,6 +3395,12 @@ async function startOpenclawBridge(flags) {
3353
3395
  }
3354
3396
  }
3355
3397
 
3398
+ if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'final') {
3399
+ updateBridgeStatus({ runtimeReady: true, lastReplyAt: bridgeNowIso() });
3400
+ } else if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'error') {
3401
+ updateBridgeStatus({ status: 'degraded', runtimeReady: false, lastErrorMessage: gatewayPayload.payload.errorMessage || 'The local agent failed to answer.' });
3402
+ }
3403
+
3356
3404
  if (isGatewayRunStartedFrame(frame)) {
3357
3405
  sendGatewayAck(brokerSocket, {
3358
3406
  sessionId,
@@ -3445,7 +3493,7 @@ async function startOpenclawBridge(flags) {
3445
3493
  const sessionBridge = ensureSessionBridge({
3446
3494
  sessions: activeGatewaySockets,
3447
3495
  sessionId,
3448
- createSocket: () => new WebSocket(gateway.gatewayUrl),
3496
+ createSocket: () => localRuntime ? localRuntime.socket() : new WebSocket(gateway.gatewayUrl),
3449
3497
  });
3450
3498
  if (sessionBridge) setupGatewaySession(sessionId, sessionBridge);
3451
3499
  return sessionBridge;
@@ -3798,8 +3846,10 @@ async function startOpenclawBridge(flags) {
3798
3846
  }));
3799
3847
  };
3800
3848
 
3801
- const markStopped = (signal) => {
3849
+ const markStopped = async (signal) => {
3802
3850
  reconnectState.stopped = true;
3851
+ stopChannelWorker();
3852
+ if (localRuntime) await localRuntime.queue;
3803
3853
  if (reconnectState.timer) {
3804
3854
  clearTimeout(reconnectState.timer);
3805
3855
  reconnectState.timer = null;
@@ -3828,7 +3878,7 @@ async function startOpenclawBridge(flags) {
3828
3878
 
3829
3879
  async function pairAndStartOpenclawBridge(flags) {
3830
3880
  const bridgeState = readBridgeState();
3831
- const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
3881
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || bridgeState.appUrl || 'https://www.oomi.ai').trim();
3832
3882
  const deviceId = resolveDeviceId(flags, bridgeState);
3833
3883
  const label = String(flags.label || `${deviceId}-bridge`).trim();
3834
3884
  const sessionKey = String(
@@ -3963,8 +4013,8 @@ async function createOpenclawInviteLink(flags) {
3963
4013
  bridgeState.brokerHttp ||
3964
4014
  ''
3965
4015
  ).trim();
3966
- const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
3967
- const configSessionKey = readOpenclawDefaultSessionKey();
4016
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || bridgeState.appUrl || 'https://www.oomi.ai').trim();
4017
+ const configSessionKey = bridgeConnectionName ? '' : readOpenclawDefaultSessionKey();
3968
4018
  const sessionKey = String(
3969
4019
  flags['session-key'] ||
3970
4020
  process.env.OOMI_SESSION_KEY ||
@@ -4013,6 +4063,7 @@ async function createOpenclawInviteLink(flags) {
4013
4063
  function printOpenclawBridgeStatus(flags) {
4014
4064
  const bridgeState = readBridgeState();
4015
4065
  const runtimeStatus = readBridgeStatus();
4066
+ if (runtimeStatus.pid && !isBridgeProcess(runtimeStatus.pid)) { runtimeStatus.status = 'stopped'; runtimeStatus.runtimeReady = false; }
4016
4067
  const jsonOutput = isTruthyFlag(flags.json);
4017
4068
  const redactToken = (value) => {
4018
4069
  const text = String(value || '').trim();
@@ -4051,7 +4102,9 @@ function printOpenclawBridgeStatus(flags) {
4051
4102
  console.log(`Device token: ${payload.bridgeState.deviceToken}`);
4052
4103
  }
4053
4104
  if (payload.runtime && typeof payload.runtime === 'object' && Object.keys(payload.runtime).length > 0) {
4054
- console.log(`Runtime state: ${String(payload.runtime.status || 'unknown')}`);
4105
+ console.log(`Bridge state: ${String(payload.runtime.status || 'unknown')}`);
4106
+ console.log(`Agent runtime: ${bridgeState.runtime || 'openclaw'}${bridgeState.localAgent?.profile ? ` (profile ${bridgeState.localAgent.profile})` : ''}`);
4107
+ console.log(`Local reply verified: ${payload.runtime.runtimeReady ? 'yes' : 'no'}${payload.runtime.verifiedAt ? ` at ${payload.runtime.verifiedAt}` : ''}`);
4055
4108
  if (payload.runtime.lastConnectedAt) {
4056
4109
  console.log(`Last connected: ${payload.runtime.lastConnectedAt}`);
4057
4110
  }
@@ -4329,6 +4382,90 @@ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
4329
4382
  );
4330
4383
  }
4331
4384
 
4385
+ function connectionDirectory() {
4386
+ return path.join(process.env.OOMI_CONNECTIONS_DIR || path.join(os.homedir(), '.oomi', 'connections'), bridgeConnectionName);
4387
+ }
4388
+
4389
+ async function verifyLocalAgent(state) {
4390
+ if (state.openclawHome) process.env.OPENCLAW_HOME = state.openclawHome;
4391
+ let result;
4392
+ if (state.localAgent) {
4393
+ await runLocalAgent(state.localAgent, {
4394
+ sessionKey: `oomi:verify:${randomUUID()}`, idempotencyKey: randomUUID(), history: [],
4395
+ message: 'Oomi connection check. Reply with OOMI_OK only. Do not use tools or change anything.',
4396
+ });
4397
+ result = { verifiedAt: new Date().toISOString() };
4398
+ } else {
4399
+ result = await probeGateway({ gateway: readOpenclawGatewayConfig(), deviceIdentity: loadGatewayDeviceIdentity(), prepareFrame: prepareGatewayFrameForLocalGateway, verifyReply: true, timeoutMs: 180000 });
4400
+ }
4401
+ return { runtime: state.runtime || 'openclaw', ...result };
4402
+ }
4403
+
4404
+ async function connectLocalAgent(flags) {
4405
+ const runtime = String(flags.runtime || '');
4406
+ if (!['openclaw', 'hermes', 'command'].includes(runtime)) throw new Error('Specify --runtime openclaw, hermes, or command. Never select a different agent just because it is installed.');
4407
+ const previous = readBridgeState();
4408
+ if (previous.runtime && previous.runtime !== runtime) throw new Error('This connection belongs to a different runtime. Choose a different --name.');
4409
+ const appUrl = String(flags['app-url'] || previous.appUrl || 'https://www.oomi.ai').replace(/\/$/, '');
4410
+ if (previous.appUrl && previous.appUrl !== appUrl) throw new Error('This connection belongs to another Oomi site. Choose a different --name.');
4411
+ const state = { ...previous, runtime, appUrl };
4412
+ if (runtime === 'openclaw') state.openclawHome = previous.openclawHome || resolveOpenclawHome();
4413
+ if (runtime !== 'openclaw') {
4414
+ const saved = previous.localAgent;
4415
+ state.localAgent = localAgentConfig({
4416
+ ...flags,
4417
+ command: flags.command || (saved ? JSON.stringify(saved.command) : undefined),
4418
+ profile: flags.profile ?? saved?.profile,
4419
+ 'hermes-home': saved?.hermesHome,
4420
+ resume: flags.resume ?? saved?.resume,
4421
+ cwd: flags.cwd ?? saved?.cwd,
4422
+ });
4423
+ if (saved && JSON.stringify(saved) !== JSON.stringify(state.localAgent)) throw new Error('This name already identifies a different agent configuration. Use a new --name to change its command, profile, session, or working directory.');
4424
+ }
4425
+ console.log(`Verifying ${runtime}${state.localAgent?.profile ? ` profile ${state.localAgent.profile}` : ''} with a local test message...`);
4426
+ try {
4427
+ const verified = await verifyLocalAgent(state);
4428
+ state.verifiedAt = verified.verifiedAt;
4429
+ } catch (err) {
4430
+ updateBridgeStatus({ runtime, runtimeReady: false, status: 'degraded', lastErrorMessage: err.message });
4431
+ throw err;
4432
+ }
4433
+ if (!state.deviceToken) {
4434
+ const config = await fetchManagedGatewayConfig({ appUrl });
4435
+ state.brokerHttp = rewriteLoopbackBrokerUrlForAppHost(config.brokerHttpUrl, appUrl);
4436
+ state.brokerWs = rewriteLoopbackBrokerUrlForAppHost(config.brokerDeviceWsUrl, appUrl);
4437
+ const label = String(flags.label || flags.name || runtime);
4438
+ const pair = await requestManagedPairCode({ appUrl, label });
4439
+ state.deviceId = `${runtime}-${randomUUID()}`;
4440
+ const claim = await claimBridgeDeviceToken({ brokerHttp: state.brokerHttp, pairCode: pair.pairCode, deviceId: state.deviceId });
4441
+ state.deviceToken = claim.deviceToken;
4442
+ state.sessionKey = String(flags['session-key'] || (runtime === 'openclaw' ? readOpenclawDefaultSessionKey() : '') || 'agent:main:webchat:channel:oomi');
4443
+ state.claimedAt = new Date().toISOString();
4444
+ }
4445
+ // Stop this connection only; never leave a worker using an old adapter after a change.
4446
+ const supervised = !bridgeConnectionName && process.platform === 'darwin' && readBridgeLaunchdStatus().installed;
4447
+ const stopped = supervised ? await stopBridgeLaunchdService() : await stopBridgeProcesses();
4448
+ if (stopped.stillAlive?.length) throw new Error('The existing bridge did not stop. Resolve it before reconnecting.');
4449
+ writeBridgeState(state);
4450
+ updateBridgeStatus({ status: 'starting', runtime, runtimeReady: true, verifiedAt: state.verifiedAt, lastErrorMessage: '' });
4451
+ const child = supervised ? (startBridgeLaunchdService(), { pid: null }) : startBridgeDetachedProcess({ 'app-url': appUrl });
4452
+ const deadline = Date.now() + 20000;
4453
+ let connected = false;
4454
+ while (Date.now() < deadline) {
4455
+ const status = readBridgeStatus();
4456
+ if (status.pid && (!child.pid || status.pid === child.pid) && status.status === 'connected') { connected = true; break; }
4457
+ if ((!child.pid || status.pid === child.pid) && status.status === 'error') throw new Error(status.lastErrorMessage || 'Bridge startup failed.');
4458
+ if (child.pid && !isPidAlive(child.pid)) throw new Error(`Bridge exited. Check ${resolveBridgeLiveLogPath()}.`);
4459
+ await delay(200);
4460
+ }
4461
+ if (!connected) throw new Error('The local agent replied, but the Oomi bridge did not connect. Run oomi status for this connection.');
4462
+ const invite = await requestConnectInviteLink({ backendHttp: state.brokerHttp, appUrl, sessionKey: state.sessionKey, deviceToken: state.deviceToken });
4463
+ console.log(`Verified agent: ${runtime}${state.localAgent?.profile ? ` (${state.localAgent.profile})` : ''}`);
4464
+ console.log('Oomi Connect Ready');
4465
+ console.log(`Auth Link: ${invite.inviteUrl}`);
4466
+ console.log('Open the Auth Link to approve ownership, then send a message in Oomi to verify delivery end to end.');
4467
+ }
4468
+
4332
4469
  async function main() {
4333
4470
  const args = parseArgs(process.argv);
4334
4471
  const command = args.command;
@@ -4339,6 +4476,29 @@ async function main() {
4339
4476
  process.exit(0);
4340
4477
  }
4341
4478
 
4479
+ const connection = args.flags.connection || args.flags.name || (command === 'connect' && args.flags.runtime !== 'openclaw' ? args.flags.runtime : '');
4480
+ if (connection && !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(connection)) throw new Error('Connection name must use lowercase letters, numbers, underscores, or hyphens.');
4481
+ bridgeConnectionName = connection || '';
4482
+
4483
+ if (command === 'connect') return connectLocalAgent(args.flags);
4484
+ if (command === 'verify') {
4485
+ const state = readBridgeState();
4486
+ if (!state.deviceToken) throw new Error('No saved connection. Run oomi connect with an explicit runtime first.');
4487
+ try {
4488
+ const result = await verifyLocalAgent(state);
4489
+ writeBridgeState({ ...state, verifiedAt: result.verifiedAt });
4490
+ updateBridgeStatus({ runtimeReady: true, verifiedAt: result.verifiedAt, lastErrorMessage: '' });
4491
+ console.log(JSON.stringify(result, null, 2));
4492
+ } catch (err) {
4493
+ updateBridgeStatus({ runtimeReady: false, status: 'degraded', lastErrorMessage: err.message });
4494
+ throw err;
4495
+ }
4496
+ return;
4497
+ }
4498
+ if (command === 'status') return printOpenclawBridgeStatus(args.flags);
4499
+ if (command === 'invite') return createOpenclawInviteLink(args.flags);
4500
+ if (command === 'bridge') return handleBridgeLifecycleCommand(args.flags, subcommand || 'start');
4501
+
4342
4502
  await maybeNotifyUpdate(command);
4343
4503
 
4344
4504
  if (command === 'init') {
@@ -4363,6 +4523,7 @@ async function main() {
4363
4523
  if (command === 'openclaw' && subcommand === 'bridge') {
4364
4524
  const bridgeAction = String(args.positionals[0] || 'start').trim().toLowerCase();
4365
4525
  if (bridgeAction === 'service') {
4526
+ if (bridgeConnectionName) throw new Error('Named connections use oomi bridge start --name NAME with your host process supervisor. The legacy OpenClaw service is only for the default connection.');
4366
4527
  const serviceAction = args.positionals[1] || 'status';
4367
4528
  await handleBridgeServiceCommand(serviceAction, args.flags);
4368
4529
  return;
@@ -4461,7 +4622,9 @@ async function main() {
4461
4622
 
4462
4623
  const __currentFilePath = fileURLToPath(import.meta.url);
4463
4624
  const __invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
4464
- const __isDirectExecution = Boolean(__invokedPath) && __invokedPath === path.resolve(__currentFilePath);
4625
+ const __isDirectExecution = Boolean(__invokedPath)
4626
+ && fs.existsSync(__invokedPath)
4627
+ && fs.realpathSync(__invokedPath) === fs.realpathSync(__currentFilePath);
4465
4628
 
4466
4629
  if (__isDirectExecution) {
4467
4630
  main().catch((err) => {
@@ -0,0 +1,60 @@
1
+ import WebSocket from 'ws';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ // The broker's protocol is independent of the installed local gateway version.
5
+ export const LOCAL_PROTOCOL_MIN = 3;
6
+ export const LOCAL_PROTOCOL_MAX = 4;
7
+
8
+ export function probeGateway({ gateway, deviceIdentity, prepareFrame, verifyReply = false, timeoutMs = 15000 }) {
9
+ return new Promise((resolve, reject) => {
10
+ const socket = new WebSocket(gateway.gatewayUrl);
11
+ const connectId = randomUUID();
12
+ const runId = randomUUID();
13
+ let settled = false;
14
+ let sent = false;
15
+ let protocol;
16
+ const finish = (error, result) => {
17
+ if (settled) return;
18
+ settled = true;
19
+ clearTimeout(timer);
20
+ socket.removeAllListeners('message');
21
+ socket.close();
22
+ if (error) reject(error); else resolve(result);
23
+ };
24
+ const timer = setTimeout(() => finish(new Error('Local agent verification timed out. Check its gateway, model credentials, and logs.')), timeoutMs);
25
+ const connect = (nonce = '') => {
26
+ if (sent) return;
27
+ const frame = prepareFrame(JSON.stringify({ type: 'req', id: connectId, method: 'connect', params: {} }), gateway, { deviceIdentity, connectNonce: nonce });
28
+ if (frame.waitForChallenge) return;
29
+ sent = true;
30
+ socket.send(frame.frameText);
31
+ };
32
+ socket.on('open', () => { if (!deviceIdentity) connect(); });
33
+ socket.on('error', (err) => finish(new Error(`Local gateway verification failed: ${err.message}`)));
34
+ socket.on('close', (code) => finish(new Error(`Local gateway closed before verification completed (${code}).`)));
35
+ socket.on('message', (raw) => {
36
+ let frame;
37
+ try { frame = JSON.parse(raw.toString()); } catch { return; }
38
+ if (frame.event === 'connect.challenge') connect(frame.payload?.nonce);
39
+ if (frame.type === 'res' && (frame.id === connectId || frame.id === runId) && !frame.ok) {
40
+ finish(new Error(`Local agent rejected ${frame.id === connectId ? 'connect' : 'verification'}: ${frame.error?.message || frame.error?.code || 'unknown error'}`));
41
+ } else if (frame.type === 'res' && frame.id === connectId && frame.ok) {
42
+ protocol = frame.payload?.protocol;
43
+ if (![3, 4].includes(protocol)) return finish(new Error('Local gateway returned an unsupported protocol version. Update oomi-ai.'));
44
+ if (!verifyReply) return finish(null, { protocol });
45
+ socket.send(JSON.stringify({ type: 'req', id: runId, method: 'chat.send', params: {
46
+ sessionKey: `agent:main:oomi:verify:${runId}`, idempotencyKey: runId,
47
+ message: 'Oomi connection check. Reply with OOMI_OK only. Do not use tools or change anything.',
48
+ } }));
49
+ } else if (verifyReply && frame.event === 'chat' && frame.payload?.runId === runId) {
50
+ if (frame.payload.state === 'error' || frame.payload.state === 'aborted') return finish(new Error('Local agent could not complete the verification message.'));
51
+ if (frame.payload.state === 'final') {
52
+ const content = frame.payload.message?.content;
53
+ const text = typeof content === 'string' ? content : (Array.isArray(content) ? content.map((part) => part.text || '').join('') : '');
54
+ if (!text.trim()) return finish(new Error('Local agent returned an empty verification reply.'));
55
+ finish(null, { protocol, verifiedAt: new Date().toISOString() });
56
+ }
57
+ }
58
+ });
59
+ });
60
+ }