oomi-ai 0.2.6 → 0.2.10

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/README.md CHANGED
@@ -59,6 +59,15 @@ If you need a fresh auth link later (without re-pairing), run:
59
59
  oomi openclaw invite --app-url https://your-oomi-app.vercel.app
60
60
  ```
61
61
 
62
+ Bridge lifecycle handler (singleton, one bridge per host/device):
63
+ ```
64
+ oomi openclaw bridge ensure --detach # start if needed; no-op if already running
65
+ oomi openclaw bridge ps # list bridge pids
66
+ oomi openclaw bridge stop # stop all bridge workers
67
+ oomi openclaw bridge restart --detach # clean restart as background worker
68
+ ```
69
+ `oomi openclaw bridge --detach` is equivalent to `oomi openclaw bridge start --detach`.
70
+
62
71
  Agent-intent mapping (recommended):
63
72
  - If user says `Connect yourself to Oomi. Use app URL https://www.oomi.ai.`
64
73
  - Run:
@@ -26,10 +26,13 @@ If the user asks to connect OpenClaw to Oomi voice/web:
26
26
  5. Configure channel account (`channels.oomi.accounts.default`) from:
27
27
  - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
28
28
  6. Restart OpenClaw.
29
- 7. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
29
+ 7. Ensure exactly one bridge worker is running (singleton handler):
30
+ - `oomi openclaw bridge ensure --detach`
31
+ - If stale/disconnected: `oomi openclaw bridge restart --detach`
32
+ 8. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
30
33
  - Use that URL directly in the pair command.
31
34
  - Example: `oomi openclaw pair --app-url <URL> --no-start`
32
- 8. Return this exact result format to the user:
35
+ 9. Return this exact result format to the user:
33
36
  - `Oomi Connect Ready`
34
37
  - `Auth Link: ...`
35
38
  - No extra narrative text before or after those lines.
package/bin/oomi-ai.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
- import { spawn } from 'child_process';
5
+ import { spawn, spawnSync } from 'child_process';
6
6
  import { createPrivateKey, createPublicKey, randomUUID, sign as cryptoSign } from 'crypto';
7
7
  import net from 'net';
8
8
  import { lookup as dnsLookup } from 'dns/promises';
@@ -151,8 +151,8 @@ Commands:
151
151
  openclaw install
152
152
  Install agent instructions and the Oomi skill into OpenClaw.
153
153
 
154
- openclaw bridge
155
- Start local OpenClaw-to-Oomi managed broker bridge.
154
+ openclaw bridge [start|ensure|stop|restart|ps]
155
+ Manage local OpenClaw-to-Oomi bridge lifecycle (singleton).
156
156
 
157
157
  openclaw pair
158
158
  Pair this OpenClaw host with Oomi and start bridge (single command).
@@ -204,8 +204,8 @@ function readFile(filePath) {
204
204
  return fs.readFileSync(filePath, 'utf-8');
205
205
  }
206
206
 
207
- function writeFile(filePath, content) {
208
- fs.writeFileSync(filePath, content);
207
+ function writeFile(filePath, content, options = undefined) {
208
+ fs.writeFileSync(filePath, content, options);
209
209
  }
210
210
 
211
211
  function resolveWorkspace() {
@@ -558,6 +558,10 @@ function resolveBridgeStatusPath() {
558
558
  return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
559
559
  }
560
560
 
561
+ function resolveBridgeLockPath() {
562
+ return path.join(os.homedir(), '.openclaw', 'oomi-bridge.lock');
563
+ }
564
+
561
565
  function defaultDeviceId() {
562
566
  const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
563
567
  return `oomi-${host}-${randomUUID().slice(0, 8)}`;
@@ -608,6 +612,136 @@ function updateBridgeStatus(partial) {
608
612
  return next;
609
613
  }
610
614
 
615
+ function normalizePid(value) {
616
+ const pid = Number(value);
617
+ if (!Number.isInteger(pid) || pid <= 0) return null;
618
+ return pid;
619
+ }
620
+
621
+ function isPidAlive(pid) {
622
+ const normalized = normalizePid(pid);
623
+ if (!normalized) return false;
624
+ try {
625
+ process.kill(normalized, 0);
626
+ return true;
627
+ } catch {
628
+ return false;
629
+ }
630
+ }
631
+
632
+ function isBridgeWorkerCommand(command) {
633
+ const text = String(command || '').trim().toLowerCase();
634
+ if (!text.includes('openclaw bridge')) return false;
635
+ if (/\bopenclaw\s+bridge\s+(ps|stop|restart|ensure)\b/.test(text)) return false;
636
+ if (/\bopenclaw\s+bridge\s+start\b/.test(text)) return true;
637
+ if (/\bopenclaw\s+bridge(\s+--|$)/.test(text)) return true;
638
+ return false;
639
+ }
640
+
641
+ function isBridgeProcess(pid) {
642
+ const normalized = normalizePid(pid);
643
+ if (!normalized) return false;
644
+ if (!isPidAlive(normalized)) return false;
645
+
646
+ try {
647
+ const result = spawnSync('ps', ['-p', String(normalized), '-o', 'command='], {
648
+ encoding: 'utf8',
649
+ stdio: ['ignore', 'pipe', 'ignore'],
650
+ });
651
+ const command = String(result.stdout || '').trim();
652
+ if (!command) return true;
653
+ return isBridgeWorkerCommand(command);
654
+ } catch {
655
+ return true;
656
+ }
657
+ }
658
+
659
+ function readBridgeLock() {
660
+ return readJsonSafe(resolveBridgeLockPath()) || {};
661
+ }
662
+
663
+ function clearStaleBridgeLock() {
664
+ const lockPath = resolveBridgeLockPath();
665
+ if (!fs.existsSync(lockPath)) return;
666
+ const lock = readBridgeLock();
667
+ const lockPid = normalizePid(lock.pid);
668
+ if (lockPid && isBridgeProcess(lockPid)) return;
669
+ try {
670
+ fs.unlinkSync(lockPath);
671
+ } catch {
672
+ // no-op
673
+ }
674
+ }
675
+
676
+ function findRunningBridgeProcess() {
677
+ clearStaleBridgeLock();
678
+
679
+ const lock = readBridgeLock();
680
+ const lockPid = normalizePid(lock.pid);
681
+ if (lockPid && isBridgeProcess(lockPid)) {
682
+ return {
683
+ pid: lockPid,
684
+ source: 'lock',
685
+ deviceId: typeof lock.deviceId === 'string' ? lock.deviceId : '',
686
+ };
687
+ }
688
+
689
+ const status = readBridgeStatus();
690
+ const statusPid = normalizePid(status.pid);
691
+ if (statusPid && isBridgeProcess(statusPid)) {
692
+ return {
693
+ pid: statusPid,
694
+ source: 'status',
695
+ deviceId: typeof status.deviceId === 'string' ? status.deviceId : '',
696
+ };
697
+ }
698
+
699
+ return null;
700
+ }
701
+
702
+ function acquireBridgeLock(deviceId) {
703
+ const lockPath = resolveBridgeLockPath();
704
+ ensureDir(path.dirname(lockPath));
705
+ const payload = {
706
+ pid: process.pid,
707
+ deviceId,
708
+ acquiredAt: bridgeNowIso(),
709
+ };
710
+
711
+ const writeLock = () => writeFile(lockPath, JSON.stringify(payload, null, 2) + '\n', { flag: 'wx' });
712
+
713
+ try {
714
+ writeLock();
715
+ } catch (err) {
716
+ const code = err && typeof err === 'object' ? err.code : '';
717
+ if (code !== 'EEXIST') {
718
+ throw err;
719
+ }
720
+ clearStaleBridgeLock();
721
+ const existing = findRunningBridgeProcess();
722
+ if (existing && existing.pid !== process.pid) {
723
+ throw new Error(
724
+ `Bridge already running (pid ${existing.pid})${existing.deviceId ? ` for device ${existing.deviceId}` : ''}.`
725
+ );
726
+ }
727
+ writeLock();
728
+ }
729
+
730
+ const release = () => {
731
+ const current = readBridgeLock();
732
+ const currentPid = normalizePid(current.pid);
733
+ if (currentPid && currentPid !== process.pid) return;
734
+ try {
735
+ fs.unlinkSync(lockPath);
736
+ } catch {
737
+ // no-op
738
+ }
739
+ };
740
+
741
+ process.once('exit', release);
742
+ return release;
743
+ }
744
+
611
745
  async function claimBridgeDeviceToken({ brokerHttp, pairCode, deviceId }) {
612
746
  const response = await fetch(`${brokerHttp.replace(/\/$/, '')}/v1/pair/claim`, {
613
747
  method: 'POST',
@@ -771,10 +905,27 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
771
905
  const params = frame.params && typeof frame.params === 'object' ? frame.params : {};
772
906
 
773
907
  const client = params.client && typeof params.client === 'object' ? params.client : {};
774
- client.id = typeof client.id === 'string' && client.id.trim() ? client.id.trim() : 'webchat-ui';
908
+ const incomingClientId = typeof client.id === 'string' ? client.id.trim().toLowerCase() : '';
909
+ const incomingClientMode = typeof client.mode === 'string' ? client.mode.trim().toLowerCase() : '';
910
+ const proxiedBrowserClient =
911
+ incomingClientMode === 'webchat' ||
912
+ incomingClientId === 'webchat-ui' ||
913
+ incomingClientId === 'webchat' ||
914
+ incomingClientId === 'clawdbot-control-ui';
915
+
916
+ // Frames relayed by this bridge originate from a local Node websocket, not a browser.
917
+ // Keep gateway auth/nonce flow, but normalize browser-mode connects to backend identity
918
+ // so Control UI/webchat Origin checks don't reject proxied sessions.
919
+ client.id = proxiedBrowserClient
920
+ ? 'node-host'
921
+ : (typeof client.id === 'string' && client.id.trim() ? client.id.trim() : 'node-host');
775
922
  client.version = typeof client.version === 'string' && client.version.trim() ? client.version.trim() : '0.1.0';
776
- client.platform = typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform;
777
- client.mode = typeof client.mode === 'string' && client.mode.trim() ? client.mode.trim() : 'webchat';
923
+ client.platform = proxiedBrowserClient
924
+ ? process.platform
925
+ : (typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform);
926
+ client.mode = proxiedBrowserClient
927
+ ? 'backend'
928
+ : (typeof client.mode === 'string' && client.mode.trim() ? client.mode.trim() : 'backend');
778
929
  params.client = client;
779
930
 
780
931
  params.role = typeof params.role === 'string' && params.role.trim() ? params.role.trim() : 'operator';
@@ -1015,13 +1166,129 @@ function buildBridgeDetachArgs(rawFlags = {}) {
1015
1166
  }
1016
1167
 
1017
1168
  function startBridgeDetachedProcess(rawFlags = {}) {
1169
+ const existing = findRunningBridgeProcess();
1170
+ if (existing) {
1171
+ return {
1172
+ pid: existing.pid,
1173
+ alreadyRunning: true,
1174
+ };
1175
+ }
1176
+
1018
1177
  const args = buildBridgeDetachArgs(rawFlags);
1019
1178
  const child = spawn(process.execPath, args, {
1020
1179
  detached: true,
1021
1180
  stdio: 'ignore',
1022
1181
  });
1023
1182
  child.unref();
1024
- return child.pid;
1183
+ return {
1184
+ pid: child.pid,
1185
+ alreadyRunning: false,
1186
+ };
1187
+ }
1188
+
1189
+ function listBridgeProcessPids() {
1190
+ const pids = new Set();
1191
+ const addPid = (value) => {
1192
+ const pid = normalizePid(value);
1193
+ if (!pid || pid === process.pid) return;
1194
+ if (!isBridgeProcess(pid)) return;
1195
+ pids.add(pid);
1196
+ };
1197
+
1198
+ const lock = readBridgeLock();
1199
+ addPid(lock.pid);
1200
+
1201
+ const status = readBridgeStatus();
1202
+ addPid(status.pid);
1203
+
1204
+ try {
1205
+ const result = spawnSync('ps', ['-Ao', 'pid=,command='], {
1206
+ encoding: 'utf8',
1207
+ stdio: ['ignore', 'pipe', 'ignore'],
1208
+ });
1209
+ const output = String(result.stdout || '');
1210
+ for (const rawLine of output.split('\n')) {
1211
+ const line = rawLine.trim();
1212
+ if (!line) continue;
1213
+ const match = line.match(/^(\d+)\s+(.+)$/);
1214
+ if (!match) continue;
1215
+ const pid = Number(match[1]);
1216
+ const command = String(match[2] || '');
1217
+ if (!isBridgeWorkerCommand(command)) continue;
1218
+ addPid(pid);
1219
+ }
1220
+ } catch {
1221
+ // best-effort process scan
1222
+ }
1223
+
1224
+ return Array.from(pids).sort((a, b) => a - b);
1225
+ }
1226
+
1227
+ async function waitForBridgePidsToExit(pids, timeoutMs) {
1228
+ const deadline = Date.now() + timeoutMs;
1229
+ while (Date.now() < deadline) {
1230
+ const alive = pids.filter((pid) => isBridgeProcess(pid));
1231
+ if (alive.length === 0) return [];
1232
+ await new Promise((resolve) => setTimeout(resolve, 100));
1233
+ }
1234
+ return pids.filter((pid) => isBridgeProcess(pid));
1235
+ }
1236
+
1237
+ async function stopBridgeProcesses() {
1238
+ const targets = listBridgeProcessPids();
1239
+ if (targets.length === 0) {
1240
+ clearStaleBridgeLock();
1241
+ updateBridgeStatus({
1242
+ status: 'stopped',
1243
+ stopSignal: 'none',
1244
+ lastDisconnectAt: bridgeNowIso(),
1245
+ pid: null,
1246
+ });
1247
+ return {
1248
+ stopped: [],
1249
+ forceKilled: [],
1250
+ found: [],
1251
+ };
1252
+ }
1253
+
1254
+ for (const pid of targets) {
1255
+ try {
1256
+ process.kill(pid, 'SIGTERM');
1257
+ } catch {
1258
+ // no-op
1259
+ }
1260
+ }
1261
+
1262
+ let remaining = await waitForBridgePidsToExit(targets, 2500);
1263
+ const forceKilled = [];
1264
+ if (remaining.length > 0) {
1265
+ for (const pid of remaining) {
1266
+ try {
1267
+ process.kill(pid, 'SIGKILL');
1268
+ forceKilled.push(pid);
1269
+ } catch {
1270
+ // no-op
1271
+ }
1272
+ }
1273
+ remaining = await waitForBridgePidsToExit(remaining, 1000);
1274
+ }
1275
+
1276
+ clearStaleBridgeLock();
1277
+
1278
+ const stopped = targets.filter((pid) => !remaining.includes(pid));
1279
+ updateBridgeStatus({
1280
+ status: 'stopped',
1281
+ stopSignal: forceKilled.length > 0 ? 'SIGKILL' : 'SIGTERM',
1282
+ lastDisconnectAt: bridgeNowIso(),
1283
+ pid: null,
1284
+ });
1285
+
1286
+ return {
1287
+ stopped,
1288
+ forceKilled,
1289
+ found: targets,
1290
+ stillAlive: remaining,
1291
+ };
1025
1292
  }
1026
1293
 
1027
1294
  async function resolveBridgeRuntimeConfig(flags, bridgeState) {
@@ -1062,6 +1329,13 @@ async function resolveBridgeRuntimeConfig(flags, bridgeState) {
1062
1329
  }
1063
1330
 
1064
1331
  async function startOpenclawBridge(flags) {
1332
+ const runningBridge = findRunningBridgeProcess();
1333
+ if (runningBridge && runningBridge.pid !== process.pid) {
1334
+ throw new Error(
1335
+ `Bridge already running (pid ${runningBridge.pid})${runningBridge.deviceId ? ` for device ${runningBridge.deviceId}` : ''}.`
1336
+ );
1337
+ }
1338
+
1065
1339
  const bridgeState = readBridgeState();
1066
1340
  const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
1067
1341
  const brokerHttp = runtimeConfig.brokerHttp;
@@ -1069,6 +1343,7 @@ async function startOpenclawBridge(flags) {
1069
1343
  const deviceId = resolveDeviceId(flags, bridgeState);
1070
1344
  const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
1071
1345
  const explicitDeviceToken = String(flags['device-token'] || '').trim();
1346
+ const releaseBridgeLock = acquireBridgeLock(deviceId);
1072
1347
  let deviceToken = explicitDeviceToken;
1073
1348
  if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
1074
1349
  deviceToken = String(bridgeState.deviceToken || '').trim();
@@ -1656,6 +1931,7 @@ async function startOpenclawBridge(flags) {
1656
1931
  stopSignal: signal,
1657
1932
  pid: process.pid,
1658
1933
  });
1934
+ releaseBridgeLock();
1659
1935
  process.exit(0);
1660
1936
  };
1661
1937
  process.once('SIGINT', () => markStopped('SIGINT'));
@@ -1750,13 +2026,17 @@ async function pairAndStartOpenclawBridge(flags) {
1750
2026
  }
1751
2027
 
1752
2028
  if (detach) {
1753
- const pid = startBridgeDetachedProcess({
2029
+ const result = startBridgeDetachedProcess({
1754
2030
  'broker-http': managedConfig.brokerHttpUrl,
1755
2031
  'broker-ws': brokerWs,
1756
2032
  'device-id': deviceId,
1757
2033
  'device-token': deviceToken,
1758
2034
  });
1759
- console.log(`Bridge started in background (pid: ${pid}).`);
2035
+ if (result.alreadyRunning) {
2036
+ console.log(`Bridge already running (pid: ${result.pid}).`);
2037
+ return;
2038
+ }
2039
+ console.log(`Bridge started in background (pid: ${result.pid}).`);
1760
2040
  return;
1761
2041
  }
1762
2042
 
@@ -1954,6 +2234,86 @@ function printOpenclawPluginSetup(flags) {
1954
2234
  }
1955
2235
  }
1956
2236
 
2237
+ async function startBridgeLifecycle(flags = {}) {
2238
+ if (Boolean(flags.detach)) {
2239
+ const detachedFlags = { ...flags };
2240
+ delete detachedFlags.detach;
2241
+ const result = startBridgeDetachedProcess(detachedFlags);
2242
+ if (result.alreadyRunning) {
2243
+ console.log(`Bridge already running (pid: ${result.pid}).`);
2244
+ return;
2245
+ }
2246
+ console.log(`Bridge started in background (pid: ${result.pid}).`);
2247
+ return;
2248
+ }
2249
+
2250
+ const running = findRunningBridgeProcess();
2251
+ if (running) {
2252
+ console.log(
2253
+ `Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
2254
+ );
2255
+ return;
2256
+ }
2257
+
2258
+ await startOpenclawBridge(flags);
2259
+ }
2260
+
2261
+ async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
2262
+ const action = String(actionRaw || 'start').trim().toLowerCase();
2263
+
2264
+ if (action === 'start' || action === 'ensure') {
2265
+ await startBridgeLifecycle(flags);
2266
+ return;
2267
+ }
2268
+
2269
+ if (action === 'ps') {
2270
+ const pids = listBridgeProcessPids();
2271
+ if (pids.length === 0) {
2272
+ console.log('No bridge processes running.');
2273
+ return;
2274
+ }
2275
+ console.log(`Bridge processes: ${pids.join(', ')}`);
2276
+ return;
2277
+ }
2278
+
2279
+ if (action === 'stop') {
2280
+ const result = await stopBridgeProcesses();
2281
+ if (result.found.length === 0) {
2282
+ console.log('No bridge processes running.');
2283
+ return;
2284
+ }
2285
+ console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
2286
+ if (result.forceKilled.length > 0) {
2287
+ console.log(`Force-killed bridge processes: ${result.forceKilled.join(', ')}.`);
2288
+ }
2289
+ if (Array.isArray(result.stillAlive) && result.stillAlive.length > 0) {
2290
+ throw new Error(`Failed to stop bridge processes: ${result.stillAlive.join(', ')}`);
2291
+ }
2292
+ return;
2293
+ }
2294
+
2295
+ if (action === 'restart') {
2296
+ const result = await stopBridgeProcesses();
2297
+ if (result.found.length > 0) {
2298
+ console.log(`Stopped bridge processes: ${result.stopped.join(', ') || 'none'}.`);
2299
+ if (result.forceKilled.length > 0) {
2300
+ console.log(`Force-killed bridge processes: ${result.forceKilled.join(', ')}.`);
2301
+ }
2302
+ } else {
2303
+ console.log('No existing bridge process found; starting fresh bridge.');
2304
+ }
2305
+ if (Array.isArray(result.stillAlive) && result.stillAlive.length > 0) {
2306
+ throw new Error(`Failed to stop bridge processes: ${result.stillAlive.join(', ')}`);
2307
+ }
2308
+ await startBridgeLifecycle(flags);
2309
+ return;
2310
+ }
2311
+
2312
+ throw new Error(
2313
+ `Unknown bridge action: ${action}. Use: oomi openclaw bridge [start|ensure|stop|restart|ps]`
2314
+ );
2315
+ }
2316
+
1957
2317
  async function main() {
1958
2318
  const args = parseArgs(process.argv);
1959
2319
  const command = args.command;
@@ -1986,14 +2346,8 @@ async function main() {
1986
2346
  }
1987
2347
 
1988
2348
  if (command === 'openclaw' && subcommand === 'bridge') {
1989
- if (Boolean(args.flags.detach)) {
1990
- const detachedFlags = { ...args.flags };
1991
- delete detachedFlags.detach;
1992
- const pid = startBridgeDetachedProcess(detachedFlags);
1993
- console.log(`Bridge started in background (pid: ${pid}).`);
1994
- return;
1995
- }
1996
- await startOpenclawBridge(args.flags);
2349
+ const bridgeAction = args.positionals[0] || 'start';
2350
+ await handleBridgeLifecycleCommand(args.flags, bridgeAction);
1997
2351
  return;
1998
2352
  }
1999
2353
 
@@ -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.4",
5
+ "version": "0.2.6",
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.6",
3
+ "version": "0.2.10",
4
4
  "description": "Oomi CLI for OpenClaw setup",
5
5
  "bin": {
6
6
  "oomi": "bin/oomi-ai.js"
@@ -26,10 +26,13 @@ If the user asks to connect OpenClaw to Oomi voice/web:
26
26
  5. Configure channel account (`channels.oomi.accounts.default`) from:
27
27
  - `oomi openclaw plugin --show-secrets --backend-url https://api.oomi.ai`
28
28
  6. Restart OpenClaw.
29
- 7. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
29
+ 7. Ensure exactly one bridge worker is running (singleton handler):
30
+ - `oomi openclaw bridge ensure --detach`
31
+ - If stale/disconnected: `oomi openclaw bridge restart --detach`
32
+ 8. If user provides an app URL (for example "Connect yourself to Oomi. Use app URL <URL>."):
30
33
  - Use that URL directly in the pair command.
31
34
  - Example: `oomi openclaw pair --app-url <URL> --no-start`
32
- 8. Return this exact result format to the user:
35
+ 9. Return this exact result format to the user:
33
36
  - `Oomi Connect Ready`
34
37
  - `Auth Link: ...`
35
38
  - No extra narrative text before or after those lines.