oomi-ai 0.2.7 → 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',
@@ -786,7 +920,9 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
786
920
  ? 'node-host'
787
921
  : (typeof client.id === 'string' && client.id.trim() ? client.id.trim() : 'node-host');
788
922
  client.version = typeof client.version === 'string' && client.version.trim() ? client.version.trim() : '0.1.0';
789
- client.platform = typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform;
923
+ client.platform = proxiedBrowserClient
924
+ ? process.platform
925
+ : (typeof client.platform === 'string' && client.platform.trim() ? client.platform.trim() : process.platform);
790
926
  client.mode = proxiedBrowserClient
791
927
  ? 'backend'
792
928
  : (typeof client.mode === 'string' && client.mode.trim() ? client.mode.trim() : 'backend');
@@ -1030,13 +1166,129 @@ function buildBridgeDetachArgs(rawFlags = {}) {
1030
1166
  }
1031
1167
 
1032
1168
  function startBridgeDetachedProcess(rawFlags = {}) {
1169
+ const existing = findRunningBridgeProcess();
1170
+ if (existing) {
1171
+ return {
1172
+ pid: existing.pid,
1173
+ alreadyRunning: true,
1174
+ };
1175
+ }
1176
+
1033
1177
  const args = buildBridgeDetachArgs(rawFlags);
1034
1178
  const child = spawn(process.execPath, args, {
1035
1179
  detached: true,
1036
1180
  stdio: 'ignore',
1037
1181
  });
1038
1182
  child.unref();
1039
- 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
+ };
1040
1292
  }
1041
1293
 
1042
1294
  async function resolveBridgeRuntimeConfig(flags, bridgeState) {
@@ -1077,6 +1329,13 @@ async function resolveBridgeRuntimeConfig(flags, bridgeState) {
1077
1329
  }
1078
1330
 
1079
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
+
1080
1339
  const bridgeState = readBridgeState();
1081
1340
  const runtimeConfig = await resolveBridgeRuntimeConfig(flags, bridgeState);
1082
1341
  const brokerHttp = runtimeConfig.brokerHttp;
@@ -1084,6 +1343,7 @@ async function startOpenclawBridge(flags) {
1084
1343
  const deviceId = resolveDeviceId(flags, bridgeState);
1085
1344
  const pairCode = String(flags['pair-code'] || '').trim().toUpperCase();
1086
1345
  const explicitDeviceToken = String(flags['device-token'] || '').trim();
1346
+ const releaseBridgeLock = acquireBridgeLock(deviceId);
1087
1347
  let deviceToken = explicitDeviceToken;
1088
1348
  if (!deviceToken && String(bridgeState.deviceId || '').trim() === deviceId) {
1089
1349
  deviceToken = String(bridgeState.deviceToken || '').trim();
@@ -1671,6 +1931,7 @@ async function startOpenclawBridge(flags) {
1671
1931
  stopSignal: signal,
1672
1932
  pid: process.pid,
1673
1933
  });
1934
+ releaseBridgeLock();
1674
1935
  process.exit(0);
1675
1936
  };
1676
1937
  process.once('SIGINT', () => markStopped('SIGINT'));
@@ -1765,13 +2026,17 @@ async function pairAndStartOpenclawBridge(flags) {
1765
2026
  }
1766
2027
 
1767
2028
  if (detach) {
1768
- const pid = startBridgeDetachedProcess({
2029
+ const result = startBridgeDetachedProcess({
1769
2030
  'broker-http': managedConfig.brokerHttpUrl,
1770
2031
  'broker-ws': brokerWs,
1771
2032
  'device-id': deviceId,
1772
2033
  'device-token': deviceToken,
1773
2034
  });
1774
- 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}).`);
1775
2040
  return;
1776
2041
  }
1777
2042
 
@@ -1969,6 +2234,86 @@ function printOpenclawPluginSetup(flags) {
1969
2234
  }
1970
2235
  }
1971
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
+
1972
2317
  async function main() {
1973
2318
  const args = parseArgs(process.argv);
1974
2319
  const command = args.command;
@@ -2001,14 +2346,8 @@ async function main() {
2001
2346
  }
2002
2347
 
2003
2348
  if (command === 'openclaw' && subcommand === 'bridge') {
2004
- if (Boolean(args.flags.detach)) {
2005
- const detachedFlags = { ...args.flags };
2006
- delete detachedFlags.detach;
2007
- const pid = startBridgeDetachedProcess(detachedFlags);
2008
- console.log(`Bridge started in background (pid: ${pid}).`);
2009
- return;
2010
- }
2011
- await startOpenclawBridge(args.flags);
2349
+ const bridgeAction = args.positionals[0] || 'start';
2350
+ await handleBridgeLifecycleCommand(args.flags, bridgeAction);
2012
2351
  return;
2013
2352
  }
2014
2353
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oomi-ai",
3
- "version": "0.2.7",
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.