oomi-ai 0.2.28 → 0.2.38

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
@@ -5,28 +5,59 @@ import path from 'path';
5
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
- import { lookup as dnsLookup } from 'dns/promises';
9
- import { fileURLToPath } from 'url';
10
- import WebSocket from 'ws';
11
- import { scaffoldPersonaApp } from '../lib/scaffold.js';
8
+ import { lookup as dnsLookup } from 'dns/promises';
9
+ import { fileURLToPath } from 'url';
10
+ import WebSocket from 'ws';
11
+ import { scaffoldPersonaApp } from '../lib/scaffold.js';
12
12
  import { createPersonaApiClient } from '../lib/personaApiClient.js';
13
13
  import { startPersonaJobPoller } from '../lib/personaJobPoller.js';
14
- import { executePersonaJob } from '../lib/personaJobExecutor.js';
15
- import { inferSpokenMetadataFromContent, normalizeSpokenMetadata } from '../lib/spokenMetadata.js';
14
+ import { startPersonaRuntimeSupervisor } from '../lib/personaRuntimeSupervisor.js';
15
+ import { executePersonaJob, extractPersonaJobPayload } from '../lib/personaJobExecutor.js';
16
+ import { inferSpokenMetadataFromContent, normalizeSpokenMetadata } from '../lib/spokenMetadata.js';
17
+ import {
18
+ resolveOpenclawBridgeLiveLogPath,
19
+ resolveOpenclawBridgeLockPath,
20
+ resolveOpenclawBridgeStatePath,
21
+ resolveOpenclawBridgeStatusPath,
22
+ resolveOpenclawConfigCandidates,
23
+ resolveOpenclawHome,
24
+ resolveOpenclawIdentityPath,
25
+ resolveOpenclawProfilePath,
26
+ resolveOpenclawSkillsDir,
27
+ resolveOpenclawUpdateStatePath,
28
+ resolveOpenclawWorkspaceRoot,
29
+ } from '../lib/openclawPaths.js';
30
+ import {
31
+ applyOpenclawProfile,
32
+ buildOomiDevLocalProfile,
33
+ readOpenclawProfile,
34
+ writeOpenclawProfile,
35
+ } from '../lib/openclawProfile.js';
36
+ import {
37
+ buildLocalPersonaRuntime,
38
+ defaultPersonaWorkspaceRoot,
39
+ installPersonaWorkspace,
40
+ isPersonaWorkspaceProcessRunning,
41
+ resolvePersonaDevCommand,
42
+ startPersonaWorkspace,
43
+ stopPersonaWorkspace,
44
+ waitForPersonaRuntime,
45
+ } from '../lib/personaRuntimeProcess.js';
16
46
  import {
17
- buildLocalPersonaRuntime,
18
- defaultPersonaWorkspaceRoot,
19
- installPersonaWorkspace,
20
- startPersonaWorkspace,
21
- waitForPersonaRuntime,
22
- } from '../lib/personaRuntimeProcess.js';
23
- import { ensureSessionBridge, flushSessionQueue, flushWaitingForConnect, forwardFrameToSession } from './sessionBridgeState.js';
47
+ destroyManagedPersonaRuntime,
48
+ getManagedPersonaRuntimeStatus,
49
+ launchManagedPersonaRuntime,
50
+ slugifyPersonaName,
51
+ stopManagedPersonaRuntime,
52
+ } from '../lib/personaRuntimeManager.js';
53
+ import { startLocalGatewayAgentServer } from '../lib/openclawDevGateway.js';
54
+ import { ensureSessionBridge, flushSessionQueue, flushWaitingForConnect, forwardFrameToSession } from './sessionBridgeState.js';
24
55
 
25
56
  const MARKER_START = '<oomi-agent-instructions>';
26
57
  const MARKER_END = '</oomi-agent-instructions>';
27
58
 
28
59
  const PACKAGE_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
29
- const UPDATE_STATE_FILE = path.join(os.homedir(), '.openclaw', 'oomi-ai-update-check.json');
60
+ const UPDATE_STATE_FILE = resolveOpenclawUpdateStatePath();
30
61
  const DEFAULT_UPDATE_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
31
62
  const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1200;
32
63
  const BRIDGE_RECONNECT_BASE_MS = 2000;
@@ -39,21 +70,32 @@ const BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS = parsePositiveInteger(
39
70
  process.env.OOMI_BRIDGE_CONNECT_CHALLENGE_TIMEOUT_MS,
40
71
  3000
41
72
  );
42
- const BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS = parsePositiveInteger(
43
- process.env.OOMI_BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS,
44
- 30000
45
- );
46
- const BRIDGE_LAUNCHD_LABEL = 'ai.oomi.bridge';
47
- const DEBUG_PROVIDER_ENV_KEYS = [
48
- 'QWEN_REALTIME_API_KEY',
49
- 'QWEN_REALTIME_BASE_URL',
50
- 'QWEN_REALTIME_ASR_MODEL',
51
- 'QWEN_REALTIME_TTS_MODEL',
52
- 'QWEN_REALTIME_TTS_VOICE',
53
- 'QWEN_REALTIME_LANGUAGE',
54
- ];
55
- const DEVICE_IDENTITY_PATH = path.join(os.homedir(), '.openclaw', 'identity', 'device.json');
56
- const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
73
+ const BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS = parsePositiveInteger(
74
+ process.env.OOMI_BRIDGE_GATEWAY_REQUEST_TIMEOUT_MS,
75
+ 30000
76
+ );
77
+ const BRIDGE_LAUNCHD_LABEL = 'ai.oomi.bridge';
78
+ const DEBUG_PROVIDER_ENV_KEYS = [
79
+ 'QWEN_REALTIME_API_KEY',
80
+ 'QWEN_REALTIME_BASE_URL',
81
+ 'QWEN_REALTIME_ASR_MODEL',
82
+ 'QWEN_REALTIME_TTS_MODEL',
83
+ 'QWEN_REALTIME_TTS_VOICE',
84
+ 'QWEN_REALTIME_LANGUAGE',
85
+ ];
86
+ const DEVICE_IDENTITY_PATH = resolveOpenclawIdentityPath();
87
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
88
+ const BRIDGE_DEBUG_ENABLED = process.env.OOMI_BRIDGE_DEBUG === '1';
89
+
90
+ function bridgeDebugLog(...args) {
91
+ if (!BRIDGE_DEBUG_ENABLED) return;
92
+ console.log(...args);
93
+ }
94
+
95
+ function bridgeDebugWarn(...args) {
96
+ if (!BRIDGE_DEBUG_ENABLED) return;
97
+ console.warn(...args);
98
+ }
57
99
 
58
100
  function parsePositiveInteger(value, fallback) {
59
101
  const num = Number(value);
@@ -177,14 +219,22 @@ Commands:
177
219
  openclaw install
178
220
  Install agent instructions and the Oomi skill into OpenClaw.
179
221
 
180
- openclaw bridge [start|ensure|stop|restart|ps]
181
- Manage local OpenClaw-to-Oomi bridge lifecycle (singleton).
182
- openclaw bridge service [install|start|stop|restart|status|uninstall]
183
- Manage macOS launchd bridge supervision.
184
- openclaw debug assistant-final
185
- Replay an assistant chat.final frame through spoken-metadata normalization.
186
- openclaw debug tts-pipeline
187
- Replay an assistant chat.final through local backend voice handling.
222
+ openclaw bridge [start|ensure|stop|restart|ps]
223
+ Manage local OpenClaw-to-Oomi bridge lifecycle (singleton).
224
+ openclaw bridge service [install|start|stop|restart|status|uninstall]
225
+ Manage macOS launchd bridge supervision.
226
+ openclaw profile init
227
+ Write a deterministic OpenClaw profile for local/dev or hosted setup flows.
228
+ openclaw profile apply
229
+ Apply an OpenClaw profile into the current OpenClaw home/config.
230
+ openclaw debug assistant-final
231
+ Replay an assistant chat.final frame through spoken-metadata normalization.
232
+ openclaw debug tts-pipeline
233
+ Replay an assistant chat.final through local backend voice handling.
234
+ openclaw debug local-gateway-agent
235
+ Run a tiny local OpenClaw gateway/agent for Docker dev testing.
236
+ openclaw debug persona-runtime
237
+ Scaffold, launch, and stop a managed persona runtime locally.
188
238
 
189
239
  openclaw pair
190
240
  Pair this OpenClaw host with Oomi and start bridge (single command).
@@ -201,26 +251,34 @@ Commands:
201
251
  personas sync
202
252
  Sync personas from the repo into the Oomi backend registry.
203
253
 
204
- personas create <id>
205
- Create a new persona manifest and optionally sync it to the backend.
206
- personas create-managed [slug]
207
- Create a managed persona in Oomi and enqueue its build job for the linked device.
208
- personas scaffold <slug>
209
- Create an Oomi-managed persona app scaffold for agent customization.
254
+ personas create <id>
255
+ Create a new persona manifest and optionally sync it to the backend.
256
+ personas create-managed [slug]
257
+ Create a managed persona in Oomi and enqueue its build job for the linked device.
258
+ personas launch-managed [slug]
259
+ Launch or reuse a managed persona runtime on this OpenClaw machine.
260
+ personas scaffold <slug>
261
+ Create an Oomi-managed persona app scaffold for agent customization.
262
+ personas status <slug>
263
+ Show local managed persona runtime state for a persona slug.
264
+ personas stop <slug>
265
+ Stop a locally running managed persona runtime.
266
+ personas delete <slug>
267
+ Stop a managed persona runtime and remove its workspace from this OpenClaw machine.
210
268
  personas runtime-register <slug>
211
269
  Register a running persona runtime with the Oomi backend.
212
- personas heartbeat <slug>
213
- Send a persona runtime heartbeat to the Oomi backend.
214
- personas runtime-fail <slug>
215
- Report persona runtime failure to the Oomi backend.
216
- persona-jobs start <jobId>
217
- Mark a persona job as running.
218
- persona-jobs succeed <jobId>
219
- Mark a persona job as succeeded.
220
- persona-jobs fail <jobId>
221
- Mark a persona job as failed.
222
- persona-jobs execute
223
- Execute a structured persona job payload end to end.
270
+ personas heartbeat <slug>
271
+ Send a persona runtime heartbeat to the Oomi backend.
272
+ personas runtime-fail <slug>
273
+ Report persona runtime failure to the Oomi backend.
274
+ persona-jobs start <jobId>
275
+ Mark a persona job as running.
276
+ persona-jobs succeed <jobId>
277
+ Mark a persona job as succeeded.
278
+ persona-jobs fail <jobId>
279
+ Mark a persona job as failed.
280
+ persona-jobs execute
281
+ Execute a structured persona job payload end to end.
224
282
 
225
283
  Common flags:
226
284
  --agents-file PATH Override AGENTS.md path
@@ -233,94 +291,100 @@ Common flags:
233
291
  --label TEXT Pairing label shown in broker logs
234
292
  --session-key KEY Session key used in generated connect URL
235
293
  --detach Start bridge in background and exit
236
- --no-start Do not start the bridge or persona runtime
294
+ --no-start Do not start the bridge or persona runtime
237
295
  --device-id ID Bridge device identifier (default: host name)
238
296
  --device-token TOKEN Existing bridge device token
239
297
  --show-secrets Print full token values in diagnostic output
240
- --json Print pairing result as JSON (for automation)
241
- --text TEXT Assistant text for local debug frame replay
242
- --frame-file PATH Read a raw gateway frame from disk for local debug replay
243
- --frame-json JSON Use raw gateway frame JSON text for local debug replay
244
- --session-id ID Debug session id override (default: ms_debug_local)
245
- --user-text TEXT User utterance text used for backend voice replay
246
- --live-provider Use the real Qwen TTS provider in local debug replay
247
- --env-file PATH Load provider env vars from a specific env file (default: <repo>/.env.local)
248
- --provider-timeout-ms N
249
- Timeout in ms for live provider audio during local debug replay
250
- --backend-url URL Override Oomi backend URL
251
- --root PATH Override repo root path for persona discovery
252
- --role ROLE Message role override for local debug frame replay
253
- --omit-role Omit message.role in the generated local debug frame
254
- --name NAME Persona display name (for create)
255
- --description TEXT Persona description (for scaffold)
256
- --slug SLUG Explicit slug override (for create-managed)
257
- --summary TEXT Persona summary (for create)
298
+ --json Print pairing result as JSON (for automation)
299
+ --text TEXT Assistant text for local debug frame replay
300
+ --frame-file PATH Read a raw gateway frame from disk for local debug replay
301
+ --frame-json JSON Use raw gateway frame JSON text for local debug replay
302
+ --session-id ID Debug session id override (default: ms_debug_local)
303
+ --user-text TEXT User utterance text used for backend voice replay
304
+ --live-provider Use the real Qwen TTS provider in local debug replay
305
+ --env-file PATH Load provider env vars from a specific env file (default: <repo>/.env.local)
306
+ --provider-timeout-ms N
307
+ Timeout in ms for live provider audio during local debug replay
308
+ --backend-url URL Override Oomi backend URL
309
+ --root PATH Override repo root path for persona discovery
310
+ --role ROLE Message role override for local debug frame replay
311
+ --omit-role Omit message.role in the generated local debug frame
312
+ --name NAME Persona display name (for create)
313
+ --description TEXT Persona description (for scaffold)
314
+ --slug SLUG Explicit slug override (for create-managed)
315
+ --summary TEXT Persona summary (for create)
258
316
  --status STATUS Persona status (for create)
259
317
  --type TYPE Persona type (for create)
260
318
  --tags a,b,c Persona tags (for create)
261
319
  --chat-session KEY Persona chat session key (for create)
262
- --out PATH Output directory for scaffolded persona app
263
- --template-version V Scaffold template version (default: v1)
264
- --force Overwrite files in an existing output directory
265
- --no-sync Skip backend sync (for create)
266
- --local-port N Local runtime port for persona runtime callbacks
267
- --endpoint URL Runtime endpoint for persona runtime callbacks
268
- --health-path PATH Runtime health path (default: /oomi.health.json)
269
- --healthcheck-url URL Runtime healthcheck URL override
270
- --started-at ISO Start timestamp override
271
- --observed-at ISO Heartbeat timestamp override
272
- --completed-at ISO Completion timestamp override
273
- --code TEXT Error code for fail callbacks
274
- --message TEXT Error message for fail callbacks
275
- --message-file PATH Structured persona job message JSON file
276
- --message-json JSON Structured persona job message JSON text
277
- --log-file PATH Runtime log file path override
278
- --no-install Skip npm install during persona job execution
279
- --no-register Skip persona runtime registration during persona job execution
280
- `);
281
- }
320
+ --out PATH Output directory for scaffolded persona app
321
+ --template-version V Scaffold template version (default: v1)
322
+ --force Overwrite files in an existing output directory
323
+ --force-install Reinstall persona workspace dependencies before launch
324
+ --no-sync Skip backend sync (for create)
325
+ --no-create Do not create a managed persona record if one does not already exist
326
+ --local-port N Local runtime port for persona runtime callbacks
327
+ --endpoint URL Runtime endpoint for persona runtime callbacks
328
+ --entry-url URL Viewer URL to register for a launched persona runtime
329
+ --health-path PATH Runtime health path (default: /oomi.health.json)
330
+ --healthcheck-url URL Runtime healthcheck URL override
331
+ --transport TEXT Runtime transport label (default: local, relay when --entry-url is used)
332
+ --workspace-root PATH Persona workspace root (default: ~/.openclaw/personas)
333
+ --restart Restart an existing managed persona runtime before launch
334
+ --started-at ISO Start timestamp override
335
+ --observed-at ISO Heartbeat timestamp override
336
+ --completed-at ISO Completion timestamp override
337
+ --code TEXT Error code for fail callbacks
338
+ --message TEXT Error message for fail callbacks
339
+ --message-file PATH Structured persona job message JSON file
340
+ --message-json JSON Structured persona job message JSON text
341
+ --log-file PATH Runtime log file path override
342
+ --no-install Skip npm install during persona job execution
343
+ --no-register Skip persona runtime registration during persona job execution
344
+ `);
345
+ }
282
346
 
283
347
  function readFile(filePath) {
284
348
  return fs.readFileSync(filePath, 'utf-8');
285
349
  }
286
350
 
287
- function writeFile(filePath, content, options = undefined) {
288
- fs.writeFileSync(filePath, content, options);
289
- }
290
-
291
- function parseDotEnvLine(line) {
292
- const trimmed = String(line || '').trim();
293
- if (!trimmed || trimmed.startsWith('#')) return null;
294
- const separatorIndex = trimmed.indexOf('=');
295
- if (separatorIndex <= 0) return null;
296
- const key = trimmed.slice(0, separatorIndex).trim();
297
- if (!key) return null;
298
- let value = trimmed.slice(separatorIndex + 1).trim();
299
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
300
- value = value.slice(1, -1);
301
- }
302
- return { key, value };
303
- }
304
-
305
- function loadEnvFile(filePath, keys = []) {
306
- if (!filePath || !fs.existsSync(filePath)) {
307
- throw new Error(`Environment file not found: ${filePath}`);
308
- }
309
- const selectedKeys = Array.isArray(keys) && keys.length ? new Set(keys) : null;
310
- const entries = {};
311
- const lines = readFile(filePath).split(/\r?\n/);
312
- for (const line of lines) {
313
- const parsed = parseDotEnvLine(line);
314
- if (!parsed) continue;
315
- if (selectedKeys && !selectedKeys.has(parsed.key)) continue;
316
- entries[parsed.key] = parsed.value;
317
- }
318
- return entries;
319
- }
320
-
321
- function xmlEscape(value) {
322
- return String(value)
323
- .replaceAll('&', '&amp;')
351
+ function writeFile(filePath, content, options = undefined) {
352
+ fs.writeFileSync(filePath, content, options);
353
+ }
354
+
355
+ function parseDotEnvLine(line) {
356
+ const trimmed = String(line || '').trim();
357
+ if (!trimmed || trimmed.startsWith('#')) return null;
358
+ const separatorIndex = trimmed.indexOf('=');
359
+ if (separatorIndex <= 0) return null;
360
+ const key = trimmed.slice(0, separatorIndex).trim();
361
+ if (!key) return null;
362
+ let value = trimmed.slice(separatorIndex + 1).trim();
363
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
364
+ value = value.slice(1, -1);
365
+ }
366
+ return { key, value };
367
+ }
368
+
369
+ function loadEnvFile(filePath, keys = []) {
370
+ if (!filePath || !fs.existsSync(filePath)) {
371
+ throw new Error(`Environment file not found: ${filePath}`);
372
+ }
373
+ const selectedKeys = Array.isArray(keys) && keys.length ? new Set(keys) : null;
374
+ const entries = {};
375
+ const lines = readFile(filePath).split(/\r?\n/);
376
+ for (const line of lines) {
377
+ const parsed = parseDotEnvLine(line);
378
+ if (!parsed) continue;
379
+ if (selectedKeys && !selectedKeys.has(parsed.key)) continue;
380
+ entries[parsed.key] = parsed.value;
381
+ }
382
+ return entries;
383
+ }
384
+
385
+ function xmlEscape(value) {
386
+ return String(value)
387
+ .replaceAll('&', '&amp;')
324
388
  .replaceAll('<', '&lt;')
325
389
  .replaceAll('>', '&gt;')
326
390
  .replaceAll('"', '&quot;')
@@ -328,11 +392,7 @@ function xmlEscape(value) {
328
392
  }
329
393
 
330
394
  function resolveWorkspace() {
331
- const envWorkspace = process.env.OPENCLAW_WORKSPACE || process.env.OPENCLAW_HOME;
332
- if (envWorkspace) return envWorkspace;
333
- const defaultWorkspace = path.join(os.homedir(), '.openclaw', 'workspace');
334
- if (fs.existsSync(defaultWorkspace)) return defaultWorkspace;
335
- return path.join(os.homedir(), '.openclaw');
395
+ return resolveOpenclawWorkspaceRoot();
336
396
  }
337
397
 
338
398
  function resolveAgentsFile(cliAgentsFile, cliWorkspace) {
@@ -409,9 +469,9 @@ function ensureDir(dirPath) {
409
469
  }
410
470
  }
411
471
 
412
- function findRepoRoot(startDir) {
413
- let current = startDir;
414
- for (let i = 0; i < 6; i += 1) {
472
+ function findRepoRoot(startDir) {
473
+ let current = startDir;
474
+ for (let i = 0; i < 6; i += 1) {
415
475
  const personasDir = path.join(current, 'personas');
416
476
  const skillsDir = path.join(current, 'skills', 'oomi');
417
477
  if (fs.existsSync(personasDir) || fs.existsSync(skillsDir)) {
@@ -420,23 +480,23 @@ function findRepoRoot(startDir) {
420
480
  const parent = path.dirname(current);
421
481
  if (parent === current) break;
422
482
  current = parent;
423
- }
424
- return null;
425
- }
426
-
427
- function resolveRepoRoot(rootFlag) {
428
- const explicitRoot =
429
- typeof rootFlag === 'string' && rootFlag.trim()
430
- ? path.resolve(rootFlag.trim())
431
- : '';
432
- const repoRoot = explicitRoot || findRepoRoot(process.cwd()) || findRepoRoot(PACKAGE_ROOT);
433
- if (!repoRoot) {
434
- throw new Error('Could not locate repo root. Use --root <repo root>.');
435
- }
436
- return repoRoot;
437
- }
438
-
439
- function resolveSkillSource(cliRoot) {
483
+ }
484
+ return null;
485
+ }
486
+
487
+ function resolveRepoRoot(rootFlag) {
488
+ const explicitRoot =
489
+ typeof rootFlag === 'string' && rootFlag.trim()
490
+ ? path.resolve(rootFlag.trim())
491
+ : '';
492
+ const repoRoot = explicitRoot || findRepoRoot(process.cwd()) || findRepoRoot(PACKAGE_ROOT);
493
+ if (!repoRoot) {
494
+ throw new Error('Could not locate repo root. Use --root <repo root>.');
495
+ }
496
+ return repoRoot;
497
+ }
498
+
499
+ function resolveSkillSource(cliRoot) {
440
500
  const packaged = path.join(PACKAGE_ROOT, 'skills', 'oomi');
441
501
  if (fs.existsSync(packaged)) {
442
502
  return packaged;
@@ -462,7 +522,7 @@ function resolveSkillTargets(cliSkillsDir) {
462
522
  }
463
523
 
464
524
  const targets = [];
465
- const openclaw = path.join(os.homedir(), '.openclaw', 'skills');
525
+ const openclaw = resolveOpenclawSkillsDir();
466
526
  const clawd = path.join(os.homedir(), 'clawd', 'skills');
467
527
 
468
528
  targets.push(openclaw);
@@ -648,7 +708,7 @@ async function createPersona({ id, root, flags }) {
648
708
  console.log(`Synced persona ${payload?.persona?.slug || id}`);
649
709
  }
650
710
 
651
- function printPersonaScaffoldResult(result, asJson = false) {
711
+ function printPersonaScaffoldResult(result, asJson = false) {
652
712
  if (asJson) {
653
713
  console.log(JSON.stringify(result, null, 2));
654
714
  return;
@@ -661,471 +721,770 @@ function printPersonaScaffoldResult(result, asJson = false) {
661
721
  console.log(`Start: ${result.startCommand}`);
662
722
  if (Array.isArray(result.editableZones) && result.editableZones.length > 0) {
663
723
  console.log(`Editable zones: ${result.editableZones.join(', ')}`);
664
- }
665
- }
666
-
667
- function printManagedPersonaCreateResult(result, asJson = false) {
668
- if (asJson) {
669
- console.log(JSON.stringify(result, null, 2));
670
- return;
671
- }
672
-
673
- const persona = result?.persona && typeof result.persona === 'object' ? result.persona : {};
674
- const personaJob = result?.personaJob && typeof result.personaJob === 'object' ? result.personaJob : {};
675
- console.log(`Managed persona created: ${String(persona.name || persona.slug || 'unknown')}`);
676
- if (persona.slug) {
677
- console.log(`Slug: ${persona.slug}`);
678
- }
679
- if (persona.lifecycle) {
680
- console.log(`Lifecycle: ${persona.lifecycle}`);
681
- }
682
- if (personaJob.jobId) {
683
- console.log(`Persona job: ${personaJob.jobId}`);
684
- }
685
- if (personaJob.status) {
686
- console.log(`Job status: ${personaJob.status}`);
687
- }
688
- if (personaJob.deviceId) {
689
- console.log(`Assigned device: ${personaJob.deviceId}`);
690
- }
691
- }
692
-
693
- function parseOptionalPositiveInteger(value) {
694
- if (value === undefined || value === null || value === '') return null;
695
- const parsed = Number(value);
696
- if (!Number.isFinite(parsed) || parsed <= 0) {
697
- throw new Error(`Expected a positive integer, received: ${value}`);
698
- }
699
- return Math.floor(parsed);
700
- }
701
-
724
+ }
725
+ }
726
+
727
+ function printManagedPersonaCreateResult(result, asJson = false) {
728
+ if (asJson) {
729
+ console.log(JSON.stringify(result, null, 2));
730
+ return;
731
+ }
732
+
733
+ const persona = result?.persona && typeof result.persona === 'object' ? result.persona : {};
734
+ const personaJob = result?.personaJob && typeof result.personaJob === 'object' ? result.personaJob : {};
735
+ console.log(`Managed persona created: ${String(persona.name || persona.slug || 'unknown')}`);
736
+ if (persona.slug) {
737
+ console.log(`Slug: ${persona.slug}`);
738
+ }
739
+ if (persona.lifecycle) {
740
+ console.log(`Lifecycle: ${persona.lifecycle}`);
741
+ }
742
+ if (personaJob.jobId) {
743
+ console.log(`Persona job: ${personaJob.jobId}`);
744
+ }
745
+ if (personaJob.status) {
746
+ console.log(`Job status: ${personaJob.status}`);
747
+ }
748
+ if (personaJob.deviceId) {
749
+ console.log(`Assigned device: ${personaJob.deviceId}`);
750
+ }
751
+ }
752
+
753
+ function parseOptionalPositiveInteger(value) {
754
+ if (value === undefined || value === null || value === '') return null;
755
+ const parsed = Number(value);
756
+ if (!Number.isFinite(parsed) || parsed <= 0) {
757
+ throw new Error(`Expected a positive integer, received: ${value}`);
758
+ }
759
+ return Math.floor(parsed);
760
+ }
761
+
702
762
  function resolvePersonaBackendUrl(flags = {}) {
703
763
  const bridgeState = readBridgeState();
704
764
  const backendUrl = String(
705
765
  flags['backend-url'] ||
706
- process.env.OOMI_BACKEND_URL ||
707
- process.env.NEXT_PUBLIC_BACKEND_URL ||
708
- bridgeState.brokerHttp ||
709
- ''
710
- ).trim();
711
- if (!backendUrl) {
712
- throw new Error('Missing backend URL. Use --backend-url or pair the device first.');
713
- }
714
- return backendUrl.replace(/\/$/, '');
715
- }
716
-
717
- function resolvePersonaDeviceToken(flags = {}) {
718
- const bridgeState = readBridgeState();
719
- const deviceToken = String(
720
- flags['device-token'] ||
721
- bridgeState.deviceToken ||
722
- ''
723
- ).trim();
724
- if (!deviceToken) {
725
- throw new Error('Missing device token. Use --device-token or pair the device first.');
726
- }
727
- return deviceToken;
728
- }
729
-
730
- function resolvePersonaDeviceId(flags = {}) {
731
- const bridgeState = readBridgeState();
732
- const deviceId = String(
733
- flags['device-id'] ||
734
- bridgeState.deviceId ||
735
- ''
766
+ process.env.OOMI_DEV_BACKEND_URL ||
767
+ process.env.OOMI_BACKEND_URL ||
768
+ process.env.NEXT_PUBLIC_BACKEND_URL ||
769
+ bridgeState.brokerHttp ||
770
+ ''
736
771
  ).trim();
737
- if (!deviceId) {
738
- throw new Error('Missing device id. Use --device-id or pair the device first.');
739
- }
740
- return deviceId;
741
- }
742
-
743
- function createCliPersonaApiClient(flags = {}) {
744
- return createPersonaApiClient({
745
- backendUrl: resolvePersonaBackendUrl(flags),
746
- deviceToken: resolvePersonaDeviceToken(flags),
747
- deviceId: resolvePersonaDeviceId(flags),
748
- });
749
- }
750
-
751
- function ensurePersonaJobWorkspace(message, workspaceRoot = defaultPersonaWorkspaceRoot()) {
752
- const metadata = message && typeof message === 'object' ? message.metadata : null;
753
- const payload = metadata && typeof metadata === 'object' ? metadata.payload : null;
754
- if (!payload || typeof payload !== 'object') {
755
- return message;
756
- }
757
-
758
- const persona = payload.persona && typeof payload.persona === 'object' ? payload.persona : {};
759
- const scaffold = payload.scaffold && typeof payload.scaffold === 'object' ? payload.scaffold : {};
760
- if (!scaffold.outDir && typeof persona.slug === 'string' && persona.slug.trim()) {
761
- scaffold.outDir = path.join(workspaceRoot, persona.slug.trim());
762
- payload.scaffold = scaffold;
763
- metadata.payload = payload;
764
- message.metadata = metadata;
765
- }
766
-
767
- return message;
768
- }
769
-
770
- async function runManagedPersonaJobExecution({
771
- message,
772
- backendUrl,
773
- deviceToken,
774
- deviceId,
775
- workspaceRoot = defaultPersonaWorkspaceRoot(),
776
- shouldInstall = true,
777
- shouldStart = true,
778
- shouldRegister = true,
779
- logFilePath = '',
780
- }) {
781
- const normalizedMessage = ensurePersonaJobWorkspace(
782
- structuredClone(message),
783
- workspaceRoot,
784
- );
785
- const client = createPersonaApiClient({
786
- backendUrl,
787
- deviceToken,
788
- deviceId,
789
- });
790
-
791
- return executePersonaJob({
792
- message: normalizedMessage,
793
- installWorkspace: shouldInstall
794
- ? async ({ workspacePath }) => {
795
- await installPersonaWorkspace({ workspacePath });
796
- }
797
- : async () => {},
798
- startWorkspace: shouldStart
799
- ? async ({ workspacePath }) =>
800
- startPersonaWorkspace({
801
- workspacePath,
802
- logFilePath,
803
- })
804
- : async () => ({ pid: null, logFilePath }),
805
- waitForRuntime: shouldStart
806
- ? async ({ runtime }) => {
807
- await waitForPersonaRuntime({
808
- healthcheckUrl: runtime.healthcheckUrl,
809
- });
810
- }
811
- : async () => {},
812
- registerRuntime: shouldRegister
813
- ? async ({ payload: jobPayload, result: runtimeResult }) => {
814
- const jobPersona = jobPayload.persona && typeof jobPayload.persona === 'object' ? jobPayload.persona : {};
815
- await client.registerRuntime({
816
- slug: String(jobPersona.slug || '').trim(),
817
- endpoint: runtimeResult.endpoint,
818
- healthcheckUrl: runtimeResult.healthcheckUrl,
819
- transport: runtimeResult.transport,
820
- localPort: runtimeResult.localPort,
821
- startedAt: new Date().toISOString(),
822
- });
823
- }
824
- : async () => {},
825
- onJobStart: async ({ jobId }) => {
826
- await client.startJob({
827
- jobId,
828
- startedAt: new Date().toISOString(),
829
- });
830
- },
831
- onJobSuccess: async ({ jobId, result: runtimeResult }) => {
832
- await client.succeedJob({
833
- jobId,
834
- workspacePath: runtimeResult.workspacePath,
835
- localPort: runtimeResult.localPort,
836
- transport: runtimeResult.transport,
837
- endpoint: runtimeResult.endpoint,
838
- healthcheckUrl: runtimeResult.healthcheckUrl,
839
- completedAt: new Date().toISOString(),
840
- });
841
- },
842
- onJobFailure: async ({ jobId, error }) => {
843
- await client.failJob({
844
- jobId,
845
- code: String(error?.code || 'PERSONA_JOB_EXECUTION_FAILED').trim(),
846
- message: String(error?.message || 'Persona job execution failed.').trim(),
847
- completedAt: new Date().toISOString(),
848
- });
849
- },
850
- });
851
- }
852
-
853
- function resolvePersonaRuntimeInput(flags = {}, defaults = {}) {
854
- const localPort = parseOptionalPositiveInteger(flags['local-port'] || flags.localPort || defaults.localPort);
855
- const endpoint = String(flags.endpoint || defaults.endpoint || '').trim();
856
- const healthPath = String(flags['health-path'] || defaults.healthPath || '/oomi.health.json').trim() || '/oomi.health.json';
857
- const healthcheckUrl = String(flags['healthcheck-url'] || defaults.healthcheckUrl || '').trim();
858
- const transport = String(flags.transport || defaults.transport || 'local').trim() || 'local';
859
-
860
- if (endpoint) {
861
- return {
862
- endpoint,
863
- healthcheckUrl: healthcheckUrl || `${endpoint.replace(/\/$/, '')}${healthPath}`,
864
- localPort,
865
- transport,
866
- };
867
- }
868
-
869
- if (!localPort) {
870
- throw new Error('Runtime endpoint or local port is required.');
871
- }
872
-
873
- return buildLocalPersonaRuntime({
874
- localPort,
875
- healthPath,
876
- });
877
- }
878
-
879
- function parseIsoTimestamp(rawValue, label) {
880
- const value = String(rawValue || '').trim();
881
- if (!value) return undefined;
882
- const timestamp = new Date(value);
883
- if (Number.isNaN(timestamp.getTime())) {
884
- throw new Error(`${label} must be a valid ISO timestamp.`);
885
- }
886
- return timestamp.toISOString();
887
- }
888
-
889
- function readStructuredPersonaJobMessage(flags = {}) {
890
- const filePath = String(flags['message-file'] || '').trim();
891
- const inlineJson = String(flags['message-json'] || '').trim();
892
-
893
- if (inlineJson) {
894
- return JSON.parse(inlineJson);
895
- }
896
- if (filePath) {
897
- return JSON.parse(readFile(path.resolve(filePath)));
898
- }
899
-
900
- throw new Error('Persona job message is required. Use --message-file or --message-json.');
901
- }
902
-
903
- function printStructuredResult(result, asJson = false) {
904
- if (asJson) {
905
- console.log(JSON.stringify(result, null, 2));
906
- return;
907
- }
908
-
909
- for (const [key, value] of Object.entries(result)) {
910
- console.log(`${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`);
911
- }
912
- }
913
-
914
- async function handlePersonaRuntimeRegisterCommand(slug, flags = {}) {
915
- const client = createCliPersonaApiClient(flags);
916
- const runtime = resolvePersonaRuntimeInput(flags);
917
- const payload = await client.registerRuntime({
918
- slug,
919
- endpoint: runtime.endpoint,
920
- healthcheckUrl: runtime.healthcheckUrl,
921
- localPort: runtime.localPort,
922
- transport: runtime.transport,
923
- startedAt: parseIsoTimestamp(flags['started-at'], 'started-at'),
924
- });
925
- printStructuredResult(payload, isTruthyFlag(flags.json));
926
- }
927
-
928
- async function handlePersonaHeartbeatCommand(slug, flags = {}) {
929
- const client = createCliPersonaApiClient(flags);
930
- const runtime = resolvePersonaRuntimeInput(flags);
931
- const payload = await client.heartbeatRuntime({
932
- slug,
933
- endpoint: runtime.endpoint,
934
- healthcheckUrl: runtime.healthcheckUrl,
935
- localPort: runtime.localPort,
936
- transport: runtime.transport,
937
- observedAt: parseIsoTimestamp(flags['observed-at'], 'observed-at'),
938
- });
939
- printStructuredResult(payload, isTruthyFlag(flags.json));
940
- }
941
-
942
- async function handlePersonaRuntimeFailCommand(slug, flags = {}) {
943
- const code = String(flags.code || '').trim();
944
- const message = String(flags.message || '').trim();
945
- if (!code) {
946
- throw new Error('Error code is required. Use --code.');
947
- }
948
- if (!message) {
949
- throw new Error('Error message is required. Use --message.');
950
- }
951
-
952
- const client = createCliPersonaApiClient(flags);
953
- const payload = await client.failRuntime({
954
- slug,
955
- code,
956
- message,
957
- });
958
- printStructuredResult(payload, isTruthyFlag(flags.json));
959
- }
960
-
961
- async function handlePersonaJobStartCommand(jobId, flags = {}) {
962
- const client = createCliPersonaApiClient(flags);
963
- const payload = await client.startJob({
964
- jobId,
965
- startedAt: parseIsoTimestamp(flags['started-at'], 'started-at'),
966
- });
967
- printStructuredResult(payload, isTruthyFlag(flags.json));
968
- }
969
-
970
- async function handlePersonaJobSucceedCommand(jobId, flags = {}) {
971
- const client = createCliPersonaApiClient(flags);
972
- const runtime = resolvePersonaRuntimeInput(flags);
973
- const workspacePath = String(flags['workspace-path'] || flags.workspacePath || '').trim();
974
- if (!workspacePath) {
975
- throw new Error('Workspace path is required. Use --workspace-path.');
976
- }
977
-
978
- const payload = await client.succeedJob({
979
- jobId,
980
- workspacePath,
981
- localPort: runtime.localPort,
982
- transport: runtime.transport,
983
- endpoint: runtime.endpoint,
984
- healthcheckUrl: runtime.healthcheckUrl,
985
- completedAt: parseIsoTimestamp(flags['completed-at'], 'completed-at'),
986
- });
987
- printStructuredResult(payload, isTruthyFlag(flags.json));
988
- }
989
-
990
- async function handlePersonaJobFailCommand(jobId, flags = {}) {
991
- const code = String(flags.code || '').trim();
992
- const message = String(flags.message || '').trim();
993
- if (!code) {
994
- throw new Error('Error code is required. Use --code.');
995
- }
996
- if (!message) {
997
- throw new Error('Error message is required. Use --message.');
998
- }
999
-
1000
- const client = createCliPersonaApiClient(flags);
1001
- const payload = await client.failJob({
1002
- jobId,
1003
- code,
1004
- message,
1005
- completedAt: parseIsoTimestamp(flags['completed-at'], 'completed-at'),
1006
- });
1007
- printStructuredResult(payload, isTruthyFlag(flags.json));
1008
- }
1009
-
1010
- async function handlePersonaJobExecuteCommand(flags = {}) {
1011
- const message = readStructuredPersonaJobMessage(flags);
1012
- const shouldInstall = !isTruthyFlag(flags['no-install']);
1013
- const shouldStart = !isTruthyFlag(flags['no-start']);
1014
- const shouldRegister = !isTruthyFlag(flags['no-register']) && shouldStart;
1015
- const logFilePath = String(flags['log-file'] || '').trim();
1016
- const result = await runManagedPersonaJobExecution({
1017
- message,
1018
- backendUrl: resolvePersonaBackendUrl(flags),
1019
- deviceToken: resolvePersonaDeviceToken(flags),
1020
- deviceId: resolvePersonaDeviceId(flags),
1021
- workspaceRoot: String(flags['workspace-root'] || defaultPersonaWorkspaceRoot()).trim(),
1022
- shouldInstall,
1023
- shouldStart,
1024
- shouldRegister,
1025
- logFilePath,
1026
- });
1027
-
1028
- printStructuredResult(result, isTruthyFlag(flags.json));
1029
- }
1030
-
1031
- async function handlePersonaCreateManagedCommand(flags = {}, positionalSlug = '') {
1032
- const name = String(flags.name || '').trim();
1033
- if (!name) {
1034
- throw new Error('Persona name is required. Usage: oomi personas create-managed [slug] --name "<name>" --description "<description>"');
1035
- }
1036
-
1037
- const description = String(flags.description || '').trim() || name;
1038
- const explicitSlug = String(flags.slug || positionalSlug || '').trim();
1039
- const client = createCliPersonaApiClient(flags);
1040
- const result = await client.createManagedPersona({
1041
- slug: explicitSlug,
1042
- name,
1043
- description,
1044
- templateType: String(flags['template-type'] || 'persona-app').trim() || 'persona-app',
1045
- promptTemplateVersion: String(flags['template-version'] || 'v1').trim() || 'v1',
1046
- });
1047
-
1048
- printManagedPersonaCreateResult(result, isTruthyFlag(flags.json));
1049
- }
1050
-
1051
- function resolveOpenclawConfigPath() {
1052
- const candidates = [
1053
- path.join(os.homedir(), '.openclaw', 'clawdbot.json'),
1054
- path.join(os.homedir(), '.openclaw', 'openclaw.json'),
1055
- ];
1056
- for (const candidate of candidates) {
1057
- if (fs.existsSync(candidate)) return candidate;
772
+ if (!backendUrl) {
773
+ throw new Error('Missing backend URL. Use --backend-url or pair the device first.');
1058
774
  }
1059
- return null;
775
+ return backendUrl.replace(/\/$/, '');
1060
776
  }
1061
777
 
1062
- function readOpenclawGatewayConfig() {
1063
- const configPath = resolveOpenclawConfigPath();
1064
- if (!configPath) {
1065
- throw new Error('OpenClaw config not found (~/.openclaw/clawdbot.json or ~/.openclaw/openclaw.json).');
778
+ function resolvePersonaDeviceToken(flags = {}) {
779
+ const bridgeState = readBridgeState();
780
+ const deviceToken = String(
781
+ flags['device-token'] ||
782
+ bridgeState.deviceToken ||
783
+ ''
784
+ ).trim();
785
+ if (!deviceToken) {
786
+ throw new Error('Missing device token. Use --device-token or pair the device first.');
1066
787
  }
1067
-
1068
- const parsed = JSON.parse(readFile(configPath));
1069
- const gateway = parsed.gateway || {};
1070
- const auth = gateway.auth || {};
1071
- const port = gateway.port || 18789;
1072
- const bind = gateway.bind || 'loopback';
1073
- const host = bind === 'all' ? '127.0.0.1' : '127.0.0.1';
1074
- const gatewayUrl = `ws://${host}:${port}`;
1075
-
1076
- return {
1077
- gatewayUrl,
1078
- token: typeof auth.token === 'string' ? auth.token.trim() : '',
1079
- password: typeof auth.password === 'string' ? auth.password.trim() : '',
1080
- configPath,
1081
- };
788
+ return deviceToken;
1082
789
  }
1083
790
 
1084
- function resolveBridgeStatePath() {
1085
- return path.join(os.homedir(), '.openclaw', 'oomi-bridge.json');
791
+ function resolvePersonaDeviceId(flags = {}) {
792
+ const bridgeState = readBridgeState();
793
+ const deviceId = String(
794
+ flags['device-id'] ||
795
+ bridgeState.deviceId ||
796
+ ''
797
+ ).trim();
798
+ if (!deviceId) {
799
+ throw new Error('Missing device id. Use --device-id or pair the device first.');
800
+ }
801
+ return deviceId;
1086
802
  }
1087
803
 
1088
- function resolveBridgeStatusPath() {
1089
- return path.join(os.homedir(), '.openclaw', 'oomi-bridge-status.json');
804
+ function createCliPersonaApiClient(flags = {}) {
805
+ return createPersonaApiClient({
806
+ backendUrl: resolvePersonaBackendUrl(flags),
807
+ deviceToken: resolvePersonaDeviceToken(flags),
808
+ deviceId: resolvePersonaDeviceId(flags),
809
+ });
1090
810
  }
1091
811
 
1092
- function resolveBridgeLockPath() {
1093
- return path.join(os.homedir(), '.openclaw', 'oomi-bridge.lock');
812
+ function isHttpErrorStatus(error, status) {
813
+ return Number(error?.status) === Number(status);
1094
814
  }
1095
815
 
1096
- function resolveBridgeLiveLogPath() {
1097
- return path.join(os.homedir(), '.openclaw', 'logs', 'oomi-bridge-live.log');
816
+ function resolvePersonaWorkspaceRoot(flags = {}) {
817
+ const workspaceRoot = String(flags['workspace-root'] || defaultPersonaWorkspaceRoot()).trim();
818
+ if (!workspaceRoot) {
819
+ throw new Error('Persona workspace root is required.');
820
+ }
821
+ return workspaceRoot;
1098
822
  }
1099
823
 
1100
- function resolveBridgeLaunchAgentPlistPath() {
1101
- return path.join(os.homedir(), 'Library', 'LaunchAgents', `${BRIDGE_LAUNCHD_LABEL}.plist`);
824
+ function resolvePersonaTemplateVersion(flags = {}, fallback = 'v1') {
825
+ return String(flags['template-version'] || fallback || 'v1').trim() || 'v1';
1102
826
  }
1103
827
 
1104
- function defaultDeviceId() {
1105
- const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
1106
- return `oomi-${host}-${randomUUID().slice(0, 8)}`;
828
+ function resolvePersonaEntryUrl(flags = {}) {
829
+ return String(flags['entry-url'] || '').trim();
1107
830
  }
1108
831
 
1109
- function resolveDeviceId(flags, bridgeState) {
1110
- const explicit = String(flags['device-id'] || process.env.OOMI_MANAGED_DEVICE_ID || '').trim();
1111
- if (explicit) return explicit;
1112
- const existing = String(bridgeState.deviceId || '').trim();
1113
- if (existing) return existing;
1114
- return defaultDeviceId();
832
+ function resolvePersonaLaunchTransport(flags = {}) {
833
+ const explicitTransport = String(flags.transport || '').trim();
834
+ if (explicitTransport) {
835
+ return explicitTransport;
836
+ }
837
+
838
+ return resolvePersonaEntryUrl(flags) ? 'relay' : 'local';
1115
839
  }
1116
840
 
1117
- function readBridgeState() {
1118
- const statePath = resolveBridgeStatePath();
1119
- if (!fs.existsSync(statePath)) return {};
841
+ async function findExistingManagedPersona(client, slug) {
1120
842
  try {
1121
- return JSON.parse(readFile(statePath));
1122
- } catch {
1123
- return {};
843
+ return await client.getPersona({ slug });
844
+ } catch (error) {
845
+ if (isHttpErrorStatus(error, 404)) {
846
+ return null;
847
+ }
848
+ throw error;
1124
849
  }
1125
850
  }
1126
851
 
1127
- function writeBridgeState(nextState) {
1128
- const statePath = resolveBridgeStatePath();
852
+ function ensurePersonaJobWorkspace(message, workspaceRoot = defaultPersonaWorkspaceRoot()) {
853
+ const metadata = message && typeof message === 'object' ? message.metadata : null;
854
+ const payload = metadata && typeof metadata === 'object' ? metadata.payload : null;
855
+ if (!payload || typeof payload !== 'object') {
856
+ return message;
857
+ }
858
+
859
+ const persona = payload.persona && typeof payload.persona === 'object' ? payload.persona : {};
860
+ const scaffold = payload.scaffold && typeof payload.scaffold === 'object' ? payload.scaffold : {};
861
+ if (!scaffold.outDir && typeof persona.slug === 'string' && persona.slug.trim()) {
862
+ scaffold.outDir = path.join(workspaceRoot, persona.slug.trim());
863
+ payload.scaffold = scaffold;
864
+ metadata.payload = payload;
865
+ message.metadata = metadata;
866
+ }
867
+
868
+ return message;
869
+ }
870
+
871
+ async function runLegacyManagedPersonaJobExecution({
872
+ message,
873
+ backendUrl,
874
+ deviceToken,
875
+ deviceId,
876
+ workspaceRoot = defaultPersonaWorkspaceRoot(),
877
+ shouldInstall = true,
878
+ shouldStart = true,
879
+ shouldRegister = true,
880
+ logFilePath = '',
881
+ }) {
882
+ const normalizedMessage = ensurePersonaJobWorkspace(
883
+ structuredClone(message),
884
+ workspaceRoot,
885
+ );
886
+ const client = createPersonaApiClient({
887
+ backendUrl,
888
+ deviceToken,
889
+ deviceId,
890
+ });
891
+
892
+ return executePersonaJob({
893
+ message: normalizedMessage,
894
+ installWorkspace: shouldInstall
895
+ ? async ({ workspacePath }) => {
896
+ await installPersonaWorkspace({ workspacePath });
897
+ }
898
+ : async () => {},
899
+ startWorkspace: shouldStart
900
+ ? async ({ workspacePath }) =>
901
+ startPersonaWorkspace({
902
+ workspacePath,
903
+ logFilePath,
904
+ })
905
+ : async () => ({ pid: null, logFilePath }),
906
+ waitForRuntime: shouldStart
907
+ ? async ({ runtime }) => {
908
+ await waitForPersonaRuntime({
909
+ healthcheckUrl: runtime.healthcheckUrl,
910
+ });
911
+ }
912
+ : async () => {},
913
+ registerRuntime: shouldRegister
914
+ ? async ({ payload: jobPayload, result: runtimeResult }) => {
915
+ const jobPersona = jobPayload.persona && typeof jobPayload.persona === 'object' ? jobPayload.persona : {};
916
+ await client.registerRuntime({
917
+ slug: String(jobPersona.slug || '').trim(),
918
+ endpoint: runtimeResult.endpoint,
919
+ healthcheckUrl: runtimeResult.healthcheckUrl,
920
+ transport: runtimeResult.transport,
921
+ localPort: runtimeResult.localPort,
922
+ startedAt: new Date().toISOString(),
923
+ });
924
+ }
925
+ : async () => {},
926
+ destroyWorkspace: async ({ payload: jobPayload }) => {
927
+ const jobPersona = jobPayload.persona && typeof jobPayload.persona === 'object' ? jobPayload.persona : {};
928
+ const safeSlug = String(jobPersona.slug || '').trim();
929
+ if (!safeSlug) {
930
+ throw new Error('Destroy persona job payload is missing persona.slug.');
931
+ }
932
+
933
+ return destroyManagedPersonaRuntime({
934
+ slug: safeSlug,
935
+ workspaceRoot,
936
+ });
937
+ },
938
+ onJobStart: async ({ jobId }) => {
939
+ await client.startJob({
940
+ jobId,
941
+ startedAt: new Date().toISOString(),
942
+ });
943
+ },
944
+ onJobSuccess: async ({ jobId, result: runtimeResult }) => {
945
+ await client.succeedJob({
946
+ jobId,
947
+ workspacePath: runtimeResult.workspacePath,
948
+ localPort: runtimeResult.localPort,
949
+ transport: runtimeResult.transport,
950
+ endpoint: runtimeResult.endpoint,
951
+ healthcheckUrl: runtimeResult.healthcheckUrl,
952
+ completedAt: new Date().toISOString(),
953
+ });
954
+ },
955
+ onJobFailure: async ({ jobId, error }) => {
956
+ await client.failJob({
957
+ jobId,
958
+ code: String(error?.code || 'PERSONA_JOB_EXECUTION_FAILED').trim(),
959
+ message: String(error?.message || 'Persona job execution failed.').trim(),
960
+ completedAt: new Date().toISOString(),
961
+ });
962
+ },
963
+ });
964
+ }
965
+
966
+ async function runManagedPersonaJobExecution({
967
+ message,
968
+ backendUrl,
969
+ deviceToken,
970
+ deviceId,
971
+ workspaceRoot = defaultPersonaWorkspaceRoot(),
972
+ shouldInstall = true,
973
+ shouldStart = true,
974
+ shouldRegister = true,
975
+ logFilePath = '',
976
+ }) {
977
+ if (!shouldStart) {
978
+ return runLegacyManagedPersonaJobExecution({
979
+ message,
980
+ backendUrl,
981
+ deviceToken,
982
+ deviceId,
983
+ workspaceRoot,
984
+ shouldInstall,
985
+ shouldStart,
986
+ shouldRegister,
987
+ logFilePath,
988
+ });
989
+ }
990
+
991
+ const normalizedMessage = ensurePersonaJobWorkspace(
992
+ structuredClone(message),
993
+ workspaceRoot,
994
+ );
995
+ const client = createPersonaApiClient({
996
+ backendUrl,
997
+ deviceToken,
998
+ deviceId,
999
+ });
1000
+ const payload = extractPersonaJobPayload(normalizedMessage);
1001
+ const jobId = String(payload.jobId || normalizedMessage?.metadata?.jobId || '').trim();
1002
+ if (!jobId) {
1003
+ throw new Error('Persona job payload is missing jobId.');
1004
+ }
1005
+ if (!['create_persona_runtime', 'destroy_persona_runtime'].includes(payload.jobType)) {
1006
+ throw new Error(`Unsupported persona job type: ${payload.jobType || 'unknown'}`);
1007
+ }
1008
+
1009
+ const persona = payload.persona && typeof payload.persona === 'object' ? payload.persona : {};
1010
+ const slug = String(persona.slug || '').trim();
1011
+ const name = String(persona.name || '').trim();
1012
+ const description = String(persona.description || '').trim() || name;
1013
+ if (!slug || !name) {
1014
+ throw new Error('Persona job payload is missing persona slug or name.');
1015
+ }
1016
+
1017
+ await client.startJob({
1018
+ jobId,
1019
+ startedAt: new Date().toISOString(),
1020
+ });
1021
+
1022
+ try {
1023
+ if (payload.jobType === 'destroy_persona_runtime') {
1024
+ const destroyResult = await destroyManagedPersonaRuntime({
1025
+ slug,
1026
+ workspaceRoot,
1027
+ });
1028
+
1029
+ await client.succeedJob({
1030
+ jobId,
1031
+ workspacePath: destroyResult.workspacePath,
1032
+ completedAt: new Date().toISOString(),
1033
+ });
1034
+
1035
+ return {
1036
+ ok: true,
1037
+ jobId,
1038
+ result: destroyResult,
1039
+ };
1040
+ }
1041
+
1042
+ const launchResult = await launchManagedPersonaRuntime({
1043
+ slug,
1044
+ name,
1045
+ description,
1046
+ workspaceRoot,
1047
+ templateVersion: String(persona.templateVersion || 'v1').trim() || 'v1',
1048
+ forceInstall: shouldInstall,
1049
+ restart: false,
1050
+ logFilePath,
1051
+ entryUrl: '',
1052
+ transport: 'local',
1053
+ });
1054
+
1055
+ let registrationPayload = null;
1056
+ if (shouldRegister) {
1057
+ registrationPayload = await client.registerRuntime({
1058
+ slug,
1059
+ endpoint: launchResult.runtime.endpoint,
1060
+ healthcheckUrl: launchResult.runtime.healthcheckUrl,
1061
+ transport: launchResult.runtime.transport,
1062
+ localPort: launchResult.runtime.localPort,
1063
+ startedAt: new Date().toISOString(),
1064
+ });
1065
+ }
1066
+
1067
+ const result = {
1068
+ workspacePath: launchResult.workspacePath,
1069
+ localPort: launchResult.runtime.localPort,
1070
+ transport: launchResult.runtime.transport,
1071
+ endpoint: launchResult.runtime.endpoint,
1072
+ healthcheckUrl: launchResult.runtime.healthcheckUrl,
1073
+ pid: launchResult.state?.pid || null,
1074
+ logFilePath: launchResult.state?.logFilePath || '',
1075
+ templateVersion: launchResult.state?.templateVersion || String(persona.templateVersion || 'v1').trim() || 'v1',
1076
+ reusedRunningProcess: launchResult.reusedRunningProcess,
1077
+ scaffolded: launchResult.scaffolded,
1078
+ installed: launchResult.installed,
1079
+ registration: registrationPayload,
1080
+ };
1081
+
1082
+ await client.succeedJob({
1083
+ jobId,
1084
+ workspacePath: result.workspacePath,
1085
+ localPort: result.localPort,
1086
+ transport: result.transport,
1087
+ endpoint: result.endpoint,
1088
+ healthcheckUrl: result.healthcheckUrl,
1089
+ completedAt: new Date().toISOString(),
1090
+ });
1091
+
1092
+ return {
1093
+ ok: true,
1094
+ jobId,
1095
+ result,
1096
+ };
1097
+ } catch (error) {
1098
+ const messageText = error instanceof Error ? error.message : 'Persona job execution failed.';
1099
+ await client.failJob({
1100
+ jobId,
1101
+ code: 'PERSONA_JOB_EXECUTION_FAILED',
1102
+ message: messageText,
1103
+ completedAt: new Date().toISOString(),
1104
+ });
1105
+
1106
+ return {
1107
+ ok: false,
1108
+ jobId,
1109
+ error: {
1110
+ code: 'PERSONA_JOB_EXECUTION_FAILED',
1111
+ message: messageText,
1112
+ },
1113
+ };
1114
+ }
1115
+ }
1116
+
1117
+ function resolvePersonaRuntimeInput(flags = {}, defaults = {}) {
1118
+ const localPort = parseOptionalPositiveInteger(flags['local-port'] || flags.localPort || defaults.localPort);
1119
+ const endpoint = String(flags.endpoint || defaults.endpoint || '').trim();
1120
+ const healthPath = String(flags['health-path'] || defaults.healthPath || '/oomi.health.json').trim() || '/oomi.health.json';
1121
+ const healthcheckUrl = String(flags['healthcheck-url'] || defaults.healthcheckUrl || '').trim();
1122
+ const transport = String(flags.transport || defaults.transport || 'local').trim() || 'local';
1123
+
1124
+ if (endpoint) {
1125
+ return {
1126
+ endpoint,
1127
+ healthcheckUrl: healthcheckUrl || `${endpoint.replace(/\/$/, '')}${healthPath}`,
1128
+ localPort,
1129
+ transport,
1130
+ };
1131
+ }
1132
+
1133
+ if (!localPort) {
1134
+ throw new Error('Runtime endpoint or local port is required.');
1135
+ }
1136
+
1137
+ return buildLocalPersonaRuntime({
1138
+ localPort,
1139
+ healthPath,
1140
+ });
1141
+ }
1142
+
1143
+ function parseIsoTimestamp(rawValue, label) {
1144
+ const value = String(rawValue || '').trim();
1145
+ if (!value) return undefined;
1146
+ const timestamp = new Date(value);
1147
+ if (Number.isNaN(timestamp.getTime())) {
1148
+ throw new Error(`${label} must be a valid ISO timestamp.`);
1149
+ }
1150
+ return timestamp.toISOString();
1151
+ }
1152
+
1153
+ function readStructuredPersonaJobMessage(flags = {}) {
1154
+ const filePath = String(flags['message-file'] || '').trim();
1155
+ const inlineJson = String(flags['message-json'] || '').trim();
1156
+
1157
+ if (inlineJson) {
1158
+ return JSON.parse(inlineJson);
1159
+ }
1160
+ if (filePath) {
1161
+ return JSON.parse(readFile(path.resolve(filePath)));
1162
+ }
1163
+
1164
+ throw new Error('Persona job message is required. Use --message-file or --message-json.');
1165
+ }
1166
+
1167
+ function printStructuredResult(result, asJson = false) {
1168
+ if (asJson) {
1169
+ console.log(JSON.stringify(result, null, 2));
1170
+ return;
1171
+ }
1172
+
1173
+ for (const [key, value] of Object.entries(result)) {
1174
+ console.log(`${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`);
1175
+ }
1176
+ }
1177
+
1178
+ async function handlePersonaRuntimeRegisterCommand(slug, flags = {}) {
1179
+ const client = createCliPersonaApiClient(flags);
1180
+ const runtime = resolvePersonaRuntimeInput(flags);
1181
+ const payload = await client.registerRuntime({
1182
+ slug,
1183
+ endpoint: runtime.endpoint,
1184
+ healthcheckUrl: runtime.healthcheckUrl,
1185
+ localPort: runtime.localPort,
1186
+ transport: runtime.transport,
1187
+ startedAt: parseIsoTimestamp(flags['started-at'], 'started-at'),
1188
+ });
1189
+ printStructuredResult(payload, isTruthyFlag(flags.json));
1190
+ }
1191
+
1192
+ async function handlePersonaHeartbeatCommand(slug, flags = {}) {
1193
+ const client = createCliPersonaApiClient(flags);
1194
+ const runtime = resolvePersonaRuntimeInput(flags);
1195
+ const payload = await client.heartbeatRuntime({
1196
+ slug,
1197
+ endpoint: runtime.endpoint,
1198
+ healthcheckUrl: runtime.healthcheckUrl,
1199
+ localPort: runtime.localPort,
1200
+ transport: runtime.transport,
1201
+ observedAt: parseIsoTimestamp(flags['observed-at'], 'observed-at'),
1202
+ });
1203
+ printStructuredResult(payload, isTruthyFlag(flags.json));
1204
+ }
1205
+
1206
+ async function handlePersonaRuntimeFailCommand(slug, flags = {}) {
1207
+ const code = String(flags.code || '').trim();
1208
+ const message = String(flags.message || '').trim();
1209
+ if (!code) {
1210
+ throw new Error('Error code is required. Use --code.');
1211
+ }
1212
+ if (!message) {
1213
+ throw new Error('Error message is required. Use --message.');
1214
+ }
1215
+
1216
+ const client = createCliPersonaApiClient(flags);
1217
+ const payload = await client.failRuntime({
1218
+ slug,
1219
+ code,
1220
+ message,
1221
+ });
1222
+ printStructuredResult(payload, isTruthyFlag(flags.json));
1223
+ }
1224
+
1225
+ async function handlePersonaJobStartCommand(jobId, flags = {}) {
1226
+ const client = createCliPersonaApiClient(flags);
1227
+ const payload = await client.startJob({
1228
+ jobId,
1229
+ startedAt: parseIsoTimestamp(flags['started-at'], 'started-at'),
1230
+ });
1231
+ printStructuredResult(payload, isTruthyFlag(flags.json));
1232
+ }
1233
+
1234
+ async function handlePersonaJobSucceedCommand(jobId, flags = {}) {
1235
+ const client = createCliPersonaApiClient(flags);
1236
+ const runtime = resolvePersonaRuntimeInput(flags);
1237
+ const workspacePath = String(flags['workspace-path'] || flags.workspacePath || '').trim();
1238
+ if (!workspacePath) {
1239
+ throw new Error('Workspace path is required. Use --workspace-path.');
1240
+ }
1241
+
1242
+ const payload = await client.succeedJob({
1243
+ jobId,
1244
+ workspacePath,
1245
+ localPort: runtime.localPort,
1246
+ transport: runtime.transport,
1247
+ endpoint: runtime.endpoint,
1248
+ healthcheckUrl: runtime.healthcheckUrl,
1249
+ completedAt: parseIsoTimestamp(flags['completed-at'], 'completed-at'),
1250
+ });
1251
+ printStructuredResult(payload, isTruthyFlag(flags.json));
1252
+ }
1253
+
1254
+ async function handlePersonaJobFailCommand(jobId, flags = {}) {
1255
+ const code = String(flags.code || '').trim();
1256
+ const message = String(flags.message || '').trim();
1257
+ if (!code) {
1258
+ throw new Error('Error code is required. Use --code.');
1259
+ }
1260
+ if (!message) {
1261
+ throw new Error('Error message is required. Use --message.');
1262
+ }
1263
+
1264
+ const client = createCliPersonaApiClient(flags);
1265
+ const payload = await client.failJob({
1266
+ jobId,
1267
+ code,
1268
+ message,
1269
+ completedAt: parseIsoTimestamp(flags['completed-at'], 'completed-at'),
1270
+ });
1271
+ printStructuredResult(payload, isTruthyFlag(flags.json));
1272
+ }
1273
+
1274
+ async function handlePersonaJobExecuteCommand(flags = {}) {
1275
+ const message = readStructuredPersonaJobMessage(flags);
1276
+ const shouldInstall = !isTruthyFlag(flags['no-install']);
1277
+ const shouldStart = !isTruthyFlag(flags['no-start']);
1278
+ const shouldRegister = !isTruthyFlag(flags['no-register']) && shouldStart;
1279
+ const logFilePath = String(flags['log-file'] || '').trim();
1280
+ const result = await runManagedPersonaJobExecution({
1281
+ message,
1282
+ backendUrl: resolvePersonaBackendUrl(flags),
1283
+ deviceToken: resolvePersonaDeviceToken(flags),
1284
+ deviceId: resolvePersonaDeviceId(flags),
1285
+ workspaceRoot: String(flags['workspace-root'] || defaultPersonaWorkspaceRoot()).trim(),
1286
+ shouldInstall,
1287
+ shouldStart,
1288
+ shouldRegister,
1289
+ logFilePath,
1290
+ });
1291
+
1292
+ printStructuredResult(result, isTruthyFlag(flags.json));
1293
+ }
1294
+
1295
+ async function handlePersonaCreateManagedCommand(flags = {}, positionalSlug = '') {
1296
+ const name = String(flags.name || '').trim();
1297
+ if (!name) {
1298
+ throw new Error('Persona name is required. Usage: oomi personas create-managed [slug] --name "<name>" --description "<description>"');
1299
+ }
1300
+
1301
+ const description = String(flags.description || '').trim() || name;
1302
+ const explicitSlug = String(flags.slug || positionalSlug || '').trim();
1303
+ const client = createCliPersonaApiClient(flags);
1304
+ const result = await client.createManagedPersona({
1305
+ slug: explicitSlug,
1306
+ name,
1307
+ description,
1308
+ templateType: String(flags['template-type'] || 'persona-app').trim() || 'persona-app',
1309
+ promptTemplateVersion: String(flags['template-version'] || 'v1').trim() || 'v1',
1310
+ });
1311
+
1312
+ printManagedPersonaCreateResult(result, isTruthyFlag(flags.json));
1313
+ }
1314
+
1315
+ async function handlePersonaLaunchManagedCommand(flags = {}, positionalSlug = '') {
1316
+ const client = createCliPersonaApiClient(flags);
1317
+ const safeName = String(flags.name || '').trim();
1318
+ const safeDescription = String(flags.description || '').trim() || safeName;
1319
+ const safeSlug = String(flags.slug || positionalSlug || (safeName ? slugifyPersonaName(safeName) : '')).trim();
1320
+ if (!safeSlug) {
1321
+ throw new Error('Persona slug or name is required. Usage: oomi personas launch-managed [slug] --name "<name>"');
1322
+ }
1323
+
1324
+ const shouldCreate = !isTruthyFlag(flags['no-create']);
1325
+ let createdPersona = false;
1326
+ let personaPayload = await findExistingManagedPersona(client, safeSlug);
1327
+ if (!personaPayload) {
1328
+ if (!shouldCreate) {
1329
+ throw new Error(`Managed persona ${safeSlug} does not exist in Oomi. Remove --no-create or create it first.`);
1330
+ }
1331
+ if (!safeName) {
1332
+ throw new Error('Persona name is required when creating a new managed persona.');
1333
+ }
1334
+ personaPayload = await client.createManagedPersona({
1335
+ slug: safeSlug,
1336
+ name: safeName,
1337
+ description: safeDescription,
1338
+ templateType: 'persona-app',
1339
+ promptTemplateVersion: resolvePersonaTemplateVersion(flags),
1340
+ });
1341
+ createdPersona = true;
1342
+ }
1343
+
1344
+ const persona = personaPayload?.persona && typeof personaPayload.persona === 'object' ? personaPayload.persona : {};
1345
+ const launchResult = await launchManagedPersonaRuntime({
1346
+ slug: String(persona.slug || safeSlug).trim(),
1347
+ name: String(persona.name || safeName || safeSlug).trim(),
1348
+ description: String(persona.description || safeDescription || safeName || safeSlug).trim(),
1349
+ workspaceRoot: resolvePersonaWorkspaceRoot(flags),
1350
+ templateVersion: String(persona.promptTemplateVersion || resolvePersonaTemplateVersion(flags)).trim() || 'v1',
1351
+ forceInstall: isTruthyFlag(flags['force-install']),
1352
+ restart: isTruthyFlag(flags.restart),
1353
+ logFilePath: String(flags['log-file'] || '').trim(),
1354
+ entryUrl: resolvePersonaEntryUrl(flags),
1355
+ transport: resolvePersonaLaunchTransport(flags),
1356
+ });
1357
+
1358
+ let registrationPayload = null;
1359
+ if (!isTruthyFlag(flags['no-register'])) {
1360
+ registrationPayload = await client.registerRuntime({
1361
+ slug: launchResult.slug,
1362
+ endpoint: launchResult.runtime.endpoint,
1363
+ healthcheckUrl: launchResult.runtime.healthcheckUrl,
1364
+ localPort: launchResult.runtime.localPort,
1365
+ transport: launchResult.runtime.transport,
1366
+ startedAt: parseIsoTimestamp(flags['started-at'], 'started-at') || new Date().toISOString(),
1367
+ });
1368
+ }
1369
+
1370
+ printStructuredResult({
1371
+ ok: true,
1372
+ persona,
1373
+ createdPersona,
1374
+ launch: {
1375
+ slug: launchResult.slug,
1376
+ workspacePath: launchResult.workspacePath,
1377
+ scaffolded: launchResult.scaffolded,
1378
+ installed: launchResult.installed,
1379
+ reusedRunningProcess: launchResult.reusedRunningProcess,
1380
+ },
1381
+ runtime: launchResult.runtime,
1382
+ localRuntime: launchResult.localRuntime,
1383
+ state: launchResult.state,
1384
+ registration: registrationPayload,
1385
+ }, isTruthyFlag(flags.json));
1386
+ }
1387
+
1388
+ async function handlePersonaStatusCommand(slug, flags = {}) {
1389
+ const result = getManagedPersonaRuntimeStatus({
1390
+ slug,
1391
+ workspaceRoot: resolvePersonaWorkspaceRoot(flags),
1392
+ });
1393
+ printStructuredResult(result, isTruthyFlag(flags.json));
1394
+ }
1395
+
1396
+ async function handlePersonaStopCommand(slug, flags = {}) {
1397
+ const result = await stopManagedPersonaRuntime({
1398
+ slug,
1399
+ workspaceRoot: resolvePersonaWorkspaceRoot(flags),
1400
+ });
1401
+ printStructuredResult(result, isTruthyFlag(flags.json));
1402
+ }
1403
+
1404
+ async function handlePersonaDeleteCommand(slug, flags = {}) {
1405
+ const result = await destroyManagedPersonaRuntime({
1406
+ slug,
1407
+ workspaceRoot: resolvePersonaWorkspaceRoot(flags),
1408
+ });
1409
+ printStructuredResult(result, isTruthyFlag(flags.json));
1410
+ }
1411
+
1412
+ function resolveOpenclawConfigPath() {
1413
+ const candidates = resolveOpenclawConfigCandidates();
1414
+ for (const candidate of candidates) {
1415
+ if (fs.existsSync(candidate)) return candidate;
1416
+ }
1417
+ return null;
1418
+ }
1419
+
1420
+ function readOpenclawGatewayConfig() {
1421
+ const configPath = resolveOpenclawConfigPath();
1422
+ if (!configPath) {
1423
+ const openclawHome = resolveOpenclawHome();
1424
+ throw new Error(`OpenClaw config not found (${path.join(openclawHome, 'clawdbot.json')} or ${path.join(openclawHome, 'openclaw.json')}).`);
1425
+ }
1426
+
1427
+ const parsed = JSON.parse(readFile(configPath));
1428
+ const gateway = parsed.gateway || {};
1429
+ const auth = gateway.auth || {};
1430
+ const port = gateway.port || 18789;
1431
+ const bind = gateway.bind || 'loopback';
1432
+ const host = bind === 'all' ? '127.0.0.1' : '127.0.0.1';
1433
+ const gatewayUrl = `ws://${host}:${port}`;
1434
+
1435
+ return {
1436
+ gatewayUrl,
1437
+ token: typeof auth.token === 'string' ? auth.token.trim() : '',
1438
+ password: typeof auth.password === 'string' ? auth.password.trim() : '',
1439
+ configPath,
1440
+ };
1441
+ }
1442
+
1443
+ function resolveBridgeStatePath() {
1444
+ return resolveOpenclawBridgeStatePath();
1445
+ }
1446
+
1447
+ function resolveBridgeStatusPath() {
1448
+ return resolveOpenclawBridgeStatusPath();
1449
+ }
1450
+
1451
+ function resolveBridgeLockPath() {
1452
+ return resolveOpenclawBridgeLockPath();
1453
+ }
1454
+
1455
+ function resolveBridgeLiveLogPath() {
1456
+ return resolveOpenclawBridgeLiveLogPath();
1457
+ }
1458
+
1459
+ function resolveBridgeLaunchAgentPlistPath() {
1460
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${BRIDGE_LAUNCHD_LABEL}.plist`);
1461
+ }
1462
+
1463
+ function defaultDeviceId() {
1464
+ const host = os.hostname().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'openclaw';
1465
+ return `oomi-${host}-${randomUUID().slice(0, 8)}`;
1466
+ }
1467
+
1468
+ function resolveDeviceId(flags, bridgeState) {
1469
+ const explicit = String(flags['device-id'] || process.env.OOMI_MANAGED_DEVICE_ID || '').trim();
1470
+ if (explicit) return explicit;
1471
+ const existing = String(bridgeState.deviceId || '').trim();
1472
+ if (existing) return existing;
1473
+ return defaultDeviceId();
1474
+ }
1475
+
1476
+ function readBridgeState() {
1477
+ const statePath = resolveBridgeStatePath();
1478
+ if (!fs.existsSync(statePath)) return {};
1479
+ try {
1480
+ return JSON.parse(readFile(statePath));
1481
+ } catch {
1482
+ return {};
1483
+ }
1484
+ }
1485
+
1486
+ function writeBridgeState(nextState) {
1487
+ const statePath = resolveBridgeStatePath();
1129
1488
  ensureDir(path.dirname(statePath));
1130
1489
  writeFile(statePath, JSON.stringify(nextState, null, 2) + '\n');
1131
1490
  }
@@ -1714,499 +2073,847 @@ function prepareGatewayFrameForLocalGateway(frameText, gatewayAuth, options = {}
1714
2073
  }
1715
2074
  }
1716
2075
 
1717
- function parseJsonPayload(raw) {
1718
- try {
1719
- return JSON.parse(raw);
1720
- } catch {
1721
- return null;
1722
- }
1723
- }
1724
-
1725
- function extractTextFromGatewayMessage(message) {
1726
- if (!message || typeof message !== 'object') return '';
1727
-
1728
- if (typeof message.content === 'string' && message.content.trim()) {
1729
- return message.content.trim();
1730
- }
1731
-
1732
- if (!Array.isArray(message.content)) return '';
1733
-
1734
- return message.content
1735
- .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
1736
- .map((block) => block.text.trim())
1737
- .filter(Boolean)
1738
- .join(' ');
1739
- }
1740
-
1741
- function summarizeVoiceFrameContract(frameText) {
1742
- const frame = parseJsonPayload(frameText);
1743
- if (!frame || typeof frame !== 'object') {
1744
- return { parseable: false };
1745
- }
1746
- const payload = frame.payload && typeof frame.payload === 'object' ? frame.payload : {};
1747
- const message = payload.message && typeof payload.message === 'object' ? payload.message : {};
1748
- const metadata = message.metadata && typeof message.metadata === 'object' ? message.metadata : {};
1749
- const spokenRaw = Object.prototype.hasOwnProperty.call(metadata, 'spoken') ? metadata.spoken : undefined;
1750
- const spokenNormalized = normalizeSpokenMetadata(spokenRaw);
1751
- const text = extractTextFromGatewayMessage(message);
1752
- return {
1753
- parseable: true,
1754
- event: typeof frame.event === 'string' ? frame.event : '',
1755
- state: typeof payload.state === 'string' ? payload.state : '',
1756
- role: typeof message.role === 'string' ? message.role : '',
1757
- contentLength: text.length,
1758
- hasMetadata: Object.keys(metadata).length > 0,
1759
- hasSpokenKey: Object.prototype.hasOwnProperty.call(metadata, 'spoken'),
1760
- spokenRawType: spokenRaw === undefined ? 'missing' : Array.isArray(spokenRaw) ? 'array' : typeof spokenRaw,
1761
- spokenNormalized: Boolean(spokenNormalized),
1762
- spokenSegmentCount: Array.isArray(spokenNormalized?.segments) ? spokenNormalized.segments.length : 0,
1763
- };
1764
- }
1765
-
1766
- function ensureAssistantSpokenMetadata(frameText) {
1767
- const frame = parseJsonPayload(frameText);
1768
- if (!frame || typeof frame !== 'object') {
1769
- return { frameText, changed: false, reason: '' };
1770
- }
1771
- if (frame.type !== 'event' || frame.event !== 'chat') {
1772
- return { frameText, changed: false, reason: '' };
1773
- }
1774
-
1775
- const payload = frame.payload && typeof frame.payload === 'object' ? frame.payload : null;
1776
- if (!payload || payload.state !== 'final') {
1777
- return { frameText, changed: false, reason: '' };
1778
- }
1779
-
1780
- const message = payload.message && typeof payload.message === 'object' ? payload.message : null;
1781
- if (!message) {
1782
- return { frameText, changed: false, reason: '' };
1783
- }
1784
-
1785
- const messageRole = typeof message.role === 'string' ? message.role.trim() : '';
1786
- if (messageRole && messageRole !== 'assistant') {
1787
- return { frameText, changed: false, reason: '' };
1788
- }
1789
-
1790
- const originalMetadata =
1791
- message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
1792
- ? message.metadata
1793
- : {};
1794
- const metadata = { ...originalMetadata };
1795
- const normalizedExplicitSpoken = normalizeSpokenMetadata(originalMetadata.spoken);
1796
- const spoken =
1797
- normalizedExplicitSpoken ||
1798
- inferSpokenMetadataFromContent(extractTextFromGatewayMessage(message));
1799
- if (!spoken) {
1800
- return { frameText, changed: false, reason: '' };
1801
- }
1802
-
1803
- metadata.spoken = spoken;
1804
- const nextFrame = JSON.stringify({
1805
- ...frame,
1806
- payload: {
1807
- ...payload,
1808
- message: {
1809
- ...message,
1810
- metadata,
1811
- },
1812
- },
1813
- });
1814
-
1815
- return {
1816
- frameText: nextFrame,
1817
- changed: nextFrame !== frameText,
1818
- reason: normalizedExplicitSpoken ? 'normalized' : (messageRole ? 'synthesized' : 'synthesized_missing_role'),
1819
- };
1820
- }
1821
-
1822
- function normalizeAssistantGatewayFrame(sessionId, frameText) {
1823
- const scope = classifyBridgeSessionScope(sessionId);
1824
- const summary = summarizeVoiceFrameContract(frameText);
1825
- if (!summary.parseable || summary.event !== 'chat' || summary.state !== 'final') {
1826
- return {
1827
- frameText,
1828
- changed: false,
1829
- reason: '',
1830
- scope,
1831
- summary,
1832
- };
1833
- }
1834
-
1835
- const normalized = ensureAssistantSpokenMetadata(frameText);
1836
- return {
1837
- ...normalized,
1838
- scope,
1839
- summary,
1840
- };
1841
- }
1842
-
1843
- function buildAssistantFinalDebugFrame({ sessionKey, text, role }) {
1844
- const trimmedSessionKey =
1845
- typeof sessionKey === 'string' && sessionKey.trim()
1846
- ? sessionKey.trim()
1847
- : 'agent:main:webchat:channel:oomi';
1848
- const message = {
1849
- content: String(text || ''),
1850
- };
1851
- if (typeof role === 'string' && role.trim()) {
1852
- message.role = role.trim();
1853
- }
1854
- return JSON.stringify({
1855
- type: 'event',
1856
- event: 'chat',
1857
- payload: {
1858
- sessionKey: trimmedSessionKey,
1859
- state: 'final',
1860
- message,
1861
- },
1862
- });
1863
- }
1864
-
1865
- function extractSpokenMetadata(frameText) {
1866
- const payload = parseJsonPayload(frameText);
1867
- const message =
1868
- payload &&
1869
- payload.payload &&
1870
- typeof payload.payload === 'object' &&
1871
- payload.payload.message &&
1872
- typeof payload.payload.message === 'object'
1873
- ? payload.payload.message
1874
- : null;
1875
- const metadata =
1876
- message &&
1877
- message.metadata &&
1878
- typeof message.metadata === 'object' &&
1879
- !Array.isArray(message.metadata)
1880
- ? message.metadata
1881
- : {};
1882
- return normalizeSpokenMetadata(metadata.spoken);
1883
- }
1884
-
1885
- function runAssistantFinalDebugCheck(options = {}) {
1886
- const sessionId =
1887
- typeof options.sessionId === 'string' && options.sessionId.trim()
1888
- ? options.sessionId.trim()
1889
- : 'ms_debug_local';
1890
- const sessionKey =
1891
- typeof options.sessionKey === 'string' && options.sessionKey.trim()
1892
- ? options.sessionKey.trim()
1893
- : 'agent:main:webchat:channel:oomi';
1894
- const role =
1895
- options.omitRole
1896
- ? ''
1897
- : (typeof options.role === 'string' && options.role.trim() ? options.role.trim() : 'assistant');
1898
-
1899
- const rawFrameText =
1900
- typeof options.frameText === 'string' && options.frameText.trim()
1901
- ? options.frameText
1902
- : buildAssistantFinalDebugFrame({
1903
- sessionKey,
1904
- text: options.text,
1905
- role,
1906
- });
1907
-
1908
- const before = summarizeVoiceFrameContract(rawFrameText);
1909
- const normalized = normalizeAssistantGatewayFrame(sessionId, rawFrameText);
1910
- const after = summarizeVoiceFrameContract(normalized.frameText);
1911
- const spoken = extractSpokenMetadata(normalized.frameText);
1912
-
1913
- return {
1914
- sessionId,
1915
- sessionKey,
1916
- scope: normalized.scope,
1917
- changed: normalized.changed,
1918
- reason: normalized.reason,
1919
- before,
1920
- after,
1921
- spoken,
1922
- frameText: normalized.frameText,
1923
- };
1924
- }
1925
-
1926
- function printAssistantFinalDebugResult(result, asJson) {
1927
- if (asJson) {
1928
- console.log(JSON.stringify(result, null, 2));
1929
- return;
1930
- }
1931
-
1932
- console.log(`Session id: ${result.sessionId}`);
1933
- console.log(`Session key: ${result.sessionKey}`);
1934
- console.log(`Scope: ${result.scope}`);
1935
- console.log(`Changed: ${result.changed ? 'yes' : 'no'}${result.reason ? ` (${result.reason})` : ''}`);
1936
- console.log(
1937
- `Before: event=${result.before.event || '<none>'} state=${result.before.state || '<none>'} role=${result.before.role || '<none>'} spoken=${result.before.spokenNormalized ? 'yes' : 'no'}`
1938
- );
1939
- console.log(
1940
- `After: event=${result.after.event || '<none>'} state=${result.after.state || '<none>'} role=${result.after.role || '<none>'} spoken=${result.after.spokenNormalized ? 'yes' : 'no'}`
1941
- );
1942
- if (result.spoken) {
1943
- console.log(`Spoken text: ${result.spoken.text}`);
1944
- console.log(`Segments: ${Array.isArray(result.spoken.segments) ? result.spoken.segments.length : 0}`);
1945
- if (typeof result.spoken.instructions === 'string' && result.spoken.instructions.trim()) {
1946
- console.log(`Instructions: ${result.spoken.instructions}`);
1947
- }
1948
- } else {
1949
- console.log('Spoken text: <missing>');
1950
- }
1951
- }
1952
-
1953
- function resolveCommandFromPath(commandName) {
1954
- const normalized = String(commandName || '').trim();
1955
- if (!normalized) return '';
1956
- try {
1957
- const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', [normalized], {
1958
- encoding: 'utf8',
1959
- stdio: ['ignore', 'pipe', 'ignore'],
1960
- });
1961
- if (probe.status !== 0) return '';
1962
- const firstLine = String(probe.stdout || '')
1963
- .split(/\r?\n/)
1964
- .map((line) => line.trim())
1965
- .find(Boolean);
1966
- return firstLine || '';
1967
- } catch {
1968
- return '';
1969
- }
1970
- }
1971
-
1972
- function resolveExecutable(candidates = []) {
1973
- for (const candidate of candidates) {
1974
- if (!candidate) continue;
1975
- const value = String(candidate).trim();
1976
- if (!value) continue;
1977
- if (path.isAbsolute(value) && fs.existsSync(value)) {
1978
- return value;
1979
- }
1980
- if (value.includes(path.sep) || value.includes('/')) {
1981
- const resolved = path.resolve(value);
1982
- if (fs.existsSync(resolved)) {
1983
- return resolved;
1984
- }
1985
- continue;
1986
- }
1987
- const fromPath = resolveCommandFromPath(value);
1988
- if (fromPath) {
1989
- return fromPath;
1990
- }
1991
- }
1992
- return '';
1993
- }
1994
-
1995
- function resolveBackendRoot(rootFlag) {
1996
- const repoRoot = resolveRepoRoot(rootFlag);
1997
- const backendRoot = path.join(repoRoot, 'apps', 'backend');
1998
- if (!fs.existsSync(backendRoot)) {
1999
- throw new Error(`Could not locate backend app at ${backendRoot}`);
2000
- }
2001
- return backendRoot;
2002
- }
2003
-
2004
- function resolveRubyExecutable() {
2005
- const candidates = [
2006
- process.env.OOMI_RUBY_BIN,
2007
- process.env.RUBY,
2008
- process.platform === 'win32' ? 'ruby.exe' : 'ruby',
2009
- process.platform === 'win32' ? 'ruby' : '',
2010
- process.platform === 'win32' ? 'C:\\Ruby33-x64\\bin\\ruby.exe' : '',
2011
- ];
2012
- const executable = resolveExecutable(candidates);
2013
- if (!executable) {
2014
- throw new Error('Ruby executable not found. Set OOMI_RUBY_BIN or install Ruby locally.');
2015
- }
2016
- return executable;
2017
- }
2018
-
2019
- function resolveBundleExecutable() {
2020
- const candidates = [
2021
- process.env.OOMI_BUNDLE_BIN,
2022
- process.platform === 'win32' ? 'bundle.bat' : 'bundle',
2023
- 'bundle',
2024
- process.platform === 'win32' ? 'C:\\Ruby33-x64\\bin\\bundle.bat' : '',
2025
- ];
2026
- const executable = resolveExecutable(candidates);
2027
- if (!executable) {
2028
- throw new Error('Bundler executable not found. Set OOMI_BUNDLE_BIN or install Bundler locally.');
2029
- }
2030
- return executable;
2031
- }
2032
-
2033
- function shellQuote(value) {
2034
- const text = String(value);
2035
- if (process.platform === 'win32') {
2036
- return `"${text.replace(/"/g, '""')}"`;
2037
- }
2038
- return `'${text.replace(/'/g, `'\\''`)}'`;
2039
- }
2040
-
2041
- async function runBundledRubyScript({ backendRoot, scriptPath, inputFile, env = undefined }) {
2042
- const rubyExecutable = resolveRubyExecutable();
2043
- const bundleExecutable = resolveBundleExecutable();
2044
- const commandText = process.platform === 'win32'
2045
- ? [bundleExecutable, 'exec', rubyExecutable, scriptPath, '--input-file', inputFile].map(shellQuote).join(' ')
2046
- : '';
2047
- const childEnv = env ? { ...process.env, ...env } : process.env;
2048
-
2049
- return await new Promise((resolve, reject) => {
2050
- const child = process.platform === 'win32'
2051
- ? spawn(commandText, [], {
2052
- cwd: backendRoot,
2053
- shell: true,
2054
- env: childEnv,
2055
- stdio: ['ignore', 'pipe', 'pipe'],
2056
- })
2057
- : spawn(bundleExecutable, ['exec', rubyExecutable, scriptPath, '--input-file', inputFile], {
2058
- cwd: backendRoot,
2059
- env: childEnv,
2060
- stdio: ['ignore', 'pipe', 'pipe'],
2061
- });
2062
-
2063
- let stdout = '';
2064
- let stderr = '';
2065
- child.stdout.on('data', (chunk) => {
2066
- stdout += chunk.toString();
2067
- });
2068
- child.stderr.on('data', (chunk) => {
2069
- stderr += chunk.toString();
2070
- });
2071
- child.on('error', reject);
2072
- child.on('close', (code) => {
2073
- resolve({ code: Number(code || 0), stdout, stderr });
2074
- });
2076
+ function parseJsonPayload(raw) {
2077
+ try {
2078
+ return JSON.parse(raw);
2079
+ } catch {
2080
+ return null;
2081
+ }
2082
+ }
2083
+
2084
+ function extractTextFromGatewayMessage(message) {
2085
+ if (!message || typeof message !== 'object') return '';
2086
+
2087
+ if (typeof message.content === 'string' && message.content.trim()) {
2088
+ return message.content.trim();
2089
+ }
2090
+
2091
+ if (!Array.isArray(message.content)) return '';
2092
+
2093
+ return message.content
2094
+ .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
2095
+ .map((block) => block.text.trim())
2096
+ .filter(Boolean)
2097
+ .join(' ');
2098
+ }
2099
+
2100
+ function summarizeVoiceFrameContract(frameText) {
2101
+ const frame = parseJsonPayload(frameText);
2102
+ if (!frame || typeof frame !== 'object') {
2103
+ return { parseable: false };
2104
+ }
2105
+ const payload = frame.payload && typeof frame.payload === 'object' ? frame.payload : {};
2106
+ const message = payload.message && typeof payload.message === 'object' ? payload.message : {};
2107
+ const metadata = message.metadata && typeof message.metadata === 'object' ? message.metadata : {};
2108
+ const spokenRaw = Object.prototype.hasOwnProperty.call(metadata, 'spoken') ? metadata.spoken : undefined;
2109
+ const spokenNormalized = normalizeSpokenMetadata(spokenRaw);
2110
+ const text = extractTextFromGatewayMessage(message);
2111
+ return {
2112
+ parseable: true,
2113
+ event: typeof frame.event === 'string' ? frame.event : '',
2114
+ state: typeof payload.state === 'string' ? payload.state : '',
2115
+ role: typeof message.role === 'string' ? message.role : '',
2116
+ contentLength: text.length,
2117
+ hasMetadata: Object.keys(metadata).length > 0,
2118
+ hasSpokenKey: Object.prototype.hasOwnProperty.call(metadata, 'spoken'),
2119
+ spokenRawType: spokenRaw === undefined ? 'missing' : Array.isArray(spokenRaw) ? 'array' : typeof spokenRaw,
2120
+ spokenNormalized: Boolean(spokenNormalized),
2121
+ spokenSegmentCount: Array.isArray(spokenNormalized?.segments) ? spokenNormalized.segments.length : 0,
2122
+ };
2123
+ }
2124
+
2125
+ function ensureAssistantSpokenMetadata(frameText) {
2126
+ const frame = parseJsonPayload(frameText);
2127
+ if (!frame || typeof frame !== 'object') {
2128
+ return { frameText, changed: false, reason: '' };
2129
+ }
2130
+ if (frame.type !== 'event' || frame.event !== 'chat') {
2131
+ return { frameText, changed: false, reason: '' };
2132
+ }
2133
+
2134
+ const payload = frame.payload && typeof frame.payload === 'object' ? frame.payload : null;
2135
+ if (!payload || payload.state !== 'final') {
2136
+ return { frameText, changed: false, reason: '' };
2137
+ }
2138
+
2139
+ const message = payload.message && typeof payload.message === 'object' ? payload.message : null;
2140
+ if (!message) {
2141
+ return { frameText, changed: false, reason: '' };
2142
+ }
2143
+
2144
+ const messageRole = typeof message.role === 'string' ? message.role.trim() : '';
2145
+ if (messageRole && messageRole !== 'assistant') {
2146
+ return { frameText, changed: false, reason: '' };
2147
+ }
2148
+
2149
+ const originalMetadata =
2150
+ message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
2151
+ ? message.metadata
2152
+ : {};
2153
+ const metadata = { ...originalMetadata };
2154
+ const normalizedExplicitSpoken = normalizeSpokenMetadata(originalMetadata.spoken);
2155
+ const spoken =
2156
+ normalizedExplicitSpoken ||
2157
+ inferSpokenMetadataFromContent(extractTextFromGatewayMessage(message));
2158
+ if (!spoken) {
2159
+ return { frameText, changed: false, reason: '' };
2160
+ }
2161
+
2162
+ metadata.spoken = spoken;
2163
+ const nextFrame = JSON.stringify({
2164
+ ...frame,
2165
+ payload: {
2166
+ ...payload,
2167
+ message: {
2168
+ ...message,
2169
+ metadata,
2170
+ },
2171
+ },
2172
+ });
2173
+
2174
+ return {
2175
+ frameText: nextFrame,
2176
+ changed: nextFrame !== frameText,
2177
+ reason: normalizedExplicitSpoken ? 'normalized' : (messageRole ? 'synthesized' : 'synthesized_missing_role'),
2178
+ };
2179
+ }
2180
+
2181
+ function normalizeAssistantGatewayFrame(sessionId, frameText) {
2182
+ const scope = classifyBridgeSessionScope(sessionId);
2183
+ const summary = summarizeVoiceFrameContract(frameText);
2184
+ if (!summary.parseable || summary.event !== 'chat' || summary.state !== 'final') {
2185
+ return {
2186
+ frameText,
2187
+ changed: false,
2188
+ reason: '',
2189
+ scope,
2190
+ summary,
2191
+ };
2192
+ }
2193
+
2194
+ const normalized = ensureAssistantSpokenMetadata(frameText);
2195
+ return {
2196
+ ...normalized,
2197
+ scope,
2198
+ summary,
2199
+ };
2200
+ }
2201
+
2202
+ function buildAssistantFinalDebugFrame({ sessionKey, text, role }) {
2203
+ const trimmedSessionKey =
2204
+ typeof sessionKey === 'string' && sessionKey.trim()
2205
+ ? sessionKey.trim()
2206
+ : 'agent:main:webchat:channel:oomi';
2207
+ const message = {
2208
+ content: String(text || ''),
2209
+ };
2210
+ if (typeof role === 'string' && role.trim()) {
2211
+ message.role = role.trim();
2212
+ }
2213
+ return JSON.stringify({
2214
+ type: 'event',
2215
+ event: 'chat',
2216
+ payload: {
2217
+ sessionKey: trimmedSessionKey,
2218
+ state: 'final',
2219
+ message,
2220
+ },
2221
+ });
2222
+ }
2223
+
2224
+ function extractSpokenMetadata(frameText) {
2225
+ const payload = parseJsonPayload(frameText);
2226
+ const message =
2227
+ payload &&
2228
+ payload.payload &&
2229
+ typeof payload.payload === 'object' &&
2230
+ payload.payload.message &&
2231
+ typeof payload.payload.message === 'object'
2232
+ ? payload.payload.message
2233
+ : null;
2234
+ const metadata =
2235
+ message &&
2236
+ message.metadata &&
2237
+ typeof message.metadata === 'object' &&
2238
+ !Array.isArray(message.metadata)
2239
+ ? message.metadata
2240
+ : {};
2241
+ return normalizeSpokenMetadata(metadata.spoken);
2242
+ }
2243
+
2244
+ function runAssistantFinalDebugCheck(options = {}) {
2245
+ const sessionId =
2246
+ typeof options.sessionId === 'string' && options.sessionId.trim()
2247
+ ? options.sessionId.trim()
2248
+ : 'ms_debug_local';
2249
+ const sessionKey =
2250
+ typeof options.sessionKey === 'string' && options.sessionKey.trim()
2251
+ ? options.sessionKey.trim()
2252
+ : 'agent:main:webchat:channel:oomi';
2253
+ const role =
2254
+ options.omitRole
2255
+ ? ''
2256
+ : (typeof options.role === 'string' && options.role.trim() ? options.role.trim() : 'assistant');
2257
+
2258
+ const rawFrameText =
2259
+ typeof options.frameText === 'string' && options.frameText.trim()
2260
+ ? options.frameText
2261
+ : buildAssistantFinalDebugFrame({
2262
+ sessionKey,
2263
+ text: options.text,
2264
+ role,
2265
+ });
2266
+
2267
+ const before = summarizeVoiceFrameContract(rawFrameText);
2268
+ const normalized = normalizeAssistantGatewayFrame(sessionId, rawFrameText);
2269
+ const after = summarizeVoiceFrameContract(normalized.frameText);
2270
+ const spoken = extractSpokenMetadata(normalized.frameText);
2271
+
2272
+ return {
2273
+ sessionId,
2274
+ sessionKey,
2275
+ scope: normalized.scope,
2276
+ changed: normalized.changed,
2277
+ reason: normalized.reason,
2278
+ before,
2279
+ after,
2280
+ spoken,
2281
+ frameText: normalized.frameText,
2282
+ };
2283
+ }
2284
+
2285
+ function printAssistantFinalDebugResult(result, asJson) {
2286
+ if (asJson) {
2287
+ console.log(JSON.stringify(result, null, 2));
2288
+ return;
2289
+ }
2290
+
2291
+ console.log(`Session id: ${result.sessionId}`);
2292
+ console.log(`Session key: ${result.sessionKey}`);
2293
+ console.log(`Scope: ${result.scope}`);
2294
+ console.log(`Changed: ${result.changed ? 'yes' : 'no'}${result.reason ? ` (${result.reason})` : ''}`);
2295
+ console.log(
2296
+ `Before: event=${result.before.event || '<none>'} state=${result.before.state || '<none>'} role=${result.before.role || '<none>'} spoken=${result.before.spokenNormalized ? 'yes' : 'no'}`
2297
+ );
2298
+ console.log(
2299
+ `After: event=${result.after.event || '<none>'} state=${result.after.state || '<none>'} role=${result.after.role || '<none>'} spoken=${result.after.spokenNormalized ? 'yes' : 'no'}`
2300
+ );
2301
+ if (result.spoken) {
2302
+ console.log(`Spoken text: ${result.spoken.text}`);
2303
+ console.log(`Segments: ${Array.isArray(result.spoken.segments) ? result.spoken.segments.length : 0}`);
2304
+ if (typeof result.spoken.instructions === 'string' && result.spoken.instructions.trim()) {
2305
+ console.log(`Instructions: ${result.spoken.instructions}`);
2306
+ }
2307
+ } else {
2308
+ console.log('Spoken text: <missing>');
2309
+ }
2310
+ }
2311
+
2312
+ function resolveCommandFromPath(commandName) {
2313
+ const normalized = String(commandName || '').trim();
2314
+ if (!normalized) return '';
2315
+ try {
2316
+ const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', [normalized], {
2317
+ encoding: 'utf8',
2318
+ stdio: ['ignore', 'pipe', 'ignore'],
2319
+ });
2320
+ if (probe.status !== 0) return '';
2321
+ const firstLine = String(probe.stdout || '')
2322
+ .split(/\r?\n/)
2323
+ .map((line) => line.trim())
2324
+ .find(Boolean);
2325
+ return firstLine || '';
2326
+ } catch {
2327
+ return '';
2328
+ }
2329
+ }
2330
+
2331
+ function resolveExecutable(candidates = []) {
2332
+ for (const candidate of candidates) {
2333
+ if (!candidate) continue;
2334
+ const value = String(candidate).trim();
2335
+ if (!value) continue;
2336
+ if (path.isAbsolute(value) && fs.existsSync(value)) {
2337
+ return value;
2338
+ }
2339
+ if (value.includes(path.sep) || value.includes('/')) {
2340
+ const resolved = path.resolve(value);
2341
+ if (fs.existsSync(resolved)) {
2342
+ return resolved;
2343
+ }
2344
+ continue;
2345
+ }
2346
+ const fromPath = resolveCommandFromPath(value);
2347
+ if (fromPath) {
2348
+ return fromPath;
2349
+ }
2350
+ }
2351
+ return '';
2352
+ }
2353
+
2354
+ function resolveBackendRoot(rootFlag) {
2355
+ const repoRoot = resolveRepoRoot(rootFlag);
2356
+ const backendRoot = path.join(repoRoot, 'apps', 'backend');
2357
+ if (!fs.existsSync(backendRoot)) {
2358
+ throw new Error(`Could not locate backend app at ${backendRoot}`);
2359
+ }
2360
+ return backendRoot;
2361
+ }
2362
+
2363
+ function resolveRubyExecutable() {
2364
+ const candidates = [
2365
+ process.env.OOMI_RUBY_BIN,
2366
+ process.env.RUBY,
2367
+ process.platform === 'win32' ? 'ruby.exe' : 'ruby',
2368
+ process.platform === 'win32' ? 'ruby' : '',
2369
+ process.platform === 'win32' ? 'C:\\Ruby33-x64\\bin\\ruby.exe' : '',
2370
+ ];
2371
+ const executable = resolveExecutable(candidates);
2372
+ if (!executable) {
2373
+ throw new Error('Ruby executable not found. Set OOMI_RUBY_BIN or install Ruby locally.');
2374
+ }
2375
+ return executable;
2376
+ }
2377
+
2378
+ function resolveBundleExecutable() {
2379
+ const candidates = [
2380
+ process.env.OOMI_BUNDLE_BIN,
2381
+ process.platform === 'win32' ? 'bundle.bat' : 'bundle',
2382
+ 'bundle',
2383
+ process.platform === 'win32' ? 'C:\\Ruby33-x64\\bin\\bundle.bat' : '',
2384
+ ];
2385
+ const executable = resolveExecutable(candidates);
2386
+ if (!executable) {
2387
+ throw new Error('Bundler executable not found. Set OOMI_BUNDLE_BIN or install Bundler locally.');
2388
+ }
2389
+ return executable;
2390
+ }
2391
+
2392
+ function shellQuote(value) {
2393
+ const text = String(value);
2394
+ if (process.platform === 'win32') {
2395
+ return `"${text.replace(/"/g, '""')}"`;
2396
+ }
2397
+ return `'${text.replace(/'/g, `'\\''`)}'`;
2398
+ }
2399
+
2400
+ async function runBundledRubyScript({ backendRoot, scriptPath, inputFile, env = undefined }) {
2401
+ const rubyExecutable = resolveRubyExecutable();
2402
+ const bundleExecutable = resolveBundleExecutable();
2403
+ const commandText = process.platform === 'win32'
2404
+ ? [bundleExecutable, 'exec', rubyExecutable, scriptPath, '--input-file', inputFile].map(shellQuote).join(' ')
2405
+ : '';
2406
+ const childEnv = env ? { ...process.env, ...env } : process.env;
2407
+
2408
+ return await new Promise((resolve, reject) => {
2409
+ const child = process.platform === 'win32'
2410
+ ? spawn(commandText, [], {
2411
+ cwd: backendRoot,
2412
+ shell: true,
2413
+ env: childEnv,
2414
+ stdio: ['ignore', 'pipe', 'pipe'],
2415
+ })
2416
+ : spawn(bundleExecutable, ['exec', rubyExecutable, scriptPath, '--input-file', inputFile], {
2417
+ cwd: backendRoot,
2418
+ env: childEnv,
2419
+ stdio: ['ignore', 'pipe', 'pipe'],
2420
+ });
2421
+
2422
+ let stdout = '';
2423
+ let stderr = '';
2424
+ child.stdout.on('data', (chunk) => {
2425
+ stdout += chunk.toString();
2426
+ });
2427
+ child.stderr.on('data', (chunk) => {
2428
+ stderr += chunk.toString();
2429
+ });
2430
+ child.on('error', reject);
2431
+ child.on('close', (code) => {
2432
+ resolve({ code: Number(code || 0), stdout, stderr });
2433
+ });
2434
+ });
2435
+ }
2436
+
2437
+ async function runLocalTtsPipelineDebugCheck(options = {}) {
2438
+ const assistant = runAssistantFinalDebugCheck(options);
2439
+ const repoRoot = resolveRepoRoot(options.root);
2440
+ const backendRoot = resolveBackendRoot(options.root);
2441
+ const scriptPath = path.join(backendRoot, 'bin', 'voice_tts_replay.rb');
2442
+ if (!fs.existsSync(scriptPath)) {
2443
+ throw new Error(`Backend replay script not found: ${scriptPath}`);
2444
+ }
2445
+
2446
+ const inputPayload = {
2447
+ repoRoot,
2448
+ sessionId: assistant.sessionId,
2449
+ sessionKey: assistant.sessionKey,
2450
+ frameText: assistant.frameText,
2451
+ userText:
2452
+ typeof options.userText === 'string' && options.userText.trim()
2453
+ ? options.userText.trim()
2454
+ : 'local debug utterance',
2455
+ liveProvider: Boolean(options.liveProvider),
2456
+ providerTimeoutMs: parsePositiveInteger(options.providerTimeoutMs, 15000),
2457
+ };
2458
+ let childEnv = undefined;
2459
+ let resolvedEnvFile = '';
2460
+ if (options.liveProvider) {
2461
+ resolvedEnvFile =
2462
+ typeof options.envFile === 'string' && options.envFile.trim()
2463
+ ? path.resolve(options.envFile.trim())
2464
+ : path.join(repoRoot, '.env.local');
2465
+ childEnv = loadEnvFile(resolvedEnvFile, DEBUG_PROVIDER_ENV_KEYS);
2466
+ }
2467
+ const inputFile = path.join(os.tmpdir(), `oomi-voice-replay-${randomUUID()}.json`);
2468
+ writeFile(inputFile, JSON.stringify(inputPayload, null, 2) + '\n');
2469
+
2470
+ try {
2471
+ const backend = await runBundledRubyScript({ backendRoot, scriptPath, inputFile, env: childEnv });
2472
+ const parsed = backend.stdout.trim() ? JSON.parse(backend.stdout) : null;
2473
+ return {
2474
+ assistant,
2475
+ backend: parsed,
2476
+ backendExitCode: backend.code,
2477
+ backendStderr: backend.stderr.trim(),
2478
+ liveProvider: Boolean(options.liveProvider),
2479
+ envFile: resolvedEnvFile || null,
2480
+ };
2481
+ } finally {
2482
+ try {
2483
+ fs.unlinkSync(inputFile);
2484
+ } catch {
2485
+ // no-op
2486
+ }
2487
+ }
2488
+ }
2489
+
2490
+ function printTtsPipelineDebugResult(result, asJson) {
2491
+ if (asJson) {
2492
+ console.log(JSON.stringify(result, null, 2));
2493
+ return;
2494
+ }
2495
+
2496
+ console.log(`Assistant normalization: ${result.assistant.changed ? 'changed' : 'unchanged'}${result.assistant.reason ? ` (${result.assistant.reason})` : ''}`);
2497
+ console.log(`Assistant spoken segments: ${Array.isArray(result.assistant.spoken?.segments) ? result.assistant.spoken.segments.length : 0}`);
2498
+ if (!result.backend) {
2499
+ console.log('Backend replay: <no output>');
2500
+ return;
2501
+ }
2502
+ console.log(`Backend replay success: ${result.backend.success ? 'yes' : 'no'}`);
2503
+ console.log(`Managed speech sidecar: ${result.backend.managed?.assistantSpeechFinal?.present ? 'yes' : 'no'}`);
2504
+ console.log(`Backend final text: ${result.backend.qwen?.assistantTextFinal || '<missing>'}`);
2505
+ console.log(`Backend TTS appends: ${Array.isArray(result.backend.qwen?.ttsAppends) ? result.backend.qwen.ttsAppends.length : 0}`);
2506
+ console.log(`Backend TTS commits: ${Number(result.backend.qwen?.commitCount || 0)}`);
2507
+ if (result.liveProvider) {
2508
+ console.log(`Live provider audio deltas: ${Number(result.backend.qwen?.audioDeltaCount || 0)}`);
2509
+ console.log(`Live provider audio bytes (base64): ${Number(result.backend.qwen?.audioDeltaBytes || 0)}`);
2510
+ console.log(`Live provider timeout: ${result.backend.qwen?.providerTimedOut ? 'yes' : 'no'}`);
2511
+ }
2512
+ if (result.backend.qwen?.errorCode) {
2513
+ console.log(`Backend error: ${result.backend.qwen.errorCode}`);
2514
+ }
2515
+ if (result.backendStderr) {
2516
+ console.log(`Backend stderr: ${result.backendStderr}`);
2517
+ }
2518
+ }
2519
+
2520
+ async function runPersonaRuntimeDebugCheck(options = {}) {
2521
+ const generatedWorkspaceRoot = !(
2522
+ typeof options.workspaceRoot === 'string' && options.workspaceRoot.trim()
2523
+ );
2524
+ const workspaceRoot = generatedWorkspaceRoot
2525
+ ? path.join(os.tmpdir(), `oomi-openclaw-dev-${randomUUID()}`, 'personas')
2526
+ : path.resolve(String(options.workspaceRoot).trim());
2527
+ const safeName =
2528
+ typeof options.name === 'string' && options.name.trim()
2529
+ ? options.name.trim()
2530
+ : 'Persona Dev Smoke';
2531
+ const safeDescription =
2532
+ typeof options.description === 'string' && options.description.trim()
2533
+ ? options.description.trim()
2534
+ : 'Local OpenClaw persona runtime smoke test.';
2535
+ const safeSlug =
2536
+ typeof options.slug === 'string' && options.slug.trim()
2537
+ ? options.slug.trim()
2538
+ : slugifyPersonaName(safeName);
2539
+ const leaveRunning = Boolean(options.leaveRunning);
2540
+ const cleanup = Boolean(options.cleanup);
2541
+
2542
+ const launch = await launchManagedPersonaRuntime({
2543
+ slug: safeSlug,
2544
+ name: safeName,
2545
+ description: safeDescription,
2546
+ workspaceRoot,
2547
+ forceInstall: Boolean(options.forceInstall),
2548
+ restart: Boolean(options.restart),
2549
+ entryUrl: '',
2550
+ transport: 'local',
2551
+ });
2552
+ const statusAfterLaunch = getManagedPersonaRuntimeStatus({
2553
+ slug: safeSlug,
2554
+ workspaceRoot,
2555
+ });
2556
+
2557
+ let stop = null;
2558
+ let statusAfterStop = null;
2559
+ if (!leaveRunning) {
2560
+ stop = await stopManagedPersonaRuntime({
2561
+ slug: safeSlug,
2562
+ workspaceRoot,
2563
+ });
2564
+ statusAfterStop = getManagedPersonaRuntimeStatus({
2565
+ slug: safeSlug,
2566
+ workspaceRoot,
2567
+ });
2568
+ }
2569
+
2570
+ let cleanedUp = false;
2571
+ if (cleanup && !leaveRunning && generatedWorkspaceRoot) {
2572
+ cleanedUp = await cleanupPersonaRuntimeDebugWorkspace(path.resolve(workspaceRoot, '..'));
2573
+ }
2574
+
2575
+ return {
2576
+ ok: true,
2577
+ workspaceRoot,
2578
+ generatedWorkspaceRoot,
2579
+ cleanedUp,
2580
+ slug: safeSlug,
2581
+ name: safeName,
2582
+ description: safeDescription,
2583
+ launch: {
2584
+ slug: launch.slug,
2585
+ workspacePath: launch.workspacePath,
2586
+ scaffolded: launch.scaffolded,
2587
+ installed: launch.installed,
2588
+ reusedRunningProcess: launch.reusedRunningProcess,
2589
+ },
2590
+ runtime: launch.runtime,
2591
+ localRuntime: launch.localRuntime,
2592
+ state: launch.state,
2593
+ statusAfterLaunch,
2594
+ stop,
2595
+ statusAfterStop,
2596
+ };
2597
+ }
2598
+
2599
+ async function cleanupPersonaRuntimeDebugWorkspace(rootPath) {
2600
+ const targetPath = path.resolve(rootPath);
2601
+ for (let attempt = 0; attempt < 5; attempt += 1) {
2602
+ try {
2603
+ fs.rmSync(targetPath, { recursive: true, force: true });
2604
+ return true;
2605
+ } catch {
2606
+ await new Promise((resolve) => setTimeout(resolve, 200));
2607
+ }
2608
+ }
2609
+ return false;
2610
+ }
2611
+
2612
+ function printPersonaRuntimeDebugResult(result, asJson) {
2613
+ if (asJson) {
2614
+ console.log(JSON.stringify(result, null, 2));
2615
+ return;
2616
+ }
2617
+
2618
+ console.log(`Persona slug: ${result.slug}`);
2619
+ console.log(`Workspace root: ${result.workspaceRoot}`);
2620
+ console.log(`Workspace: ${result.launch.workspacePath}`);
2621
+ console.log(`Scaffolded: ${result.launch.scaffolded ? 'yes' : 'no'}`);
2622
+ console.log(`Installed dependencies: ${result.launch.installed ? 'yes' : 'no'}`);
2623
+ console.log(`Reused running process: ${result.launch.reusedRunningProcess ? 'yes' : 'no'}`);
2624
+ console.log(`Local endpoint: ${result.localRuntime.endpoint}`);
2625
+ console.log(`Local port: ${result.localRuntime.localPort}`);
2626
+ console.log(`Healthcheck: ${result.localRuntime.healthcheckUrl}`);
2627
+ console.log(`Process running after launch: ${result.statusAfterLaunch.processRunning ? 'yes' : 'no'}`);
2628
+ if (result.stop) {
2629
+ console.log(`Stopped runtime: ${result.stop.stopped ? 'yes' : 'no'}`);
2630
+ console.log(`Process running after stop: ${result.statusAfterStop?.processRunning ? 'yes' : 'no'}`);
2631
+ } else {
2632
+ console.log('Runtime left running for inspection.');
2633
+ }
2634
+ if (result.cleanedUp) {
2635
+ console.log('Temporary workspace cleaned up.');
2636
+ }
2637
+ }
2638
+
2639
+ async function runLocalGatewayAgentDebug(flags) {
2640
+ const logger = isTruthyFlag(flags.json)
2641
+ ? () => {}
2642
+ : (...args) => {
2643
+ console.log(...args);
2644
+ };
2645
+
2646
+ const server = await startLocalGatewayAgentServer({
2647
+ host: flags.host,
2648
+ port: flags.port,
2649
+ token: flags.token,
2650
+ password: flags.password,
2651
+ logger,
2652
+ });
2653
+
2654
+ const readyPayload = {
2655
+ ok: true,
2656
+ host: server.host,
2657
+ port: server.port,
2658
+ url: `ws://${server.host}:${server.port}`,
2659
+ tokenConfigured: Boolean(server.token),
2660
+ passwordConfigured: Boolean(server.password),
2661
+ };
2662
+
2663
+ if (isTruthyFlag(flags.json)) {
2664
+ console.log(JSON.stringify(readyPayload, null, 2));
2665
+ } else {
2666
+ console.log(`Local OpenClaw dev gateway listening on ${readyPayload.url}`);
2667
+ }
2668
+
2669
+ let closing = false;
2670
+ const shutdown = async () => {
2671
+ if (closing) return;
2672
+ closing = true;
2673
+ await server.close();
2674
+ process.exit(0);
2675
+ };
2676
+
2677
+ process.once('SIGINT', () => {
2678
+ void shutdown();
2679
+ });
2680
+ process.once('SIGTERM', () => {
2681
+ void shutdown();
2682
+ });
2683
+
2684
+ await new Promise(() => {});
2685
+ }
2686
+
2687
+ async function handleOpenclawDebugCommand(action, flags) {
2688
+ const normalizedAction = String(action || '').trim().toLowerCase();
2689
+ if (normalizedAction === 'local-gateway-agent') {
2690
+ await runLocalGatewayAgentDebug(flags);
2691
+ return;
2692
+ }
2693
+
2694
+ if (normalizedAction === 'persona-runtime') {
2695
+ const result = await runPersonaRuntimeDebugCheck({
2696
+ slug: flags.slug,
2697
+ name: flags.name,
2698
+ description: flags.description,
2699
+ workspaceRoot: flags['workspace-root'] || flags['openclaw-home'],
2700
+ forceInstall: isTruthyFlag(flags['force-install']),
2701
+ restart: isTruthyFlag(flags.restart),
2702
+ leaveRunning: isTruthyFlag(flags['leave-running']),
2703
+ cleanup: !isTruthyFlag(flags['no-cleanup']),
2704
+ });
2705
+ printPersonaRuntimeDebugResult(result, isTruthyFlag(flags.json));
2706
+ if (!result.statusAfterLaunch.processRunning) {
2707
+ throw new Error('Persona runtime smoke check failed to keep the process running after launch.');
2708
+ }
2709
+ if (result.stop && result.statusAfterStop?.processRunning) {
2710
+ throw new Error('Persona runtime smoke check failed to stop the process cleanly.');
2711
+ }
2712
+ return;
2713
+ }
2714
+
2715
+ const frameFile =
2716
+ typeof flags['frame-file'] === 'string' && flags['frame-file'].trim()
2717
+ ? path.resolve(flags['frame-file'])
2718
+ : '';
2719
+ const frameText =
2720
+ frameFile
2721
+ ? readFile(frameFile)
2722
+ : (typeof flags['frame-json'] === 'string' && flags['frame-json'].trim() ? flags['frame-json'] : '');
2723
+ const text = typeof flags.text === 'string' ? flags.text : '';
2724
+
2725
+ if (!frameText && !text.trim()) {
2726
+ throw new Error(
2727
+ 'Assistant text or frame input is required. Usage: oomi openclaw debug assistant-final --text "<assistant text>"'
2728
+ );
2729
+ }
2730
+
2731
+ const debugOptions = {
2732
+ sessionId: flags['session-id'],
2733
+ sessionKey: flags['session-key'],
2734
+ role: flags.role,
2735
+ omitRole: isTruthyFlag(flags['omit-role']),
2736
+ text,
2737
+ frameText,
2738
+ root: flags.root,
2739
+ userText: flags['user-text'],
2740
+ liveProvider: isTruthyFlag(flags['live-provider']),
2741
+ envFile: flags['env-file'],
2742
+ providerTimeoutMs: flags['provider-timeout-ms'],
2743
+ };
2744
+
2745
+ if (normalizedAction === 'assistant-final') {
2746
+ const result = runAssistantFinalDebugCheck(debugOptions);
2747
+ printAssistantFinalDebugResult(result, isTruthyFlag(flags.json));
2748
+ return;
2749
+ }
2750
+
2751
+ if (normalizedAction === 'tts-pipeline') {
2752
+ const result = await runLocalTtsPipelineDebugCheck(debugOptions);
2753
+ printTtsPipelineDebugResult(result, isTruthyFlag(flags.json));
2754
+ if (!result.backend?.success) {
2755
+ throw new Error(result.backend?.qwen?.errorCode || 'Local backend TTS replay failed.');
2756
+ }
2757
+ return;
2758
+ }
2759
+
2760
+ throw new Error('Unknown debug action: ' + normalizedAction + '. Use: oomi openclaw debug assistant-final|tts-pipeline|local-gateway-agent|persona-runtime');
2761
+ }
2762
+
2763
+ function buildOpenclawProfileFromFlags(flags) {
2764
+ const defaultProfilePath = resolveOpenclawProfilePath();
2765
+ const defaultOpenclawHome = path.dirname(defaultProfilePath);
2766
+ const defaultWorkspaceRoot = String(
2767
+ flags['workspace-root'] ||
2768
+ flags.workspace ||
2769
+ process.env.OPENCLAW_WORKSPACE ||
2770
+ resolveOpenclawWorkspaceRoot()
2771
+ ).trim();
2772
+ const defaultGatewayPort = parsePositiveInteger(
2773
+ flags['gateway-port'] || process.env.OPENCLAW_GATEWAY_PORT,
2774
+ 18789
2775
+ );
2776
+ const pluginTrustMode =
2777
+ String(flags['plugin-trust-mode'] || '').trim() === 'plugins.allow' ||
2778
+ isTruthyFlag(flags['strict-plugin-allow'])
2779
+ ? 'plugins.allow'
2780
+ : 'auto-discovery';
2781
+
2782
+ return buildOomiDevLocalProfile({
2783
+ profileId: flags['profile-id'] || flags.id || 'oomi-dev-local',
2784
+ label: flags.label || 'Oomi Local Dev',
2785
+ workspaceRoot: defaultWorkspaceRoot,
2786
+ deviceId: flags['device-id'] || process.env.OOMI_DEV_DEVICE_ID || '',
2787
+ gatewayPort: defaultGatewayPort,
2788
+ gatewayToken:
2789
+ flags['gateway-token'] ||
2790
+ process.env.OPENCLAW_GATEWAY_TOKEN ||
2791
+ process.env.OOMI_DEV_GATEWAY_TOKEN ||
2792
+ 'dev-gateway-token',
2793
+ backendUrl:
2794
+ flags['backend-url'] ||
2795
+ process.env.OOMI_DEV_BACKEND_URL ||
2796
+ process.env.OOMI_BACKEND_URL ||
2797
+ '',
2798
+ deviceToken:
2799
+ flags['device-token'] ||
2800
+ process.env.OOMI_DEV_DEVICE_TOKEN ||
2801
+ process.env.OOMI_DEVICE_TOKEN ||
2802
+ '',
2803
+ defaultSessionKey:
2804
+ flags['session-key'] ||
2805
+ flags['default-session-key'] ||
2806
+ process.env.OOMI_DEV_DEFAULT_SESSION_KEY ||
2807
+ process.env.OOMI_DEV_SESSION_KEY ||
2808
+ 'agent:main:webchat:channel:oomi',
2809
+ enableOomiChannel:
2810
+ isTruthyFlag(flags['enable-channel']) ||
2811
+ (!isTruthyFlag(flags['disable-channel']) &&
2812
+ Boolean(
2813
+ String(
2814
+ flags['device-token'] ||
2815
+ process.env.OOMI_DEV_DEVICE_TOKEN ||
2816
+ process.env.OOMI_DEVICE_TOKEN ||
2817
+ ''
2818
+ ).trim()
2819
+ )),
2820
+ requestTimeoutMs: parsePositiveInteger(flags['request-timeout-ms'], 15000),
2821
+ pluginTrustMode,
2822
+ modelPreset: flags['model-preset'] || 'openrouter-free',
2823
+ modelAuthMode: flags['model-auth-mode'] || 'oomi-managed',
2824
+ openclawHome: flags['openclaw-home'] || defaultOpenclawHome,
2075
2825
  });
2076
2826
  }
2077
-
2078
- async function runLocalTtsPipelineDebugCheck(options = {}) {
2079
- const assistant = runAssistantFinalDebugCheck(options);
2080
- const repoRoot = resolveRepoRoot(options.root);
2081
- const backendRoot = resolveBackendRoot(options.root);
2082
- const scriptPath = path.join(backendRoot, 'bin', 'voice_tts_replay.rb');
2083
- if (!fs.existsSync(scriptPath)) {
2084
- throw new Error(`Backend replay script not found: ${scriptPath}`);
2085
- }
2086
-
2087
- const inputPayload = {
2088
- repoRoot,
2089
- sessionId: assistant.sessionId,
2090
- sessionKey: assistant.sessionKey,
2091
- frameText: assistant.frameText,
2092
- userText:
2093
- typeof options.userText === 'string' && options.userText.trim()
2094
- ? options.userText.trim()
2095
- : 'local debug utterance',
2096
- liveProvider: Boolean(options.liveProvider),
2097
- providerTimeoutMs: parsePositiveInteger(options.providerTimeoutMs, 15000),
2098
- };
2099
- let childEnv = undefined;
2100
- let resolvedEnvFile = '';
2101
- if (options.liveProvider) {
2102
- resolvedEnvFile =
2103
- typeof options.envFile === 'string' && options.envFile.trim()
2104
- ? path.resolve(options.envFile.trim())
2105
- : path.join(repoRoot, '.env.local');
2106
- childEnv = loadEnvFile(resolvedEnvFile, DEBUG_PROVIDER_ENV_KEYS);
2107
- }
2108
- const inputFile = path.join(os.tmpdir(), `oomi-voice-replay-${randomUUID()}.json`);
2109
- writeFile(inputFile, JSON.stringify(inputPayload, null, 2) + '\n');
2110
-
2111
- try {
2112
- const backend = await runBundledRubyScript({ backendRoot, scriptPath, inputFile, env: childEnv });
2113
- const parsed = backend.stdout.trim() ? JSON.parse(backend.stdout) : null;
2114
- return {
2115
- assistant,
2116
- backend: parsed,
2117
- backendExitCode: backend.code,
2118
- backendStderr: backend.stderr.trim(),
2119
- liveProvider: Boolean(options.liveProvider),
2120
- envFile: resolvedEnvFile || null,
2121
- };
2122
- } finally {
2123
- try {
2124
- fs.unlinkSync(inputFile);
2125
- } catch {
2126
- // no-op
2127
- }
2128
- }
2129
- }
2130
-
2131
- function printTtsPipelineDebugResult(result, asJson) {
2132
- if (asJson) {
2133
- console.log(JSON.stringify(result, null, 2));
2134
- return;
2135
- }
2136
-
2137
- console.log(`Assistant normalization: ${result.assistant.changed ? 'changed' : 'unchanged'}${result.assistant.reason ? ` (${result.assistant.reason})` : ''}`);
2138
- console.log(`Assistant spoken segments: ${Array.isArray(result.assistant.spoken?.segments) ? result.assistant.spoken.segments.length : 0}`);
2139
- if (!result.backend) {
2140
- console.log('Backend replay: <no output>');
2141
- return;
2142
- }
2143
- console.log(`Backend replay success: ${result.backend.success ? 'yes' : 'no'}`);
2144
- console.log(`Managed speech sidecar: ${result.backend.managed?.assistantSpeechFinal?.present ? 'yes' : 'no'}`);
2145
- console.log(`Backend final text: ${result.backend.qwen?.assistantTextFinal || '<missing>'}`);
2146
- console.log(`Backend TTS appends: ${Array.isArray(result.backend.qwen?.ttsAppends) ? result.backend.qwen.ttsAppends.length : 0}`);
2147
- console.log(`Backend TTS commits: ${Number(result.backend.qwen?.commitCount || 0)}`);
2148
- if (result.liveProvider) {
2149
- console.log(`Live provider audio deltas: ${Number(result.backend.qwen?.audioDeltaCount || 0)}`);
2150
- console.log(`Live provider audio bytes (base64): ${Number(result.backend.qwen?.audioDeltaBytes || 0)}`);
2151
- console.log(`Live provider timeout: ${result.backend.qwen?.providerTimedOut ? 'yes' : 'no'}`);
2152
- }
2153
- if (result.backend.qwen?.errorCode) {
2154
- console.log(`Backend error: ${result.backend.qwen.errorCode}`);
2155
- }
2156
- if (result.backendStderr) {
2157
- console.log(`Backend stderr: ${result.backendStderr}`);
2158
- }
2159
- }
2160
-
2161
- async function handleOpenclawDebugCommand(action, flags) {
2162
- const normalizedAction = String(action || '').trim().toLowerCase();
2163
- const frameFile =
2164
- typeof flags['frame-file'] === 'string' && flags['frame-file'].trim()
2165
- ? path.resolve(flags['frame-file'])
2166
- : '';
2167
- const frameText =
2168
- frameFile
2169
- ? readFile(frameFile)
2170
- : (typeof flags['frame-json'] === 'string' && flags['frame-json'].trim() ? flags['frame-json'] : '');
2171
- const text = typeof flags.text === 'string' ? flags.text : '';
2172
-
2173
- if (!frameText && !text.trim()) {
2174
- throw new Error(
2175
- 'Assistant text or frame input is required. Usage: oomi openclaw debug assistant-final --text "<assistant text>"'
2176
- );
2177
- }
2178
-
2179
- const debugOptions = {
2180
- sessionId: flags['session-id'],
2181
- sessionKey: flags['session-key'],
2182
- role: flags.role,
2183
- omitRole: isTruthyFlag(flags['omit-role']),
2184
- text,
2185
- frameText,
2186
- root: flags.root,
2187
- userText: flags['user-text'],
2188
- liveProvider: isTruthyFlag(flags['live-provider']),
2189
- envFile: flags['env-file'],
2190
- providerTimeoutMs: flags['provider-timeout-ms'],
2191
- };
2192
-
2193
- if (normalizedAction === 'assistant-final') {
2194
- const result = runAssistantFinalDebugCheck(debugOptions);
2195
- printAssistantFinalDebugResult(result, isTruthyFlag(flags.json));
2196
- return;
2197
- }
2198
-
2199
- if (normalizedAction === 'tts-pipeline') {
2200
- const result = await runLocalTtsPipelineDebugCheck(debugOptions);
2201
- printTtsPipelineDebugResult(result, isTruthyFlag(flags.json));
2202
- if (!result.backend?.success) {
2203
- throw new Error(result.backend?.qwen?.errorCode || 'Local backend TTS replay failed.');
2204
- }
2205
- return;
2206
- }
2207
-
2208
- throw new Error('Unknown debug action: ' + normalizedAction + '. Use: oomi openclaw debug assistant-final|tts-pipeline');
2209
- }
2827
+
2828
+ function printOpenclawProfileResult(payload, asJson) {
2829
+ if (asJson) {
2830
+ console.log(JSON.stringify(payload, null, 2));
2831
+ return;
2832
+ }
2833
+
2834
+ if (payload.profilePath) {
2835
+ console.log(`Profile written: ${payload.profilePath}`);
2836
+ }
2837
+ if (payload.configPath) {
2838
+ console.log(`Config updated: ${payload.configPath}`);
2839
+ }
2840
+ if (payload.identityPath) {
2841
+ console.log(`Identity path: ${payload.identityPath}`);
2842
+ }
2843
+ if (payload.profile?.profileId) {
2844
+ console.log(`Profile id: ${payload.profile.profileId}`);
2845
+ }
2846
+ if (payload.profile?.preset) {
2847
+ console.log(`Preset: ${payload.profile.preset}`);
2848
+ }
2849
+ if (payload.pluginTrustMode) {
2850
+ console.log(`Plugin trust mode: ${payload.pluginTrustMode}`);
2851
+ }
2852
+ if (typeof payload.oomiChannelEnabled === 'boolean') {
2853
+ console.log(`Oomi channel enabled: ${payload.oomiChannelEnabled ? 'yes' : 'no'}`);
2854
+ }
2855
+ if (typeof payload.identityCreated === 'boolean') {
2856
+ console.log(`Device identity created: ${payload.identityCreated ? 'yes' : 'no'}`);
2857
+ }
2858
+ }
2859
+
2860
+ async function handleOpenclawProfileCommand(action, flags) {
2861
+ const normalizedAction = String(action || '').trim().toLowerCase();
2862
+ if (!normalizedAction || normalizedAction === 'help') {
2863
+ throw new Error('OpenClaw profile action is required. Use: oomi openclaw profile init|apply');
2864
+ }
2865
+
2866
+ const profilePath = path.resolve(
2867
+ String(flags.profile || flags['profile-file'] || resolveOpenclawProfilePath()).trim()
2868
+ );
2869
+
2870
+ if (normalizedAction === 'init') {
2871
+ const profile = buildOpenclawProfileFromFlags(flags);
2872
+ writeOpenclawProfile(profilePath, profile);
2873
+ printOpenclawProfileResult(
2874
+ {
2875
+ ok: true,
2876
+ profilePath,
2877
+ profile,
2878
+ },
2879
+ isTruthyFlag(flags.json)
2880
+ );
2881
+ return;
2882
+ }
2883
+
2884
+ if (normalizedAction === 'apply') {
2885
+ const profile = readOpenclawProfile(profilePath);
2886
+ if (!profile) {
2887
+ throw new Error(`OpenClaw profile not found or unreadable: ${profilePath}`);
2888
+ }
2889
+ const openclawHome = path.resolve(String(flags['openclaw-home'] || resolveOpenclawHome()).trim());
2890
+ const defaultIdentityPath = path.join(openclawHome, 'identity', 'device.json');
2891
+ const applyResult = applyOpenclawProfile({
2892
+ profile,
2893
+ openclawHome,
2894
+ configPath:
2895
+ typeof flags['config-path'] === 'string' && flags['config-path'].trim()
2896
+ ? path.resolve(flags['config-path'])
2897
+ : '',
2898
+ identityPath:
2899
+ typeof flags['identity-path'] === 'string' && flags['identity-path'].trim()
2900
+ ? path.resolve(flags['identity-path'])
2901
+ : defaultIdentityPath,
2902
+ ensureIdentity: !isTruthyFlag(flags['skip-identity']),
2903
+ });
2904
+ printOpenclawProfileResult(
2905
+ {
2906
+ ...applyResult,
2907
+ profilePath,
2908
+ profile,
2909
+ },
2910
+ isTruthyFlag(flags.json)
2911
+ );
2912
+ return;
2913
+ }
2914
+
2915
+ throw new Error(`Unknown profile action: ${normalizedAction}. Use: oomi openclaw profile init|apply`);
2916
+ }
2210
2917
 
2211
2918
  function extractCorrelationId(params) {
2212
2919
  if (!params || typeof params !== 'object') return '';
@@ -2385,11 +3092,11 @@ async function runBridgePreflight({ brokerWs, gatewayUrl, gatewayConfigPath }) {
2385
3092
  await assertTcpReachable(parsedGatewayUrl.toString());
2386
3093
  }
2387
3094
 
2388
- function buildBridgeDetachArgs(rawFlags = {}) {
2389
- const orderedKeys = [
2390
- 'broker-http',
2391
- 'broker-ws',
2392
- 'pair-code',
3095
+ function buildBridgeDetachArgs(rawFlags = {}) {
3096
+ const orderedKeys = [
3097
+ 'broker-http',
3098
+ 'broker-ws',
3099
+ 'pair-code',
2393
3100
  'app-url',
2394
3101
  'device-id',
2395
3102
  'device-token',
@@ -2407,13 +3114,13 @@ function buildBridgeDetachArgs(rawFlags = {}) {
2407
3114
  if (!text) continue;
2408
3115
  args.push(`--${key}`, text);
2409
3116
  }
2410
-
2411
- return args;
2412
- }
2413
-
2414
- function isServiceManagedBridgeStart(flags = {}) {
2415
- return isTruthyFlag(flags['service-managed']);
2416
- }
3117
+
3118
+ return args;
3119
+ }
3120
+
3121
+ function isServiceManagedBridgeStart(flags = {}) {
3122
+ return isTruthyFlag(flags['service-managed']);
3123
+ }
2417
3124
 
2418
3125
  function startBridgeDetachedProcess(rawFlags = {}) {
2419
3126
  const existing = findRunningBridgeProcess();
@@ -2584,17 +3291,31 @@ function runLaunchctl(args, { allowFailure = false } = {}) {
2584
3291
  return { status, stdout, stderr };
2585
3292
  }
2586
3293
 
2587
- function buildBridgeLaunchAgentPlist() {
2588
- const scriptPath = (() => {
2589
- try {
2590
- return fs.realpathSync(process.argv[1]);
2591
- } catch {
2592
- return process.argv[1];
2593
- }
2594
- })();
2595
- const programArgs = [process.execPath, scriptPath, 'openclaw', 'bridge', 'start', '--service-managed'];
2596
- const bridgeLogPath = resolveBridgeLiveLogPath();
2597
- const argsXml = programArgs.map((arg) => `<string>${xmlEscape(arg)}</string>`).join('\n ');
3294
+ function buildBridgeLaunchAgentPlist() {
3295
+ const scriptPath = (() => {
3296
+ try {
3297
+ return fs.realpathSync(process.argv[1]);
3298
+ } catch {
3299
+ return process.argv[1];
3300
+ }
3301
+ })();
3302
+ const programArgs = [process.execPath, scriptPath, 'openclaw', 'bridge', 'start', '--service-managed'];
3303
+ const bridgeLogPath = resolveBridgeLiveLogPath();
3304
+ const argsXml = programArgs.map((arg) => `<string>${xmlEscape(arg)}</string>`).join('\n ');
3305
+ const openclawHome = resolveOpenclawHome();
3306
+ const openclawWorkspace = resolveOpenclawWorkspaceRoot();
3307
+ const envVars = {
3308
+ OOMI_SKIP_UPDATE_CHECK: '1',
3309
+ };
3310
+ if (process.env.OPENCLAW_HOME) {
3311
+ envVars.OPENCLAW_HOME = openclawHome;
3312
+ }
3313
+ if (process.env.OPENCLAW_WORKSPACE) {
3314
+ envVars.OPENCLAW_WORKSPACE = openclawWorkspace;
3315
+ }
3316
+ const envXml = Object.entries(envVars)
3317
+ .map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
3318
+ .join('\n');
2598
3319
 
2599
3320
  return `<?xml version="1.0" encoding="UTF-8"?>
2600
3321
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -2607,7 +3328,7 @@ function buildBridgeLaunchAgentPlist() {
2607
3328
  ${argsXml}
2608
3329
  </array>
2609
3330
  <key>WorkingDirectory</key>
2610
- <string>${xmlEscape(os.homedir())}</string>
3331
+ <string>${xmlEscape(openclawHome)}</string>
2611
3332
  <key>RunAtLoad</key>
2612
3333
  <true/>
2613
3334
  <key>KeepAlive</key>
@@ -2616,8 +3337,7 @@ function buildBridgeLaunchAgentPlist() {
2616
3337
  <integer>5</integer>
2617
3338
  <key>EnvironmentVariables</key>
2618
3339
  <dict>
2619
- <key>OOMI_SKIP_UPDATE_CHECK</key>
2620
- <string>1</string>
3340
+ ${envXml}
2621
3341
  </dict>
2622
3342
  <key>StandardOutPath</key>
2623
3343
  <string>${xmlEscape(bridgeLogPath)}</string>
@@ -2650,18 +3370,18 @@ function readBridgeLaunchdStatus() {
2650
3370
  };
2651
3371
  }
2652
3372
 
2653
- function startBridgeLaunchdService() {
2654
- assertMacOSLaunchdAvailable();
2655
- const plistPath = resolveBridgeLaunchAgentPlistPath();
2656
- if (!fs.existsSync(plistPath)) {
2657
- throw new Error('Bridge service is not installed. Run: oomi openclaw bridge service install');
2658
- }
2659
- writeFile(plistPath, buildBridgeLaunchAgentPlist());
2660
- const domain = launchctlDomain();
2661
- const target = launchctlServiceTarget();
2662
- runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
2663
- runLaunchctl(['bootstrap', domain, plistPath]);
2664
- runLaunchctl(['enable', target], { allowFailure: true });
3373
+ function startBridgeLaunchdService() {
3374
+ assertMacOSLaunchdAvailable();
3375
+ const plistPath = resolveBridgeLaunchAgentPlistPath();
3376
+ if (!fs.existsSync(plistPath)) {
3377
+ throw new Error('Bridge service is not installed. Run: oomi openclaw bridge service install');
3378
+ }
3379
+ writeFile(plistPath, buildBridgeLaunchAgentPlist());
3380
+ const domain = launchctlDomain();
3381
+ const target = launchctlServiceTarget();
3382
+ runLaunchctl(['bootout', domain, plistPath], { allowFailure: true });
3383
+ runLaunchctl(['bootstrap', domain, plistPath]);
3384
+ runLaunchctl(['enable', target], { allowFailure: true });
2665
3385
  runLaunchctl(['kickstart', '-k', target], { allowFailure: true });
2666
3386
  }
2667
3387
 
@@ -2820,22 +3540,22 @@ async function startOpenclawBridge(flags) {
2820
3540
  console.log(`Broker WS: ${brokerWs}`);
2821
3541
 
2822
3542
  const activeGatewaySockets = new Map();
2823
- const reconnectState = {
2824
- attempt: 0,
2825
- timer: null,
2826
- stopped: false,
2827
- lastFailure: null,
2828
- };
2829
- const personaJobPollEnabled = !isTruthyFlag(process.env.OOMI_DISABLE_PERSONA_JOB_POLL);
2830
- const personaJobPollIntervalMs = parsePositiveInteger(
2831
- process.env.OOMI_PERSONA_JOB_POLL_INTERVAL_MS,
2832
- 3000,
2833
- );
2834
- const personaJobIdlePollIntervalMs = parsePositiveInteger(
2835
- process.env.OOMI_PERSONA_JOB_IDLE_POLL_INTERVAL_MS,
2836
- 3000,
2837
- );
2838
- const personaWorkspaceRoot = defaultPersonaWorkspaceRoot();
3543
+ const reconnectState = {
3544
+ attempt: 0,
3545
+ timer: null,
3546
+ stopped: false,
3547
+ lastFailure: null,
3548
+ };
3549
+ const personaJobPollEnabled = !isTruthyFlag(process.env.OOMI_DISABLE_PERSONA_JOB_POLL);
3550
+ const personaJobPollIntervalMs = parsePositiveInteger(
3551
+ process.env.OOMI_PERSONA_JOB_POLL_INTERVAL_MS,
3552
+ 3000,
3553
+ );
3554
+ const personaJobIdlePollIntervalMs = parsePositiveInteger(
3555
+ process.env.OOMI_PERSONA_JOB_IDLE_POLL_INTERVAL_MS,
3556
+ 3000,
3557
+ );
3558
+ const personaWorkspaceRoot = defaultPersonaWorkspaceRoot();
2839
3559
  const brokerPath = (() => {
2840
3560
  try {
2841
3561
  return new URL(brokerWs).pathname || '';
@@ -2900,33 +3620,38 @@ async function startOpenclawBridge(flags) {
2900
3620
  onReport: ({ phase, status, error }) => {
2901
3621
  reportBridgeProcessFault({ phase, status, error });
2902
3622
  },
2903
- onExit: (code) => {
2904
- reconnectState.stopped = true;
2905
- if (reconnectState.timer) {
2906
- clearTimeout(reconnectState.timer);
2907
- reconnectState.timer = null;
2908
- }
2909
- personaJobPoller?.stop();
2910
- releaseBridgeLock();
2911
- process.exit(code);
2912
- },
2913
- });
3623
+ onExit: (code) => {
3624
+ reconnectState.stopped = true;
3625
+ if (reconnectState.timer) {
3626
+ clearTimeout(reconnectState.timer);
3627
+ reconnectState.timer = null;
3628
+ }
3629
+ personaJobPoller?.stop();
3630
+ releaseBridgeLock();
3631
+ process.exit(code);
3632
+ },
3633
+ });
2914
3634
 
2915
3635
  const uncaughtExceptionHandler = (error) => {
2916
3636
  handleBridgeProcessFault({ phase: 'process.uncaughtException', error });
2917
3637
  };
2918
3638
 
2919
- const unhandledRejectionHandler = (reason) => {
2920
- handleBridgeProcessFault({ phase: 'process.unhandledRejection', error: reason });
2921
- };
2922
-
2923
- process.on('uncaughtException', uncaughtExceptionHandler);
2924
- process.on('unhandledRejection', unhandledRejectionHandler);
3639
+ const unhandledRejectionHandler = (reason) => {
3640
+ handleBridgeProcessFault({ phase: 'process.unhandledRejection', error: reason });
3641
+ };
3642
+
3643
+ process.on('uncaughtException', uncaughtExceptionHandler);
3644
+ process.on('unhandledRejection', unhandledRejectionHandler);
3645
+
3646
+ const personaBackendUrl =
3647
+ deviceToken
3648
+ ? resolvePersonaBackendUrl({ 'backend-url': process.env.OOMI_DEV_BACKEND_URL || process.env.OOMI_BACKEND_URL || brokerHttp })
3649
+ : '';
2925
3650
 
2926
3651
  const personaJobPoller =
2927
- personaJobPollEnabled && brokerHttp && deviceToken
3652
+ personaJobPollEnabled && personaBackendUrl && deviceToken
2928
3653
  ? startPersonaJobPoller({
2929
- backendUrl: brokerHttp,
3654
+ backendUrl: personaBackendUrl,
2930
3655
  deviceToken,
2931
3656
  pollIntervalMs: personaJobPollIntervalMs,
2932
3657
  idleIntervalMs: personaJobIdlePollIntervalMs,
@@ -2934,38 +3659,49 @@ async function startOpenclawBridge(flags) {
2934
3659
  onMessage: async (message) => {
2935
3660
  const result = await runManagedPersonaJobExecution({
2936
3661
  message,
2937
- backendUrl: brokerHttp,
3662
+ backendUrl: personaBackendUrl,
2938
3663
  deviceToken,
2939
3664
  deviceId,
2940
3665
  workspaceRoot: personaWorkspaceRoot,
2941
3666
  shouldInstall: true,
2942
3667
  shouldStart: true,
2943
- shouldRegister: true,
2944
- });
2945
-
2946
- if (result && result.ok) {
2947
- console.log(
2948
- `[persona-jobs] completed ${result.jobId} on port ${result.result?.localPort || 'unknown'}`
2949
- );
2950
- return;
2951
- }
2952
-
2953
- if (result) {
2954
- console.warn(
2955
- `[persona-jobs] job ${result.jobId} completed with failure: ${result.error?.message || 'unknown error'}`
2956
- );
2957
- }
2958
- },
3668
+ shouldRegister: true,
3669
+ });
3670
+
3671
+ if (result && result.ok) {
3672
+ console.log(
3673
+ `[persona-jobs] completed ${result.jobId} on port ${result.result?.localPort || 'unknown'}`
3674
+ );
3675
+ return;
3676
+ }
3677
+
3678
+ if (result) {
3679
+ console.warn(
3680
+ `[persona-jobs] job ${result.jobId} completed with failure: ${result.error?.message || 'unknown error'}`
3681
+ );
3682
+ }
3683
+ },
3684
+ })
3685
+ : null;
3686
+ const personaRuntimeSupervisor =
3687
+ personaBackendUrl && deviceToken
3688
+ ? startPersonaRuntimeSupervisor({
3689
+ backendUrl: personaBackendUrl,
3690
+ deviceToken,
3691
+ workspaceRoot: personaWorkspaceRoot,
3692
+ intervalMs: 30000,
3693
+ logger: console,
3694
+ autoRestart: true,
2959
3695
  })
2960
3696
  : null;
2961
3697
 
2962
- if (personaJobPollEnabled && brokerHttp && deviceToken) {
2963
- console.log('[persona-jobs] polling filtered control queue for persona_job messages.');
3698
+ if (personaJobPollEnabled && personaBackendUrl && deviceToken) {
3699
+ bridgeDebugLog('[persona-jobs] polling filtered control queue for persona_job messages.');
2964
3700
  } else if (personaJobPollEnabled) {
2965
3701
  console.warn('[persona-jobs] disabled because broker HTTP URL or device token is unavailable.');
2966
3702
  }
2967
-
2968
- const sendBrokerPayload = (brokerSocket, payload) => {
3703
+
3704
+ const sendBrokerPayload = (brokerSocket, payload) => {
2969
3705
  if (brokerSocket.readyState !== WebSocket.OPEN) return;
2970
3706
  if (!actionCableMode) {
2971
3707
  brokerSocket.send(JSON.stringify(payload));
@@ -3195,7 +3931,7 @@ async function startOpenclawBridge(flags) {
3195
3931
  classifyBridgeFailure({ reason: 'connection closed without classified error' });
3196
3932
  const delayMs = computeReconnectDelayMs(reconnectState.attempt, failure.baseDelayMs);
3197
3933
 
3198
- console.warn(
3934
+ bridgeDebugWarn(
3199
3935
  `[bridge] reconnect scheduled in ${delayMs}ms (attempt ${reconnectState.attempt}, class=${failure.failureClass}, code=${failure.errorCode})`
3200
3936
  );
3201
3937
 
@@ -3323,7 +4059,7 @@ async function startOpenclawBridge(flags) {
3323
4059
  }
3324
4060
  const result = forwardFrameToSession(sessionBridge, prepared.frameText);
3325
4061
  if (result === 'queued') {
3326
- console.log(`[bridge] client.frame queued after challenge ${sessionId}`);
4062
+ bridgeDebugLog(`[bridge] client.frame queued after challenge ${sessionId}`);
3327
4063
  if (requestMeta) {
3328
4064
  startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
3329
4065
  }
@@ -3337,7 +4073,7 @@ async function startOpenclawBridge(flags) {
3337
4073
  });
3338
4074
  }
3339
4075
  } else if (result === 'dropped') {
3340
- console.log(`[bridge] client.frame dropped after challenge ${sessionId}`);
4076
+ bridgeDebugLog(`[bridge] client.frame dropped after challenge ${sessionId}`);
3341
4077
  incrementBridgeMetric('bridge_drop_count');
3342
4078
  if (requestMeta) {
3343
4079
  const pending = sessionBridge.pendingRequests instanceof Map
@@ -3369,7 +4105,7 @@ async function startOpenclawBridge(flags) {
3369
4105
  });
3370
4106
  }
3371
4107
  } else {
3372
- console.log(`[bridge] client.frame sent after challenge ${sessionId}`);
4108
+ bridgeDebugLog(`[bridge] client.frame sent after challenge ${sessionId}`);
3373
4109
  if (requestMeta) {
3374
4110
  startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
3375
4111
  }
@@ -3435,27 +4171,27 @@ async function startOpenclawBridge(flags) {
3435
4171
  clearTimeout(connectTimeout);
3436
4172
  connectTimeout = null;
3437
4173
  }
3438
- console.log(`[bridge] gateway.open ${sessionId}`);
4174
+ bridgeDebugLog(`[bridge] gateway.open ${sessionId}`);
3439
4175
  flushSessionQueue(sessionBridge);
3440
4176
  });
3441
4177
 
3442
- gatewaySocket.on('message', runBridgeCallbackSafely((gatewayRaw) => {
3443
- let frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
3444
- const spokenNormalized = normalizeAssistantGatewayFrame(sessionId, frame);
3445
- if (spokenNormalized.changed) {
3446
- frame = spokenNormalized.frameText;
3447
- if (spokenNormalized.scope === 'voice') {
3448
- console.log(`[bridge] voice.spoken_metadata.${spokenNormalized.reason} ${sessionId} ${JSON.stringify({
3449
- before: spokenNormalized.summary,
3450
- after: summarizeVoiceFrameContract(frame),
3451
- })}`);
3452
- }
3453
- } else if (spokenNormalized.scope === 'voice' && spokenNormalized.summary.event === 'chat' && spokenNormalized.summary.state === 'final') {
3454
- console.log(`[bridge] voice.chat.final ${sessionId} ${JSON.stringify(spokenNormalized.summary)}`);
3455
- }
3456
- const gatewayPayload = parseJsonPayload(frame);
3457
- if (gatewayPayload?.event === 'connect.challenge') {
3458
- console.log(`[bridge] gateway.connect.challenge ${sessionId}`);
4178
+ gatewaySocket.on('message', runBridgeCallbackSafely((gatewayRaw) => {
4179
+ let frame = typeof gatewayRaw === 'string' ? gatewayRaw : gatewayRaw.toString();
4180
+ const spokenNormalized = normalizeAssistantGatewayFrame(sessionId, frame);
4181
+ if (spokenNormalized.changed) {
4182
+ frame = spokenNormalized.frameText;
4183
+ if (spokenNormalized.scope === 'voice') {
4184
+ bridgeDebugLog(`[bridge] voice.spoken_metadata.${spokenNormalized.reason} ${sessionId} ${JSON.stringify({
4185
+ before: spokenNormalized.summary,
4186
+ after: summarizeVoiceFrameContract(frame),
4187
+ })}`);
4188
+ }
4189
+ } else if (spokenNormalized.scope === 'voice' && spokenNormalized.summary.event === 'chat' && spokenNormalized.summary.state === 'final') {
4190
+ bridgeDebugLog(`[bridge] voice.chat.final ${sessionId} ${JSON.stringify(spokenNormalized.summary)}`);
4191
+ }
4192
+ const gatewayPayload = parseJsonPayload(frame);
4193
+ if (gatewayPayload?.event === 'connect.challenge') {
4194
+ bridgeDebugLog(`[bridge] gateway.connect.challenge ${sessionId}`);
3459
4195
  const nonce =
3460
4196
  gatewayPayload.payload && typeof gatewayPayload.payload.nonce === 'string'
3461
4197
  ? gatewayPayload.payload.nonce.trim()
@@ -3601,7 +4337,7 @@ async function startOpenclawBridge(flags) {
3601
4337
  clearChallengeTimer(sessionBridge);
3602
4338
  const reasonText = reason ? reason.toString() : '';
3603
4339
  const closeMeta = classifyGatewayClose(code, reasonText);
3604
- console.log(
4340
+ bridgeDebugLog(
3605
4341
  `[bridge] gateway.close ${sessionId} code=${String(code)}${reasonText ? ` reason=${reasonText}` : ''}`
3606
4342
  );
3607
4343
  if (sessionBridge.pendingRequests instanceof Map) {
@@ -3678,7 +4414,7 @@ async function startOpenclawBridge(flags) {
3678
4414
  };
3679
4415
 
3680
4416
  brokerSocket.on('open', () => {
3681
- console.log('[bridge] Connected to managed broker.');
4417
+ bridgeDebugLog('[bridge] Connected to managed broker.');
3682
4418
  reconnectState.attempt = 0;
3683
4419
  reconnectState.lastFailure = null;
3684
4420
  if (actionCableMode) {
@@ -3795,29 +4531,29 @@ async function startOpenclawBridge(flags) {
3795
4531
  }
3796
4532
 
3797
4533
  if (payload.type === 'device.ready') {
3798
- console.log(`[bridge] Broker ready for device ${payload.deviceId || deviceId}.`);
4534
+ bridgeDebugLog(`[bridge] Broker ready for device ${payload.deviceId || deviceId}.`);
3799
4535
  return;
3800
4536
  }
3801
4537
 
3802
4538
  if (payload.type === 'client.open') {
3803
4539
  const sessionId = String(payload.sessionId || '').trim();
3804
4540
  if (!sessionId) return;
3805
- console.log(`[bridge] client.open ${sessionId}`);
4541
+ bridgeDebugLog(`[bridge] client.open ${sessionId}`);
3806
4542
  getOrCreateGatewaySession(sessionId);
3807
4543
  return;
3808
4544
  }
3809
4545
 
3810
- if (payload.type === 'client.frame') {
3811
- const sessionId = String(payload.sessionId || '').trim();
3812
- const frame = typeof payload.frame === 'string' ? payload.frame : '';
3813
- if (!sessionId || !frame) return;
3814
- if (classifyBridgeSessionScope(sessionId) === 'voice') {
3815
- console.log(`[bridge] client.frame ${sessionId} ${JSON.stringify(summarizeVoiceFrameContract(frame))}`);
3816
- } else {
3817
- console.log(`[bridge] client.frame ${sessionId}`);
3818
- }
3819
- const sessionBridge = getOrCreateGatewaySession(sessionId);
3820
- if (!sessionBridge) return;
4546
+ if (payload.type === 'client.frame') {
4547
+ const sessionId = String(payload.sessionId || '').trim();
4548
+ const frame = typeof payload.frame === 'string' ? payload.frame : '';
4549
+ if (!sessionId || !frame) return;
4550
+ if (classifyBridgeSessionScope(sessionId) === 'voice') {
4551
+ bridgeDebugLog(`[bridge] client.frame ${sessionId} ${JSON.stringify(summarizeVoiceFrameContract(frame))}`);
4552
+ } else {
4553
+ bridgeDebugLog(`[bridge] client.frame ${sessionId}`);
4554
+ }
4555
+ const sessionBridge = getOrCreateGatewaySession(sessionId);
4556
+ if (!sessionBridge) return;
3821
4557
  const requestMeta = extractGatewayRequestMeta(frame);
3822
4558
  if (requestMeta) {
3823
4559
  if (!(sessionBridge.pendingRequests instanceof Map)) {
@@ -3840,7 +4576,7 @@ async function startOpenclawBridge(flags) {
3840
4576
  });
3841
4577
  if (prepared.waitForChallenge) {
3842
4578
  queueConnectUntilChallenge(sessionId, sessionBridge, frame);
3843
- console.log(`[bridge] client.frame waiting for challenge ${sessionId}`);
4579
+ bridgeDebugLog(`[bridge] client.frame waiting for challenge ${sessionId}`);
3844
4580
  if (requestMeta) {
3845
4581
  sendGatewayAck(brokerSocket, {
3846
4582
  sessionId,
@@ -3886,7 +4622,7 @@ async function startOpenclawBridge(flags) {
3886
4622
  requiresConnectAccepted: Boolean(requestMeta && requestMeta.method !== 'connect'),
3887
4623
  });
3888
4624
  if (result === 'waiting_for_connect') {
3889
- console.log(`[bridge] client.frame waiting for connect ${sessionId}`);
4625
+ bridgeDebugLog(`[bridge] client.frame waiting for connect ${sessionId}`);
3890
4626
  if (requestMeta) {
3891
4627
  sendGatewayAck(brokerSocket, {
3892
4628
  sessionId,
@@ -3899,7 +4635,7 @@ async function startOpenclawBridge(flags) {
3899
4635
  return;
3900
4636
  }
3901
4637
  if (result === 'queued') {
3902
- console.log(`[bridge] client.frame queued ${sessionId}`);
4638
+ bridgeDebugLog(`[bridge] client.frame queued ${sessionId}`);
3903
4639
  if (requestMeta) {
3904
4640
  startPendingRequestTimeout(brokerSocket, sessionId, sessionBridge, requestMeta);
3905
4641
  }
@@ -3913,7 +4649,7 @@ async function startOpenclawBridge(flags) {
3913
4649
  });
3914
4650
  }
3915
4651
  } else if (result === 'dropped') {
3916
- console.log(`[bridge] client.frame dropped (socket not open) ${sessionId}`);
4652
+ bridgeDebugLog(`[bridge] client.frame dropped (socket not open) ${sessionId}`);
3917
4653
  incrementBridgeMetric('bridge_drop_count');
3918
4654
  if (requestMeta) {
3919
4655
  const pending = sessionBridge.pendingRequests instanceof Map
@@ -3959,7 +4695,7 @@ async function startOpenclawBridge(flags) {
3959
4695
 
3960
4696
  if (payload.type === 'client.close') {
3961
4697
  const sessionId = String(payload.sessionId || '').trim();
3962
- console.log(`[bridge] client.close ${sessionId}`);
4698
+ bridgeDebugLog(`[bridge] client.close ${sessionId}`);
3963
4699
  const sessionBridge = activeGatewaySockets.get(sessionId);
3964
4700
  if (sessionBridge && sessionBridge.socket) {
3965
4701
  clearChallengeTimer(sessionBridge);
@@ -3982,7 +4718,7 @@ async function startOpenclawBridge(flags) {
3982
4718
  actionCableHeartbeat = null;
3983
4719
  }
3984
4720
  const reasonText = reason ? reason.toString() : '';
3985
- console.log(`[bridge] Broker disconnected (${code}) ${reasonText}`);
4721
+ bridgeDebugLog(`[bridge] Broker disconnected (${code}) ${reasonText}`);
3986
4722
  incrementBridgeMetric('bridge_disconnect_count');
3987
4723
  for (const [sessionId, sessionBridge] of activeGatewaySockets.entries()) {
3988
4724
  clearChallengeTimer(sessionBridge);
@@ -4024,16 +4760,17 @@ async function startOpenclawBridge(flags) {
4024
4760
  }));
4025
4761
  };
4026
4762
 
4027
- const markStopped = (signal) => {
4028
- reconnectState.stopped = true;
4763
+ const markStopped = (signal) => {
4764
+ reconnectState.stopped = true;
4029
4765
  if (reconnectState.timer) {
4030
4766
  clearTimeout(reconnectState.timer);
4031
4767
  reconnectState.timer = null;
4032
4768
  }
4033
4769
  personaJobPoller?.stop();
4770
+ personaRuntimeSupervisor?.stop();
4034
4771
  process.off('uncaughtException', uncaughtExceptionHandler);
4035
4772
  process.off('unhandledRejection', unhandledRejectionHandler);
4036
- updateBridgeStatus({
4773
+ updateBridgeStatus({
4037
4774
  status: 'stopped',
4038
4775
  deviceId,
4039
4776
  brokerWs,
@@ -4434,17 +5171,17 @@ async function handleBridgeServiceCommand(actionRaw = '', flags = {}) {
4434
5171
  );
4435
5172
  }
4436
5173
 
4437
- async function startBridgeLifecycle(flags = {}) {
4438
- const serviceManaged = isServiceManagedBridgeStart(flags);
4439
- if (serviceManaged && Boolean(flags.detach)) {
4440
- throw new Error('Detached bridge mode cannot be combined with --service-managed.');
4441
- }
4442
-
4443
- if (Boolean(flags.detach)) {
4444
- const detachedFlags = { ...flags };
4445
- delete detachedFlags.detach;
4446
- const result = startBridgeDetachedProcess(detachedFlags);
4447
- if (result.alreadyRunning) {
5174
+ async function startBridgeLifecycle(flags = {}) {
5175
+ const serviceManaged = isServiceManagedBridgeStart(flags);
5176
+ if (serviceManaged && Boolean(flags.detach)) {
5177
+ throw new Error('Detached bridge mode cannot be combined with --service-managed.');
5178
+ }
5179
+
5180
+ if (Boolean(flags.detach)) {
5181
+ const detachedFlags = { ...flags };
5182
+ delete detachedFlags.detach;
5183
+ const result = startBridgeDetachedProcess(detachedFlags);
5184
+ if (result.alreadyRunning) {
4448
5185
  incrementBridgeMetric('duplicate_start_attempt_count');
4449
5186
  console.log(`Bridge already running (pid: ${result.pid}).`);
4450
5187
  return;
@@ -4453,30 +5190,30 @@ async function startBridgeLifecycle(flags = {}) {
4453
5190
  console.log(`Bridge started in background (pid: ${result.pid}).`);
4454
5191
  return;
4455
5192
  }
4456
-
4457
- const running = findRunningBridgeProcess();
4458
- if (running) {
4459
- if (!serviceManaged) {
4460
- incrementBridgeMetric('duplicate_start_attempt_count');
4461
- console.log(
4462
- `Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
4463
- );
4464
- return;
4465
- }
4466
-
4467
- incrementBridgeMetric('bridge_restart_count');
4468
- console.log(
4469
- `Service-managed bridge start detected existing bridge (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}; reclaiming ownership.`
4470
- );
4471
- const result = await stopBridgeProcesses();
4472
- if (Array.isArray(result.stillAlive) && result.stillAlive.length > 0) {
4473
- throw new Error(`Failed to stop bridge processes: ${result.stillAlive.join(', ')}`);
4474
- }
4475
- }
4476
-
4477
- incrementBridgeMetric('bridge_start_count');
4478
- await startOpenclawBridge(flags);
4479
- }
5193
+
5194
+ const running = findRunningBridgeProcess();
5195
+ if (running) {
5196
+ if (!serviceManaged) {
5197
+ incrementBridgeMetric('duplicate_start_attempt_count');
5198
+ console.log(
5199
+ `Bridge already running (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}.`
5200
+ );
5201
+ return;
5202
+ }
5203
+
5204
+ incrementBridgeMetric('bridge_restart_count');
5205
+ console.log(
5206
+ `Service-managed bridge start detected existing bridge (pid ${running.pid})${running.deviceId ? ` for device ${running.deviceId}` : ''}; reclaiming ownership.`
5207
+ );
5208
+ const result = await stopBridgeProcesses();
5209
+ if (Array.isArray(result.stillAlive) && result.stillAlive.length > 0) {
5210
+ throw new Error(`Failed to stop bridge processes: ${result.stillAlive.join(', ')}`);
5211
+ }
5212
+ }
5213
+
5214
+ incrementBridgeMetric('bridge_start_count');
5215
+ await startOpenclawBridge(flags);
5216
+ }
4480
5217
 
4481
5218
  async function handleBridgeLifecycleCommand(flags = {}, actionRaw = '') {
4482
5219
  const action = String(actionRaw || 'start').trim().toLowerCase();
@@ -4592,40 +5329,50 @@ async function main() {
4592
5329
  return;
4593
5330
  }
4594
5331
 
4595
- if (command === 'openclaw' && subcommand === 'plugin') {
4596
- printOpenclawPluginSetup(args.flags);
4597
- return;
4598
- }
4599
-
4600
- if (command === 'openclaw' && subcommand === 'debug') {
4601
- await handleOpenclawDebugCommand(args.positionals[0], args.flags);
4602
- return;
4603
- }
5332
+ if (command === 'openclaw' && subcommand === 'plugin') {
5333
+ printOpenclawPluginSetup(args.flags);
5334
+ return;
5335
+ }
5336
+
5337
+ if (command === 'openclaw' && subcommand === 'profile') {
5338
+ await handleOpenclawProfileCommand(args.positionals[0], args.flags);
5339
+ return;
5340
+ }
5341
+
5342
+ if (command === 'openclaw' && subcommand === 'debug') {
5343
+ await handleOpenclawDebugCommand(args.positionals[0], args.flags);
5344
+ return;
5345
+ }
4604
5346
 
4605
5347
  if (command === 'personas' && subcommand === 'sync') {
4606
5348
  await syncPersonas({ backendUrl: args.flags['backend-url'], root: args.flags.root });
4607
5349
  return;
4608
5350
  }
4609
5351
 
4610
- if (command === 'personas' && subcommand === 'create') {
5352
+ if (command === 'personas' && subcommand === 'create') {
4611
5353
  const id = args.positionals[0];
4612
5354
  if (!id) {
4613
5355
  throw new Error('Persona id is required. Usage: oomi personas create <id>');
4614
5356
  }
4615
5357
  await createPersona({ id, root: args.flags.root, flags: args.flags });
4616
5358
  return;
4617
- }
4618
-
4619
- if (command === 'personas' && subcommand === 'create-managed') {
4620
- await handlePersonaCreateManagedCommand(args.flags, args.positionals[0]);
4621
- return;
4622
- }
4623
-
4624
- if (command === 'personas' && subcommand === 'scaffold') {
4625
- const slug = args.positionals[0];
4626
- if (!slug) {
4627
- throw new Error('Persona slug is required. Usage: oomi personas scaffold <slug> --name "<name>" --description "<description>" --out <path>');
4628
- }
5359
+ }
5360
+
5361
+ if (command === 'personas' && subcommand === 'create-managed') {
5362
+ await handlePersonaCreateManagedCommand(args.flags, args.positionals[0]);
5363
+ return;
5364
+ }
5365
+
5366
+ if (command === 'personas' && subcommand === 'launch-managed') {
5367
+ await handlePersonaLaunchManagedCommand(args.flags, args.positionals[0]);
5368
+ return;
5369
+ }
5370
+
5371
+ if (command === 'personas' && subcommand === 'scaffold') {
5372
+ const slug = args.positionals[0];
5373
+ if (!slug) {
5374
+ throw new Error('Persona slug is required. Usage: oomi personas scaffold <slug> --name "<name>" --description "<description>" --out <path>');
5375
+ }
4629
5376
  const result = scaffoldPersonaApp({
4630
5377
  slug,
4631
5378
  name: args.flags.name,
@@ -4634,70 +5381,97 @@ async function main() {
4634
5381
  templateVersion: args.flags['template-version'],
4635
5382
  force: isTruthyFlag(args.flags.force),
4636
5383
  });
4637
- printPersonaScaffoldResult(result, isTruthyFlag(args.flags.json));
4638
- return;
4639
- }
4640
-
4641
- if (command === 'personas' && subcommand === 'runtime-register') {
4642
- const slug = args.positionals[0];
4643
- if (!slug) {
4644
- throw new Error('Persona slug is required. Usage: oomi personas runtime-register <slug> --local-port 4789');
4645
- }
4646
- await handlePersonaRuntimeRegisterCommand(slug, args.flags);
4647
- return;
4648
- }
4649
-
4650
- if (command === 'personas' && subcommand === 'heartbeat') {
5384
+ printPersonaScaffoldResult(result, isTruthyFlag(args.flags.json));
5385
+ return;
5386
+ }
5387
+
5388
+ if (command === 'personas' && subcommand === 'runtime-register') {
5389
+ const slug = args.positionals[0];
5390
+ if (!slug) {
5391
+ throw new Error('Persona slug is required. Usage: oomi personas runtime-register <slug> --local-port 4789');
5392
+ }
5393
+ await handlePersonaRuntimeRegisterCommand(slug, args.flags);
5394
+ return;
5395
+ }
5396
+
5397
+ if (command === 'personas' && subcommand === 'status') {
5398
+ const slug = args.positionals[0];
5399
+ if (!slug) {
5400
+ throw new Error('Persona slug is required. Usage: oomi personas status <slug>');
5401
+ }
5402
+ await handlePersonaStatusCommand(slug, args.flags);
5403
+ return;
5404
+ }
5405
+
5406
+ if (command === 'personas' && subcommand === 'stop') {
4651
5407
  const slug = args.positionals[0];
4652
5408
  if (!slug) {
4653
- throw new Error('Persona slug is required. Usage: oomi personas heartbeat <slug> --local-port 4789');
5409
+ throw new Error('Persona slug is required. Usage: oomi personas stop <slug>');
4654
5410
  }
4655
- await handlePersonaHeartbeatCommand(slug, args.flags);
5411
+ await handlePersonaStopCommand(slug, args.flags);
4656
5412
  return;
4657
5413
  }
4658
5414
 
4659
- if (command === 'personas' && subcommand === 'runtime-fail') {
5415
+ if (command === 'personas' && subcommand === 'delete') {
4660
5416
  const slug = args.positionals[0];
4661
5417
  if (!slug) {
4662
- throw new Error('Persona slug is required. Usage: oomi personas runtime-fail <slug> --code RUNTIME_FAILED --message "<text>"');
4663
- }
4664
- await handlePersonaRuntimeFailCommand(slug, args.flags);
4665
- return;
4666
- }
4667
-
4668
- if (command === 'persona-jobs' && subcommand === 'start') {
4669
- const jobId = args.positionals[0];
4670
- if (!jobId) {
4671
- throw new Error('Persona job id is required. Usage: oomi persona-jobs start <jobId>');
4672
- }
4673
- await handlePersonaJobStartCommand(jobId, args.flags);
4674
- return;
4675
- }
4676
-
4677
- if (command === 'persona-jobs' && subcommand === 'succeed') {
4678
- const jobId = args.positionals[0];
4679
- if (!jobId) {
4680
- throw new Error('Persona job id is required. Usage: oomi persona-jobs succeed <jobId> --workspace-path <path> --local-port 4789');
4681
- }
4682
- await handlePersonaJobSucceedCommand(jobId, args.flags);
4683
- return;
4684
- }
4685
-
4686
- if (command === 'persona-jobs' && subcommand === 'fail') {
4687
- const jobId = args.positionals[0];
4688
- if (!jobId) {
4689
- throw new Error('Persona job id is required. Usage: oomi persona-jobs fail <jobId> --code JOB_FAILED --message "<text>"');
5418
+ throw new Error('Persona slug is required. Usage: oomi personas delete <slug>');
4690
5419
  }
4691
- await handlePersonaJobFailCommand(jobId, args.flags);
4692
- return;
4693
- }
4694
-
4695
- if (command === 'persona-jobs' && subcommand === 'execute') {
4696
- await handlePersonaJobExecuteCommand(args.flags);
5420
+ await handlePersonaDeleteCommand(slug, args.flags);
4697
5421
  return;
4698
5422
  }
4699
5423
 
4700
- console.error(`Unknown command: ${command} ${subcommand || ''}`.trim());
5424
+ if (command === 'personas' && subcommand === 'heartbeat') {
5425
+ const slug = args.positionals[0];
5426
+ if (!slug) {
5427
+ throw new Error('Persona slug is required. Usage: oomi personas heartbeat <slug> --local-port 4789');
5428
+ }
5429
+ await handlePersonaHeartbeatCommand(slug, args.flags);
5430
+ return;
5431
+ }
5432
+
5433
+ if (command === 'personas' && subcommand === 'runtime-fail') {
5434
+ const slug = args.positionals[0];
5435
+ if (!slug) {
5436
+ throw new Error('Persona slug is required. Usage: oomi personas runtime-fail <slug> --code RUNTIME_FAILED --message "<text>"');
5437
+ }
5438
+ await handlePersonaRuntimeFailCommand(slug, args.flags);
5439
+ return;
5440
+ }
5441
+
5442
+ if (command === 'persona-jobs' && subcommand === 'start') {
5443
+ const jobId = args.positionals[0];
5444
+ if (!jobId) {
5445
+ throw new Error('Persona job id is required. Usage: oomi persona-jobs start <jobId>');
5446
+ }
5447
+ await handlePersonaJobStartCommand(jobId, args.flags);
5448
+ return;
5449
+ }
5450
+
5451
+ if (command === 'persona-jobs' && subcommand === 'succeed') {
5452
+ const jobId = args.positionals[0];
5453
+ if (!jobId) {
5454
+ throw new Error('Persona job id is required. Usage: oomi persona-jobs succeed <jobId> --workspace-path <path> --local-port 4789');
5455
+ }
5456
+ await handlePersonaJobSucceedCommand(jobId, args.flags);
5457
+ return;
5458
+ }
5459
+
5460
+ if (command === 'persona-jobs' && subcommand === 'fail') {
5461
+ const jobId = args.positionals[0];
5462
+ if (!jobId) {
5463
+ throw new Error('Persona job id is required. Usage: oomi persona-jobs fail <jobId> --code JOB_FAILED --message "<text>"');
5464
+ }
5465
+ await handlePersonaJobFailCommand(jobId, args.flags);
5466
+ return;
5467
+ }
5468
+
5469
+ if (command === 'persona-jobs' && subcommand === 'execute') {
5470
+ await handlePersonaJobExecuteCommand(args.flags);
5471
+ return;
5472
+ }
5473
+
5474
+ console.error(`Unknown command: ${command} ${subcommand || ''}`.trim());
4701
5475
  usage();
4702
5476
  process.exit(1);
4703
5477
  }
@@ -4713,23 +5487,25 @@ if (__isDirectExecution) {
4713
5487
  });
4714
5488
  }
4715
5489
 
4716
- export {
4717
- prepareGatewayFrameForLocalGateway,
4718
- ensureAssistantSpokenMetadata,
4719
- normalizeAssistantGatewayFrame,
4720
- runAssistantFinalDebugCheck,
4721
- buildBridgeLaunchAgentPlist,
4722
- classifyBridgeFailure,
4723
- classifyBridgeSessionScope,
4724
- createBridgeProcessFaultHandler,
4725
- computeReconnectDelayMs,
4726
- resolveBridgeStatusForBrokerOpen,
4727
- resolveBridgeStatusForRuntimeFault,
4728
- runBridgeCallbackSafely,
4729
- extractGatewayRequestMeta,
4730
- extractGatewayResponseMeta,
4731
- isServiceManagedBridgeStart,
4732
- isGatewayRunStartedFrame,
4733
- isBridgeWorkerCommand,
4734
- parsePositiveInteger,
4735
- };
5490
+ export {
5491
+ prepareGatewayFrameForLocalGateway,
5492
+ ensureAssistantSpokenMetadata,
5493
+ normalizeAssistantGatewayFrame,
5494
+ runAssistantFinalDebugCheck,
5495
+ buildOpenclawProfileFromFlags,
5496
+ handleOpenclawProfileCommand,
5497
+ buildBridgeLaunchAgentPlist,
5498
+ classifyBridgeFailure,
5499
+ classifyBridgeSessionScope,
5500
+ createBridgeProcessFaultHandler,
5501
+ computeReconnectDelayMs,
5502
+ resolveBridgeStatusForBrokerOpen,
5503
+ resolveBridgeStatusForRuntimeFault,
5504
+ runBridgeCallbackSafely,
5505
+ extractGatewayRequestMeta,
5506
+ extractGatewayResponseMeta,
5507
+ isServiceManagedBridgeStart,
5508
+ isGatewayRunStartedFrame,
5509
+ isBridgeWorkerCommand,
5510
+ parsePositiveInteger,
5511
+ };