infinicode 2.8.54 → 2.8.56

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.
@@ -1324,9 +1324,21 @@ button.act:disabled{opacity:0.5;cursor:default;}
1324
1324
  var rows = data[0], devices = data[1];
1325
1325
  var deviceById = {}, deviceByName = {};
1326
1326
  function norm(v){ return String(v||'').trim().toLowerCase(); }
1327
+ // A stale re-enrollment can leave more than one row with the same
1328
+ // display name. Keep the live/fresh device, never whichever SQLite
1329
+ // happened to return last, because all operator routes are id-keyed.
1330
+ function deviceScore(device){
1331
+ var status=norm(device&&device.status), at=Date.parse(device&&device.last_heartbeat||''), score=0;
1332
+ if(status==='online'||status==='running'||status==='detecting'||status==='connecting') score+=10000;
1333
+ if(Number.isFinite(at)) score+=Math.max(0, Math.floor(at/1000));
1334
+ return score;
1335
+ }
1327
1336
  (Array.isArray(devices)?devices:[]).forEach(function(device){
1328
1337
  deviceById[device.id] = device;
1329
- if(device.name) deviceByName[norm(device.name)] = device;
1338
+ if(device.name){
1339
+ var key=norm(device.name), current=deviceByName[key];
1340
+ if(!current || deviceScore(device)>deviceScore(current)) deviceByName[key] = device;
1341
+ }
1330
1342
  });
1331
1343
  schedulerDevicesById = deviceById;
1332
1344
  schedulerDevicesByName = deviceByName;
@@ -1336,7 +1348,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
1336
1348
  // legacy robots table has not been populated for that device yet.
1337
1349
  (Array.isArray(devices)?devices:[]).forEach(function(device){
1338
1350
  var already = candidates.some(function(row){
1339
- return norm(row.id||row.robot_id)===norm(device.id) || norm(row.name)===norm(device.name);
1351
+ return norm(row.id||row.robot_id)===norm(device.id);
1340
1352
  });
1341
1353
  if(!already) candidates.push({id:device.id, name:device.name});
1342
1354
  });
@@ -5313,8 +5325,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
5313
5325
  // name, which is not necessarily the scheduler's robots.id (e.g. an
5314
5326
  // enrolled device's id is a generated dev_xxxx, distinct from its name).
5315
5327
  // Falls back to n.name if telemetry hasn't matched a row yet.
5316
- function schedRow(n){
5317
- var row=roboparkByName[String(n.name||'').toLowerCase()] || roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase()]);
5328
+ function schedRow(n){
5329
+ var preferred=schedulerDevice(n), preferredId=preferred&&preferred.id;
5330
+ var row=(preferredId&&roboparkByName[String(preferredId).toLowerCase()]) || roboparkByName[String(n.name||'').toLowerCase()] || roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase(),String(preferredId||'').toLowerCase()]);
5318
5331
  var seen={}, candidates=[];
5319
5332
  for(var rk in roboparkByName){
5320
5333
  var candidate=roboparkByName[rk], cid=candidate&&(candidate.robot_id||candidate.id);
@@ -5324,8 +5337,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
5324
5337
  for(var ki=0;ki<keys.length;ki++){ var key=String(keys[ki]||'').toLowerCase(); if(key&&(label===key||idlabel===key||label.indexOf(key)>-1||key.indexOf(label)>-1)){ matches=true; break; } }
5325
5338
  if(matches) candidates.push(candidate);
5326
5339
  }
5327
- function readyScore(candidate){
5328
- var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;
5340
+ function readyScore(candidate){
5341
+ var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;
5342
+ if(preferredId && String(candidate.robot_id||candidate.id||'')===String(preferredId)) score+=100000;
5329
5343
  if(candidate.production_mode===true||device.production_mode===true) score+=1000;
5330
5344
  if(status==='online'||status==='running'||status==='detecting') score+=500;
5331
5345
  if(candidate.readiness_code==='ready') score+=250;
@@ -5335,12 +5349,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
5335
5349
  candidates.sort(function(a,b){ return readyScore(b)-readyScore(a); });
5336
5350
  return candidates[0]||row;
5337
5351
  }
5338
- function schedId(n){
5339
- var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;
5340
- if(n.schedulerRobotId) return n.schedulerRobotId;
5341
- var byName=schedulerDevicesByName[String(n.name||'').toLowerCase()]; if(byName&&byName.id) return byName.id;
5342
- return n.name;
5343
- }
5352
+ function schedId(n){
5353
+ // Device ids are authoritative. A legacy robots row may share the
5354
+ // name but have no preview, heartbeat, or hardware inventory.
5355
+ var device=schedulerDevice(n); if(device&&device.id) return device.id;
5356
+ var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;
5357
+ if(n.schedulerRobotId) return n.schedulerRobotId;
5358
+ return n.name;
5359
+ }
5344
5360
  function closeDrawer(){
5345
5361
  // tell the scheduler to end any live preview so the robot stops its cam
5346
5362
  if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }
@@ -5474,10 +5490,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
5474
5490
  refreshDrawer();
5475
5491
  }).catch(function(){});
5476
5492
  }
5477
- function schedulerDevice(n){
5478
- var row=schedRow(n), id=row&&(row.robot_id||row.id);
5479
- return (id&&schedulerDevicesById[id]) || schedulerDevicesById[n.schedulerDeviceId] || schedulerDevicesById[n.schedulerRobotId] || schedulerDevicesByName[String(n.name||'').toLowerCase()] || schedulerDevicesById[n.name] || null;
5480
- }
5493
+ function schedulerDevice(n){
5494
+ // Deliberately does not call schedRow: telemetry can contain a legacy
5495
+ // robot name, while this lookup must choose the current enrolled device.
5496
+ var ids=[n.schedulerDeviceId,n.schedulerRobotId,n.id,n.nodeId];
5497
+ for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]]) return schedulerDevicesById[ids[i]];
5498
+ var names=[n.name,n.displayName,n.label];
5499
+ for(var j=0;j<names.length;j++){
5500
+ var key=String(names[j]||'').toLowerCase();
5501
+ if(key&&schedulerDevicesByName[key]) return schedulerDevicesByName[key];
5502
+ }
5503
+ return null;
5504
+ }
5481
5505
  function deviceList(inv, key, fallback){
5482
5506
  var list=inv&&Array.isArray(inv[key])?inv[key]:[];
5483
5507
  return list.length?list:fallback.map(function(id){ return {id:id,name:id}; });
@@ -1,5 +1,5 @@
1
1
  export interface ServiceArgs {
2
- role: 'hub' | 'robot' | 'control' | 'hub-scheduler' | 'robot-preview-agent' | 'robot-vision-agent';
2
+ role: 'hub' | 'robot' | 'control' | 'hub-scheduler' | 'robot-runtime' | 'robot-preview-agent' | 'robot-vision-agent';
3
3
  name: string;
4
4
  command: string;
5
5
  args: string[];
@@ -8,10 +8,10 @@
8
8
  *
9
9
  * This is used by `robopark setup --auto-start`.
10
10
  */
11
- import { writeFileSync, mkdirSync } from 'node:fs';
11
+ import { existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join } from 'node:path';
14
- import { spawn } from 'node:child_process';
14
+ import { spawn, spawnSync } from 'node:child_process';
15
15
  function shellQuote(s) {
16
16
  if (!/[^a-zA-Z0-9_./:=,-]/.test(s))
17
17
  return s;
@@ -138,5 +138,31 @@ export function unregisterAutoStart(role, name) {
138
138
  return { ok: false, message: `failed: ${e instanceof Error ? e.message : String(e)}` };
139
139
  }
140
140
  }
141
- return { ok: false, message: `unregister not implemented for ${platform}` };
141
+ if (platform === 'linux') {
142
+ const unitName = `robopark-${role}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.service`;
143
+ const unitPath = `/etc/systemd/system/${unitName}`;
144
+ const stopped = spawnSync('systemctl', ['disable', '--now', unitName], { encoding: 'utf8' });
145
+ try {
146
+ if (existsSync(unitPath))
147
+ rmSync(unitPath);
148
+ }
149
+ catch { /* systemd output is more useful below */ }
150
+ spawnSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
151
+ return {
152
+ ok: stopped.status === 0 || !existsSync(unitPath),
153
+ message: `removed systemd unit ${unitName}`,
154
+ };
155
+ }
156
+ if (platform === 'darwin') {
157
+ const label = `ai.robopark.${role}.${name}`;
158
+ const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
159
+ spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
160
+ try {
161
+ if (existsSync(plistPath))
162
+ rmSync(plistPath);
163
+ }
164
+ catch { /* ignore */ }
165
+ return { ok: true, message: `removed launchd plist ${plistPath}` };
166
+ }
167
+ return { ok: false, message: `unregister not supported on ${platform}` };
142
168
  }
@@ -0,0 +1,14 @@
1
+ export interface RobotRuntimeOptions {
2
+ name: string;
3
+ hubUrl: string;
4
+ schedulerUrl: string;
5
+ token: string;
6
+ port?: string;
7
+ network?: 'lan' | 'tailscale';
8
+ enrollmentToken?: string;
9
+ videoDevice?: string;
10
+ audioDevice?: string;
11
+ vision?: boolean;
12
+ foreground?: boolean;
13
+ }
14
+ export declare function roboparkRobotRuntime(opts: RobotRuntimeOptions): Promise<void>;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * RoboPark robot runtime.
3
+ *
4
+ * One long-lived parent for a robot's mesh satellite, RoboVision camera/audio
5
+ * service, and preview agent. Starting these as unrelated detached commands
6
+ * allowed a healthy-looking mesh node with no motion service or no device
7
+ * inventory. The runtime starts dependencies in order and restarts a child if
8
+ * it exits unexpectedly.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ import { existsSync, createWriteStream, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import chalk from 'chalk';
16
+ import { resolveInfinicodeBin } from './serve.js';
17
+ const VISION_URL = 'http://127.0.0.1:5000/api/media/inventory';
18
+ const RESTART_DELAY_MS = 5_000;
19
+ function resetStaleEnrollment() {
20
+ const configDir = join(homedir(), '.robopark');
21
+ const tokenPath = join(configDir, 'device_token');
22
+ const configPath = join(configDir, 'preview_agent.json');
23
+ try {
24
+ if (existsSync(tokenPath))
25
+ rmSync(tokenPath);
26
+ }
27
+ catch { /* enrollment will report a real error */ }
28
+ if (!existsSync(configPath))
29
+ return;
30
+ try {
31
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
32
+ delete config.device_id;
33
+ delete config.device_token;
34
+ writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
35
+ }
36
+ catch {
37
+ // A malformed optional config must not block first boot.
38
+ }
39
+ }
40
+ function roboparkCliPath() {
41
+ // dist/robopark/robot-runtime.js -> package root -> dist/robopark-cli.js
42
+ const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
43
+ const compiledCli = join(packageRoot, 'dist', 'robopark-cli.js');
44
+ return existsSync(compiledCli) ? compiledCli : process.argv[1];
45
+ }
46
+ function runtimeLog(label, foreground) {
47
+ if (foreground)
48
+ return 'inherit';
49
+ const logDir = join(homedir(), '.robopark', 'logs');
50
+ mkdirSync(logDir, { recursive: true });
51
+ return [
52
+ 'ignore',
53
+ createWriteStream(join(logDir, `${label}.log`), { flags: 'a' }),
54
+ createWriteStream(join(logDir, `${label}.err.log`), { flags: 'a' }),
55
+ ];
56
+ }
57
+ async function waitForVision() {
58
+ const deadline = Date.now() + 120_000;
59
+ while (Date.now() < deadline) {
60
+ try {
61
+ const response = await fetch(VISION_URL, { signal: AbortSignal.timeout(1_500) });
62
+ if (response.ok)
63
+ return true;
64
+ }
65
+ catch {
66
+ // Dependency installation on a fresh Pi can take a little while.
67
+ }
68
+ await new Promise(resolve => setTimeout(resolve, 1_000));
69
+ }
70
+ return false;
71
+ }
72
+ export async function roboparkRobotRuntime(opts) {
73
+ const port = opts.port ?? '47913';
74
+ const network = opts.network ?? 'lan';
75
+ const foreground = opts.foreground === true;
76
+ const cli = roboparkCliPath();
77
+ const children = [];
78
+ let stopping = false;
79
+ const infinicode = await resolveInfinicodeBin();
80
+ if (!infinicode)
81
+ throw new Error('could not resolve infinicode CLI');
82
+ children.push({
83
+ label: 'mesh',
84
+ command: infinicode.node,
85
+ args: [infinicode.script, 'serve', '--role', 'satellite', '--name', opts.name,
86
+ '--port', port, '--token', opts.token, '--seed', opts.hubUrl,
87
+ network === 'tailscale' ? '--tailscale' : '--lan', '--auto-update'],
88
+ });
89
+ if (opts.vision !== false) {
90
+ children.push({
91
+ label: 'vision',
92
+ command: process.execPath,
93
+ args: [cli, 'vision-agent', '--foreground', '--port', '5000',
94
+ '--motion-webhook-url', 'http://127.0.0.1:5057/'],
95
+ });
96
+ }
97
+ const previewArgs = [cli, 'preview-agent', '--foreground',
98
+ '--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
99
+ '--video-device', opts.videoDevice ?? 'auto', '--audio-device', opts.audioDevice ?? 'default',
100
+ '--save-config'];
101
+ if (opts.vision !== false)
102
+ previewArgs.push('--robovision-url', 'http://127.0.0.1:5000');
103
+ if (opts.enrollmentToken)
104
+ previewArgs.push('--enrollment-token', opts.enrollmentToken);
105
+ const preview = { label: 'preview', command: process.execPath, args: previewArgs };
106
+ const start = (entry) => {
107
+ const child = spawn(entry.command, entry.args, {
108
+ env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
109
+ stdio: runtimeLog(`robot-${opts.name}-${entry.label}`, foreground),
110
+ });
111
+ entry.child = child;
112
+ child.on('error', error => console.error(chalk.red(` ${entry.label} failed to start: ${error.message}`)));
113
+ child.on('exit', (code, signal) => {
114
+ entry.child = undefined;
115
+ if (stopping)
116
+ return;
117
+ console.error(chalk.yellow(` ${entry.label} exited (${signal ?? code ?? 'unknown'}); restarting in 5s`));
118
+ setTimeout(() => { if (!stopping)
119
+ start(entry); }, RESTART_DELAY_MS);
120
+ });
121
+ };
122
+ console.log(chalk.bold('\n robopark robot up'));
123
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
124
+ console.log(` robot: ${chalk.cyan(opts.name)}`);
125
+ console.log(` scheduler: ${chalk.cyan(opts.schedulerUrl)}`);
126
+ console.log(` network: ${chalk.cyan(network)}`);
127
+ console.log(` services: ${opts.vision === false ? 'mesh + preview' : 'mesh + RoboVision + preview'}`);
128
+ console.log();
129
+ if (opts.enrollmentToken) {
130
+ console.log(chalk.dim(' clearing stale device identity for fresh enrollment…'));
131
+ resetStaleEnrollment();
132
+ }
133
+ // The mesh can reconnect independently; camera/audio must be healthy before
134
+ // preview sends its first inventory heartbeat or opens the motion webhook.
135
+ start(children[0]);
136
+ if (opts.vision !== false) {
137
+ start(children[1]);
138
+ console.log(chalk.dim(' waiting for RoboVision camera/audio inventory…'));
139
+ if (!await waitForVision()) {
140
+ stopping = true;
141
+ for (const entry of children)
142
+ entry.child?.kill('SIGTERM');
143
+ throw new Error('RoboVision did not become healthy at http://127.0.0.1:5000 within 120s');
144
+ }
145
+ console.log(chalk.green(' ✓ RoboVision ready; starting preview agent'));
146
+ }
147
+ start(preview);
148
+ const shutdown = () => {
149
+ stopping = true;
150
+ for (const entry of [...children, preview])
151
+ entry.child?.kill('SIGTERM');
152
+ };
153
+ process.once('SIGINT', shutdown);
154
+ process.once('SIGTERM', shutdown);
155
+ await new Promise(resolve => {
156
+ const check = setInterval(() => {
157
+ if (stopping && ![...children, preview].some(entry => entry.child)) {
158
+ clearInterval(check);
159
+ resolve();
160
+ }
161
+ }, 250);
162
+ });
163
+ }
@@ -9,12 +9,12 @@
9
9
  * robopark setup --control --start
10
10
  */
11
11
  import chalk from 'chalk';
12
- import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
12
+ import { mkdirSync, writeFileSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
14
  import { join } from 'node:path';
15
15
  import { spawn } from 'node:child_process';
16
16
  import { discoverContext, generateToken } from './discovery.js';
17
- import { registerAutoStart } from './auto-start.js';
17
+ import { registerAutoStart, unregisterAutoStart } from './auto-start.js';
18
18
  import { resolveInfinicodeBin } from './serve.js';
19
19
  const DEFAULT_MESH_PORT = 47913;
20
20
  const DEFAULT_SCHEDULER_PORT = 8080;
@@ -23,43 +23,6 @@ function saveToken(token) {
23
23
  mkdirSync(dir, { recursive: true });
24
24
  writeFileSync(join(dir, 'mesh.token'), token, 'utf8');
25
25
  }
26
- /**
27
- * Clear a stale device identity before a fresh enrollment.
28
- *
29
- * preview_agent.py only re-enrolls (POSTs /api/devices/enroll) when it has
30
- * NO device_token. If ~/.robopark/device_token and ~/.robopark/preview_agent.json
31
- * survive from a previous enrollment (e.g. against a different scheduler),
32
- * PreviewAgent.__init__ loads that old token/device_id and skips enrollment
33
- * entirely — silently reusing a stale identity, which then 401s against the
34
- * new target scheduler. An explicit --enrollment-token means the caller
35
- * wants a fresh enrollment, not to reuse a stale identity from a previous
36
- * target scheduler, so wipe the persisted device_token/device_id (and the
37
- * token file) whenever one is passed.
38
- */
39
- function resetStaleEnrollment() {
40
- const dir = join(homedir(), '.robopark');
41
- const configPath = join(dir, 'preview_agent.json');
42
- const tokenPath = join(dir, 'device_token');
43
- if (existsSync(tokenPath)) {
44
- try {
45
- rmSync(tokenPath);
46
- }
47
- catch (err) {
48
- console.log(chalk.yellow(` ⚠ could not clear stale device token: ${err.message}`));
49
- }
50
- }
51
- if (existsSync(configPath)) {
52
- try {
53
- const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
54
- delete cfg.device_id;
55
- delete cfg.device_token;
56
- writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8');
57
- }
58
- catch (err) {
59
- console.log(chalk.yellow(` ⚠ could not clear stale enrollment config: ${err.message}`));
60
- }
61
- }
62
- }
63
26
  /** Run an external command and detach unless foreground is requested. */
64
27
  function runDetached(cmd, args, env) {
65
28
  const proc = spawn(cmd, args, {
@@ -193,16 +156,6 @@ async function setupRobot(config, opts, ctx, internal) {
193
156
  lan: network === 'lan',
194
157
  };
195
158
  config.set('federation', fed);
196
- const args = [
197
- 'serve', '--role', 'satellite',
198
- '--name', internal.name,
199
- '--port', String(internal.port),
200
- '--token', internal.token,
201
- '--seed', hubUrl,
202
- networkFlag,
203
- '--auto-update',
204
- '--supervised',
205
- ];
206
159
  // Derive the scheduler host from the SAME hubUrl used for the mesh seed
207
160
  // (respects an explicit --hub-url), not from ctx.hub — using auto-discovery
208
161
  // here independently of the mesh seed meant an explicit --hub-url only
@@ -211,86 +164,46 @@ async function setupRobot(config, opts, ctx, internal) {
211
164
  // found (e.g. a real hub reachable over Tailscale), not the one requested.
212
165
  const hubHost = new URL(hubUrl).hostname;
213
166
  const schedulerUrl = `http://${hubHost}:${opts.schedulerPort ? parseInt(opts.schedulerPort, 10) : DEFAULT_SCHEDULER_PORT}`;
214
- const previewAgentArgs = [
215
- 'preview-agent',
167
+ // Vision (camera/motion detection -> presence-triggered session) is the
168
+ // production trigger — on by default. --no-vision opts out (e.g. no camera
169
+ // on this box, or RoboVisionAI_PI isn't installed).
170
+ const visionEnabled = opts.vision !== false;
171
+ const runtimeArgs = [
172
+ 'robot', 'up',
173
+ '--name', internal.name,
174
+ '--hub-url', hubUrl,
216
175
  '--scheduler-url', schedulerUrl,
217
- '--robot-id', internal.name,
176
+ '--token', internal.token,
177
+ '--port', String(internal.port),
178
+ networkFlag,
218
179
  '--video-device', opts.videoDevice ?? 'auto',
219
180
  '--audio-device', opts.audioDevice ?? 'default',
220
- '--save-config',
221
181
  ];
222
182
  if (opts.enrollmentToken)
223
- previewAgentArgs.push('--enrollment-token', opts.enrollmentToken);
224
- // Vision (camera/motion detection -> presence-triggered session) is the
225
- // production trigger — on by default. --no-vision opts out (e.g. no camera
226
- // on this box, or RoboVisionAI_PI isn't installed).
227
- const visionEnabled = opts.vision !== false;
228
- if (visionEnabled)
229
- previewAgentArgs.push('--robovision-url', 'http://127.0.0.1:5000');
230
- const visionAgentArgs = ['vision-agent'];
231
- console.log(chalk.dim(' robot command:'));
232
- console.log(' ' + chalk.cyan(`infinicode ${args.join(' ')}`));
233
- console.log(' ' + chalk.cyan(`robopark ${previewAgentArgs.join(' ')}`));
234
- if (visionEnabled)
235
- console.log(' ' + chalk.cyan(`robopark ${visionAgentArgs.join(' ')}`));
183
+ runtimeArgs.push('--enrollment-token', opts.enrollmentToken);
184
+ if (!visionEnabled)
185
+ runtimeArgs.push('--no-vision');
186
+ console.log(chalk.dim(' single robot command:'));
187
+ console.log(' ' + chalk.cyan(`robopark ${runtimeArgs.join(' ')}`));
236
188
  console.log(chalk.dim(` network: ${network} (scheduler: ${schedulerUrl})`));
237
189
  console.log();
238
190
  if (opts.start) {
239
- const robotArgv = await infinicodeArgv(args);
240
- console.log(chalk.dim(' starting robot satellite node…'));
191
+ const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs];
192
+ console.log(chalk.dim(' starting unified robot runtime…'));
241
193
  runDetached(robotArgv[0], robotArgv.slice(1));
242
- // NOTE: there is deliberately no separate `robopark enroll` spawn here.
243
- // preview_agent.py's own startup (PreviewAgent.start(), see
244
- // packages/robopark/scheduler/preview_agent.py) already performs
245
- // enrollment itself whenever it has an --enrollment-token and no
246
- // device_token yet. Spawning `robopark enroll` as well used to launch a
247
- // SECOND full preview_agent.py process (enroll.ts runs the whole script,
248
- // not a one-shot enroll-and-exit) with a different arg set (no
249
- // --video-device/--audio-device/vision flags), which raced the "real"
250
- // preview-agent process below for the same camera/mic and the vision
251
- // webhook port (5057). --enrollment-token is already forwarded via
252
- // previewAgentArgs, so one process below covers both enrollment and the
253
- // long-running agent.
254
- if (opts.enrollmentToken) {
255
- console.log(chalk.dim(' fresh --enrollment-token given — clearing any stale device identity…'));
256
- resetStaleEnrollment();
257
- }
258
- console.log(chalk.dim(' starting preview agent…'));
259
- const previewArgv = [process.execPath, process.argv[1], ...previewAgentArgs];
260
- runDetached(previewArgv[0], previewArgv.slice(1));
261
- if (visionEnabled) {
262
- console.log(chalk.dim(' starting vision agent…'));
263
- const visionArgv = [process.execPath, process.argv[1], ...visionAgentArgs];
264
- runDetached(visionArgv[0], visionArgv.slice(1));
265
- }
266
194
  }
267
195
  if (opts.autoStart) {
268
- const robotArgv = await infinicodeArgv(args);
196
+ const robotArgv = [process.execPath, process.argv[1], ...runtimeArgs, '--foreground'];
197
+ for (const role of ['robot', 'robot-preview-agent', 'robot-vision-agent']) {
198
+ unregisterAutoStart(role, internal.name);
199
+ }
269
200
  const reg = await registerAutoStart({
270
- role: 'robot',
201
+ role: 'robot-runtime',
271
202
  name: internal.name,
272
203
  command: robotArgv[0],
273
204
  args: robotArgv.slice(1),
274
205
  });
275
206
  console.log(reg.ok ? chalk.green(` ✓ ${reg.message}`) : chalk.yellow(` ⚠ ${reg.message}`));
276
- const previewArgv = [process.execPath, process.argv[1], ...previewAgentArgs, '--foreground'];
277
- const reg2 = await registerAutoStart({
278
- role: 'robot-preview-agent',
279
- name: internal.name,
280
- command: previewArgv[0],
281
- args: previewArgv.slice(1),
282
- });
283
- console.log(reg2.ok ? chalk.green(` ✓ ${reg2.message}`) : chalk.yellow(` ⚠ ${reg2.message}`));
284
- if (visionEnabled) {
285
- const visionArgv = [process.execPath, process.argv[1], ...visionAgentArgs, '--foreground'];
286
- const reg3 = await registerAutoStart({
287
- role: 'robot-vision-agent',
288
- name: internal.name,
289
- command: visionArgv[0],
290
- args: visionArgv.slice(1),
291
- });
292
- console.log(reg3.ok ? chalk.green(` ✓ ${reg3.message}`) : chalk.yellow(` ⚠ ${reg3.message}`));
293
- }
294
207
  }
295
208
  }
296
209
  async function setupControl(config, opts, ctx, internal) {
@@ -18,6 +18,9 @@ const PATTERNS = [
18
18
  'cli.js serve', // source/dev invocation: `node dist/cli.js serve ...`
19
19
  'robopark serve',
20
20
  'robopark-cli.js serve',
21
+ 'robopark-cli.js robot-run',
22
+ 'robopark-cli.js robot up',
23
+ 'robopark.js robot up',
21
24
  // `robopark serve` detaches this child, so the Node launcher is gone while
22
25
  // the Python scheduler remains. Match both Windows and POSIX separators.
23
26
  'scheduler.{0,3}main\\.py',
@@ -21,6 +21,7 @@ import { roboparkAddRobot } from './robopark/add-robot.js';
21
21
  import { saveHubProfile, clearHubProfile } from './robopark/profile.js';
22
22
  import { roboparkAgentUp, roboparkAgentDown, roboparkAgentLogs } from './robopark/agent-ctl.js';
23
23
  import { roboparkSecrets } from './robopark/secrets.js';
24
+ import { roboparkRobotRuntime } from './robopark/robot-runtime.js';
24
25
  // Read the real version from package.json so `--version` never drifts from
25
26
  // what is actually installed (this was a hardcoded constant that went stale
26
27
  // across several releases — see the identical fix + comment in cli.ts).
@@ -157,6 +158,42 @@ program
157
158
  const { roboparkVisionAgent } = await import('./robopark/vision-agent-launcher.js');
158
159
  await roboparkVisionAgent(opts);
159
160
  });
161
+ const robotProgram = program
162
+ .command('robot')
163
+ .description('Run the complete robot runtime as one supervised service');
164
+ robotProgram
165
+ .command('up')
166
+ .description('Start mesh, RoboVision, audio, motion webhook, and preview agent in dependency order')
167
+ .requiredOption('--name <name>', 'robot name')
168
+ .requiredOption('--hub-url <url>', 'hub mesh URL, e.g. http://192.168.1.12:47913')
169
+ .requiredOption('--token <token>', 'shared mesh token')
170
+ .option('--scheduler-url <url>', 'scheduler URL (derived from --hub-url when omitted)')
171
+ .option('--enrollment-token <token>', 'one-time scheduler enrollment token for a fresh device')
172
+ .option('--port <port>', 'robot mesh port', '47913')
173
+ .option('--lan', 'use LAN transport (default)')
174
+ .option('--tailscale', 'use Tailscale transport')
175
+ .option('--video-device <dev>', 'camera selection', 'auto')
176
+ .option('--audio-device <dev>', 'microphone selection', 'default')
177
+ .option('--no-vision', 'do not start RoboVision camera/audio/motion services')
178
+ .option('--foreground', 'keep logs in this terminal instead of ~/.robopark/logs')
179
+ .action(async (opts) => {
180
+ if (opts.lan && opts.tailscale)
181
+ throw new Error('choose exactly one of --lan or --tailscale');
182
+ const hub = new URL(opts.hubUrl);
183
+ await roboparkRobotRuntime({
184
+ name: opts.name,
185
+ hubUrl: opts.hubUrl,
186
+ schedulerUrl: opts.schedulerUrl ?? `http://${hub.hostname}:8080`,
187
+ token: opts.token,
188
+ port: opts.port,
189
+ network: opts.tailscale ? 'tailscale' : 'lan',
190
+ enrollmentToken: opts.enrollmentToken,
191
+ videoDevice: opts.videoDevice,
192
+ audioDevice: opts.audioDevice,
193
+ vision: opts.vision,
194
+ foreground: opts.foreground,
195
+ });
196
+ });
160
197
  program
161
198
  .command('stop')
162
199
  .description('Stop every infinicode/robopark process on this device AND remove any supervisor (systemd/Task Scheduler/launchd) that would respawn it')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.54",
3
+ "version": "2.8.56",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",