oomi-ai 0.3.8 → 0.5.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,9 @@ 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';
13
+ import { validateAgentName, identityRequest, startIdentityWorker, handleIdentityCommand } from '../lib/agentIdentity.js';
11
14
  import { createPersonaApiClient } from '../lib/personaApiClient.js';
12
15
  import { inferSpokenMetadataFromContent, normalizeSpokenMetadata } from '../lib/spokenMetadata.js';
13
16
  import {
@@ -31,6 +34,8 @@ import {
31
34
  } from '../lib/openclawProfile.js';
32
35
  import { ensureSessionBridge, flushSessionQueue, flushWaitingForConnect, forwardFrameToSession } from './sessionBridgeState.js';
33
36
 
37
+ let bridgeConnectionName = '';
38
+
34
39
  const MARKER_START = '<oomi-agent-instructions>';
35
40
  const MARKER_END = '</oomi-agent-instructions>';
36
41
 
@@ -53,7 +58,6 @@ const BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS = parsePositiveInteger(
53
58
  30000
54
59
  );
55
60
  const BRIDGE_LAUNCHD_LABEL = 'ai.oomi.bridge';
56
- const DEVICE_IDENTITY_PATH = resolveOpenclawIdentityPath();
57
61
  const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
58
62
  const BRIDGE_DEBUG_ENABLED = process.env.OOMI_BRIDGE_DEBUG === '1';
59
63
 
@@ -195,6 +199,25 @@ function usage() {
195
199
  console.log(`oomi <command>
196
200
 
197
201
  Commands:
202
+ connect --runtime openclaw|hermes|command [--name CONNECTION] [--agent-name "Your name"] [--app-url URL]
203
+ Verify the selected local agent, pair once, start its bridge, and return an Auth Link.
204
+ Hermes: --profile PROFILE and --resume SESSION select the intended agent/session.
205
+ Other agents: --command '["/absolute/path/to/adapter"]' (JSON stdin / JSON stdout).
206
+ --cwd PATH selects the agent working directory; --name isolates multiple agents.
207
+ status [--name NAME]
208
+ Show broker connection and verified local agent health separately.
209
+ verify [--name NAME]
210
+ Send a harmless local test message and require a real agent reply.
211
+ identity get|set [--connection NAME] [--agent-name NAME] [--bio TEXT] [--profile-image-url URL]
212
+ identity acknowledge --identity-id ID --revision N [--connection NAME]
213
+ identity backup --files '["/path/SOUL.md"]' [--connection NAME]
214
+ identity backups [--connection NAME]
215
+ identity restore --snapshot ID --output NEW_FOLDER [--connection NAME]
216
+ invite [--name NAME] [--app-url URL]
217
+ Generate a fresh Auth Link from saved pairing without changing agents.
218
+ bridge [start|ensure|stop|restart|ps] [--name NAME] [--detach]
219
+ Manage only the named agent's bridge.
220
+
198
221
  init
199
222
  Install Oomi agent instructions into OpenClaw AGENTS.md.
200
223
 
@@ -233,8 +256,11 @@ Commands:
233
256
  persona-apps apply-action <slug>
234
257
  Apply an approved persona app action through the Oomi backend.
235
258
 
236
- context health
259
+ context health [--window day|week|month|year|all|range] [--date YYYY-MM-DD]
260
+ [--from YYYY-MM-DD --to YYYY-MM-DD] [--shape summary|series|both]
261
+ [--group-by day|week|month|year] [--timezone IANA-zone] [--cursor YYYY-MM-DD]
237
262
  Show the latest account-linked health context available to this paired OpenClaw device.
263
+ context plugins [--plugin apple_calendar|apple_reminders|apple_contacts|apple_location|apple_notes|google_gmail|google_drive|google_calendar|bluetooth_devices]
238
264
  context health sync [--wait]
239
265
  Request the linked Oomi mobile app to sync fresh HealthKit context.
240
266
  Use --wait to poll until fresh context is uploaded or the timeout expires.
@@ -341,7 +367,7 @@ function parseArgs(argv) {
341
367
  if (val.startsWith('--')) {
342
368
  const key = val.replace(/^--/, '');
343
369
  const next = rest[i + 1];
344
- if (!next || next.startsWith('--')) {
370
+ if (next === undefined || next.startsWith('--')) {
345
371
  args.flags[key] = true;
346
372
  } else {
347
373
  args.flags[key] = next;
@@ -572,7 +598,10 @@ async function handlePersonaAppApplyActionCommand(slug, flags = {}) {
572
598
 
573
599
  async function handleContextHealthCommand(flags = {}) {
574
600
  const client = createCliPersonaApiClient(flags);
575
- const payload = await client.getHealthContext();
601
+ const payload = await client.getHealthContext({
602
+ window: flags.window, date: flags.date, from: flags.from, to: flags.to,
603
+ shape: flags.shape, groupBy: flags['group-by'], timezone: flags.timezone, cursor: flags.cursor,
604
+ });
576
605
  printStructuredResult(payload, isTruthyFlag(flags.json));
577
606
  }
578
607
 
@@ -770,19 +799,19 @@ function readOpenclawDefaultSessionKey() {
770
799
  }
771
800
 
772
801
  function resolveBridgeStatePath() {
773
- return resolveOpenclawBridgeStatePath();
802
+ return bridgeConnectionName ? path.join(connectionDirectory(), 'oomi-bridge.json') : resolveOpenclawBridgeStatePath();
774
803
  }
775
804
 
776
805
  function resolveBridgeStatusPath() {
777
- return resolveOpenclawBridgeStatusPath();
806
+ return bridgeConnectionName ? path.join(connectionDirectory(), 'oomi-bridge-status.json') : resolveOpenclawBridgeStatusPath();
778
807
  }
779
808
 
780
809
  function resolveBridgeLockPath() {
781
- return resolveOpenclawBridgeLockPath();
810
+ return bridgeConnectionName ? path.join(connectionDirectory(), 'oomi-bridge.lock') : resolveOpenclawBridgeLockPath();
782
811
  }
783
812
 
784
813
  function resolveBridgeLiveLogPath() {
785
- return resolveOpenclawBridgeLiveLogPath();
814
+ return bridgeConnectionName ? path.join(connectionDirectory(), 'logs', 'oomi-bridge-live.log') : resolveOpenclawBridgeLiveLogPath();
786
815
  }
787
816
 
788
817
  function resolveBridgeLaunchAgentPlistPath() {
@@ -815,7 +844,7 @@ function readBridgeState() {
815
844
  function writeBridgeState(nextState) {
816
845
  const statePath = resolveBridgeStatePath();
817
846
  ensureDir(path.dirname(statePath));
818
- writeFile(statePath, JSON.stringify(nextState, null, 2) + '\n');
847
+ writePrivateJson(statePath, nextState);
819
848
  }
820
849
 
821
850
  function readBridgeStatus() {
@@ -933,11 +962,11 @@ function isPidAlive(pid) {
933
962
 
934
963
  function isBridgeWorkerCommand(command) {
935
964
  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;
965
+ if (!/(?:oomi(?:-ai\.js)?|openclaw)\s+bridge\b/.test(text)) return false;
966
+ const connection = text.match(/--(?:connection|name)[=\s]+([a-z0-9_-]+)/)?.[1] || '';
967
+ if (connection !== bridgeConnectionName) return false;
968
+ if (/\bbridge\s+(ps|stop|restart|ensure|service)\b/.test(text)) return false;
969
+ return /\bbridge(?:\s+start)?(?:\s+--|$)/.test(text);
941
970
  }
942
971
 
943
972
  function isBridgeProcess(pid) {
@@ -1047,6 +1076,7 @@ function acquireBridgeLock(deviceId) {
1047
1076
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
1048
1077
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
1049
1078
  method: 'POST',
1079
+ signal: AbortSignal.timeout(15000),
1050
1080
  headers: { 'Content-Type': 'application/json' },
1051
1081
  body: JSON.stringify({ pairCode, deviceId }),
1052
1082
  });
@@ -1064,6 +1094,7 @@ async function requestManagedPairCode({ appUrl, label }) {
1064
1094
  const baseUrl = appUrl.replace(/\/$/, '');
1065
1095
  const response = await fetch(`${baseUrl}/api/gateway/managed/pair/start`, {
1066
1096
  method: 'POST',
1097
+ signal: AbortSignal.timeout(15000),
1067
1098
  headers: { 'Content-Type': 'application/json' },
1068
1099
  body: JSON.stringify({ label }),
1069
1100
  });
@@ -1080,6 +1111,7 @@ async function requestManagedPairCode({ appUrl, label }) {
1080
1111
  async function requestBrokerPairCode({ brokerHttp, adminKey, label }) {
1081
1112
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/start`, {
1082
1113
  method: 'POST',
1114
+ signal: AbortSignal.timeout(15000),
1083
1115
  headers: {
1084
1116
  'Content-Type': 'application/json',
1085
1117
  Authorization: `Bearer ${adminKey}`,
@@ -1099,6 +1131,7 @@ async function requestBrokerPairCode({ brokerHttp, adminKey, label }) {
1099
1131
  async function requestConnectInviteLink({ backendHttp, appUrl, sessionKey, deviceToken }) {
1100
1132
  const response = await fetch(`${backendHttp.replace(/\/$/, '')}/v1/invite_links/start`, {
1101
1133
  method: 'POST',
1134
+ signal: AbortSignal.timeout(15000),
1102
1135
  headers: {
1103
1136
  'Content-Type': 'application/json',
1104
1137
  Authorization: `Bearer ${deviceToken}`,
@@ -1119,6 +1152,7 @@ async function fetchManagedGatewayConfig({ appUrl }) {
1119
1152
  const baseUrl = appUrl.replace(/\/$/, '');
1120
1153
  const response = await fetch(`${baseUrl}/api/gateway/managed/config`, {
1121
1154
  method: 'GET',
1155
+ signal: AbortSignal.timeout(15000),
1122
1156
  headers: { 'Content-Type': 'application/json' },
1123
1157
  });
1124
1158
  const payload = await response.json().catch(() => ({}));
@@ -1218,11 +1252,12 @@ function buildDeviceAuthPayloadV3({
1218
1252
  }
1219
1253
 
1220
1254
  function loadGatewayDeviceIdentity() {
1221
- if (!fs.existsSync(DEVICE_IDENTITY_PATH)) {
1255
+ const identityPath = resolveOpenclawIdentityPath();
1256
+ if (!fs.existsSync(identityPath)) {
1222
1257
  return null;
1223
1258
  }
1224
1259
  try {
1225
- const parsed = JSON.parse(readFile(DEVICE_IDENTITY_PATH));
1260
+ const parsed = JSON.parse(readFile(identityPath));
1226
1261
  if (
1227
1262
  parsed &&
1228
1263
  parsed.version === 1 &&
@@ -1312,12 +1347,8 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
1312
1347
  const rawParams = frame.params && typeof frame.params === 'object' ? frame.params : {};
1313
1348
  const params = {};
1314
1349
 
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;
1350
+ params.minProtocol = gatewayAuth.protocol || LOCAL_PROTOCOL_MIN;
1351
+ params.maxProtocol = gatewayAuth.protocol || LOCAL_PROTOCOL_MAX;
1321
1352
 
1322
1353
  const clientInput = rawParams.client && typeof rawParams.client === 'object' ? rawParams.client : {};
1323
1354
  const client = {};
@@ -2174,6 +2205,7 @@ async function runBridgePreflight({ brokerWs, gatewayUrl, gatewayConfigPath }) {
2174
2205
  throw new Error('Broker WS URL is missing hostname.');
2175
2206
  }
2176
2207
  await dnsLookup(brokerHost);
2208
+ if (gatewayUrl === 'local-agent:') return;
2177
2209
 
2178
2210
  let parsedGatewayUrl;
2179
2211
  try {
@@ -2197,6 +2229,7 @@ function buildBridgeDetachArgs(rawFlags = {}) {
2197
2229
  'device-token',
2198
2230
  ];
2199
2231
  const args = [process.argv[1], 'openclaw', 'bridge'];
2232
+ if (bridgeConnectionName) args.push('--connection', bridgeConnectionName);
2200
2233
 
2201
2234
  for (const key of orderedKeys) {
2202
2235
  const value = rawFlags[key];
@@ -2262,6 +2295,10 @@ function listBridgeProcessPids() {
2262
2295
  const status = readBridgeStatus();
2263
2296
  addPid(status.pid);
2264
2297
 
2298
+ // Named connections are owned by their own lock and status, including when a
2299
+ // custom connections directory is used. Never sweep another installation.
2300
+ if (bridgeConnectionName) return Array.from(pids);
2301
+
2265
2302
  try {
2266
2303
  const result = spawnSync('ps', ['-Ao', 'pid=,command='], {
2267
2304
  encoding: 'utf8',
@@ -2491,7 +2528,7 @@ async function stopBridgeLaunchdService() {
2491
2528
  async function resolveBridgeRuntimeConfig(flags, bridgeState) {
2492
2529
  const explicitBrokerHttp = String(flags['broker-http'] || '').trim();
2493
2530
  const explicitBrokerWs = String(flags['broker-ws'] || '').trim();
2494
- const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || '').trim();
2531
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || bridgeState.appUrl || '').trim();
2495
2532
 
2496
2533
  let brokerHttp = String(explicitBrokerHttp || process.env.OOMI_CHAT_BROKER_HTTP_URL || bridgeState.brokerHttp || '').trim();
2497
2534
  let brokerWs = String(explicitBrokerWs || process.env.OOMI_CHAT_BROKER_DEVICE_WS_URL || bridgeState.brokerWs || '').trim();
@@ -2534,6 +2571,7 @@ async function startOpenclawBridge(flags) {
2534
2571
  }
2535
2572
 
2536
2573
  const bridgeState = readBridgeState();
2574
+ if (bridgeState.openclawHome) process.env.OPENCLAW_HOME = bridgeState.openclawHome;
2537
2575
  const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
2538
2576
  const brokerHttp = runtimeConfig.brokerHttp;
2539
2577
  const brokerWs = runtimeConfig.brokerWs;
@@ -2574,14 +2612,17 @@ async function startOpenclawBridge(flags) {
2574
2612
  console.log(`Claimed bridge device token for ${deviceId}.`);
2575
2613
  }
2576
2614
 
2577
- const gateway = readOpenclawGatewayConfig();
2578
- if (!gateway.token && !gateway.password) {
2615
+ const localRuntime = bridgeState.localAgent
2616
+ ? new LocalAgentRuntime(bridgeState.localAgent, path.dirname(resolveBridgeStatePath()), (health) => updateBridgeStatus(health))
2617
+ : null;
2618
+ const gateway = localRuntime ? { gatewayUrl: 'local-agent:', configPath: resolveBridgeStatePath() } : readOpenclawGatewayConfig();
2619
+ if (!localRuntime && !gateway.token && !gateway.password) {
2579
2620
  throw new Error(`Gateway auth token/password not found in ${gateway.configPath}.`);
2580
2621
  }
2581
- const gatewayDeviceIdentity = loadGatewayDeviceIdentity();
2582
- if (!gatewayDeviceIdentity) {
2622
+ const gatewayDeviceIdentity = localRuntime ? null : loadGatewayDeviceIdentity();
2623
+ if (!localRuntime && !gatewayDeviceIdentity) {
2583
2624
  console.warn(
2584
- `[bridge] OpenClaw device identity not found at ${DEVICE_IDENTITY_PATH}; device-signed connect may fail on newer gateways.`
2625
+ `[bridge] OpenClaw device identity not found at ${resolveOpenclawIdentityPath()}; device-signed connect may fail on newer gateways.`
2585
2626
  );
2586
2627
  }
2587
2628
 
@@ -2599,10 +2640,16 @@ async function startOpenclawBridge(flags) {
2599
2640
  gatewayUrl: gateway.gatewayUrl,
2600
2641
  gatewayConfigPath: gateway.configPath,
2601
2642
  });
2643
+ if (!localRuntime) {
2644
+ const result = await probeGateway({ gateway, deviceIdentity: gatewayDeviceIdentity, prepareFrame: prepareGatewayFrameForLocalGateway });
2645
+ gateway.protocol = result.protocol;
2646
+ }
2647
+ updateBridgeStatus({ runtime: bridgeState.runtime || 'openclaw', runtimeReady: Boolean(bridgeState.verifiedAt), verifiedAt: bridgeState.verifiedAt || null, gatewayProtocol: gateway.protocol || null });
2602
2648
  } catch (err) {
2603
2649
  const failure = classifyBridgeFailure({ err });
2604
2650
  updateBridgeStatus({
2605
2651
  status: 'error',
2652
+ runtimeReady: false,
2606
2653
  deviceId,
2607
2654
  brokerWs,
2608
2655
  brokerHttp,
@@ -2632,7 +2679,16 @@ async function startOpenclawBridge(flags) {
2632
2679
  startedAt: bridgeNowIso(),
2633
2680
  });
2634
2681
 
2635
- console.log(`Starting OpenClaw bridge: device=${deviceId}`);
2682
+ console.log(`Starting ${bridgeState.runtime || 'openclaw'} bridge: device=${deviceId}`);
2683
+ const stopChannelWorker = localRuntime ? startChannelWorker(bridgeState, localRuntime) : () => {};
2684
+ const stopIdentityWorker = startIdentityWorker({
2685
+ state: { ...bridgeState, brokerHttp, deviceToken },
2686
+ receiptPath: path.join(path.dirname(resolveBridgeStatePath()), 'oomi-identity-receipt.json'),
2687
+ // Applying a specific settings revision is idempotent, unlike a user turn.
2688
+ deliver: (turn) => localRuntime ? localRuntime.turn({ ...turn, retryInterrupted: true }) : probeGateway({ gateway, deviceIdentity: gatewayDeviceIdentity, prepareFrame: prepareGatewayFrameForLocalGateway, turn, timeoutMs: 180000 }),
2689
+ acknowledgeCommand: (identity) => `oomi identity acknowledge${bridgeConnectionName ? ` --connection ${bridgeConnectionName}` : ''} --identity-id ${identity.identityId} --revision ${identity.revision}`,
2690
+ onError: () => updateBridgeStatus({ identitySyncError: 'Identity notification could not be delivered. It will retry while the bridge is running.' }),
2691
+ });
2636
2692
  console.log(`Local gateway: ${gateway.gatewayUrl}`);
2637
2693
  console.log(`Broker WS: ${brokerWs}`);
2638
2694
  bridgeDebugLog(
@@ -3316,6 +3372,7 @@ async function startOpenclawBridge(flags) {
3316
3372
  }
3317
3373
  }
3318
3374
  } else if (requestMeta.method === 'connect' && !responseMeta.ok) {
3375
+ 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
3376
  const deferredFrames = Array.isArray(sessionBridge.waitingForConnect)
3320
3377
  ? sessionBridge.waitingForConnect.splice(0, sessionBridge.waitingForConnect.length)
3321
3378
  : [];
@@ -3353,6 +3410,12 @@ async function startOpenclawBridge(flags) {
3353
3410
  }
3354
3411
  }
3355
3412
 
3413
+ if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'final') {
3414
+ updateBridgeStatus({ runtimeReady: true, lastReplyAt: bridgeNowIso() });
3415
+ } else if (gatewayPayload?.event === 'chat' && gatewayPayload.payload?.state === 'error') {
3416
+ updateBridgeStatus({ status: 'degraded', runtimeReady: false, lastErrorMessage: gatewayPayload.payload.errorMessage || 'The local agent failed to answer.' });
3417
+ }
3418
+
3356
3419
  if (isGatewayRunStartedFrame(frame)) {
3357
3420
  sendGatewayAck(brokerSocket, {
3358
3421
  sessionId,
@@ -3445,7 +3508,7 @@ async function startOpenclawBridge(flags) {
3445
3508
  const sessionBridge = ensureSessionBridge({
3446
3509
  sessions: activeGatewaySockets,
3447
3510
  sessionId,
3448
- createSocket: () => new WebSocket(gateway.gatewayUrl),
3511
+ createSocket: () => localRuntime ? localRuntime.socket() : new WebSocket(gateway.gatewayUrl),
3449
3512
  });
3450
3513
  if (sessionBridge) setupGatewaySession(sessionId, sessionBridge);
3451
3514
  return sessionBridge;
@@ -3798,8 +3861,11 @@ async function startOpenclawBridge(flags) {
3798
3861
  }));
3799
3862
  };
3800
3863
 
3801
- const markStopped = (signal) => {
3864
+ const markStopped = async (signal) => {
3802
3865
  reconnectState.stopped = true;
3866
+ stopChannelWorker();
3867
+ stopIdentityWorker();
3868
+ if (localRuntime) await localRuntime.queue;
3803
3869
  if (reconnectState.timer) {
3804
3870
  clearTimeout(reconnectState.timer);
3805
3871
  reconnectState.timer = null;
@@ -3828,7 +3894,7 @@ async function startOpenclawBridge(flags) {
3828
3894
 
3829
3895
  async function pairAndStartOpenclawBridge(flags) {
3830
3896
  const bridgeState = readBridgeState();
3831
- const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
3897
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || bridgeState.appUrl || 'https://www.oomi.ai').trim();
3832
3898
  const deviceId = resolveDeviceId(flags, bridgeState);
3833
3899
  const label = String(flags.label || `${deviceId}-bridge`).trim();
3834
3900
  const sessionKey = String(
@@ -3963,8 +4029,8 @@ async function createOpenclawInviteLink(flags) {
3963
4029
  bridgeState.brokerHttp ||
3964
4030
  ''
3965
4031
  ).trim();
3966
- const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || 'http://127.0.0.1:3456').trim();
3967
- const configSessionKey = readOpenclawDefaultSessionKey();
4032
+ const appUrl = String(flags['app-url'] || process.env.OOMI_APP_URL || bridgeState.appUrl || 'https://www.oomi.ai').trim();
4033
+ const configSessionKey = bridgeConnectionName ? '' : readOpenclawDefaultSessionKey();
3968
4034
  const sessionKey = String(
3969
4035
  flags['session-key'] ||
3970
4036
  process.env.OOMI_SESSION_KEY ||
@@ -4013,6 +4079,7 @@ async function createOpenclawInviteLink(flags) {
4013
4079
  function printOpenclawBridgeStatus(flags) {
4014
4080
  const bridgeState = readBridgeState();
4015
4081
  const runtimeStatus = readBridgeStatus();
4082
+ if (runtimeStatus.pid && !isBridgeProcess(runtimeStatus.pid)) { runtimeStatus.status = 'stopped'; runtimeStatus.runtimeReady = false; }
4016
4083
  const jsonOutput = isTruthyFlag(flags.json);
4017
4084
  const redactToken = (value) => {
4018
4085
  const text = String(value || '').trim();
@@ -4051,7 +4118,9 @@ function printOpenclawBridgeStatus(flags) {
4051
4118
  console.log(`Device token: ${payload.bridgeState.deviceToken}`);
4052
4119
  }
4053
4120
  if (payload.runtime && typeof payload.runtime === 'object' && Object.keys(payload.runtime).length > 0) {
4054
- console.log(`Runtime state: ${String(payload.runtime.status || 'unknown')}`);
4121
+ console.log(`Bridge state: ${String(payload.runtime.status || 'unknown')}`);
4122
+ console.log(`Agent runtime: ${bridgeState.runtime || 'openclaw'}${bridgeState.localAgent?.profile ? ` (profile ${bridgeState.localAgent.profile})` : ''}`);
4123
+ console.log(`Local reply verified: ${payload.runtime.runtimeReady ? 'yes' : 'no'}${payload.runtime.verifiedAt ? ` at ${payload.runtime.verifiedAt}` : ''}`);
4055
4124
  if (payload.runtime.lastConnectedAt) {
4056
4125
  console.log(`Last connected: ${payload.runtime.lastConnectedAt}`);
4057
4126
  }
@@ -4329,6 +4398,98 @@ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
4329
4398
  );
4330
4399
  }
4331
4400
 
4401
+ function connectionDirectory() {
4402
+ return path.join(process.env.OOMI_CONNECTIONS_DIR || path.join(os.homedir(), '.oomi', 'connections'), bridgeConnectionName);
4403
+ }
4404
+
4405
+ async function verifyLocalAgent(state) {
4406
+ if (state.openclawHome) process.env.OPENCLAW_HOME = state.openclawHome;
4407
+ let result;
4408
+ if (state.localAgent) {
4409
+ await runLocalAgent(state.localAgent, {
4410
+ sessionKey: `oomi:verify:${randomUUID()}`, idempotencyKey: randomUUID(), history: [],
4411
+ message: 'Oomi connection check. Reply with OOMI_OK only. Do not use tools or change anything.',
4412
+ });
4413
+ result = { verifiedAt: new Date().toISOString() };
4414
+ } else {
4415
+ result = await probeGateway({ gateway: readOpenclawGatewayConfig(), deviceIdentity: loadGatewayDeviceIdentity(), prepareFrame: prepareGatewayFrameForLocalGateway, verifyReply: true, timeoutMs: 180000 });
4416
+ }
4417
+ return { runtime: state.runtime || 'openclaw', ...result };
4418
+ }
4419
+
4420
+ async function connectLocalAgent(flags) {
4421
+ const agentName = flags['agent-name'] === undefined ? undefined : validateAgentName(flags['agent-name']);
4422
+ const runtime = String(flags.runtime || '');
4423
+ 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.');
4424
+ const previous = readBridgeState();
4425
+ if (previous.runtime && previous.runtime !== runtime) throw new Error('This connection belongs to a different runtime. Choose a different --name.');
4426
+ const appUrl = String(flags['app-url'] || previous.appUrl || 'https://www.oomi.ai').replace(/\/$/, '');
4427
+ if (previous.appUrl && previous.appUrl !== appUrl) throw new Error('This connection belongs to another Oomi site. Choose a different --name.');
4428
+ const state = { ...previous, runtime, appUrl };
4429
+ if (runtime === 'openclaw') state.openclawHome = previous.openclawHome || resolveOpenclawHome();
4430
+ if (runtime !== 'openclaw') {
4431
+ const saved = previous.localAgent;
4432
+ state.localAgent = localAgentConfig({
4433
+ ...flags,
4434
+ command: flags.command || (saved ? JSON.stringify(saved.command) : undefined),
4435
+ profile: flags.profile ?? saved?.profile,
4436
+ 'hermes-home': saved?.hermesHome,
4437
+ resume: flags.resume ?? saved?.resume,
4438
+ cwd: flags.cwd ?? saved?.cwd,
4439
+ });
4440
+ 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.');
4441
+ }
4442
+ console.log(`Verifying ${runtime}${state.localAgent?.profile ? ` profile ${state.localAgent.profile}` : ''} with a local test message...`);
4443
+ try {
4444
+ const verified = await verifyLocalAgent(state);
4445
+ state.verifiedAt = verified.verifiedAt;
4446
+ } catch (err) {
4447
+ updateBridgeStatus({ runtime, runtimeReady: false, status: 'degraded', lastErrorMessage: err.message });
4448
+ throw err;
4449
+ }
4450
+ if (!state.deviceToken) {
4451
+ const config = await fetchManagedGatewayConfig({ appUrl });
4452
+ state.brokerHttp = rewriteLoopbackBrokerUrlForAppHost(config.brokerHttpUrl, appUrl);
4453
+ state.brokerWs = rewriteLoopbackBrokerUrlForAppHost(config.brokerDeviceWsUrl, appUrl);
4454
+ const label = String(flags.label || agentName || flags.name || runtime);
4455
+ const pair = await requestManagedPairCode({ appUrl, label });
4456
+ state.deviceId = `${runtime}-${randomUUID()}`;
4457
+ const claim = await claimBridgeDeviceToken({ brokerHttp: state.brokerHttp, pairCode: pair.pairCode, deviceId: state.deviceId });
4458
+ state.deviceToken = claim.deviceToken;
4459
+ state.sessionKey = String(flags['session-key'] || (runtime === 'openclaw' ? readOpenclawDefaultSessionKey() : '') || 'agent:main:webchat:channel:oomi');
4460
+ state.claimedAt = new Date().toISOString();
4461
+ }
4462
+ writeBridgeState(state);
4463
+ try {
4464
+ await identityRequest(state, '/registration', { method: 'PUT', body: { runtime, ...(agentName ? { name: agentName } : {}) } });
4465
+ } catch (error) {
4466
+ if (error.status !== 404 || agentName) throw error;
4467
+ console.warn('This Oomi server does not support agent settings yet. Connecting without shared settings.');
4468
+ }
4469
+ // Stop this connection only; never leave a worker using an old adapter after a change.
4470
+ const supervised = !bridgeConnectionName && process.platform === 'darwin' && readBridgeLaunchdStatus().installed;
4471
+ const stopped = supervised ? await stopBridgeLaunchdService() : await stopBridgeProcesses();
4472
+ if (stopped.stillAlive?.length) throw new Error('The existing bridge did not stop. Resolve it before reconnecting.');
4473
+ writeBridgeState(state);
4474
+ updateBridgeStatus({ status: 'starting', runtime, runtimeReady: true, verifiedAt: state.verifiedAt, lastErrorMessage: '' });
4475
+ const child = supervised ? (startBridgeLaunchdService(), { pid: null }) : startBridgeDetachedProcess({ 'app-url': appUrl });
4476
+ const deadline = Date.now() + 20000;
4477
+ let connected = false;
4478
+ while (Date.now() < deadline) {
4479
+ const status = readBridgeStatus();
4480
+ if (status.pid && (!child.pid || status.pid === child.pid) && status.status === 'connected') { connected = true; break; }
4481
+ if ((!child.pid || status.pid === child.pid) && status.status === 'error') throw new Error(status.lastErrorMessage || 'Bridge startup failed.');
4482
+ if (child.pid && !isPidAlive(child.pid)) throw new Error(`Bridge exited. Check ${resolveBridgeLiveLogPath()}.`);
4483
+ await delay(200);
4484
+ }
4485
+ if (!connected) throw new Error('The local agent replied, but the Oomi bridge did not connect. Run oomi status for this connection.');
4486
+ const invite = await requestConnectInviteLink({ backendHttp: state.brokerHttp, appUrl, sessionKey: state.sessionKey, deviceToken: state.deviceToken });
4487
+ console.log(`Verified agent: ${runtime}${state.localAgent?.profile ? ` (${state.localAgent.profile})` : ''}`);
4488
+ console.log('Oomi Connect Ready');
4489
+ console.log(`Auth Link: ${invite.inviteUrl}`);
4490
+ console.log('Open the Auth Link to approve ownership, then send a message in Oomi to verify delivery end to end.');
4491
+ }
4492
+
4332
4493
  async function main() {
4333
4494
  const args = parseArgs(process.argv);
4334
4495
  const command = args.command;
@@ -4339,6 +4500,30 @@ async function main() {
4339
4500
  process.exit(0);
4340
4501
  }
4341
4502
 
4503
+ const connection = args.flags.connection || args.flags.name || (command === 'connect' && args.flags.runtime !== 'openclaw' ? args.flags.runtime : '');
4504
+ 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.');
4505
+ bridgeConnectionName = connection || '';
4506
+
4507
+ if (command === 'connect') return connectLocalAgent(args.flags);
4508
+ if (command === 'identity') return handleIdentityCommand(readBridgeState(), subcommand, args.flags);
4509
+ if (command === 'verify') {
4510
+ const state = readBridgeState();
4511
+ if (!state.deviceToken) throw new Error('No saved connection. Run oomi connect with an explicit runtime first.');
4512
+ try {
4513
+ const result = await verifyLocalAgent(state);
4514
+ writeBridgeState({ ...state, verifiedAt: result.verifiedAt });
4515
+ updateBridgeStatus({ runtimeReady: true, verifiedAt: result.verifiedAt, lastErrorMessage: '' });
4516
+ console.log(JSON.stringify(result, null, 2));
4517
+ } catch (err) {
4518
+ updateBridgeStatus({ runtimeReady: false, status: 'degraded', lastErrorMessage: err.message });
4519
+ throw err;
4520
+ }
4521
+ return;
4522
+ }
4523
+ if (command === 'status') return printOpenclawBridgeStatus(args.flags);
4524
+ if (command === 'invite') return createOpenclawInviteLink(args.flags);
4525
+ if (command === 'bridge') return handleBridgeLifecycleCommand(args.flags, subcommand || 'start');
4526
+
4342
4527
  await maybeNotifyUpdate(command);
4343
4528
 
4344
4529
  if (command === 'init') {
@@ -4363,6 +4548,7 @@ async function main() {
4363
4548
  if (command === 'openclaw' && subcommand === 'bridge') {
4364
4549
  const bridgeAction = String(args.positionals[0] || 'start').trim().toLowerCase();
4365
4550
  if (bridgeAction === 'service') {
4551
+ 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
4552
  const serviceAction = args.positionals[1] || 'status';
4367
4553
  await handleBridgeServiceCommand(serviceAction, args.flags);
4368
4554
  return;
@@ -4429,6 +4615,12 @@ async function main() {
4429
4615
  return;
4430
4616
  }
4431
4617
 
4618
+ if (command === 'context' && subcommand === 'plugins') {
4619
+ const payload = await createCliPersonaApiClient(args.flags).getPluginContext({ plugin: args.flags.plugin });
4620
+ printStructuredResult(payload, isTruthyFlag(args.flags.json));
4621
+ return;
4622
+ }
4623
+
4432
4624
  if (command === 'context' && subcommand === 'health') {
4433
4625
  if (args.positionals[0] === 'sync') {
4434
4626
  await handleContextHealthSyncCommand(args.flags);
@@ -4461,7 +4653,9 @@ async function main() {
4461
4653
 
4462
4654
  const __currentFilePath = fileURLToPath(import.meta.url);
4463
4655
  const __invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
4464
- const __isDirectExecution = Boolean(__invokedPath) && __invokedPath === path.resolve(__currentFilePath);
4656
+ const __isDirectExecution = Boolean(__invokedPath)
4657
+ && fs.existsSync(__invokedPath)
4658
+ && fs.realpathSync(__invokedPath) === fs.realpathSync(__currentFilePath);
4465
4659
 
4466
4660
  if (__isDirectExecution) {
4467
4661
  main().catch((err) => {