robopark 2.8.35 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +88 -63
  2. package/bin/robopark.js +7 -17
  3. package/conversation/elevenlabs_agent.py +1985 -0
  4. package/conversation/requirements.txt +3 -0
  5. package/conversation/supervisor_store.py +189 -0
  6. package/dist/kernel/config-schema.js +37 -0
  7. package/dist/kernel/types.js +7 -0
  8. package/dist/robopark/access.js +99 -0
  9. package/dist/robopark/add-robot.js +188 -0
  10. package/dist/robopark/agent-ctl.js +305 -0
  11. package/dist/robopark/auto-start.js +289 -0
  12. package/dist/robopark/conversation.js +505 -0
  13. package/dist/robopark/deployment-commands.js +47 -0
  14. package/dist/robopark/discovery.js +180 -0
  15. package/dist/robopark/doctor.js +175 -0
  16. package/dist/robopark/enroll.js +68 -0
  17. package/dist/robopark/llm-set.js +87 -0
  18. package/dist/robopark/motor-control.js +195 -0
  19. package/dist/robopark/preview-agent-launcher.js +77 -0
  20. package/dist/robopark/probe.js +138 -0
  21. package/dist/robopark/profile.js +69 -0
  22. package/dist/robopark/python-env.js +162 -0
  23. package/dist/robopark/robot-runtime.js +489 -0
  24. package/dist/robopark/scan.js +97 -0
  25. package/dist/robopark/screen-control.js +55 -0
  26. package/dist/robopark/secrets.js +41 -0
  27. package/dist/robopark/serve.js +285 -0
  28. package/dist/robopark/server-add.js +114 -0
  29. package/dist/robopark/setup-livekit.js +300 -0
  30. package/dist/robopark/setup.js +286 -0
  31. package/dist/robopark/standalone.js +466 -0
  32. package/dist/robopark/stop-all.js +141 -0
  33. package/dist/robopark/verify.js +192 -0
  34. package/dist/robopark/vision-agent-launcher.js +98 -0
  35. package/dist/robopark/vision-control.js +81 -0
  36. package/dist/robopark-cli.js +799 -0
  37. package/package.json +21 -5
  38. package/pi-client/_install_steps.sh +29 -29
  39. package/pi-client/client.py +61 -2
  40. package/pi-client/install.sh +40 -40
  41. package/pi-client/join_convo.sh +54 -54
  42. package/pi-client/livekit_bridge.py +16 -7
  43. package/pi-client/motor_bridge.py +6 -3
  44. package/scheduler/fleet_config.json +75 -0
  45. package/scheduler/main.py +4505 -135
  46. package/scheduler/media_lock.py +57 -0
  47. package/scheduler/preview_agent.py +1465 -87
  48. package/scheduler/production_config.json +139 -0
  49. package/scheduler/robot_supervisor.py +1705 -0
  50. package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
  51. package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
  52. package/scheduler/scripts/robopark-supervisor.service +20 -0
  53. package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
  54. package/scheduler/supervisor.example.json +26 -0
  55. package/scheduler/vision_motion_trigger.py +101 -0
  56. package/screen/screen_runtime.py +75 -0
  57. package/vision/app_pi_clean.py +253 -16
  58. package/vision/audio_server_pi.py +19 -0
  59. package/vision/install.sh +34 -34
  60. package/vision/motor_server.py +224 -61
  61. package/vision/requirements_camera.txt +6 -0
  62. package/vision/requirements_motor.txt +4 -0
  63. package/vision/requirements_pi_unified.txt +1 -0
  64. package/vision/requirements_vision_agent.txt +19 -0
  65. package/vision/run.sh +244 -244
  66. package/vision/services/services.sh +12 -12
  67. package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
  68. package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
@@ -0,0 +1,799 @@
1
+ /**
2
+ * RoboPark CLI entry point (shipped as `infinicode/robopark` and `robopark`).
3
+ *
4
+ * A thin operator layer over the infinicode mesh. Keeps product concerns
5
+ * (scheduler, fleet UI, video sessions, InfiniBot map) separate from the
6
+ * execution kernel.
7
+ */
8
+ import { Command } from 'commander';
9
+ import chalk from 'chalk';
10
+ import Conf from 'conf';
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname, join } from 'node:path';
14
+ import { roboparkScan } from './robopark/scan.js';
15
+ import { roboparkServe } from './robopark/serve.js';
16
+ import { roboparkSetup } from './robopark/setup.js';
17
+ import { stopAll } from './robopark/stop-all.js';
18
+ import { roboparkDoctor } from './robopark/doctor.js';
19
+ import { roboparkAddRobot } from './robopark/add-robot.js';
20
+ import { saveHubProfile, clearHubProfile } from './robopark/profile.js';
21
+ import { roboparkAgentUp, roboparkAgentDown, roboparkAgentLogs } from './robopark/agent-ctl.js';
22
+ import { roboparkSecrets } from './robopark/secrets.js';
23
+ import { roboparkRobotRuntime } from './robopark/robot-runtime.js';
24
+ import { roboparkVisionDown, roboparkVisionRestart, roboparkVisionStatus, roboparkVisionUp, } from './robopark/vision-control.js';
25
+ import { roboparkConversationDown, roboparkConversationInstall, roboparkConversationRestart, roboparkConversationStart, roboparkConversationStop, roboparkConversationUp, } from './robopark/conversation.js';
26
+ import { roboparkScreenDown, roboparkScreenInstall, roboparkScreenRestart, roboparkScreenUp } from './robopark/screen-control.js';
27
+ import { roboparkMotorDiscover, roboparkMotorDown, roboparkMotorRestart, roboparkMotorStatus, roboparkMotorStop, roboparkMotorTest, roboparkMotorUp, } from './robopark/motor-control.js';
28
+ import { connectDevice, gatewayStart, printRuntimeStatus, runGatewayService, startDevice, superviseDevice, } from './robopark/standalone.js';
29
+ import { requirePackageAccess } from './robopark/access.js';
30
+ import { deploymentCommandSheet } from './robopark/deployment-commands.js';
31
+ // Read the real version from package.json so `--version` never drifts from
32
+ // what is actually installed (this was a hardcoded constant that went stale
33
+ // across several releases — see the identical fix + comment in cli.ts).
34
+ function pkgVersion() {
35
+ const here = dirname(fileURLToPath(import.meta.url));
36
+ for (const rel of ['../package.json', '../../package.json']) {
37
+ try {
38
+ return JSON.parse(readFileSync(join(here, rel), 'utf8')).version ?? '0.0.0';
39
+ }
40
+ catch { /* try next */ }
41
+ }
42
+ return '0.0.0';
43
+ }
44
+ const config = new Conf({
45
+ // Preserve the deployed config location so packaging never forks an
46
+ // existing robot into a fresh identity/config namespace.
47
+ projectName: 'infinicode',
48
+ ...(process.env.ROBOPARK_CONFIG_DIR ? { cwd: process.env.ROBOPARK_CONFIG_DIR } : {}),
49
+ });
50
+ const program = new Command('robopark')
51
+ .description('RoboPark fleet control CLI — set up, watch, and drive talking robots')
52
+ .version(pkgVersion())
53
+ .option('--password <password>', 'one-time package activation password (prefer ROBOPARK_PACKAGE_PASSWORD)');
54
+ program.hook('preAction', (_command, actionCommand) => {
55
+ // A valid gateway pairing token is itself the device bootstrap capability.
56
+ // connectDevice() records activation only after enrollment succeeds.
57
+ if (actionCommand.name() === 'connect' || actionCommand.name() === 'commands')
58
+ return;
59
+ requirePackageAccess(program.opts().password);
60
+ });
61
+ program
62
+ .command('commands')
63
+ .description('Print the exact gateway and persistent per-character deployment commands')
64
+ .option('--token <token>', 'include a gateway pairing token in the device command')
65
+ .option('--public-url <url>', 'include the gateway public/Tailscale URL')
66
+ .action((opts) => {
67
+ try {
68
+ console.log(deploymentCommandSheet({ version: pkgVersion(), ...opts }));
69
+ }
70
+ catch (err) {
71
+ console.error(chalk.red(` ✗ command sheet failed: ${err?.message ?? err}`));
72
+ process.exitCode = 1;
73
+ }
74
+ });
75
+ const gateway = program
76
+ .command('gateway')
77
+ .description('Run the standalone RoboPark dashboard, scheduler, and device gateway');
78
+ gateway
79
+ .command('start')
80
+ .description('Start the dashboard/gateway and print a one-line device connection token')
81
+ .option('--port <port>', 'gateway HTTP port', '8080')
82
+ .option('--host <host>', 'gateway bind host', '0.0.0.0')
83
+ .option('--public-url <url>', 'URL devices use to reach this gateway')
84
+ .option('--data-dir <dir>', 'durable gateway data directory')
85
+ .option('--no-open', 'do not open the dashboard')
86
+ .option('--foreground', 'keep the gateway attached to this terminal')
87
+ .action(async (opts) => {
88
+ try {
89
+ await gatewayStart(opts);
90
+ }
91
+ catch (err) {
92
+ console.error(chalk.red(` ✗ gateway start failed: ${err?.message ?? err}`));
93
+ process.exitCode = 1;
94
+ }
95
+ });
96
+ program
97
+ .command('connect <token>')
98
+ .description('Register this device with a gateway using one copy/paste token')
99
+ .option('--name <name>', 'device name (defaults to hostname)')
100
+ .action(async (token, opts) => {
101
+ try {
102
+ await connectDevice(token, opts);
103
+ }
104
+ catch (err) {
105
+ console.error(chalk.red(` ✗ connect failed: ${err?.message ?? err}`));
106
+ process.exitCode = 1;
107
+ }
108
+ });
109
+ program
110
+ .command('start')
111
+ .description('Persist and supervise this device camera/voice runtime')
112
+ .option('--voice', 'stream microphone audio and accept supervised voice sessions')
113
+ .option('--video', 'stream the selected camera')
114
+ .option('--character <id>', 'gateway character preset, e.g. vixen')
115
+ .option('--video-device <device>', 'camera selection', 'auto')
116
+ .option('--audio-device <device>', 'microphone selection', 'default')
117
+ .option('--width <px>', 'video width', '640')
118
+ .option('--height <px>', 'video height', '480')
119
+ .option('--fps <n>', 'video frame rate', '15')
120
+ .option('--foreground', 'run the supervisor in this terminal')
121
+ .action(async (opts) => {
122
+ try {
123
+ await startDevice(opts);
124
+ }
125
+ catch (err) {
126
+ console.error(chalk.red(` ✗ start failed: ${err?.message ?? err}`));
127
+ process.exitCode = 1;
128
+ }
129
+ });
130
+ program
131
+ .command('status')
132
+ .description('Show the local supervisor state')
133
+ .action(() => {
134
+ try {
135
+ printRuntimeStatus();
136
+ }
137
+ catch (err) {
138
+ console.error(chalk.red(` ✗ status failed: ${err?.message ?? err}`));
139
+ process.exitCode = 1;
140
+ }
141
+ });
142
+ program
143
+ .command('_gateway', { hidden: true })
144
+ .option('--port <port>', 'gateway HTTP port', '8080')
145
+ .option('--host <host>', 'gateway bind host', '0.0.0.0')
146
+ .option('--data-dir <dir>', 'durable gateway data directory')
147
+ .action(async (opts) => {
148
+ try {
149
+ await runGatewayService({ ...opts, open: false, foreground: true });
150
+ }
151
+ catch (err) {
152
+ console.error(chalk.red(` gateway service fatal: ${err?.message ?? err}`));
153
+ process.exitCode = 1;
154
+ }
155
+ });
156
+ program
157
+ .command('_supervise', { hidden: true })
158
+ .requiredOption('--config <path>')
159
+ .action(async (opts) => {
160
+ try {
161
+ await superviseDevice(opts.config);
162
+ }
163
+ catch (err) {
164
+ console.error(chalk.red(` supervisor fatal: ${err?.message ?? err}`));
165
+ process.exitCode = 1;
166
+ }
167
+ });
168
+ const conversation = program
169
+ .command('conversation')
170
+ .description('Run a direct motion/voice-triggered ElevenLabs agent on robot hardware');
171
+ conversation
172
+ .command('install')
173
+ .description('Idempotently install and verify Pi audio/Python dependencies')
174
+ .option('--with-vision', 'also install and verify RoboVision dependencies')
175
+ .option('--skip-apt', 'skip apt and only repair/verify the RoboPark Python environment')
176
+ .action(async (opts) => {
177
+ try {
178
+ await roboparkConversationInstall(opts);
179
+ }
180
+ catch (err) {
181
+ console.error(chalk.red(` ✗ conversation install failed: ${err?.message ?? err}`));
182
+ process.exitCode = 1;
183
+ }
184
+ });
185
+ conversation
186
+ .command('up')
187
+ .description('Arm motion and local voice triggers, then join the configured ElevenLabs agent')
188
+ .requiredOption('--agent-id <id>', 'ElevenLabs agent id')
189
+ .option('--branch-id <id>', 'pin conversations to an ElevenLabs agent branch')
190
+ .option('--api-key <key>', 'ElevenLabs API key (prefer ELEVENLABS_API_KEY)')
191
+ .option('--public-agent', 'agent does not require an API key')
192
+ .option('--always-on', 'join immediately and reconnect forever without waiting for a trigger')
193
+ .option('--name <name>', 'robot name (defaults to hostname)')
194
+ .option('--audio-input <device>', 'stable USB microphone name or ALSA/PyAudio device', 'Usb Audio Device: USB Audio')
195
+ .option('--audio-output <device>', 'stable USB speaker name or ALSA/PyAudio device', 'USB Audio Device: -')
196
+ .option('--no-motion', 'disable the local motion webhook')
197
+ .option('--no-voice-trigger', 'disable the local sound-level trigger')
198
+ .option('--motion-host <host>', 'motion webhook bind host', '127.0.0.1')
199
+ .option('--motion-port <port>', 'motion webhook port', '5057')
200
+ .option('--vision-url <url>', 'running RoboVision server to configure for motion', 'http://127.0.0.1:5000')
201
+ .option('--no-manage-vision', 'do not start and supervise the local RoboVision camera process')
202
+ .option('--camera-device <device>', 'RoboVision camera device (auto prefers /dev/robopark-camera)', 'auto')
203
+ .option('--voice-threshold <rms>', 'idle microphone RMS required to trigger', '900')
204
+ .option('--idle-timeout <sec>', 'end after this many seconds without a transcript; 0 disables', '120')
205
+ .option('--max-session <sec>', 'hard maximum conversation duration; 0 disables', '540')
206
+ .option('--cooldown <sec>', 'minimum delay before another session', '2')
207
+ .option('--motor <pin-name>', 'register motor/relay, e.g. 17-head (repeatable)', (value, previous) => [...previous, value], [])
208
+ .option('--motor-active-high', 'registered motor relays activate HIGH (default: active-low)')
209
+ .option('--motor-pulse-ms <ms>', 'default bounded motor pulse duration', '300')
210
+ .option('--no-provision-agent-tools', 'skip automatic ElevenLabs robotMotor tool creation/attachment')
211
+ .option('--auto-start', 'install and start a production systemd service')
212
+ .action(async (opts) => {
213
+ try {
214
+ await roboparkConversationUp(opts);
215
+ }
216
+ catch (err) {
217
+ console.error(chalk.red(` ✗ conversation failed: ${err?.message ?? err}`));
218
+ process.exitCode = 1;
219
+ }
220
+ });
221
+ conversation
222
+ .command('stop')
223
+ .description('Stop local conversation workers without deleting their configuration')
224
+ .option('--name <name>', 'explicit service override (normally unnecessary)')
225
+ .action((opts) => {
226
+ try {
227
+ roboparkConversationStop(opts.name);
228
+ }
229
+ catch (err) {
230
+ console.error(chalk.red(` ✗ conversation stop failed: ${err?.message ?? err}`));
231
+ process.exitCode = 1;
232
+ }
233
+ });
234
+ conversation
235
+ .command('start')
236
+ .description('Start the selected local conversation worker and wait for real readiness')
237
+ .option('--name <name>', 'explicit service override (normally unnecessary)')
238
+ .option('--trigger', 'trigger one conversation after readiness')
239
+ .action(async (opts) => {
240
+ try {
241
+ await roboparkConversationStart(opts.name, Boolean(opts.trigger));
242
+ }
243
+ catch (err) {
244
+ console.error(chalk.red(` ✗ conversation start failed: ${err?.message ?? err}`));
245
+ process.exitCode = 1;
246
+ }
247
+ });
248
+ conversation
249
+ .command('down')
250
+ .description('Stop and remove the direct conversation auto-start service')
251
+ .option('--name <name>', 'robot name (defaults to hostname)')
252
+ .action((opts) => {
253
+ try {
254
+ roboparkConversationDown(opts.name);
255
+ }
256
+ catch (err) {
257
+ console.error(chalk.red(` ✗ conversation stop failed: ${err?.message ?? err}`));
258
+ process.exitCode = 1;
259
+ }
260
+ });
261
+ conversation
262
+ .command('restart')
263
+ .description('Cleanly restart a production conversation worker and wait until it is ready')
264
+ .option('--name <name>', 'explicit service override (normally unnecessary)')
265
+ .option('--trigger', 'trigger one conversation after readiness')
266
+ .action(async (opts) => {
267
+ try {
268
+ await roboparkConversationRestart(opts.name, Boolean(opts.trigger));
269
+ }
270
+ catch (err) {
271
+ console.error(chalk.red(` ✗ conversation restart failed: ${err?.message ?? err}`));
272
+ process.exitCode = 1;
273
+ }
274
+ });
275
+ program
276
+ .command('scan')
277
+ .description('Auto-discover the fleet and print ready-to-run setup commands')
278
+ .option('--token <token>', 'mesh auth token to use')
279
+ .option('--output <dir>', 'write install scripts to this directory')
280
+ .option('--no-gateway', 'skip InfiniBot gateway in generated commands')
281
+ .action(async (opts) => {
282
+ await roboparkScan({
283
+ token: opts.token,
284
+ output: opts.output,
285
+ gateway: opts.gateway,
286
+ });
287
+ });
288
+ program
289
+ .command('probe')
290
+ .description('Dispatch opencode agents across the mesh to map infrastructure')
291
+ .option('--token <token>', 'mesh auth token (optional, for context)')
292
+ .option('--timeout <sec>', 'seconds per node', '60')
293
+ .option('--output <file>', 'write JSON results to this file')
294
+ .option('--local', 'probe only this machine')
295
+ .action(async (opts) => {
296
+ const { roboparkProbe } = await import('./robopark/probe.js');
297
+ await roboparkProbe(opts);
298
+ });
299
+ program
300
+ .command('setup-livekit')
301
+ .description('Dispatch an opencode agent on the hub to install LiveKit server via Docker')
302
+ .option('--hub-url <url>', 'hub federation URL, e.g. http://100.64.1.2:47913')
303
+ .option('--token <token>', 'mesh auth token')
304
+ .option('--scheduler-url <url>', 'scheduler URL to register the new server, e.g. http://100.64.1.2:8080')
305
+ .option('--timeout <sec>', 'max seconds to wait for the agent', '180')
306
+ .option('--dry-run', 'print the prompt instead of dispatching')
307
+ .action(async (opts) => {
308
+ const { roboparkSetupLivekit } = await import('./robopark/setup-livekit.js');
309
+ await roboparkSetupLivekit(opts);
310
+ });
311
+ program
312
+ .command('verify')
313
+ .description('Verify the RoboPark fleet is ready for live preview')
314
+ .option('--scheduler-url <url>', 'scheduler base URL, e.g. http://livekit-1:8080')
315
+ .option('--robot-id <id>', 'robot name to verify (defaults to hostname)')
316
+ .action(async (opts) => {
317
+ const { roboparkVerify } = await import('./robopark/verify.js');
318
+ await roboparkVerify(opts);
319
+ });
320
+ program
321
+ .command('serve')
322
+ .description('Start the RoboPark scheduler + web UI')
323
+ .option('--port <port>', 'scheduler HTTP port', '8080')
324
+ .option('--host <host>', 'scheduler bind host', '0.0.0.0')
325
+ .option('--data-dir <dir>', 'scheduler data directory')
326
+ .option('--gateway <url>', 'InfiniBot gateway ws:// URL')
327
+ .option('--gateway-token <token>', 'InfiniBot gateway token')
328
+ .option('--gateway-password <pw>', 'InfiniBot gateway password')
329
+ .option('--no-open', 'do not open the RoboPark Control Center in a browser')
330
+ .option('--foreground', 'stay in foreground; do not detach')
331
+ .action(async (opts) => {
332
+ await roboparkServe(opts);
333
+ });
334
+ program
335
+ .command('secrets')
336
+ .description('Provision the scheduler-managed ROBOVOICE authentication secret')
337
+ .option('--scheduler-url <url>', 'scheduler base URL', 'http://localhost:8080')
338
+ .option('--worker-env <path>', 'ROBOVOICE worker .env file', '.env')
339
+ .option('--rotate', 'rotate the shared secret before synchronizing it')
340
+ .action(async (opts) => {
341
+ try {
342
+ await roboparkSecrets(opts);
343
+ }
344
+ catch (err) {
345
+ console.error(chalk.red(` ✗ secret provisioning failed: ${err?.message ?? err}`));
346
+ process.exitCode = 1;
347
+ }
348
+ });
349
+ program
350
+ .command('enroll')
351
+ .description('Enroll this robot/satellite with the RoboPark scheduler')
352
+ .option('--scheduler-url <url>', 'scheduler base URL', 'http://localhost:8080')
353
+ .option('--robot-id <id>', 'robot/device name (defaults to hostname)')
354
+ .option('--enrollment-token <token>', 'one-time enrollment token (or ENV ENROLLMENT_TOKEN)')
355
+ .option('--save-config', 'write settings to ~/.robopark/preview_agent.json')
356
+ .action(async (opts) => {
357
+ const { roboparkEnroll } = await import('./robopark/enroll.js');
358
+ await roboparkEnroll(opts);
359
+ });
360
+ program
361
+ .command('preview-agent')
362
+ .description('Start the robot-side preview agent (camera + mic to scheduler)')
363
+ .option('--scheduler-url <url>', 'scheduler base URL', 'http://localhost:8080')
364
+ .option('--robot-id <id>', 'robot/device name (defaults to hostname)')
365
+ .option('--device-token <token>', 'long-lived device token')
366
+ .option('--enrollment-token <token>', 'one-time enrollment token')
367
+ .option('--video-device <dev>', 'camera: auto, /dev/video0, 0, picamera', 'auto')
368
+ .option('--audio-device <dev>', 'microphone: default, none, or name/index', 'default')
369
+ .option('--robovision-url <url>', 'RoboVision base URL used for camera streaming and hardware inventory')
370
+ .option('--width <px>', 'video width', '640')
371
+ .option('--height <px>', 'video height', '480')
372
+ .option('--fps <n>', 'video fps', '15')
373
+ .option('--vision-webhook-port <port>', 'local port for RoboVisionAI_PI\'s motion webhook (0 disables)', '5057')
374
+ .option('--vision-trigger-cooldown <sec>', 'min seconds between motion-triggered sessions', '20')
375
+ .option('--vision-session-seconds <sec>', 'how long a motion-triggered session holds the publisher', '90')
376
+ .option('--save-config', 'write settings to ~/.robopark/preview_agent.json')
377
+ .option('--foreground', 'stay in foreground; do not detach')
378
+ .action(async (opts) => {
379
+ const { roboparkPreviewAgent } = await import('./robopark/preview-agent-launcher.js');
380
+ await roboparkPreviewAgent(opts);
381
+ });
382
+ program
383
+ .command('vision-agent')
384
+ .description('Start the robot-side camera/motion detector (RoboVisionAI_PI) — self-arms and wires to preview-agent\'s vision webhook')
385
+ .option('--port <port>', 'vision server port', '5000')
386
+ .option('--motion-webhook-url <url>', 'where to POST on motion (defaults to the local preview-agent\'s vision webhook, http://127.0.0.1:5057/)')
387
+ .option('--no-motion-active', 'do not arm motion detection on startup (armed by default)')
388
+ .option('--foreground', 'stay in foreground; do not detach')
389
+ .action(async (opts) => {
390
+ const { roboparkVisionAgent } = await import('./robopark/vision-agent-launcher.js');
391
+ await roboparkVisionAgent(opts);
392
+ });
393
+ const visionProgram = program
394
+ .command('vision')
395
+ .description('Register and operate an always-on robot camera feed independently of voice calls');
396
+ const collectOption = (value, previous) => previous.concat(value);
397
+ const motorProgram = program
398
+ .command('motor')
399
+ .description('Register and operate a persistent robot-local GPIO motor server');
400
+ motorProgram.command('up <name>')
401
+ .description('Start the motor server and persist its safe relay registry')
402
+ .option('--token <token>', 'motor API token (defaults to the saved mesh token)')
403
+ .option('--port <port>', 'loopback motor API port', '8001')
404
+ .option('--motor <gpio-name>', 'register a relay, e.g. 17-head or 27-eyes:low', collectOption, [])
405
+ .option('--no-auto-start', 'run without registering the service at boot')
406
+ .action(async (name, opts) => {
407
+ try {
408
+ await roboparkMotorUp(name, opts);
409
+ }
410
+ catch (err) {
411
+ console.error(chalk.red(` ✗ motor up failed: ${err?.message ?? err}`));
412
+ process.exitCode = 1;
413
+ }
414
+ });
415
+ motorProgram.command('status <name>')
416
+ .description('Show motor service, GPIO, and registry health')
417
+ .action(async (name) => {
418
+ try {
419
+ await roboparkMotorStatus(name);
420
+ }
421
+ catch (err) {
422
+ console.error(chalk.red(` ✗ motor status failed: ${err?.message ?? err}`));
423
+ process.exitCode = 1;
424
+ }
425
+ });
426
+ motorProgram.command('discover <name>')
427
+ .description('Read the robot-local persisted motor registry')
428
+ .action(async (name) => {
429
+ try {
430
+ await roboparkMotorDiscover(name);
431
+ }
432
+ catch (err) {
433
+ console.error(chalk.red(` ✗ motor discovery failed: ${err?.message ?? err}`));
434
+ process.exitCode = 1;
435
+ }
436
+ });
437
+ motorProgram.command('test <name> [motor]')
438
+ .description('Pulse one registered motor, or safely test all registered relays')
439
+ .option('--seconds <seconds>', 'pulse duration', '0.3')
440
+ .action(async (name, motor, opts) => {
441
+ try {
442
+ await roboparkMotorTest(name, motor, Number(opts.seconds));
443
+ }
444
+ catch (err) {
445
+ console.error(chalk.red(` ✗ motor test failed: ${err?.message ?? err}`));
446
+ process.exitCode = 1;
447
+ }
448
+ });
449
+ motorProgram.command('stop <name>')
450
+ .description('Immediately de-energize all registered relays')
451
+ .action(async (name) => {
452
+ try {
453
+ await roboparkMotorStop(name);
454
+ }
455
+ catch (err) {
456
+ console.error(chalk.red(` ✗ motor stop failed: ${err?.message ?? err}`));
457
+ process.exitCode = 1;
458
+ }
459
+ });
460
+ motorProgram.command('restart <name>')
461
+ .description('Cleanly restart the persistent motor server')
462
+ .action((name) => {
463
+ try {
464
+ roboparkMotorRestart(name);
465
+ }
466
+ catch (err) {
467
+ console.error(chalk.red(` ✗ motor restart failed: ${err?.message ?? err}`));
468
+ process.exitCode = 1;
469
+ }
470
+ });
471
+ motorProgram.command('down <name>')
472
+ .description('Stop and remove the persistent motor server')
473
+ .action((name) => {
474
+ try {
475
+ roboparkMotorDown(name);
476
+ }
477
+ catch (err) {
478
+ console.error(chalk.red(` ✗ motor down failed: ${err?.message ?? err}`));
479
+ process.exitCode = 1;
480
+ }
481
+ });
482
+ const screenProgram = program
483
+ .command('screen')
484
+ .description('Operate an authenticated internal noVNC desktop relay over the Infinicode mesh');
485
+ screenProgram.command('install')
486
+ .description('Install Wayland/X11 VNC and noVNC relay dependencies on a Pi')
487
+ .action(() => { try {
488
+ roboparkScreenInstall();
489
+ }
490
+ catch (err) {
491
+ console.error(chalk.red(` ✗ screen install failed: ${err?.message ?? err}`));
492
+ process.exitCode = 1;
493
+ } });
494
+ screenProgram.command('up <name>')
495
+ .description('Start and persist a loopback-only desktop relay for a robot')
496
+ .option('--desktop-user <user>', 'Linux desktop account to share')
497
+ .action(async (name, opts) => { try {
498
+ await roboparkScreenUp(name, opts.desktopUser);
499
+ }
500
+ catch (err) {
501
+ console.error(chalk.red(` ✗ screen up failed: ${err?.message ?? err}`));
502
+ process.exitCode = 1;
503
+ } });
504
+ screenProgram.command('restart <name>')
505
+ .description('Restart a robot desktop relay')
506
+ .action((name) => { try {
507
+ roboparkScreenRestart(name);
508
+ }
509
+ catch (err) {
510
+ console.error(chalk.red(` ✗ screen restart failed: ${err?.message ?? err}`));
511
+ process.exitCode = 1;
512
+ } });
513
+ screenProgram.command('down <name>')
514
+ .description('Stop and remove a robot desktop relay')
515
+ .action((name) => { try {
516
+ roboparkScreenDown(name);
517
+ }
518
+ catch (err) {
519
+ console.error(chalk.red(` ✗ screen down failed: ${err?.message ?? err}`));
520
+ process.exitCode = 1;
521
+ } });
522
+ visionProgram
523
+ .command('up <name>')
524
+ .description('Start and persist a named camera relay using saved hub credentials')
525
+ .option('--hub-url <url>', 'hub mesh URL (defaults to `robopark hub-use` profile)')
526
+ .option('--token <token>', 'mesh token (defaults to `robopark hub-use` profile)')
527
+ .option('--character <id>', 'optional character preset associated with this robot')
528
+ .option('--video-device <dev>', 'camera selection', 'auto')
529
+ .option('--port <port>', 'robot mesh port', '47913')
530
+ .option('--lan', 'use LAN transport (default)')
531
+ .option('--tailscale', 'use Tailscale transport')
532
+ .option('--no-auto-start', 'run now without registering the feed at boot')
533
+ .action(async (name, opts) => {
534
+ try {
535
+ await roboparkVisionUp(config, name, opts);
536
+ }
537
+ catch (err) {
538
+ console.error(chalk.red(` ✗ vision up failed: ${err?.message ?? err}`));
539
+ process.exitCode = 1;
540
+ }
541
+ });
542
+ visionProgram
543
+ .command('status <name>')
544
+ .description('Show the camera runtime and local feed status')
545
+ .action(async (name) => {
546
+ try {
547
+ await roboparkVisionStatus(name);
548
+ }
549
+ catch (err) {
550
+ console.error(chalk.red(` ✗ vision status failed: ${err?.message ?? err}`));
551
+ process.exitCode = 1;
552
+ }
553
+ });
554
+ visionProgram
555
+ .command('restart <name>')
556
+ .description('Cleanly restart the persistent camera relay')
557
+ .action((name) => {
558
+ try {
559
+ roboparkVisionRestart(name);
560
+ }
561
+ catch (err) {
562
+ console.error(chalk.red(` ✗ vision restart failed: ${err?.message ?? err}`));
563
+ process.exitCode = 1;
564
+ }
565
+ });
566
+ visionProgram
567
+ .command('down <name>')
568
+ .description('Stop and remove the persistent camera relay')
569
+ .action((name) => {
570
+ try {
571
+ roboparkVisionDown(name);
572
+ }
573
+ catch (err) {
574
+ console.error(chalk.red(` ✗ vision down failed: ${err?.message ?? err}`));
575
+ process.exitCode = 1;
576
+ }
577
+ });
578
+ const robotProgram = program
579
+ .command('robot')
580
+ .description('Run the complete robot runtime as one supervised service');
581
+ robotProgram
582
+ .command('up')
583
+ .description('Start mesh, RoboVision, audio, motion webhook, and preview agent in dependency order')
584
+ .requiredOption('--name <name>', 'robot name')
585
+ .requiredOption('--hub-url <url>', 'hub mesh URL, e.g. http://192.168.1.12:47913')
586
+ .requiredOption('--token <token>', 'shared mesh token')
587
+ .option('--scheduler-url <url>', 'scheduler URL (derived from --hub-url when omitted)')
588
+ .option('--enrollment-token <token>', 'one-time scheduler enrollment token for a fresh device')
589
+ .option('--character <id>', 'character preset assigned during canonical device bootstrap')
590
+ .option('--port <port>', 'robot mesh port', '47913')
591
+ .option('--lan', 'use LAN transport (default)')
592
+ .option('--tailscale', 'use Tailscale transport')
593
+ .option('--video-device <dev>', 'camera selection', 'auto')
594
+ .option('--audio-device <dev>', 'microphone selection', 'default')
595
+ .option('--device-role <role>', 'hardware role: combined | voice_vision | motor', 'combined')
596
+ .option('--no-vision', 'do not start RoboVision camera/audio/motion services')
597
+ .option('--foreground', 'keep logs in this terminal instead of ~/.robopark/logs')
598
+ .action(async (opts) => {
599
+ if (opts.lan && opts.tailscale)
600
+ throw new Error('choose exactly one of --lan or --tailscale');
601
+ if (!['combined', 'voice_vision', 'motor'].includes(opts.deviceRole))
602
+ throw new Error('device role must be combined, voice_vision, or motor');
603
+ await roboparkRobotRuntime({
604
+ name: opts.name,
605
+ hubUrl: opts.hubUrl,
606
+ schedulerUrl: opts.schedulerUrl ?? `${opts.hubUrl.replace(/\/+$/, '')}/robopark`,
607
+ token: opts.token,
608
+ port: opts.port,
609
+ network: opts.tailscale ? 'tailscale' : 'lan',
610
+ enrollmentToken: opts.enrollmentToken,
611
+ character: opts.character,
612
+ videoDevice: opts.videoDevice,
613
+ audioDevice: opts.audioDevice,
614
+ deviceRole: opts.deviceRole,
615
+ vision: opts.vision,
616
+ foreground: opts.foreground,
617
+ });
618
+ });
619
+ program
620
+ .command('stop')
621
+ .description('Stop every infinicode/robopark process on this device AND remove any supervisor (systemd/Task Scheduler/launchd) that would respawn it')
622
+ .action(async () => {
623
+ console.log(chalk.bold('\n robopark stop\n ' + '─'.repeat(48)));
624
+ try {
625
+ const result = await stopAll();
626
+ if (result.killedPids.length)
627
+ console.log(chalk.green(` ✓ killed ${result.killedPids.length} process(es): ${result.killedPids.join(', ')}`));
628
+ else
629
+ console.log(chalk.dim(' no matching processes were running'));
630
+ if (result.removedUnits.length)
631
+ console.log(chalk.green(` ✓ removed ${result.removedUnits.length} supervisor unit(s): ${result.removedUnits.join(', ')}`));
632
+ else
633
+ console.log(chalk.dim(' no supervisor units registered'));
634
+ for (const err of result.errors)
635
+ console.log(chalk.yellow(` ! ${err}`));
636
+ console.log('');
637
+ }
638
+ catch (e) {
639
+ console.error('stop failed:', e instanceof Error ? (e.stack ?? e.message) : e);
640
+ process.exitCode = 1;
641
+ }
642
+ });
643
+ program
644
+ .command('doctor')
645
+ .description('Run a full fleet health check: hub, scheduler, LiveKit servers, device heartbeats, production mode')
646
+ .option('--hub-url <url>', 'hub federation URL, e.g. http://100.64.1.2:47913')
647
+ .option('--token <token>', 'mesh auth token')
648
+ .option('--scheduler-url <url>', 'scheduler base URL, e.g. http://100.64.1.2:8080')
649
+ .action(async (opts) => {
650
+ await roboparkDoctor(opts);
651
+ });
652
+ program
653
+ .command('server-add')
654
+ .description('Register a LiveKit server with the RoboPark scheduler')
655
+ .option('--hub-url <url>', 'hub federation URL, e.g. http://100.64.1.2:47913')
656
+ .option('--scheduler-url <url>', 'scheduler URL, e.g. http://100.64.1.2:8080 (derived from --hub-url if omitted)')
657
+ .option('--token <token>', 'mesh auth token (optional)')
658
+ .requiredOption('--id <id>', 'unique server id, e.g. livekit-hub-docker')
659
+ .option('--name <name>', 'display name (defaults to --id)')
660
+ .requiredOption('--url <url>', 'LiveKit ws:// URL, e.g. ws://100.64.1.2:7880')
661
+ .option('--webhook-url <url>', 'internal http URL for health/metrics (defaults to --url with ws->http)')
662
+ .option('--api-key <key>', "LiveKit API key (defaults to 'devkey')")
663
+ .option('--api-secret <secret>', "LiveKit API secret (defaults to 'secret')")
664
+ .option('--max-sessions <n>', 'max concurrent sessions', '8')
665
+ .action(async (opts) => {
666
+ const { roboparkServerAdd } = await import('./robopark/server-add.js');
667
+ await roboparkServerAdd(opts);
668
+ });
669
+ program
670
+ .command('llm-set')
671
+ .description('Upsert LLM_PROVIDER/OLLAMA_* keys into a ROBOVOICE .env file')
672
+ .requiredOption('--env-path <path>', 'path to the target .env file (e.g. a ROBOVOICE checkout)')
673
+ .requiredOption('--provider <name>', 'ollama | ollama-cloud | groq')
674
+ .option('--model <name>', 'model name (ollama providers only)')
675
+ .option('--host <url>', 'Ollama server URL (ollama providers only)')
676
+ .option('--api-key <key>', 'API key for cloud auth (ollama-cloud; currently unused by ROBOVOICE — see CLI warning)')
677
+ .action(async (opts) => {
678
+ const { roboparkLlmSet } = await import('./robopark/llm-set.js');
679
+ await roboparkLlmSet(opts);
680
+ });
681
+ program
682
+ .command('hub-use')
683
+ .description('Save hub/scheduler/token/site as defaults so other commands stop needing them repeated')
684
+ .option('--hub-url <url>', 'hub federation URL, e.g. http://100.64.1.2:47913')
685
+ .option('--token <token>', 'shared mesh token')
686
+ .option('--scheduler-url <url>', 'scheduler base URL, e.g. http://100.64.1.2:8080')
687
+ .option('--site <site>', 'physical location, e.g. "Tel Aviv"')
688
+ .option('--clear', 'wipe the saved hub profile')
689
+ .action(async (opts) => {
690
+ if (opts.clear) {
691
+ clearHubProfile();
692
+ console.log(chalk.green(' ✓ cleared saved hub profile'));
693
+ return;
694
+ }
695
+ saveHubProfile({ hubUrl: opts.hubUrl, token: opts.token, schedulerUrl: opts.schedulerUrl, site: opts.site });
696
+ console.log(chalk.green(' ✓ saved hub profile to ~/.robopark/hub-profile.json'));
697
+ });
698
+ program
699
+ .command('add-robot <name>')
700
+ .description('Mint a scheduler enrollment token and print (or run) the robot setup one-liner')
701
+ .option('--site <site>', 'physical location, e.g. "Tel Aviv"')
702
+ .option('--hub-url <url>', 'hub federation URL (defaults to saved hub profile / auto-discovery)')
703
+ .option('--token <token>', 'shared mesh token (defaults to saved hub profile / auto-discovery)')
704
+ .option('--scheduler-url <url>', 'scheduler base URL (defaults to saved hub profile / auto-discovery)')
705
+ .option('--character <id>', 'force a specific park-map pad/character preset id (default: fuzzy-matched from the robot name)')
706
+ .option('--lan', 'generate a robot command for the same local network (default)')
707
+ .option('--tailscale', 'generate a robot command for a Tailscale-reachable hub')
708
+ .option('--start', 'start the robot node on this machine now, instead of printing the one-liner')
709
+ .option('--auto-start', 'register the robot runtime to start on boot')
710
+ .option('--yes', 'non-interactive production setup')
711
+ .action(async (name, opts) => {
712
+ await roboparkAddRobot(config, { name, ...opts });
713
+ });
714
+ program
715
+ .command('agent-up')
716
+ .description('Start the ROBOVOICE docker stack and register its LiveKit server with the scheduler')
717
+ .requiredOption('--path <dir>', 'path to the ROBOVOICE checkout (contains docker-compose.yaml)')
718
+ .option('--scheduler-url <url>', 'scheduler URL (defaults to resolved hub context)')
719
+ .option('--hub-url <url>', 'hub URL (defaults to resolved hub context)')
720
+ .option('--token <token>', 'mesh token')
721
+ .option('--server-id <id>', "server id to register (default: 'robovoice-<hostname>')")
722
+ .option('--server-name <name>', 'server display name')
723
+ .option('--no-register', 'bring up docker only; skip scheduler registration')
724
+ .action(roboparkAgentUp);
725
+ program
726
+ .command('agent-down')
727
+ .description('Stop the ROBOVOICE docker stack')
728
+ .requiredOption('--path <dir>', 'path to the ROBOVOICE checkout')
729
+ .action(roboparkAgentDown);
730
+ program
731
+ .command('agent-logs')
732
+ .description('Tail ROBOVOICE docker compose logs')
733
+ .requiredOption('--path <dir>', 'path to the ROBOVOICE checkout')
734
+ .option('-f, --follow', 'follow log output')
735
+ .option('--service <name>', 'scope logs to one compose service (e.g. agent, livekit)')
736
+ .action(roboparkAgentLogs);
737
+ program
738
+ .command('setup')
739
+ .description('Set up this device in one pass')
740
+ .option('--hub', 'this device is the on-site hub')
741
+ .option('--robot', 'this device is a robot')
742
+ .option('--control', 'this device is a control station')
743
+ .option('--voice-vision', 'this Windows device owns voice and vision')
744
+ .option('--motor-node', 'this Pi owns motors only')
745
+ .option('--role <role>', 'explicit role: hub | robot | voice-vision | motor | control')
746
+ .option('--name <name>', 'device name (defaults to hostname)')
747
+ .option('--site <site>', 'physical location, e.g. "Tel Aviv"')
748
+ .option('--hub-url <url>', 'hub base URL for robots/control')
749
+ .option('--token <token>', 'shared mesh token')
750
+ .option('--port <port>', 'mesh listen port', '47913')
751
+ .option('--gateway <url>', 'InfiniBot gateway ws:// URL')
752
+ .option('--gateway-token <token>', 'InfiniBot gateway token')
753
+ .option('--scheduler-port <port>', 'scheduler port on hub', '8080')
754
+ .option('--tailscale-auth <token>', 'Tailscale auth key for the hub')
755
+ .option('--tag <tag>', 'Tailscale tag filter')
756
+ .option('--enrollment-token <token>', 'one-time scheduler enrollment token for robots')
757
+ .option('--character <id>', 'character preset assigned during canonical device bootstrap')
758
+ .option('--lan', 'use LAN discovery and LAN hub/scheduler addresses (default for robots)')
759
+ .option('--tailscale', 'use Tailscale discovery and Tailscale hub/scheduler addresses')
760
+ .option('--video-device <dev>', 'default camera for robot preview agent', 'auto')
761
+ .option('--audio-device <dev>', 'default microphone for robot preview agent', 'default')
762
+ .option('--no-vision', 'do not start the vision (camera/motion trigger) agent for a robot — armed by default')
763
+ .option('--start', 'launch the node now')
764
+ .option('--auto-start', 'register to start on boot')
765
+ .option('--yes', 'non-interactive mode')
766
+ .action(async (opts) => {
767
+ const role = opts.role ?? (opts.hub ? 'hub' : opts.robot ? 'robot' : opts.voiceVision ? 'voice-vision' : opts.motorNode ? 'motor' : opts.control ? 'control' : undefined);
768
+ if (!role) {
769
+ console.log(chalk.red(' pass a role: --hub | --robot | --voice-vision | --motor-node | --control'));
770
+ process.exit(1);
771
+ }
772
+ if (!['hub', 'robot', 'voice-vision', 'motor', 'control'].includes(role))
773
+ throw new Error('invalid setup role');
774
+ await roboparkSetup(config, {
775
+ role,
776
+ name: opts.name,
777
+ site: opts.site,
778
+ hub: opts.hubUrl,
779
+ token: opts.token,
780
+ port: opts.port,
781
+ gateway: opts.gateway,
782
+ gatewayToken: opts.gatewayToken,
783
+ schedulerPort: opts.schedulerPort,
784
+ tailscaleAuth: opts.tailscaleAuth,
785
+ tag: opts.tag,
786
+ enrollmentToken: opts.enrollmentToken,
787
+ character: opts.character,
788
+ videoDevice: opts.videoDevice,
789
+ audioDevice: opts.audioDevice,
790
+ vision: opts.vision,
791
+ start: opts.start,
792
+ autoStart: opts.autoStart,
793
+ yes: opts.yes,
794
+ lan: opts.lan,
795
+ tailscale: opts.tailscale,
796
+ });
797
+ });
798
+ // Keep legacy fleet/run/apply/dashboard commands available while migrating.
799
+ await program.parseAsync(process.argv);