robopark 3.0.0 → 3.1.1

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.
@@ -0,0 +1,148 @@
1
+ import { createServer, request as httpRequest } from 'node:http';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { dirname, extname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+ import { ROBOPARK_CONTROL_HTML } from './control-center-html.js';
7
+ import { ROBOPARK_VOICE_JOIN_HTML } from './voice-join-html.js';
8
+ function packageRoot() {
9
+ return dirname(dirname(dirname(fileURLToPath(import.meta.url))));
10
+ }
11
+ function contentType(path) {
12
+ const ext = extname(path).toLowerCase();
13
+ if (ext === '.png')
14
+ return 'image/png';
15
+ if (ext === '.jpg' || ext === '.jpeg')
16
+ return 'image/jpeg';
17
+ if (ext === '.js')
18
+ return 'application/javascript; charset=utf-8';
19
+ return 'application/octet-stream';
20
+ }
21
+ function proxy(req, res, schedulerUrl) {
22
+ const incoming = new URL(req.url ?? '/', 'http://robopark.local');
23
+ const upstreamBase = new URL(schedulerUrl);
24
+ const path = incoming.pathname.replace(/^\/robopark/, '') || '/';
25
+ const upstream = httpRequest({
26
+ protocol: upstreamBase.protocol,
27
+ hostname: upstreamBase.hostname,
28
+ port: upstreamBase.port,
29
+ method: req.method,
30
+ path: `${path}${incoming.search}`,
31
+ headers: { ...req.headers, host: upstreamBase.host },
32
+ }, response => {
33
+ res.writeHead(response.statusCode ?? 502, response.headers);
34
+ response.pipe(res);
35
+ });
36
+ upstream.on('error', error => {
37
+ if (!res.headersSent)
38
+ res.writeHead(502, { 'content-type': 'application/json' });
39
+ res.end(JSON.stringify({ ok: false, error: `scheduler unavailable: ${error.message}` }));
40
+ });
41
+ req.pipe(upstream);
42
+ }
43
+ async function fleetStatus(schedulerUrl) {
44
+ const [devices, robots] = await Promise.all([
45
+ fetch(`${schedulerUrl}/api/devices`).then(r => r.ok ? r.json() : []),
46
+ fetch(`${schedulerUrl}/api/robots`).then(r => r.ok ? r.json() : []),
47
+ ]);
48
+ const nodes = devices.map(device => ({
49
+ nodeId: device.id,
50
+ displayName: device.name,
51
+ role: 'robot',
52
+ connected: device.status === 'online',
53
+ hardware: device.device_inventory ?? {},
54
+ characterId: device.character_id,
55
+ tailscaleIp: device.tailscale_ip,
56
+ lanIp: device.lan_ip,
57
+ }));
58
+ return {
59
+ product: 'robopark',
60
+ nodes,
61
+ robots,
62
+ control: { schedulerLinked: true, schedulerUrl },
63
+ generatedAt: new Date().toISOString(),
64
+ };
65
+ }
66
+ export async function startControlServer(opts) {
67
+ const root = packageRoot();
68
+ const server = createServer((req, res) => {
69
+ const url = new URL(req.url ?? '/', 'http://robopark.local');
70
+ const path = url.pathname;
71
+ if (req.method === 'GET' && (path === '/' || path === '/ui' || path === '/dashboard' || path === '/robopark-control.html')) {
72
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
73
+ res.end(ROBOPARK_CONTROL_HTML);
74
+ return;
75
+ }
76
+ if (req.method === 'GET' && path === '/fed/ui-build') {
77
+ const build = ROBOPARK_CONTROL_HTML.match(/<meta name="robopark-ui-build" content="([^"]+)">/)?.[1] ?? 'standalone';
78
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
79
+ res.end(JSON.stringify({ build, owner: 'robopark' }));
80
+ return;
81
+ }
82
+ if (req.method === 'GET' && path === '/voice-join.html') {
83
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'strict-origin' });
84
+ res.end(ROBOPARK_VOICE_JOIN_HTML);
85
+ return;
86
+ }
87
+ if (req.method === 'GET' && path === '/fed/status') {
88
+ void fleetStatus(opts.schedulerUrl).then(status => {
89
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
90
+ res.end(JSON.stringify(status));
91
+ }).catch(error => {
92
+ res.writeHead(503, { 'content-type': 'application/json' });
93
+ res.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }));
94
+ });
95
+ return;
96
+ }
97
+ if (req.method === 'GET' && path === '/fed/stream') {
98
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
99
+ res.write('retry: 3000\n\n');
100
+ const timer = setInterval(() => res.write(': robopark heartbeat\n\n'), 10_000);
101
+ req.on('close', () => clearInterval(timer));
102
+ return;
103
+ }
104
+ if (req.method === 'GET' && path.startsWith('/static/')) {
105
+ const relative = path.slice(8).replace(/\.\./g, '');
106
+ const file = join(root, 'static', relative);
107
+ if (!existsSync(file)) {
108
+ res.writeHead(404).end('asset not found');
109
+ return;
110
+ }
111
+ res.writeHead(200, { 'content-type': contentType(file), 'cache-control': 'public,max-age=86400' });
112
+ res.end(readFileSync(file));
113
+ return;
114
+ }
115
+ if (req.method === 'GET' && path === '/vendor/livekit-client.umd.js') {
116
+ try {
117
+ const file = createRequire(import.meta.url).resolve('livekit-client');
118
+ res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'public,max-age=86400' });
119
+ res.end(readFileSync(file));
120
+ }
121
+ catch {
122
+ res.writeHead(404).end('livekit-client not installed');
123
+ }
124
+ return;
125
+ }
126
+ if (req.method === 'GET' && path === '/vendor/elevenlabs-client.iife.js') {
127
+ const file = join(root, 'static', 'vendor', 'elevenlabs-client.iife.js');
128
+ if (!existsSync(file)) {
129
+ res.writeHead(404).end('ElevenLabs client not installed');
130
+ return;
131
+ }
132
+ res.writeHead(200, { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'public,max-age=86400' });
133
+ res.end(readFileSync(file));
134
+ return;
135
+ }
136
+ if (path.startsWith('/robopark/')) {
137
+ proxy(req, res, opts.schedulerUrl);
138
+ return;
139
+ }
140
+ res.writeHead(404, { 'content-type': 'application/json' });
141
+ res.end(JSON.stringify({ ok: false, error: 'not found' }));
142
+ });
143
+ await new Promise((resolve, reject) => {
144
+ server.once('error', reject);
145
+ server.listen(opts.port, opts.host, () => resolve());
146
+ });
147
+ return server;
148
+ }
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { registerAutoStart } from './auto-start.js';
8
8
  import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
9
9
  import { roboparkServe, schedulerHealthy } from './serve.js';
10
+ import { startControlServer } from './control-server.js';
10
11
  import { activateFromPairing } from './access.js';
11
12
  const STATE_DIR = process.env.ROBOPARK_HOME
12
13
  ? resolve(process.env.ROBOPARK_HOME)
@@ -393,10 +394,12 @@ function localPublicUrl(host, port, explicit) {
393
394
  }
394
395
  export async function gatewayStart(opts) {
395
396
  const port = positiveInteger(opts.port, 8080, 'port');
397
+ const controlPort = positiveInteger(opts.controlPort, 47913, 'control port');
396
398
  const host = opts.host ?? '0.0.0.0';
397
399
  if (opts.foreground) {
398
400
  await runGatewayService({
399
401
  port: String(port),
402
+ controlPort: String(controlPort),
400
403
  host,
401
404
  dataDir: opts.dataDir,
402
405
  open: opts.open,
@@ -404,6 +407,7 @@ export async function gatewayStart(opts) {
404
407
  }
405
408
  else {
406
409
  const args = [cliEntry(), '_gateway', '--port', String(port), '--host', host];
410
+ args.push('--control-port', String(controlPort));
407
411
  if (opts.dataDir)
408
412
  args.push('--data-dir', opts.dataDir);
409
413
  const registration = await registerAutoStart({
@@ -427,6 +431,8 @@ export async function gatewayStart(opts) {
427
431
  await wait(500);
428
432
  }
429
433
  const token = await createGatewayTokenAt(healthUrl, schedulerUrl);
434
+ const controlHost = new URL(schedulerUrl).hostname;
435
+ console.log(` Park Control Center: ${chalk.cyan(`http://${controlHost}:${controlPort}/`)}`);
430
436
  console.log(chalk.bold('\n Connect a device'));
431
437
  console.log(` ${chalk.cyan(`robopark connect ${token}`)}`);
432
438
  console.log(chalk.dim(' This pairing token expires in 24 hours. Connected devices keep their own durable credentials.\n'));
@@ -448,13 +454,27 @@ async function createGatewayTokenAt(localUrl, publicUrl) {
448
454
  return pairing;
449
455
  }
450
456
  export async function runGatewayService(opts) {
451
- await roboparkServe({
452
- port: opts.port,
453
- host: opts.host,
454
- dataDir: opts.dataDir,
455
- open: opts.open,
456
- foreground: true,
457
+ const schedulerPort = positiveInteger(opts.port, 8080, 'port');
458
+ const controlPort = positiveInteger(opts.controlPort, 47913, 'control port');
459
+ const host = opts.host ?? '0.0.0.0';
460
+ const control = await startControlServer({
461
+ host,
462
+ port: controlPort,
463
+ schedulerUrl: `http://127.0.0.1:${schedulerPort}`,
457
464
  });
465
+ console.log(` Park Control Center listening on ${host}:${controlPort}`);
466
+ try {
467
+ await roboparkServe({
468
+ port: String(schedulerPort),
469
+ host,
470
+ dataDir: opts.dataDir,
471
+ open: false,
472
+ foreground: true,
473
+ });
474
+ }
475
+ finally {
476
+ control.close();
477
+ }
458
478
  }
459
479
  export function printRuntimeStatus() {
460
480
  if (!existsSync(STATUS_PATH)) {
@@ -0,0 +1,57 @@
1
+ /** Public, character-specific ElevenLabs conversation surface. */
2
+ export const ROBOPARK_VOICE_JOIN_HTML = `<!doctype html>
3
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4
+ <meta name="theme-color" content="#080704"><meta name="robopark-voice-join-build" content="voice-join-supervisor-20260723-6">
5
+ <title>RoboPark Character Conversation</title>
6
+ <style>
7
+ .deployment{display:grid;gap:8px;margin:14px 0}.deployment-row{display:grid;grid-template-columns:minmax(150px,1fr) 2fr auto;gap:9px;align-items:center;padding:.7rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025)}.deployment-row b{font:.62rem var(--mono);color:var(--pale)}.deployment-row span{font:.54rem var(--mono);color:var(--muted)}.deployment-row .ready{color:var(--ok)}.deployment-actions{display:flex;flex-wrap:wrap;gap:5px}.deployment-actions button{border:1px solid var(--line);border-radius:8px;background:rgba(244,207,114,.09);color:var(--gold);padding:.38rem .5rem;font:.5rem var(--mono);cursor:pointer}
8
+ .audio-controls{position:relative;width:min(390px,92%);margin-top:12px;border:1px solid var(--line);border-radius:14px;background:rgba(0,0,0,.3);text-align:left}.audio-controls summary{padding:.65rem .8rem;cursor:pointer;color:var(--gold);font:600 .58rem var(--mono);letter-spacing:.07em;text-transform:uppercase}.audio-controls summary::marker{color:var(--gold)}.audio-panel{display:grid;gap:.75rem;padding:0 .85rem .85rem}.audio-row{display:grid;grid-template-columns:105px 1fr 45px;gap:.55rem;align-items:center}.audio-row label,.audio-value{font:600 .54rem var(--mono);color:var(--muted)}.audio-value{text-align:right;color:var(--pale)}.audio-row input[type=range]{width:100%;accent-color:var(--gold)}.audio-note{margin:0;color:#7e755f;font:.5rem/1.45 var(--mono)}
9
+ :root{--bg:#080704;--panel:rgba(20,17,11,.88);--line:rgba(247,211,116,.22);--gold:#f4cf72;--pale:#fff0bd;--ink:#f7f1df;--muted:#a99e84;--ok:#64dfa0;--warn:#f0bd55;--bad:#ff776c;--mono:"JetBrains Mono","Cascadia Code",monospace;--display:Georgia,"Times New Roman",serif}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:radial-gradient(circle at 50% 8%,#332510 0,#100d08 35%,#050403 78%);color:var(--ink);font-family:Verdana,Geneva,sans-serif}body:before{content:"";position:fixed;inset:0;pointer-events:none;background:linear-gradient(115deg,transparent 0 48%,rgba(244,207,114,.025) 49% 50%,transparent 51%),repeating-linear-gradient(0deg,transparent 0 34px,rgba(255,255,255,.012) 35px)}button,input{font:inherit}.shell{width:min(1120px,100%);min-height:100vh;margin:auto;padding:clamp(14px,3vw,34px);display:grid;grid-template-rows:auto 1fr auto;gap:18px}.top{display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{font:700 .65rem var(--mono);letter-spacing:.18em;text-transform:uppercase;color:var(--gold)}.ops-toggle{border:1px solid var(--line);border-radius:999px;background:rgba(0,0,0,.35);color:var(--muted);padding:.55rem .8rem;cursor:pointer}.ops-toggle.on{color:var(--pale);border-color:var(--gold)}.stage{position:relative;min-height:620px;display:grid;grid-template-columns:minmax(0,1.3fr) minmax(280px,.7fr);overflow:hidden;border:1px solid var(--line);border-radius:28px;background:linear-gradient(145deg,rgba(34,27,14,.82),rgba(8,7,5,.92));box-shadow:0 28px 90px rgba(0,0,0,.45)}.hero{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:34px;text-align:center;overflow:hidden}.hero:before{content:"";position:absolute;width:500px;height:500px;border:1px solid rgba(244,207,114,.1);border-radius:50%;box-shadow:0 0 0 55px rgba(244,207,114,.018),0 0 0 120px rgba(244,207,114,.012)}.avatar-wrap{position:relative;width:min(330px,72vw);aspect-ratio:1;display:grid;place-items:center;border:1px solid rgba(244,207,114,.3);border-radius:50%;background:radial-gradient(circle,rgba(244,207,114,.16),rgba(8,7,5,.62) 58%,#050403 74%);box-shadow:0 0 90px rgba(214,164,55,.16)}.avatar-wrap.live{animation:breathe 2.2s ease-in-out infinite}.avatar-wrap.speaking{box-shadow:0 0 110px rgba(244,207,114,.42);border-color:var(--pale)}@keyframes breathe{50%{transform:scale(1.018)}}.avatar{position:relative;z-index:2;width:78%;height:78%;object-fit:contain;filter:drop-shadow(0 20px 28px rgba(0,0,0,.58))}.fallback{position:absolute;font:700 4rem var(--display);color:var(--gold)}h1{position:relative;margin:22px 0 5px;font:700 clamp(2rem,5vw,3.5rem) var(--display);color:var(--pale)}.subtitle{position:relative;color:var(--muted);font-size:.8rem}.state{position:relative;margin-top:14px;font:600 .68rem var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--gold)}.join{position:relative;margin-top:22px;min-width:210px;border:1px solid var(--gold);border-radius:999px;padding:.9rem 1.25rem;background:linear-gradient(135deg,#f5d77f,#b87b1d);color:#171005;font-weight:800;cursor:pointer;box-shadow:0 12px 30px rgba(184,123,29,.24)}.join:disabled{opacity:.48;cursor:not-allowed}.end{position:relative;margin-top:10px;border:0;background:transparent;color:var(--muted);cursor:pointer}.side{position:relative;border-left:1px solid var(--line);padding:25px;display:flex;flex-direction:column;gap:14px;background:rgba(0,0,0,.2)}.vision{position:relative;aspect-ratio:16/10;border:1px solid var(--line);border-radius:17px;overflow:hidden;background:#050403}.vision img{width:100%;height:100%;object-fit:cover}.vision .label{position:absolute;left:9px;top:9px;padding:.32rem .45rem;border-radius:7px;background:rgba(0,0,0,.7);font:600 .5rem var(--mono);color:var(--gold)}.transcript{flex:1;min-height:220px;max-height:360px;overflow:auto;padding:.65rem;border:1px solid var(--line);border-radius:15px;background:rgba(0,0,0,.22)}.turn{margin:.45rem 0;padding:.55rem .65rem;border-radius:11px;font-size:.75rem;line-height:1.45}.turn.user{margin-left:13%;background:rgba(244,207,114,.12);border:1px solid rgba(244,207,114,.18)}.turn.assistant{margin-right:13%;background:rgba(255,255,255,.045)}.compose{display:flex;gap:8px}.compose input{min-width:0;flex:1;border:1px solid var(--line);border-radius:11px;background:#090805;color:var(--ink);padding:.7rem}.compose button{border:1px solid var(--line);border-radius:11px;background:rgba(244,207,114,.12);color:var(--gold);padding:.7rem;cursor:pointer}.ops{display:none;position:absolute;inset:12px;z-index:8;padding:16px;border:1px solid rgba(244,207,114,.35);border-radius:18px;background:rgba(5,4,3,.94);backdrop-filter:blur(16px);overflow:auto}.ops.open{display:block}.ops-head{display:flex;align-items:center;justify-content:space-between}.ops h2{margin:0;font:700 1.2rem var(--display);color:var(--pale)}.close{border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--muted);padding:.4rem .55rem;cursor:pointer}.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;margin:14px 0}.fact{padding:.7rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.025)}.fact span{display:block;color:var(--muted);font:500 .49rem var(--mono);text-transform:uppercase;letter-spacing:.11em}.fact b{display:block;margin-top:.3rem;color:var(--ink);font:600 .64rem var(--mono);word-break:break-all}.pipeline{display:grid;grid-template-columns:repeat(5,1fr);gap:7px;margin:15px 0}.step{padding:.65rem .3rem;border:1px solid var(--line);border-radius:11px;text-align:center;color:var(--muted);font:600 .52rem var(--mono);text-transform:uppercase}.step.done{color:var(--ok);border-color:rgba(100,223,160,.4)}.step.live{color:#171005;border-color:var(--gold);background:var(--gold);box-shadow:0 0 24px rgba(244,207,114,.25)}.step.bad{color:var(--bad);border-color:rgba(255,119,108,.5)}.events{display:grid;gap:7px}.event{display:grid;grid-template-columns:95px 70px 1fr;gap:8px;padding:.6rem;border-bottom:1px solid var(--line);font:500 .55rem var(--mono)}.event .ok{color:var(--ok)}.event .error,.event .failed{color:var(--bad)}.foot{text-align:center;color:#716956;font:500 .52rem var(--mono);letter-spacing:.08em}.error-box{display:none;margin-top:12px;color:var(--bad);font:.65rem var(--mono)}@media(max-width:780px){.shell{padding:10px}.stage{grid-template-columns:1fr;min-height:0}.hero{min-height:600px;padding:24px 15px}.side{border-left:0;border-top:1px solid var(--line)}.pipeline{grid-template-columns:1fr 1fr}.top{padding:4px}.event{grid-template-columns:75px 55px 1fr}}
10
+ </style></head><body><main class="shell"><header class="top"><div class="brand">RoboPark / managed character channel</div><div><button class="ops-toggle on" id="always-on" aria-pressed="true">Always on: ON</button> <button class="ops-toggle" id="ops-toggle">Operations</button></div></header><section class="stage"><div class="hero"><div class="avatar-wrap" id="avatar-wrap"><div class="fallback" id="fallback">RP</div><img class="avatar" id="avatar" alt=""></div><h1 id="character-name">Loading character...</h1><div class="subtitle" id="subtitle">Secure ElevenLabs conversation managed by RoboPark</div><div class="state" id="state">validating link</div><button class="join" id="join" disabled>Join conversation</button><button class="end" id="end" hidden>End conversation</button><div class="error-box" id="error"></div></div><aside class="side"><div class="vision" id="vision" hidden><img id="robot-video" alt="Live robot camera"><span class="label">LIVE ROBOT VISION</span></div><div class="transcript" id="transcript"><div class="turn assistant">The transcript will appear here when the conversation begins.</div></div><form class="compose" id="compose"><input id="message" placeholder="Type a message" disabled><button id="send" disabled>Send</button></form></aside><section class="ops" id="ops"><div class="ops-head"><h2>Live management overlay</h2><button class="close" id="ops-close">Close</button></div><div class="facts"><div class="fact"><span>Session</span><b id="f-session">not started</b></div><div class="fact"><span>Character</span><b id="f-character">--</b></div><div class="fact"><span>Agent</span><b id="f-agent">--</b></div><div class="fact"><span>Branch</span><b id="f-branch">default</b></div><div class="fact"><span>Robot</span><b id="f-robot">operator only</b></div><div class="fact"><span>Share link</span><b id="f-share">--</b></div></div><div class="pipeline" id="pipeline"><div class="step live" data-stage="configured">configured</div><div class="step" data-stage="permission">permission</div><div class="step" data-stage="signed_session">signed</div><div class="step" data-stage="connected">connected</div><div class="step" data-stage="conversation">conversation</div></div><div class="events" id="events"><div class="event"><span>waiting</span><span>info</span><span>Session telemetry will stream here.</span></div></div></section></section><footer class="foot">No API credentials are stored in this link. Each visit creates an isolated signed session.</footer></main><script src="/vendor/elevenlabs-client.iife.js"></script><script>
11
+ (function(){
12
+ var deployment=document.createElement('div');deployment.id='deployment';deployment.className='deployment';deployment.innerHTML='<div class="deployment-row"><b>Character hardware</b><span>Loading registered voice, vision, and motor nodes...</span><span></span></div>';document.getElementById('pipeline').before(deployment);
13
+ var audioControls=document.createElement('details');audioControls.id='audio-controls';audioControls.className='audio-controls';audioControls.innerHTML='<summary>Audio levels</summary><div class="audio-panel"><div class="audio-row"><label for="speaker-volume">Speaker</label><input id="speaker-volume" type="range" min="0" max="100" step="1"><output class="audio-value" id="speaker-value"></output></div><div class="audio-row"><label for="mic-sensitivity">Mic sensitivity</label><input id="mic-sensitivity" type="range" min="25" max="200" step="5"><output class="audio-value" id="mic-value"></output></div><p class="audio-note">Changes apply immediately and stay on this browser. High microphone gain may increase background noise.</p></div>';document.getElementById('error').after(audioControls);
14
+ var q=new URLSearchParams(location.search),character=q.get('character')||'',agent=q.get('agent_id')||'',branch=q.get('branch_id')||'',mode=q.get('mode')==='production'?'production':'test',robot=mode==='production'?(q.get('robot')||''):'',stack=q.get('stack')||'',share=q.get('share_id')||'',session=null,eventToken=null,conversation=null,conversationId=null,keepalive=null,poller=null,retryTimer=null,ending=false,desired=false,alwaysOn=true,connecting=false,generation=0,retryAttempt=0,lastProviderError=null,history=[],seen={},micPipelines=[],speakerVolume=storedNumber('robopark.voice.speaker',100,0,100),micSensitivity=storedNumber('robopark.voice.micSensitivity',100,25,200);
15
+ function storedNumber(key,fallback,min,max){try{var raw=localStorage.getItem(key);if(raw===null)return fallback;var value=Number(raw);return Number.isFinite(value)?Math.max(min,Math.min(max,value)):fallback}catch(_){return fallback}}
16
+ function persistNumber(key,value){try{localStorage.setItem(key,String(value))}catch(_){}}
17
+ function applySpeakerVolume(){if(conversation&&typeof conversation.setVolume==='function')try{conversation.setVolume({volume:speakerVolume/100})}catch(_){}}
18
+ function setMicSensitivity(value){micSensitivity=Math.max(25,Math.min(200,Number(value)||100));micPipelines.slice().forEach(function(pipe){try{pipe.gain.gain.setTargetAtTime(micSensitivity/100,pipe.context.currentTime,.015)}catch(_){}})}
19
+ function processMicrophone(stream){if(!stream||!stream.getAudioTracks||!stream.getAudioTracks().length)return stream;var AudioCtor=window.AudioContext||window.webkitAudioContext;if(!AudioCtor)return stream;try{var context=new AudioCtor(),source=context.createMediaStreamSource(stream),gain=context.createGain(),destination=context.createMediaStreamDestination(),pipe={context:context,source:source,gain:gain,destination:destination};gain.gain.value=micSensitivity/100;source.connect(gain);gain.connect(destination);micPipelines.push(pipe);if(context.state==='suspended')context.resume().catch(function(){});var outputTrack=destination.stream.getAudioTracks()[0],nativeStop=outputTrack.stop.bind(outputTrack),closed=false;outputTrack.stop=function(){if(closed)return;closed=true;nativeStop();stream.getTracks().forEach(function(track){try{track.stop()}catch(_){}});try{source.disconnect();gain.disconnect()}catch(_){};micPipelines=micPipelines.filter(function(item){return item!==pipe});context.close().catch(function(){})};stream.getAudioTracks().forEach(function(track){track.addEventListener('ended',function(){try{outputTrack.stop()}catch(_){}})});return new MediaStream(stream.getVideoTracks().concat([outputTrack]))}catch(_){return stream}}
20
+ function createSilentTestMicrophone(){var AudioCtor=window.AudioContext||window.webkitAudioContext;if(!AudioCtor)throw new Error('This browser has no usable microphone or Web Audio support.');var context=new AudioCtor(),oscillator=context.createOscillator(),gain=context.createGain(),destination=context.createMediaStreamDestination();gain.gain.value=0;oscillator.connect(gain);gain.connect(destination);oscillator.start();var track=destination.stream.getAudioTracks()[0],nativeStop=track.stop.bind(track),closed=false;track.stop=function(){if(closed)return;closed=true;try{oscillator.stop()}catch(_){}nativeStop();context.close().catch(function(){})};var subtitle=document.getElementById('subtitle');if(subtitle)subtitle.textContent='No local microphone attached · agent audio and typed test enabled';return new MediaStream([track])}
21
+ function installMicrophoneRecovery(){
22
+ var media=navigator.mediaDevices;if(!media||!media.getUserMedia||media.__roboparkRecovered)return;var nativeGetUserMedia=media.getUserMedia.bind(media);media.__roboparkRecovered=true;
23
+ media.getUserMedia=function(constraints){return nativeGetUserMedia(constraints).catch(function(error){var audio=constraints&&constraints.audio,recoverable=error&&['NotFoundError','DevicesNotFoundError','OverconstrainedError'].includes(error.name);if(!audio||!recoverable)throw error;var fallback=audio===true?{echoCancellation:true,noiseSuppression:true,autoGainControl:true}:Object.assign({},audio);delete fallback.deviceId;delete fallback.groupId;delete fallback.sampleRate;delete fallback.voiceIsolation;return media.enumerateDevices().then(function(devices){var inputs=devices.filter(function(device){return device.kind==='audioinput'&&device.deviceId}),input=inputs.find(function(device){return device.deviceId!=='default'&&device.deviceId!=='communications'})||inputs[0];if(!input){if(mode==='test')return createSilentTestMicrophone();throw new Error('No browser microphone is currently attached. Connect or enable a microphone, then retry.')}fallback.deviceId={exact:input.deviceId};return nativeGetUserMedia({audio:fallback})}).catch(function(secondError){if(secondError&&String(secondError.message||'').indexOf('No browser microphone')===0)throw secondError;if(mode==='test'&&secondError&&['NotFoundError','DevicesNotFoundError','OverconstrainedError'].includes(secondError.name))return createSilentTestMicrophone();delete fallback.deviceId;return nativeGetUserMedia({audio:fallback})})}).then(function(stream){return constraints&&constraints.audio?processMicrophone(stream):stream})};
24
+ }
25
+ installMicrophoneRecovery();
26
+ function ensureElevenLabsClient(){if(window.ElevenLabsClient&&window.ElevenLabsClient.Conversation)return Promise.resolve(window.ElevenLabsClient);return new Promise(function(resolve,reject){var script=document.createElement('script');script.src='/vendor/elevenlabs-client.iife.js';script.onload=function(){if(window.ElevenLabsClient&&window.ElevenLabsClient.Conversation)resolve(window.ElevenLabsClient);else reject(new Error('ElevenLabs browser SDK loaded without a Conversation client.'))};script.onerror=function(){reject(new Error('ElevenLabs browser SDK is unavailable. Check the RoboPark control service build.'))};document.head.appendChild(script)})}
27
+ function el(id){return document.getElementById(id)}function text(id,value){var node=el(id);if(node)node.textContent=value==null?'--':String(value)}function fail(message){text('state','unable to connect');el('error').style.display='block';text('error',message);el('join').disabled=false;stage('configured','bad')}
28
+ function stage(name,status){var order=['configured','permission','signed_session','connected','conversation'],index=order.indexOf(name);document.querySelectorAll('.step').forEach(function(node,i){node.className='step '+(status==='bad'&&i===index?'bad':i<index?'done':i===index?'live':'')})}
29
+ function headers(){return {'content-type':'application/json','X-RoboPark-Session-Token':eventToken||''}}
30
+ function api(path,options){options=options||{};if(path==='/api/sessions/voice-call'&&options.body){var payload=JSON.parse(options.body);payload.call_mode=mode;payload.robot_id=mode==='production'?(robot||null):null;options.body=JSON.stringify(payload)}return fetch('/robopark'+path,Object.assign({cache:'no-store'},options)).then(function(response){if(!response.ok)return response.text().then(function(body){throw new Error(body||('request '+response.status))});return response.json()})}
31
+ function append(role,message){message=String(message||'').trim();if(!message)return;var key=role+'|'+message;if(seen[key])return;seen[key]=1;var node=document.createElement('div');node.className='turn '+role;node.textContent=message;el('transcript').appendChild(node);el('transcript').scrollTop=el('transcript').scrollHeight}
32
+ function management(path,body){if(!session||!eventToken)return Promise.resolve();return api('/api/sessions/'+encodeURIComponent(session)+path,{method:'POST',headers:headers(),body:JSON.stringify(body||{})}).catch(function(){})}
33
+ function event(stageName,status,message,details){var canonical={signed_session:'scheduler_session',provider_connected:'voice_worker',provider_error:'voice_worker',mode:String(message||'')==='speaking'?'playback_started':'stt_listening',interruption:'stt_listening',mcp_tool:'llm_response'}[stageName]||stageName;return management('/pipeline-events',{stage:canonical,status:status||'ok',message:message||'',details:details||{},source:'public_join'})}
34
+ function saveTurn(role,message){message=String(message||'').trim();if(!message)return Promise.resolve();return management('/transcript',{role:role,text:message,is_final:true,sequence:Object.keys(seen).length,source:'public_join'})}
35
+ function renderDetail(detail){var events=(detail&&detail.pipeline_events)||[],latest=events.length?events[events.length-1]:null;if(latest&&latest.status==='failed')stage('conversation','bad');el('events').innerHTML=events.slice(-9).reverse().map(function(item){return '<div class="event"><span>'+String(item.stage||'event').replace(/[<>]/g,'')+'</span><span class="'+String(item.status||'')+'">'+String(item.status||'')+'</span><span>'+String(item.message||'').replace(/[<>]/g,'')+'</span></div>'}).join('')||'<div class="event"><span>connected</span><span class="ok">ok</span><span>Waiting for conversation events.</span></div>'}
36
+ function pollDetail(){applySpeakerVolume();if(!session)return;api('/api/sessions/'+encodeURIComponent(session)+'/detail',{headers:headers()}).then(renderDetail).catch(function(){})}
37
+ function plainDetails(value){try{return JSON.parse(JSON.stringify(value||{}))}catch(_){return {raw:String(value||'')}}}
38
+ function closeManaged(reason,details){var oldSession=session,oldToken=eventToken;if(!oldSession||!oldToken)return Promise.resolve();return api('/api/sessions/'+encodeURIComponent(oldSession)+'/pipeline-events',{method:'POST',headers:{'content-type':'application/json','X-RoboPark-Session-Token':oldToken},body:JSON.stringify({stage:'voice_worker',status:reason==='visitor'?'ok':'warning',message:reason==='visitor'?'Operator ended voice conversation':'Provider session closed; supervisor will replace it',details:plainDetails(details),source:'public_join'})}).catch(function(){}).then(function(){return api('/api/sessions/'+encodeURIComponent(oldSession)+'/end?reason='+encodeURIComponent(reason),{method:'POST',headers:{'content-type':'application/json','X-RoboPark-Session-Token':oldToken}}).catch(function(){})})}
39
+ function stopTimers(){if(retryTimer){clearTimeout(retryTimer);retryTimer=null}if(keepalive){clearInterval(keepalive);keepalive=null}if(poller){clearInterval(poller);poller=null}}
40
+ function scheduleReconnect(){if(!desired||!alwaysOn||ending)return;connecting=false;conversation=null;conversationId=null;var attempt=++retryAttempt,delay=Math.min(30000,Math.round(750*Math.pow(2,Math.min(attempt-1,6))+Math.random()*500));text('state','reconnecting in '+Math.ceil(delay/1000)+'s');stage('connected');el('avatar-wrap').classList.remove('speaking');if(retryTimer)clearTimeout(retryTimer);retryTimer=setTimeout(function(){retryTimer=null;connectAttempt('reconnect')},delay)}
41
+ function recover(myGeneration,details){if(myGeneration!==generation||ending||!desired)return;var captured=plainDetails(details);captured.last_provider_error=lastProviderError;event('provider_error','failed','ElevenLabs disconnected; recovery scheduled',captured);closeManaged('provider_disconnected',captured);session=null;eventToken=null;conversation=null;conversationId=null;scheduleReconnect()}
42
+ function end(reason){if(ending)return;ending=true;desired=false;generation++;stopTimers();var active=conversation,details={manual:true,reason:reason||'visitor'};if(active)try{active.endSession()}catch(_){};closeManaged(reason||'visitor',details);session=null;eventToken=null;conversation=null;conversationId=null;connecting=false;retryAttempt=0;stage('conversation','done');text('state','manually disconnected - supervisor stopped');el('avatar-wrap').classList.remove('live','speaking');el('join').hidden=false;el('join').disabled=false;el('join').textContent='Start conversation';el('end').hidden=true;el('message').disabled=true;el('send').disabled=true;ending=false}
43
+ function callPayload(){return {character_preset_id:character,voice_stack_id:stack||null,client_name:'Public character link',engine:'elevenlabs',agent_id:agent,branch_id:branch||null,robot_id:robot||null,join_link_id:share||null}}
44
+ function connectAttempt(kind){if(!desired||ending||connecting||conversation)return;connecting=true;var myGeneration=++generation;text('state',kind==='reconnect'?'obtaining fresh voice session':'connecting');stage('signed_session');api('/api/sessions/voice-call',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(callPayload())}).then(function(call){if(myGeneration!==generation||!desired)throw new Error('connection superseded');session=call.session_id;eventToken=call.event_token;text('f-session',session);text('f-agent',call.agent_id);text('f-branch',call.branch_id||'default');text('f-robot',call.robot_id||'operator only');if(call.robot_video_ref&&robot){el('vision').hidden=false;el('robot-video').src='/fed/media/robots/'+encodeURIComponent(call.robot_video_ref)+'/video_feed'}stage('connected');event('signed_session','ok','Fresh signed ElevenLabs session created',{join_link_id:share,robot_id:robot,reconnect_attempt:retryAttempt});return ensureElevenLabsClient().then(function(SDK){return SDK.Conversation.startSession({signedUrl:call.signed_url,connectionType:'websocket',useWakeLock:true,onConnect:function(info){if(myGeneration!==generation)return;conversationId=info.conversationId;lastProviderError=null;retryAttempt=0;text('state','listening');el('error').style.display='none';el('avatar-wrap').classList.add('live');stage('conversation');management('/engine-connected',{conversation_id:conversationId,connection_type:'websocket',metadata:{join_link_id:share,public_join:true,supervised:true}});event('provider_connected','ok','ElevenLabs conversation connected',{conversation_id:conversationId,supervised:true})},onDisconnect:function(info){recover(myGeneration,info||{reason:'provider_disconnected'})},onError:function(message,context){if(myGeneration!==generation)return;lastProviderError={message:String(message||'Provider error'),context:plainDetails(context)};event('provider_error','failed',lastProviderError.message,lastProviderError);text('state','provider error - recovering')},onStatusChange:function(info){if(myGeneration===generation)event('provider_status',info&&info.status==='disconnected'?'warning':'ok',info&&info.status||'unknown',{})},onMessage:function(info){if(myGeneration!==generation)return;var message=String(info&&info.message||'').trim(),role=info&&info.role==='user'?'user':'assistant';append(role,message);if(message){history.push({role:role,text:message});history=history.slice(-20)}saveTurn(role,message)},onModeChange:function(info){if(myGeneration!==generation)return;var speaking=info.mode==='speaking';el('avatar-wrap').classList.toggle('speaking',speaking);text('state',speaking?'speaking':'listening');event('mode','ok',info.mode,{})},onInterruption:function(info){event('interruption','ok','Agent interrupted',info||{})},onMCPToolCall:function(info){event('mcp_tool','running','Tool call',info||{})}})})}).then(function(active){connecting=false;if(myGeneration!==generation||!desired){try{active.endSession()}catch(_){}return}conversation=active;if(kind==='reconnect'&&history.length&&conversation.sendContextualUpdate)conversation.sendContextualUpdate('RoboPark continuity context from the session that just reconnected:\\n'+history.map(function(turn){return turn.role+': '+turn.text}).join('\\n'));el('join').hidden=true;el('end').hidden=false;el('message').disabled=false;el('send').disabled=false;if(!keepalive)keepalive=setInterval(function(){management('/keepalive',{engine:'elevenlabs',conversation_id:conversationId,robot_id:robot,supervised:true})},10000);if(!poller)poller=setInterval(pollDetail,2000);pollDetail()}).catch(function(error){connecting=false;if(myGeneration!==generation||!desired)return;var details={message:error&&error.message||String(error),last_provider_error:lastProviderError};event('provider_error','failed','Voice connection attempt failed',details);closeManaged('connection_failed',details);session=null;eventToken=null;conversation=null;conversationId=null;if(alwaysOn)scheduleReconnect();else fail(details.message)})}
45
+ function start(){if(desired||conversation||connecting)return;desired=true;retryAttempt=0;el('join').disabled=true;text('state','requesting microphone permission');stage('permission');navigator.mediaDevices.getUserMedia({audio:true}).then(function(stream){stream.getTracks().forEach(function(track){track.stop()});connectAttempt('initial')}).catch(function(error){desired=false;fail(error&&error.message||String(error))})}
46
+ el('always-on').onclick=function(){alwaysOn=!alwaysOn;this.classList.toggle('on',alwaysOn);this.setAttribute('aria-pressed',String(alwaysOn));this.textContent='Always on: '+(alwaysOn?'ON':'OFF');if(!alwaysOn&&retryTimer){clearTimeout(retryTimer);retryTimer=null;text('state','disconnected - automatic recovery paused')}else if(alwaysOn&&desired&&!conversation&&!connecting){scheduleReconnect()}};
47
+ function pollDetail(){applySpeakerVolume();if(!session)return;api('/api/sessions/'+encodeURIComponent(session)+'/detail').then(renderDetail).catch(function(){})}
48
+ function deploymentRequest(path,options){var token=q.get('token')||'',suffix=token?(path.indexOf('?')>=0?'&':'?')+'token='+encodeURIComponent(token):'';return fetch('/robopark'+path+suffix,Object.assign({cache:'no-store'},options||{})).then(function(response){if(!response.ok)throw new Error('request '+response.status);return response.json()})}
49
+ function renderDeployment(data){var token=q.get('token')||'',rows=[];[['voice_vision_boxes','Windows voice + vision',['vision','conversation']],['motor_nodes','Pi motor server',['motor']]].forEach(function(group){var devices=data[group[0]]||[];if(!devices.length){rows.push('<div class="deployment-row"><b>'+group[1]+'</b><span>not registered</span><span></span></div>');return}devices.forEach(function(device){var seen=Date.parse(device.last_heartbeat||''),live=isFinite(seen)&&Date.now()-seen<90000,buttons=token?group[2].map(function(service){return '<button data-device="'+device.id+'" data-service="'+service+'" data-action="start">Start '+service+'</button><button data-device="'+device.id+'" data-service="'+service+'" data-action="restart">Restart</button><button data-device="'+device.id+'" data-service="'+service+'" data-action="stop">Stop</button>'}).join(''):'management token required for controls';rows.push('<div class="deployment-row"><b>'+group[1]+'</b><span class="'+(live?'ready':'')+'">'+String(device.name||device.id)+' / '+(live?'online':'stale')+'</span><div class="deployment-actions">'+buttons+'</div></div>')})});deployment.innerHTML=rows.join('');deployment.querySelectorAll('button[data-action]').forEach(function(button){button.onclick=function(){var action=button.getAttribute('data-action'),service=button.getAttribute('data-service'),device=button.getAttribute('data-device');if((action==='stop'||action==='restart')&&!confirm(action+' '+service+'?'))return;button.disabled=true;deploymentRequest('/api/devices/'+encodeURIComponent(device)+'/services/'+encodeURIComponent(service)+'/'+action,{method:'POST',headers:{'content-type':'application/json'}}).then(function(){button.textContent='queued'}).catch(function(error){button.textContent='failed';fail(error.message)}).then(function(){setTimeout(function(){button.disabled=false},1800)})}})}
50
+ function loadDeployment(){deploymentRequest('/api/characters/'+encodeURIComponent(character)+'/deployment').then(renderDeployment).catch(function(){deployment.innerHTML='<div class="deployment-row"><b>Character hardware</b><span>deployment status unavailable</span><span></span></div>'})}
51
+ el('speaker-volume').value=String(speakerVolume);text('speaker-value',speakerVolume+'%');el('mic-sensitivity').value=String(micSensitivity);text('mic-value',micSensitivity+'%');el('speaker-volume').oninput=function(){speakerVolume=Math.max(0,Math.min(100,Number(this.value)||0));text('speaker-value',speakerVolume+'%');persistNumber('robopark.voice.speaker',speakerVolume);applySpeakerVolume()};el('mic-sensitivity').oninput=function(){setMicSensitivity(this.value);text('mic-value',micSensitivity+'%');persistNumber('robopark.voice.micSensitivity',micSensitivity)};
52
+ el('ops-toggle').onclick=function(){el('ops').classList.toggle('open');this.classList.toggle('on',el('ops').classList.contains('open'))};el('ops-close').onclick=function(){el('ops').classList.remove('open');el('ops-toggle').classList.remove('on')};el('join').onclick=start;el('end').onclick=function(){end('visitor')};el('compose').onsubmit=function(e){e.preventDefault();var message=el('message').value.trim();if(message&&conversation){conversation.sendUserMessage(message);el('message').value=''}};
53
+ text('f-mode',mode);text('f-character',character);text('f-agent',agent);text('f-branch',branch||'default');text('f-robot',mode==='production'?(robot||'missing robot'):'not requested');text('f-share',share||'untracked');
54
+ if(character)loadDeployment();
55
+ if(!character||!agent){fail('This join link is incomplete. Ask the operator for a new character link.');return}if(mode==='production'&&!robot){fail('This production link has no registered robot. Ask the operator for a corrected link.');return}api('/api/character-presets/'+encodeURIComponent(character)).then(function(preset){text('character-name',preset.name||preset.id);text('subtitle',(mode==='test'?'Browser-only test call · ':'Production on-site call · ')+(preset.description||'Secure ElevenLabs conversation managed by RoboPark'));text('f-character',preset.name||preset.id);text('fallback',String(preset.name||preset.id).slice(0,2).toUpperCase());if(preset.img){el('avatar').src=preset.img;el('avatar').onload=function(){el('fallback').style.display='none'}};text('state','ready to connect');el('join').disabled=false}).catch(function(){fail('Character configuration is unavailable.')});
56
+ })();
57
+ </script></body></html>`;
@@ -79,6 +79,7 @@ gateway
79
79
  .command('start')
80
80
  .description('Start the dashboard/gateway and print a one-line device connection token')
81
81
  .option('--port <port>', 'gateway HTTP port', '8080')
82
+ .option('--control-port <port>', 'full Park Control Center port', '47913')
82
83
  .option('--host <host>', 'gateway bind host', '0.0.0.0')
83
84
  .option('--public-url <url>', 'URL devices use to reach this gateway')
84
85
  .option('--data-dir <dir>', 'durable gateway data directory')
@@ -142,6 +143,7 @@ program
142
143
  program
143
144
  .command('_gateway', { hidden: true })
144
145
  .option('--port <port>', 'gateway HTTP port', '8080')
146
+ .option('--control-port <port>', 'full Park Control Center port', '47913')
145
147
  .option('--host <host>', 'gateway bind host', '0.0.0.0')
146
148
  .option('--data-dir <dir>', 'durable gateway data directory')
147
149
  .action(async (opts) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robopark",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
4
4
  "description": "Standalone packaged RoboPark control center and supervised robot runtime.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,17 +9,20 @@
9
9
  "files": [
10
10
  "bin/robopark.js",
11
11
  "dist",
12
+ "scripts/windows-shim-fix.cjs",
12
13
  "scheduler",
13
14
  "pi-client",
14
15
  "conversation",
15
16
  "vision",
16
17
  "screen",
18
+ "static",
17
19
  "README.md"
18
20
  ],
19
21
  "scripts": {
20
22
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
21
23
  "build": "npm run clean && tsc -p tsconfig.json",
22
24
  "test": "node --test \"test/**/*.test.mjs\"",
25
+ "postinstall": "node scripts/windows-shim-fix.cjs",
23
26
  "prepack": "npm run build && npm test"
24
27
  },
25
28
  "engines": {
@@ -29,7 +32,8 @@
29
32
  "chalk": "^5.3.0",
30
33
  "commander": "^12.1.0",
31
34
  "conf": "^12.0.0",
32
- "execa": "^9.3.0"
35
+ "execa": "^9.3.0",
36
+ "livekit-client": "^2.20.1"
33
37
  },
34
38
  "devDependencies": {
35
39
  "@types/node": "^20.14.0",
package/scheduler/main.py CHANGED
@@ -7009,7 +7009,7 @@ async def dashboard():
7009
7009
  </head>
7010
7010
  <body class="bg-gray-900 text-white">
7011
7011
  <div class="bg-amber-950 border-b border-amber-600 px-4 py-3 text-sm text-amber-100">
7012
- <strong>Legacy scheduler view.</strong> The canonical operator UI is the full RoboPark Control Center Park view on the mesh hub at port 47913.
7012
+ <strong>Scheduler service view.</strong> The canonical operator UI is the full standalone RoboPark Control Center on this gateway at port 47913.
7013
7013
  <a id="fullControlCenterLink" class="underline ml-2" href="#">Open full Park Control Center</a>
7014
7014
  </div>
7015
7015
  <div id="app"></div>
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const { readFileSync, unlinkSync } = require('node:fs');
4
+ const { dirname, join } = require('node:path');
5
+
6
+ function isTruthy(value) {
7
+ return /^(1|true|yes)$/i.test(String(value || ''));
8
+ }
9
+
10
+ function resolveGlobalBinDirectory() {
11
+ if (process.env.ROBOPARK_SHIM_DIR) return process.env.ROBOPARK_SHIM_DIR;
12
+ if (!isTruthy(process.env.npm_config_global)) return null;
13
+ return process.env.npm_config_prefix || dirname(process.execPath);
14
+ }
15
+
16
+ function isOurNpmShim(contents) {
17
+ return contents
18
+ .replaceAll('\\', '/')
19
+ .includes('node_modules/robopark/bin/robopark.js');
20
+ }
21
+
22
+ function removeConflictingShim(path) {
23
+ let contents;
24
+ try {
25
+ contents = readFileSync(path, 'utf8');
26
+ } catch (error) {
27
+ if (error && error.code === 'ENOENT') return false;
28
+ throw error;
29
+ }
30
+ if (!isOurNpmShim(contents)) return false;
31
+ unlinkSync(path);
32
+ return true;
33
+ }
34
+
35
+ function repairWindowsCommandResolution() {
36
+ const forced = isTruthy(process.env.ROBOPARK_FORCE_SHIM_FIX);
37
+ if (process.platform !== 'win32' && !forced) return [];
38
+ const binDirectory = resolveGlobalBinDirectory();
39
+ if (!binDirectory) return [];
40
+
41
+ // Keep robopark.cmd as the single Windows global entrypoint. On affected
42
+ // hosts cmd.exe opens the extensionless POSIX shim with an application
43
+ // picker, while PowerShell may reject the .ps1 shim by execution policy.
44
+ return ['robopark', 'robopark.ps1']
45
+ .filter((name) => removeConflictingShim(join(binDirectory, name)));
46
+ }
47
+
48
+ try {
49
+ const removed = repairWindowsCommandResolution();
50
+ if (removed.length > 0) {
51
+ console.log(`robopark: repaired Windows command launch (${removed.join(', ')} removed; robopark.cmd retained).`);
52
+ }
53
+ } catch (error) {
54
+ // robopark.cmd remains available even on machines that forbid shim cleanup.
55
+ console.warn(`robopark: Windows command repair skipped: ${error.message}`);
56
+ }
57
+
58
+ module.exports = { isOurNpmShim, repairWindowsCommandResolution };
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ElevenLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.