robopark 2.8.36 → 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,305 @@
1
+ /**
2
+ * RoboPark — `robopark agent up/down/logs`.
3
+ *
4
+ * ROBOVOICE (the production voice stack: LiveKit + STT/TTS + agent) today
5
+ * requires an operator to manually `docker compose up -d` in that checkout,
6
+ * then manually `curl POST /api/servers` on the RoboPark scheduler to
7
+ * register the LiveKit instance it brought up. This file collapses both
8
+ * steps into one command so a hub/GPU machine only needs:
9
+ *
10
+ * robopark setup --role hub
11
+ * robopark agent-up --path C:\path\to\ROBOVOICE
12
+ *
13
+ * to be fully wired end to end.
14
+ *
15
+ * Reuses `roboparkServerAdd` (server-add.ts) for the exact POST /api/servers
16
+ * shape, and `resolveContext` (profile.ts) for hub/scheduler/token
17
+ * resolution with saved hub-profile fallback — nothing about the
18
+ * registration mechanics is reimplemented here.
19
+ */
20
+ import { spawn, execFile } from 'node:child_process';
21
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { hostname, networkInterfaces } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import chalk from 'chalk';
25
+ import { roboparkServerAdd } from './server-add.js';
26
+ import { resolveContext } from './profile.js';
27
+ const LIVEKIT_PORT = 7880;
28
+ const LIVEKIT_DEV_API_KEY = 'devkey';
29
+ const LIVEKIT_DEV_API_SECRET = 'secret';
30
+ /** This machine's first non-internal IPv4 LAN address, if any. */
31
+ function getLanIps() {
32
+ const out = [];
33
+ for (const addrs of Object.values(networkInterfaces())) {
34
+ if (!addrs)
35
+ continue;
36
+ for (const a of addrs) {
37
+ if (a.family === 'IPv4' && !a.internal)
38
+ out.push(a.address);
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+ /** Run `cmd version` quietly; resolve true if it exits 0. */
44
+ function commandWorks(cmd, args) {
45
+ return new Promise((resolve) => {
46
+ execFile(cmd, args, { timeout: 8000 }, (err) => resolve(!err));
47
+ });
48
+ }
49
+ /**
50
+ * Detect whether to use Docker Compose v2 (`docker compose`, space-separated
51
+ * subcommand) or the legacy v1 standalone `docker-compose` binary. v2 is
52
+ * tried first since it's bundled with modern Docker Desktop/Engine; v1 is
53
+ * the fallback for older hosts that only have the standalone binary.
54
+ */
55
+ async function resolveComposeCommand() {
56
+ if (await commandWorks('docker', ['compose', 'version'])) {
57
+ return { cmd: 'docker', baseArgs: ['compose'] };
58
+ }
59
+ if (await commandWorks('docker-compose', ['version'])) {
60
+ return { cmd: 'docker-compose', baseArgs: [] };
61
+ }
62
+ return null;
63
+ }
64
+ /** Spawn a compose subcommand, streaming stdout/stderr to the console. */
65
+ function runCompose(compose, args, cwd) {
66
+ return new Promise((resolve, reject) => {
67
+ const proc = spawn(compose.cmd, [...compose.baseArgs, ...args], {
68
+ cwd,
69
+ stdio: 'inherit',
70
+ });
71
+ proc.on('error', reject);
72
+ proc.on('close', (code) => resolve(code ?? 1));
73
+ });
74
+ }
75
+ function sleep(ms) {
76
+ return new Promise((resolve) => setTimeout(resolve, ms));
77
+ }
78
+ /** Poll http://localhost:<port> until it responds (any HTTP status counts as "up" — LiveKit's bare HTTP port returns 404 for GET, which is fine). */
79
+ async function waitForLivekitHealthy(port, timeoutMs, intervalMs) {
80
+ const deadline = Date.now() + timeoutMs;
81
+ while (Date.now() < deadline) {
82
+ try {
83
+ const res = await fetch(`http://localhost:${port}/`, { signal: AbortSignal.timeout(2000) });
84
+ // LiveKit's plain HTTP listener answers (even 4xx) once the process is up.
85
+ if (res.status < 500)
86
+ return true;
87
+ }
88
+ catch {
89
+ // not up yet — fall through to retry
90
+ }
91
+ await sleep(intervalMs);
92
+ }
93
+ return false;
94
+ }
95
+ /** Validate that `path` looks like a ROBOVOICE checkout (has a docker-compose.yaml). */
96
+ function validateComposePath(path) {
97
+ const candidates = ['docker-compose.yaml', 'docker-compose.yml'];
98
+ for (const name of candidates) {
99
+ const full = join(path, name);
100
+ if (existsSync(full))
101
+ return full;
102
+ }
103
+ throw new Error(`no docker-compose.yaml/.yml found in '${path}'. Pass --path pointing at a ROBOVOICE checkout ` +
104
+ `(the directory containing docker-compose.yaml), not this repo.`);
105
+ }
106
+ /** Persist the single-track greeting fix in a ROBOVOICE checkout. */
107
+ function ensureRobovoiceSingleAudioTrack(path) {
108
+ const candidates = [
109
+ join(path, 'voice_agent.py'),
110
+ join(path, 'app', 'voice_agent.py'),
111
+ join(path, 'src', 'voice_agent.py'),
112
+ ];
113
+ const file = candidates.find((candidate) => existsSync(candidate));
114
+ if (!file) {
115
+ return { patched: false, warning: 'voice_agent.py was not found; skipping RoboPark greeting compatibility patch' };
116
+ }
117
+ const source = readFileSync(file, 'utf8');
118
+ let updated = source;
119
+ let patched = false;
120
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
121
+ const singleTrackMarker = 'Initial greeting completed through AgentSession audio track';
122
+ const startMarker = ' # Synthesize the greeting first';
123
+ const endMarker = ' except Exception as e:';
124
+ if (!updated.includes(singleTrackMarker)) {
125
+ const start = updated.indexOf(startMarker);
126
+ const end = start >= 0 ? updated.indexOf(endMarker, start) : -1;
127
+ if (start < 0 || end < 0) {
128
+ return {
129
+ patched: false,
130
+ file,
131
+ warning: 'voice_agent.py does not contain the known duplicate greeting-track block; no automatic patch applied',
132
+ };
133
+ }
134
+ const replacement = [
135
+ ' # Use the existing conversation track. A dedicated greeting',
136
+ ' # track makes Linux robots compete for the same ALSA device.',
137
+ ' import inspect as _inspect',
138
+ ' speech = session.say(greeting, allow_interruptions=False)',
139
+ ' if _inspect.isawaitable(speech):',
140
+ ' await speech',
141
+ " elif hasattr(speech, 'wait_for_playout'):",
142
+ ' await speech.wait_for_playout()',
143
+ " logger.info('Initial greeting completed through AgentSession audio track')",
144
+ '',
145
+ ].join(newline);
146
+ updated = updated.slice(0, start) + replacement + updated.slice(end);
147
+ patched = true;
148
+ }
149
+ // The job is dispatched after the robot participant joins. Waiting for its
150
+ // camera adds camera startup to voice latency even though audio is ready.
151
+ const cameraGate = / await asyncio\.wait_for\(robot_camera_ready\.wait\(\), timeout\s*=\s*[0-9.]+\)/;
152
+ if (cameraGate.test(updated)) {
153
+ updated = updated.replace(cameraGate, [
154
+ ' # The robot is already in the room when this job is dispatched.',
155
+ ' # Yield once for subscription signalling; do not gate voice on camera.',
156
+ ' await asyncio.sleep(0)',
157
+ ].join(newline));
158
+ patched = true;
159
+ }
160
+ if (patched)
161
+ writeFileSync(file, updated, 'utf8');
162
+ return { patched, file };
163
+ }
164
+ export async function roboparkAgentUp(opts) {
165
+ console.log(chalk.bold('\n robopark agent up'));
166
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
167
+ if (!existsSync(opts.path)) {
168
+ console.log(chalk.red(` ✗ path does not exist: ${opts.path}`));
169
+ process.exit(1);
170
+ }
171
+ try {
172
+ validateComposePath(opts.path);
173
+ }
174
+ catch (e) {
175
+ console.log(chalk.red(` ✗ ${e.message}`));
176
+ process.exit(1);
177
+ }
178
+ const compose = await resolveComposeCommand();
179
+ if (!compose) {
180
+ console.log(chalk.red(' ✗ neither `docker compose` (v2) nor `docker-compose` (v1) is available on PATH.'));
181
+ process.exit(1);
182
+ }
183
+ console.log(chalk.dim(` using: ${compose.cmd} ${compose.baseArgs.join(' ')}`.trimEnd()));
184
+ console.log(` path: ${chalk.cyan(opts.path)}`);
185
+ console.log();
186
+ const greetingPatch = ensureRobovoiceSingleAudioTrack(opts.path);
187
+ if (greetingPatch.patched) {
188
+ console.log(chalk.green(` ✓ persisted single-track RoboPark greeting in ${greetingPatch.file}`));
189
+ }
190
+ else if (greetingPatch.warning) {
191
+ console.log(chalk.yellow(` ⚠ ${greetingPatch.warning}`));
192
+ }
193
+ console.log(chalk.bold(' 1. starting ROBOVOICE docker stack (docker compose up -d)…'));
194
+ // Rebuild only when compatibility source changed, making the fix survive
195
+ // container recreation without slowing normal restarts.
196
+ const upArgs = greetingPatch.patched ? ['up', '-d', '--build'] : ['up', '-d'];
197
+ const upCode = await runCompose(compose, upArgs, opts.path);
198
+ if (upCode !== 0) {
199
+ console.log(chalk.red(`\n ✗ docker compose up -d exited with code ${upCode}`));
200
+ process.exit(upCode);
201
+ }
202
+ console.log(chalk.green(' ✓ docker compose up -d completed'));
203
+ console.log();
204
+ console.log(chalk.bold(` 2. waiting for LiveKit to become healthy on :${LIVEKIT_PORT} (up to 60s)…`));
205
+ const healthy = await waitForLivekitHealthy(LIVEKIT_PORT, 60_000, 3_000);
206
+ if (!healthy) {
207
+ console.log(chalk.red(` ✗ LiveKit did not respond on http://localhost:${LIVEKIT_PORT} within 60s.`));
208
+ console.log(chalk.dim(` check container health: ${compose.cmd} ${compose.baseArgs.join(' ')} ps`.trim()));
209
+ process.exit(1);
210
+ }
211
+ console.log(chalk.green(` ✓ LiveKit is up on :${LIVEKIT_PORT}`));
212
+ if (opts.register === false) {
213
+ console.log();
214
+ console.log(chalk.dim(' --no-register passed; skipping scheduler registration.'));
215
+ console.log(chalk.green('\n ✓ robopark agent up complete (docker only)\n'));
216
+ return;
217
+ }
218
+ console.log();
219
+ console.log(chalk.bold(' 3. registering LiveKit server with the RoboPark scheduler…'));
220
+ const ctx = await resolveContext({ hubUrl: opts.hubUrl, token: opts.token, schedulerUrl: opts.schedulerUrl });
221
+ if (!ctx.schedulerUrl) {
222
+ console.log(chalk.red(' ✗ no scheduler URL resolved. Pass --scheduler-url/--hub-url, or run `robopark setup --role hub` first.'));
223
+ process.exit(1);
224
+ }
225
+ // Advertise this machine's own reachable address. This is a best-effort
226
+ // guess (first non-internal LAN IPv4); if the scheduler runs on a
227
+ // *different* machine than the one running `agent up` and that machine
228
+ // can't reach this LAN IP (e.g. NAT, VPN split), correct the registration
229
+ // manually afterwards with `robopark server-add --id <id> --url ws://<correct-host>:7880`.
230
+ const lanIps = getLanIps();
231
+ const advertiseHost = lanIps[0] ?? hostname();
232
+ if (lanIps.length === 0) {
233
+ console.log(chalk.yellow(` ⚠ no LAN IPv4 detected; advertising hostname '${advertiseHost}'. Correct with 'robopark server-add' if the scheduler can't resolve it.`));
234
+ }
235
+ const id = opts.serverId ?? `robovoice-${hostname()}`;
236
+ const name = opts.serverName ?? `ROBOVOICE (${hostname()})`;
237
+ const url = `ws://${advertiseHost}:${LIVEKIT_PORT}`;
238
+ await roboparkServerAdd({
239
+ hubUrl: ctx.hubUrl,
240
+ schedulerUrl: ctx.schedulerUrl,
241
+ token: ctx.token,
242
+ id,
243
+ name,
244
+ url,
245
+ apiKey: LIVEKIT_DEV_API_KEY,
246
+ apiSecret: LIVEKIT_DEV_API_SECRET,
247
+ });
248
+ console.log(chalk.green('\n ✓ robopark agent up complete — ROBOVOICE is running and registered with the scheduler\n'));
249
+ }
250
+ export async function roboparkAgentDown(opts) {
251
+ console.log(chalk.bold('\n robopark agent down'));
252
+ console.log(chalk.dim(' ' + '─'.repeat(52)));
253
+ if (!existsSync(opts.path)) {
254
+ console.log(chalk.red(` ✗ path does not exist: ${opts.path}`));
255
+ process.exit(1);
256
+ }
257
+ try {
258
+ validateComposePath(opts.path);
259
+ }
260
+ catch (e) {
261
+ console.log(chalk.red(` ✗ ${e.message}`));
262
+ process.exit(1);
263
+ }
264
+ const compose = await resolveComposeCommand();
265
+ if (!compose) {
266
+ console.log(chalk.red(' ✗ neither `docker compose` (v2) nor `docker-compose` (v1) is available on PATH.'));
267
+ process.exit(1);
268
+ }
269
+ console.log(` path: ${chalk.cyan(opts.path)}`);
270
+ console.log();
271
+ const code = await runCompose(compose, ['down'], opts.path);
272
+ if (code !== 0) {
273
+ console.log(chalk.red(`\n ✗ docker compose down exited with code ${code}`));
274
+ process.exit(code);
275
+ }
276
+ console.log(chalk.green('\n ✓ ROBOVOICE stack stopped\n'));
277
+ }
278
+ export async function roboparkAgentLogs(opts) {
279
+ if (!existsSync(opts.path)) {
280
+ console.log(chalk.red(` ✗ path does not exist: ${opts.path}`));
281
+ process.exit(1);
282
+ }
283
+ try {
284
+ validateComposePath(opts.path);
285
+ }
286
+ catch (e) {
287
+ console.log(chalk.red(` ✗ ${e.message}`));
288
+ process.exit(1);
289
+ }
290
+ const compose = await resolveComposeCommand();
291
+ if (!compose) {
292
+ console.log(chalk.red(' ✗ neither `docker compose` (v2) nor `docker-compose` (v1) is available on PATH.'));
293
+ process.exit(1);
294
+ }
295
+ const args = ['logs'];
296
+ if (opts.follow)
297
+ args.push('-f');
298
+ if (opts.service)
299
+ args.push(opts.service);
300
+ // Live tail: no buffering, no capture — inherit stdio directly.
301
+ const code = await runCompose(compose, args, opts.path);
302
+ if (code !== 0 && !opts.follow) {
303
+ process.exit(code);
304
+ }
305
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * RoboPark — auto-start registration for production machines.
3
+ *
4
+ * Registers the RoboPark service to start on boot:
5
+ * - Windows → Task Scheduler
6
+ * - Linux → systemd
7
+ * - macOS → launchd
8
+ *
9
+ * This is used by `robopark setup --auto-start`.
10
+ */
11
+ import { existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { spawn, spawnSync } from 'node:child_process';
15
+ function shellQuote(s) {
16
+ if (!/[^a-zA-Z0-9_./:=,-]/.test(s))
17
+ return s;
18
+ return "'" + s.replace(/'/g, "'\\''") + "'";
19
+ }
20
+ export async function registerAutoStart(service) {
21
+ const platform = process.platform;
22
+ if (platform === 'win32')
23
+ return registerWindows(service);
24
+ if (platform === 'linux')
25
+ return registerSystemd(service);
26
+ if (platform === 'darwin')
27
+ return registerLaunchd(service);
28
+ return { ok: false, message: `auto-start not supported on ${platform}` };
29
+ }
30
+ async function registerWindows(service) {
31
+ const taskName = `RoboPark-${service.role}-${service.name}`;
32
+ const logDir = join(homedir(), 'AppData', 'Roaming', 'robopark', 'logs');
33
+ mkdirSync(logDir, { recursive: true });
34
+ const outLog = join(logDir, `${taskName}.log`);
35
+ const errLog = join(logDir, `${taskName}.err.log`);
36
+ const invocation = [service.command, ...service.args].map(shellQuote).join(' ');
37
+ const script = `& ${invocation} 1>> ${shellQuote(outLog)} 2>> ${shellQuote(errLog)}`;
38
+ const cmd = ['powershell.exe', '-WindowStyle', 'Hidden', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script];
39
+ const xmlPath = join(homedir(), 'AppData', 'Roaming', 'robopark', `${taskName}.xml`);
40
+ mkdirSync(dirname(xmlPath), { recursive: true });
41
+ const xml = `<?xml version="1.0" encoding="UTF-16"?>\n<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n <RegistrationInfo>\n <Description>RoboPark ${service.role} for ${service.name}</Description>\n </RegistrationInfo>\n <Triggers>\n <BootTrigger>\n <Enabled>true</Enabled>\n </BootTrigger>\n <LogonTrigger>\n <Enabled>true</Enabled>\n </LogonTrigger>\n </Triggers>\n <Principals>\n <Principal id="Author">\n <LogonType>S4U</LogonType>\n <RunLevel>HighestAvailable</RunLevel>\n </Principal>\n </Principals>\n <Settings>\n <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n <StartWhenAvailable>true</StartWhenAvailable>\n <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n <IdleSettings>\n <StopOnIdleEnd>false</StopOnIdleEnd>\n <RestartOnIdle>false</RestartOnIdle>\n </IdleSettings>\n <AllowStartOnDemand>true</AllowStartOnDemand>\n <Enabled>true</Enabled>\n <Hidden>false</Hidden>\n <RunOnlyIfIdle>false</RunOnlyIfIdle>\n <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>\n <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>\n <WakeToRun>false</WakeToRun>\n <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n </Settings>\n <Actions Context="Author">\n <Exec>\n <Command>${cmd[0]}</Command>\n <Arguments>${cmd.slice(1).map(a => a.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')).join(' ')}</Arguments>\n <WorkingDirectory>${service.workingDir ?? homedir()}</WorkingDirectory>\n </Exec>\n </Actions>\n</Task>`;
42
+ const hardenedXml = xml.replace(' </Settings>', ' <RestartOnFailure>\n <Interval>PT5S</Interval>\n <Count>999</Count>\n </RestartOnFailure>\n </Settings>');
43
+ // schtasks /XML requires UTF-16 LE *with* BOM when the XML declares UTF-16.
44
+ const bom = Buffer.from([0xff, 0xfe]);
45
+ const body = Buffer.from(hardenedXml, 'utf16le');
46
+ writeFileSync(xmlPath, Buffer.concat([bom, body]));
47
+ return new Promise((resolve) => {
48
+ const schtasks = spawn('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], { stdio: 'pipe' });
49
+ let out = '';
50
+ let err = '';
51
+ schtasks.stdout.on('data', d => (out += d.toString()));
52
+ schtasks.stderr.on('data', d => (err += d.toString()));
53
+ schtasks.on('close', code => {
54
+ if (code === 0) {
55
+ const started = spawnSync('schtasks', ['/Run', '/TN', taskName], { encoding: 'utf8' });
56
+ if (started.status === 0) {
57
+ resolve({ ok: true, message: `registered and started Windows Task Scheduler task "${taskName}"` });
58
+ }
59
+ else {
60
+ resolve({ ok: false, message: `registered "${taskName}", but could not start it: ${started.stderr || started.stdout}` });
61
+ }
62
+ }
63
+ else {
64
+ resolve({ ok: false, message: `schtasks failed (${code}): ${err || out}` });
65
+ }
66
+ });
67
+ schtasks.on('error', e => resolve({ ok: false, message: `could not run schtasks: ${e.message}` }));
68
+ });
69
+ }
70
+ async function registerSystemd(service) {
71
+ const unitName = `robopark-${service.role}-${service.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.service`;
72
+ const unitPath = `/etc/systemd/system/${unitName}`;
73
+ const envLines = Object.entries(service.env ?? {})
74
+ .map(([k, v]) => `Environment="${k}=${v.replace(/"/g, '\\"')}"`)
75
+ .join('\n');
76
+ const mediaService = service.role === 'robot-runtime' || service.role === 'robot-preview-agent' || service.role === 'robot-vision-agent' || service.role === 'robot-motor' || service.role === 'robot-conversation' || service.role === 'robot-screen';
77
+ const mediaGroups = mediaService
78
+ ? ['audio', 'video', 'render', 'input', 'plugdev', 'gpio'].filter(group => spawnSync('getent', ['group', group], { stdio: 'ignore' }).status === 0)
79
+ : [];
80
+ const mediaPolicy = mediaService ? [
81
+ mediaGroups.length ? `SupplementaryGroups=${mediaGroups.join(' ')}` : '',
82
+ 'UMask=0007',
83
+ 'LimitNOFILE=65536',
84
+ 'TasksMax=512',
85
+ 'OOMScoreAdjust=-500',
86
+ ].filter(Boolean).join('\n') : '';
87
+ const restartSeconds = service.role === 'robot-conversation' ? 1 : 3;
88
+ const startLimit = service.role === 'robot-conversation'
89
+ ? 'StartLimitIntervalSec=0'
90
+ : 'StartLimitIntervalSec=120\nStartLimitBurst=20';
91
+ // Conversation ownership is coordinated by canonical service retirement
92
+ // below and by the local media lease. Never kill desktop audio daemons,
93
+ // cameras, or unrelated ALSA owners as a side effect of starting voice.
94
+ const exclusiveMediaPreflight = service.exclusiveMedia && service.role !== 'robot-conversation'
95
+ ? "ExecStartPre=/bin/sh -c 'command -v fuser >/dev/null 2>&1 || exit 1; fuser -s /dev/snd/* /dev/video* >/dev/null 2>&1 && exit 1 || exit 0'"
96
+ : '';
97
+ const motorPreflight = service.role === 'robot-motor'
98
+ ? "ExecStartPre=-/bin/sh -c 'command -v fuser >/dev/null 2>&1 && fuser -k -TERM 8001/tcp >/dev/null 2>&1 || true; sleep 1'"
99
+ : '';
100
+ let mediaRulesInstalled = false;
101
+ if (mediaService) {
102
+ const rulesPath = '/etc/udev/rules.d/70-robopark-media.rules';
103
+ const rules = [
104
+ '# Managed by RoboPark. Shared hardware profile for production robots.',
105
+ 'SUBSYSTEM=="video4linux", GROUP="video", MODE="0660"',
106
+ 'SUBSYSTEM=="video4linux", ENV{ID_BUS}=="usb", ATTR{index}=="0", SYMLINK+="robopark-camera"',
107
+ 'SUBSYSTEM=="sound", GROUP="audio", MODE="0660"',
108
+ '',
109
+ ].join('\n');
110
+ try {
111
+ writeFileSync(rulesPath, rules, 'utf8');
112
+ spawnSync('udevadm', ['control', '--reload-rules'], { stdio: 'ignore' });
113
+ spawnSync('udevadm', ['trigger', '--subsystem-match=video4linux'], { stdio: 'ignore' });
114
+ spawnSync('udevadm', ['trigger', '--subsystem-match=sound'], { stdio: 'ignore' });
115
+ mediaRulesInstalled = true;
116
+ }
117
+ catch {
118
+ // The explicit systemd groups below still cover standard distro rules.
119
+ }
120
+ }
121
+ const unit = `[Unit]
122
+ Description=RoboPark ${service.role} for ${service.name}
123
+ After=network-online.target sound.target
124
+ Wants=network-online.target sound.target
125
+ ${startLimit}
126
+
127
+ [Service]
128
+ Type=simple
129
+ ${exclusiveMediaPreflight}
130
+ ${motorPreflight}
131
+ ExecStart=${service.command} ${service.args.map(shellQuote).join(' ')}
132
+ WorkingDirectory=${service.workingDir ?? homedir()}
133
+ Restart=always
134
+ RestartSec=${restartSeconds}
135
+ KillMode=control-group
136
+ TimeoutStopSec=20
137
+ SendSIGKILL=yes
138
+ FinalKillSignal=SIGKILL
139
+ ${mediaPolicy}
140
+ ${envLines}
141
+
142
+ [Install]
143
+ WantedBy=multi-user.target
144
+ `;
145
+ try {
146
+ writeFileSync(unitPath, unit, 'utf8');
147
+ const retiredUnits = [];
148
+ if (service.role === 'robot-conversation') {
149
+ // A Pi has one physical microphone/speaker pair. Historical robot names
150
+ // must not leave Restart=always workers racing the selected character.
151
+ const discovered = new Set();
152
+ for (const args of [
153
+ ['list-unit-files', 'robopark-robot-conversation-*.service', '--no-legend', '--no-pager'],
154
+ ['list-units', '--all', 'robopark-robot-conversation-*.service', '--no-legend', '--no-pager'],
155
+ ]) {
156
+ const listed = spawnSync('systemctl', args, { encoding: 'utf8' });
157
+ for (const line of String(listed.stdout ?? '').split(/\r?\n/)) {
158
+ const candidate = line.trim().split(/\s+/, 1)[0];
159
+ if (/^robopark-robot-conversation-[a-z0-9-]+\.service$/.test(candidate)) {
160
+ discovered.add(candidate);
161
+ }
162
+ }
163
+ }
164
+ for (const candidate of discovered) {
165
+ if (candidate === unitName)
166
+ continue;
167
+ spawnSync('systemctl', ['disable', '--now', candidate], { stdio: 'ignore' });
168
+ spawnSync('systemctl', ['kill', '--kill-who=all', '--signal=SIGKILL', candidate], { stdio: 'ignore' });
169
+ const stalePath = `/etc/systemd/system/${candidate}`;
170
+ try {
171
+ if (existsSync(stalePath))
172
+ rmSync(stalePath);
173
+ }
174
+ catch { /* daemon reload below reconciles state */ }
175
+ retiredUnits.push(candidate);
176
+ }
177
+ }
178
+ const reload = spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
179
+ if (reload.status !== 0) {
180
+ return { ok: false, message: `wrote ${unitPath}, but systemctl daemon-reload failed: ${reload.stderr || reload.stdout}` };
181
+ }
182
+ const enabled = spawnSync('systemctl', ['enable', unitName], { encoding: 'utf8' });
183
+ if (enabled.status !== 0) {
184
+ return { ok: false, message: `wrote ${unitPath}, but systemctl enable failed: ${enabled.stderr || enabled.stdout}` };
185
+ }
186
+ // `enable --now` leaves an already-running unit untouched. Always restart
187
+ // so the newly written config and exclusive-device preflight take effect.
188
+ const restarted = spawnSync('systemctl', ['restart', unitName], { encoding: 'utf8' });
189
+ if (restarted.status !== 0) {
190
+ return { ok: false, message: `enabled ${unitName}, but systemctl restart failed: ${restarted.stderr || restarted.stdout}` };
191
+ }
192
+ return {
193
+ ok: true,
194
+ message: `enabled and started systemd unit ${unitName}${mediaRulesInstalled ? ' with persistent media permissions' : ''}`
195
+ + (retiredUnits.length ? `; retired ${retiredUnits.join(', ')}` : ''),
196
+ };
197
+ }
198
+ catch (e) {
199
+ return { ok: false, message: `could not write ${unitPath}: ${e instanceof Error ? e.message : String(e)}` };
200
+ }
201
+ }
202
+ async function registerLaunchd(service) {
203
+ const label = `ai.robopark.${service.role}.${service.name}`;
204
+ const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
205
+ mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
206
+ const env = service.env ?? {};
207
+ const envBlock = Object.keys(env).length
208
+ ? `<key>EnvironmentVariables</key>
209
+ <dict>
210
+ ${Object.entries(env).map(([k, v]) => `<key>${k}</key>\n <string>${v}</string>`).join('\n ')}
211
+ </dict>`
212
+ : '';
213
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
214
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
215
+ <plist version="1.0">
216
+ <dict>
217
+ <key>Label</key>
218
+ <string>${label}</string>
219
+ <key>ProgramArguments</key>
220
+ <array>
221
+ <string>${service.command}</string>
222
+ ${service.args.map(a => `<string>${a.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')}</string>`).join('\n ')}
223
+ </array>
224
+ <key>WorkingDirectory</key>
225
+ <string>${service.workingDir ?? homedir()}</string>
226
+ <key>RunAtLoad</key>
227
+ <true/>
228
+ <key>KeepAlive</key>
229
+ <true/>
230
+ <key>StandardOutPath</key>
231
+ <string>${join(homedir(), 'Library', 'Logs', `${label}.log`)}</string>
232
+ <key>StandardErrorPath</key>
233
+ <string>${join(homedir(), 'Library', 'Logs', `${label}.err.log`)}</string>
234
+ ${envBlock}
235
+ </dict>
236
+ </plist>`;
237
+ try {
238
+ writeFileSync(plistPath, plist, 'utf8');
239
+ spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
240
+ const loaded = spawnSync('launchctl', ['load', '-w', plistPath], { encoding: 'utf8' });
241
+ if (loaded.status !== 0) {
242
+ return { ok: false, message: `wrote ${plistPath}, but launchctl failed: ${loaded.stderr || loaded.stdout}` };
243
+ }
244
+ return { ok: true, message: `registered and started launchd service ${label}` };
245
+ }
246
+ catch (e) {
247
+ return { ok: false, message: `could not write ${plistPath}: ${e instanceof Error ? e.message : String(e)}` };
248
+ }
249
+ }
250
+ export function unregisterAutoStart(role, name) {
251
+ const platform = process.platform;
252
+ const taskName = `RoboPark-${role}-${name}`;
253
+ if (platform === 'win32') {
254
+ try {
255
+ spawn('schtasks', ['/Delete', '/TN', taskName, '/F'], { stdio: 'ignore' }).unref();
256
+ return { ok: true, message: `deleted Windows task "${taskName}"` };
257
+ }
258
+ catch (e) {
259
+ return { ok: false, message: `failed: ${e instanceof Error ? e.message : String(e)}` };
260
+ }
261
+ }
262
+ if (platform === 'linux') {
263
+ const unitName = `robopark-${role}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.service`;
264
+ const unitPath = `/etc/systemd/system/${unitName}`;
265
+ const stopped = spawnSync('systemctl', ['disable', '--now', unitName], { encoding: 'utf8' });
266
+ try {
267
+ if (existsSync(unitPath))
268
+ rmSync(unitPath);
269
+ }
270
+ catch { /* systemd output is more useful below */ }
271
+ spawnSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
272
+ return {
273
+ ok: stopped.status === 0 || !existsSync(unitPath),
274
+ message: `removed systemd unit ${unitName}`,
275
+ };
276
+ }
277
+ if (platform === 'darwin') {
278
+ const label = `ai.robopark.${role}.${name}`;
279
+ const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
280
+ spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
281
+ try {
282
+ if (existsSync(plistPath))
283
+ rmSync(plistPath);
284
+ }
285
+ catch { /* ignore */ }
286
+ return { ok: true, message: `removed launchd plist ${plistPath}` };
287
+ }
288
+ return { ok: false, message: `unregister not supported on ${platform}` };
289
+ }