infinicode 2.8.23 → 2.8.26

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.
@@ -55,26 +55,52 @@ async function meshRpc(targetUrl, token, env) {
55
55
  }
56
56
  return (await res.json());
57
57
  }
58
- async function getHubNodeId(hubUrl, token) {
58
+ async function getHubManifest(hubUrl, token) {
59
59
  try {
60
60
  const url = `${hubUrl.replace(/\/$/, '')}/fed/manifest?token=${encodeURIComponent(token)}`;
61
61
  const res = await fetch(url, { headers: { accept: 'application/json' } });
62
62
  if (!res.ok)
63
- return null;
63
+ return { nodeId: null, platform: null };
64
64
  const manifest = (await res.json());
65
- return manifest.nodeId || manifest.id || null;
65
+ return { nodeId: manifest.nodeId || manifest.id || null, platform: manifest.platform ?? null };
66
+ }
67
+ catch {
68
+ return { nodeId: null, platform: null };
69
+ }
70
+ }
71
+ /** Fallback: GET /fed/status returns the hub's own NodeManifest under `self`,
72
+ * including `platform`. Used when /fed/manifest doesn't carry it. */
73
+ async function getHubPlatformFromStatus(hubUrl, token) {
74
+ try {
75
+ const url = `${hubUrl.replace(/\/$/, '')}/fed/status?token=${encodeURIComponent(token)}`;
76
+ const res = await fetch(url, { headers: { accept: 'application/json' } });
77
+ if (!res.ok)
78
+ return null;
79
+ const status = (await res.json());
80
+ return status.self?.platform ?? null;
66
81
  }
67
82
  catch {
68
83
  return null;
69
84
  }
70
85
  }
71
- const LIVEKIT_SETUP_PROMPT = `You are the RoboPark infrastructure agent running on the fleet hub.
86
+ const LIVEKIT_SETUP_PROMPT_HEADER = `You are the RoboPark infrastructure agent running on the fleet hub.
72
87
 
73
88
  GOAL: Install and start a LiveKit server in Docker on this machine so the RoboPark scheduler can mint tokens and the robot preview agent can publish camera/audio.
74
89
 
75
90
  DO NOT ask the user for confirmation. Execute the steps directly. If a step is already done, skip it and report it.
76
-
77
- Steps:
91
+ `;
92
+ const LIVEKIT_SETUP_PROMPT_FOOTER = `
93
+ IMPORTANT: this is a headless fleet node. Do not open a browser. Only run shell commands and report the final LiveKit URL + credentials in a JSON code block like:
94
+ \`\`\`json
95
+ {
96
+ "url": "ws://127.0.0.1:7880",
97
+ "api_key": "...",
98
+ "api_secret": "...",
99
+ "running": true
100
+ }
101
+ \`\`\`
102
+ `;
103
+ const LIVEKIT_SETUP_STEPS_LINUX = `Steps:
78
104
  1. Check if Docker is installed and running. If not, install Docker (apt-get install docker.io on Debian/Ubuntu, or dnf install docker on Fedora, or use the official convenience script if needed) and start it with systemctl.
79
105
  2. Pull the official LiveKit server image: docker pull livekit/livekit-server:latest
80
106
  3. Create a directory /opt/livekit if it does not exist.
@@ -82,23 +108,58 @@ Steps:
82
108
  docker run --rm livekit/livekit-server:latest generate-keys
83
109
  Parse the output and write it as a YAML file with fields api_key and api_secret.
84
110
  5. Start the LiveKit server container with host networking on port 7880:
85
- docker run -d --name livekit-server --restart unless-stopped --network host \
86
- -v /opt/livekit/keys.yaml:/etc/livekit/keys.yaml:ro \
87
- livekit/livekit-server:latest \
111
+ docker run -d --name livekit-server --restart unless-stopped --network host \\
112
+ -v /opt/livekit/keys.yaml:/etc/livekit/keys.yaml:ro \\
113
+ livekit/livekit-server:latest \\
88
114
  --config /etc/livekit/keys.yaml --bind 0.0.0.0 --port 7880
89
115
  6. Wait up to 30 seconds and verify it is healthy: curl -sf http://127.0.0.1:7880
90
116
  7. Report back the WebSocket URL (ws://THIS_MACHINE_TAILSCALE_IP:7880 or ws://127.0.0.1:7880), the api_key, the api_secret, and whether the container is running.
117
+ `;
118
+ // Windows hosts: Docker Desktop does not support --network host, so ports are
119
+ // mapped explicitly. --dev mode ships with well-known devkey/secret, which
120
+ // sidesteps the `generate-keys` dance (that container command is awkward to
121
+ // run/parse consistently from PowerShell).
122
+ const LIVEKIT_SETUP_STEPS_WINDOWS = `Steps (Windows host — use PowerShell, NOT bash):
123
+ 1. Check if Docker Desktop is installed and running: docker info
124
+ If it is not installed, stop and report that Docker Desktop must be installed manually (winget install Docker.DockerDesktop), since it requires a GUI/WSL2 backend and cannot be silently installed headlessly.
125
+ 2. Pull the official LiveKit server image: docker pull livekit/livekit-server:latest
126
+ 3. Start the LiveKit server container in --dev mode (uses the well-known devkey/secret credentials, no key generation needed). Docker Desktop on Windows does not support --network host, so map ports explicitly:
127
+ docker run -d --name livekit-server --restart unless-stopped \`
128
+ -p 7880:7880 -p 7881:7881 -p 7881:7881/udp -p 50000-50100:50000-50100/udp \`
129
+ livekit/livekit-server:latest --dev --bind 0.0.0.0
130
+ (If a container named livekit-server already exists, remove it first with: docker rm -f livekit-server)
131
+ 4. Wait up to 30 seconds and verify it is healthy: curl.exe -sf http://127.0.0.1:7880 (or Invoke-WebRequest)
132
+ 5. The credentials in --dev mode are fixed: api_key = devkey, api_secret = secret.
133
+ 6. Report back the WebSocket URL (ws://THIS_MACHINE_TAILSCALE_IP:7880 or ws://127.0.0.1:7880), api_key "devkey", api_secret "secret", and whether the container is running.
134
+ `;
135
+ // Used when the hub's platform could not be determined before dispatch: ask
136
+ // the agent to detect its own OS first, then follow the matching branch.
137
+ const LIVEKIT_SETUP_STEPS_AUTODETECT = `Steps:
138
+ 0. FIRST, detect your own OS before running anything else:
139
+ - On Windows, $env:OS will be "Windows_NT" (PowerShell) — or the presence of \`Get-Command\` with no \`uname\` binary.
140
+ - On Linux/macOS, \`uname\` will succeed and print Linux/Darwin.
141
+ Branch into ONE of the two command sets below based on that detection. Do not run both.
91
142
 
92
- IMPORTANT: this is a headless fleet node. Do not open a browser. Only run shell commands and report the final LiveKit URL + credentials in a JSON code block like:
93
- \`\`\`json
94
- {
95
- "url": "ws://127.0.0.1:7880",
96
- "api_key": "...",
97
- "api_secret": "...",
98
- "running": true
99
- }
100
- \`\`\`
143
+ --- IF WINDOWS (use PowerShell): ---
144
+ ${LIVEKIT_SETUP_STEPS_WINDOWS}
145
+
146
+ --- IF LINUX/macOS (use bash/sh): ---
147
+ ${LIVEKIT_SETUP_STEPS_LINUX}
101
148
  `;
149
+ function buildLivekitSetupPrompt(platform) {
150
+ let steps;
151
+ if (platform === 'win32') {
152
+ steps = LIVEKIT_SETUP_STEPS_WINDOWS;
153
+ }
154
+ else if (platform === 'linux' || platform === 'darwin') {
155
+ steps = LIVEKIT_SETUP_STEPS_LINUX;
156
+ }
157
+ else {
158
+ // Platform unknown ahead of dispatch — let the agent detect itself.
159
+ steps = LIVEKIT_SETUP_STEPS_AUTODETECT;
160
+ }
161
+ return LIVEKIT_SETUP_PROMPT_HEADER + '\n' + steps + LIVEKIT_SETUP_PROMPT_FOOTER;
162
+ }
102
163
  function extractJson(text) {
103
164
  const m = text.match(/\`\`\`json\s*([\s\S]*?)\s*\`\`\`/);
104
165
  if (!m)
@@ -152,14 +213,20 @@ export async function roboparkSetupLivekit(opts) {
152
213
  console.log(chalk.dim(' ' + '─'.repeat(52)));
153
214
  console.log(` hub: ${chalk.cyan(hubUrl)}`);
154
215
  console.log(` scheduler: ${chalk.cyan(schedulerUrl ?? 'not provided')}`);
216
+ const from = hostname().split('.')[0];
217
+ const manifest = await getHubManifest(hubUrl, token);
218
+ let platform = manifest.platform;
219
+ if (!platform)
220
+ platform = await getHubPlatformFromStatus(hubUrl, token);
221
+ console.log(` hub platform: ${chalk.cyan(platform ?? 'unknown (agent will self-detect)')}`);
155
222
  console.log();
223
+ const prompt = buildLivekitSetupPrompt(platform);
156
224
  if (opts.dryRun) {
157
225
  console.log(chalk.yellow(' dry run — prompt that would be sent:'));
158
- console.log(chalk.dim(LIVEKIT_SETUP_PROMPT.split('\n').map(l => ' ' + l).join('\n')));
226
+ console.log(chalk.dim(prompt.split('\n').map(l => ' ' + l).join('\n')));
159
227
  return;
160
228
  }
161
- const from = hostname().split('.')[0];
162
- const nodeId = await getHubNodeId(hubUrl, token);
229
+ const nodeId = manifest.nodeId;
163
230
  if (!nodeId) {
164
231
  console.log(chalk.red(' ✗ hub manifest not reachable. Is infinicode serve running on the hub?'));
165
232
  process.exit(1);
@@ -168,7 +235,7 @@ export async function roboparkSetupLivekit(opts) {
168
235
  const dispatchEnv = envelope('task.dispatch', from, {
169
236
  description: 'Install LiveKit server on hub via Docker',
170
237
  capabilities: ['shell', 'docker', 'reasoning'],
171
- prompt: LIVEKIT_SETUP_PROMPT,
238
+ prompt,
172
239
  agent: 'opencode',
173
240
  context: { role: 'hub', site: 'fleet-hub' },
174
241
  }, { to: nodeId, auth: token });
@@ -9,7 +9,7 @@
9
9
  * robopark setup --control --start
10
10
  */
11
11
  import chalk from 'chalk';
12
- import { mkdirSync, writeFileSync } from 'node:fs';
12
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } 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';
@@ -23,6 +23,43 @@ 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
+ }
26
63
  /** Run an external command and detach unless foreground is requested. */
27
64
  function runDetached(cmd, args, env) {
28
65
  const proc = spawn(cmd, args, {
@@ -193,14 +230,21 @@ async function setupRobot(config, opts, ctx, internal) {
193
230
  const robotArgv = await infinicodeArgv(args);
194
231
  console.log(chalk.dim(' starting robot satellite node…'));
195
232
  runDetached(robotArgv[0], robotArgv.slice(1));
233
+ // NOTE: there is deliberately no separate `robopark enroll` spawn here.
234
+ // preview_agent.py's own startup (PreviewAgent.start(), see
235
+ // packages/robopark/scheduler/preview_agent.py) already performs
236
+ // enrollment itself whenever it has an --enrollment-token and no
237
+ // device_token yet. Spawning `robopark enroll` as well used to launch a
238
+ // SECOND full preview_agent.py process (enroll.ts runs the whole script,
239
+ // not a one-shot enroll-and-exit) with a different arg set (no
240
+ // --video-device/--audio-device/vision flags), which raced the "real"
241
+ // preview-agent process below for the same camera/mic and the vision
242
+ // webhook port (5057). --enrollment-token is already forwarded via
243
+ // previewAgentArgs, so one process below covers both enrollment and the
244
+ // long-running agent.
196
245
  if (opts.enrollmentToken) {
197
- console.log(chalk.dim(' enrolling robot with scheduler…'));
198
- const enrollArgv = [process.execPath, process.argv[1], 'enroll',
199
- '--scheduler-url', schedulerUrl,
200
- '--robot-id', internal.name,
201
- '--enrollment-token', opts.enrollmentToken,
202
- '--save-config'];
203
- runDetached(enrollArgv[0], enrollArgv.slice(1));
246
+ console.log(chalk.dim(' fresh --enrollment-token given — clearing any stale device identity…'));
247
+ resetStaleEnrollment();
204
248
  }
205
249
  console.log(chalk.dim(' starting preview agent…'));
206
250
  const previewArgv = [process.execPath, process.argv[1], ...previewAgentArgs];
@@ -101,11 +101,15 @@ function stopAllUnix() {
101
101
  }
102
102
  }
103
103
  if (process.platform === 'linux') {
104
- const list = spawnSync('systemctl', ['list-units', '--all', '--plain', '--no-legend', 'robopark-*'], { encoding: 'utf8' });
104
+ // Broad match, not just "robopark-*" known unit names in the wild
105
+ // include a bare "infinicode.service" (see pi-client install scripts),
106
+ // which a narrower glob would silently leave running (and respawning
107
+ // whatever we just killed, if it has Restart=always).
108
+ const list = spawnSync('systemctl', ['list-units', '--all', '--plain', '--no-legend'], { encoding: 'utf8' });
105
109
  const units = (list.stdout ?? '')
106
110
  .split('\n')
107
111
  .map(l => l.trim().split(/\s+/)[0])
108
- .filter((u) => !!u && u.endsWith('.service'));
112
+ .filter((u) => !!u && u.endsWith('.service') && /infinicode|robopark/i.test(u));
109
113
  for (const unit of units) {
110
114
  spawnSync('systemctl', ['stop', unit], { encoding: 'utf8' });
111
115
  const disable = spawnSync('systemctl', ['disable', unit], { encoding: 'utf8' });
@@ -16,6 +16,10 @@ import { roboparkScan } from './robopark/scan.js';
16
16
  import { roboparkServe } from './robopark/serve.js';
17
17
  import { roboparkSetup } from './robopark/setup.js';
18
18
  import { stopAll } from './robopark/stop-all.js';
19
+ import { roboparkDoctor } from './robopark/doctor.js';
20
+ import { roboparkAddRobot } from './robopark/add-robot.js';
21
+ import { saveHubProfile, clearHubProfile } from './robopark/profile.js';
22
+ import { roboparkAgentUp, roboparkAgentDown, roboparkAgentLogs } from './robopark/agent-ctl.js';
19
23
  // Read the real version from package.json so `--version` never drifts from
20
24
  // what is actually installed (this was a hardcoded constant that went stale
21
25
  // across several releases — see the identical fix + comment in cli.ts).
@@ -160,6 +164,95 @@ program
160
164
  process.exitCode = 1;
161
165
  }
162
166
  });
167
+ program
168
+ .command('doctor')
169
+ .description('Run a full fleet health check: hub, scheduler, LiveKit servers, device heartbeats, production mode')
170
+ .option('--hub-url <url>', 'hub federation URL, e.g. http://100.64.1.2:47913')
171
+ .option('--token <token>', 'mesh auth token')
172
+ .option('--scheduler-url <url>', 'scheduler base URL, e.g. http://100.64.1.2:8080')
173
+ .action(async (opts) => {
174
+ await roboparkDoctor(opts);
175
+ });
176
+ program
177
+ .command('server-add')
178
+ .description('Register a LiveKit server with the RoboPark scheduler')
179
+ .option('--hub-url <url>', 'hub federation URL, e.g. http://100.64.1.2:47913')
180
+ .option('--scheduler-url <url>', 'scheduler URL, e.g. http://100.64.1.2:8080 (derived from --hub-url if omitted)')
181
+ .option('--token <token>', 'mesh auth token (optional)')
182
+ .requiredOption('--id <id>', 'unique server id, e.g. livekit-hub-docker')
183
+ .option('--name <name>', 'display name (defaults to --id)')
184
+ .requiredOption('--url <url>', 'LiveKit ws:// URL, e.g. ws://100.64.1.2:7880')
185
+ .option('--webhook-url <url>', 'internal http URL for health/metrics (defaults to --url with ws->http)')
186
+ .option('--api-key <key>', "LiveKit API key (defaults to 'devkey')")
187
+ .option('--api-secret <secret>', "LiveKit API secret (defaults to 'secret')")
188
+ .option('--max-sessions <n>', 'max concurrent sessions', '8')
189
+ .action(async (opts) => {
190
+ const { roboparkServerAdd } = await import('./robopark/server-add.js');
191
+ await roboparkServerAdd(opts);
192
+ });
193
+ program
194
+ .command('llm-set')
195
+ .description('Upsert LLM_PROVIDER/OLLAMA_* keys into a ROBOVOICE .env file')
196
+ .requiredOption('--env-path <path>', 'path to the target .env file (e.g. a ROBOVOICE checkout)')
197
+ .requiredOption('--provider <name>', 'ollama | ollama-cloud | groq')
198
+ .option('--model <name>', 'model name (ollama providers only)')
199
+ .option('--host <url>', 'Ollama server URL (ollama providers only)')
200
+ .option('--api-key <key>', 'API key for cloud auth (ollama-cloud; currently unused by ROBOVOICE — see CLI warning)')
201
+ .action(async (opts) => {
202
+ const { roboparkLlmSet } = await import('./robopark/llm-set.js');
203
+ await roboparkLlmSet(opts);
204
+ });
205
+ program
206
+ .command('hub-use')
207
+ .description('Save hub/scheduler/token/site as defaults so other commands stop needing them repeated')
208
+ .option('--hub-url <url>', 'hub federation URL, e.g. http://100.64.1.2:47913')
209
+ .option('--token <token>', 'shared mesh token')
210
+ .option('--scheduler-url <url>', 'scheduler base URL, e.g. http://100.64.1.2:8080')
211
+ .option('--site <site>', 'physical location, e.g. "Tel Aviv"')
212
+ .option('--clear', 'wipe the saved hub profile')
213
+ .action(async (opts) => {
214
+ if (opts.clear) {
215
+ clearHubProfile();
216
+ console.log(chalk.green(' ✓ cleared saved hub profile'));
217
+ return;
218
+ }
219
+ saveHubProfile({ hubUrl: opts.hubUrl, token: opts.token, schedulerUrl: opts.schedulerUrl, site: opts.site });
220
+ console.log(chalk.green(' ✓ saved hub profile to ~/.robopark/hub-profile.json'));
221
+ });
222
+ program
223
+ .command('add-robot <name>')
224
+ .description('Mint a scheduler enrollment token and print (or run) the robot setup one-liner')
225
+ .option('--site <site>', 'physical location, e.g. "Tel Aviv"')
226
+ .option('--hub-url <url>', 'hub federation URL (defaults to saved hub profile / auto-discovery)')
227
+ .option('--token <token>', 'shared mesh token (defaults to saved hub profile / auto-discovery)')
228
+ .option('--scheduler-url <url>', 'scheduler base URL (defaults to saved hub profile / auto-discovery)')
229
+ .option('--start', 'start the robot node on this machine now, instead of printing the one-liner')
230
+ .action(async (name, opts) => {
231
+ await roboparkAddRobot(config, { name, ...opts });
232
+ });
233
+ program
234
+ .command('agent-up')
235
+ .description('Start the ROBOVOICE docker stack and register its LiveKit server with the scheduler')
236
+ .requiredOption('--path <dir>', 'path to the ROBOVOICE checkout (contains docker-compose.yaml)')
237
+ .option('--scheduler-url <url>', 'scheduler URL (defaults to resolved hub context)')
238
+ .option('--hub-url <url>', 'hub URL (defaults to resolved hub context)')
239
+ .option('--token <token>', 'mesh token')
240
+ .option('--server-id <id>', "server id to register (default: 'robovoice-<hostname>')")
241
+ .option('--server-name <name>', 'server display name')
242
+ .option('--no-register', 'bring up docker only; skip scheduler registration')
243
+ .action(roboparkAgentUp);
244
+ program
245
+ .command('agent-down')
246
+ .description('Stop the ROBOVOICE docker stack')
247
+ .requiredOption('--path <dir>', 'path to the ROBOVOICE checkout')
248
+ .action(roboparkAgentDown);
249
+ program
250
+ .command('agent-logs')
251
+ .description('Tail ROBOVOICE docker compose logs')
252
+ .requiredOption('--path <dir>', 'path to the ROBOVOICE checkout')
253
+ .option('-f, --follow', 'follow log output')
254
+ .option('--service <name>', 'scope logs to one compose service (e.g. agent, livekit)')
255
+ .action(roboparkAgentLogs);
163
256
  program
164
257
  .command('setup')
165
258
  .description('Set up this device in one pass')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.23",
3
+ "version": "2.8.26",
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",
@@ -73,6 +73,7 @@
73
73
  "node": ">=20"
74
74
  },
75
75
  "dependencies": {
76
+ "@anthropic-ai/claude-code": "^2.1.207",
76
77
  "@modelcontextprotocol/sdk": "^1.29.0",
77
78
  "chalk": "^5.3.0",
78
79
  "commander": "^12.1.0",
@@ -298,6 +298,13 @@ async def init_db():
298
298
  ("sessions", "joined_at", "TEXT"),
299
299
  ("devices", "video_device", "TEXT"),
300
300
  ("devices", "audio_device", "TEXT"),
301
+ # Silence-based session end (see POST /api/sessions/{id}/keepalive
302
+ # and GET /api/sessions/{id}/status): tracks the last time some
303
+ # caller signaled this session was still active. Defaults to
304
+ # started_at at INSERT time; existing rows on an upgraded DB will
305
+ # have NULL here until touched, so readers should fall back to
306
+ # started_at (see the /status endpoint below).
307
+ ("sessions", "last_activity_at", "TEXT"),
301
308
  ):
302
309
  try:
303
310
  await db.execute(f"ALTER TABLE {_table} ADD COLUMN {_column} {_coldef}")
@@ -485,6 +492,12 @@ async def request_session(robot_id: str,
485
492
  async with db.execute("SELECT name FROM devices WHERE id = ?", (robot_id,)) as dc:
486
493
  device_row = await dc.fetchone()
487
494
  if device_row:
495
+ # OR IGNORE means this name is only ever written on first
496
+ # creation of the robots row. PATCH /api/devices/{device_id}
497
+ # now keeps robots.name in sync on every rename going forward,
498
+ # so a stale robots.name here is only possible if a session
499
+ # request race wins before that PATCH sync runs — not an
500
+ # ongoing drift concern.
488
501
  await db.execute(
489
502
  "INSERT OR IGNORE INTO robots (id, name, status) VALUES (?, ?, 'idle')",
490
503
  (robot_id, device_row["name"]),
@@ -517,11 +530,12 @@ async def request_session(robot_id: str,
517
530
  session_id = f"session_{robot_id}_{int(datetime.utcnow().timestamp())}"
518
531
  room_name = f"robopark-{robot_id}-{int(datetime.utcnow().timestamp())}"
519
532
 
533
+ _now_iso = datetime.utcnow().isoformat()
520
534
  await db.execute("""
521
- INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
522
- VALUES (?, ?, ?, ?, ?)
523
- """, (session_id, robot_id, server["id"], room_name, datetime.utcnow().isoformat()))
524
-
535
+ INSERT INTO sessions (id, robot_id, server_id, room_name, started_at, last_activity_at)
536
+ VALUES (?, ?, ?, ?, ?, ?)
537
+ """, (session_id, robot_id, server["id"], room_name, _now_iso, _now_iso))
538
+
525
539
  # Update robot status + count this as a camera trigger. request-session
526
540
  # is the natural trigger point ("robot requests a session after scene
527
541
  # detection"), so the trigger_count populates here with no client change.
@@ -808,10 +822,11 @@ async def create_web_client_session(req: Optional[WebClientSessionRequest] = Non
808
822
  session_id = f"web_{int(datetime.utcnow().timestamp())}"
809
823
  room_name = f"robopark_web_{int(datetime.utcnow().timestamp())}"
810
824
 
825
+ _now_iso = datetime.utcnow().isoformat()
811
826
  await db.execute("""
812
- INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
813
- VALUES (?, ?, ?, ?, ?)
814
- """, (session_id, "web-client", server["id"], room_name, datetime.utcnow().isoformat()))
827
+ INSERT INTO sessions (id, robot_id, server_id, room_name, started_at, last_activity_at)
828
+ VALUES (?, ?, ?, ?, ?, ?)
829
+ """, (session_id, "web-client", server["id"], room_name, _now_iso, _now_iso))
815
830
 
816
831
  await db.commit()
817
832
 
@@ -899,6 +914,61 @@ async def session_joined(session_id: str,
899
914
  await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
900
915
  return {"status": "ok", "joined_at": now, "latency_ms": latency_ms}
901
916
 
917
+ @app.post("/api/sessions/{session_id}/keepalive")
918
+ async def session_keepalive(session_id: str,
919
+ authorization: Optional[str] = Header(default=None)):
920
+ """Silence-based session extension signal.
921
+
922
+ INTEGRATION POINT (out of scope here — lives in a different repo): the
923
+ VOICE AGENT is the one process that actually knows, via VAD, whether the
924
+ user/agent are still talking. While a motion-triggered session's
925
+ conversation is active, the voice agent should call this endpoint
926
+ periodically (e.g. every few seconds) to bump last_activity_at. The robot
927
+ side (preview_agent.py) has no independent way to know if a conversation
928
+ is ongoing — it only knows this via what it reads back from here through
929
+ GET /api/sessions/{session_id}/status. If nothing ever calls this
930
+ endpoint, last_activity_at simply stays at started_at and the session
931
+ will be torn down by preview_agent.py's silence timeout shortly after it
932
+ starts — that's an intentionally conservative default, not a bug.
933
+
934
+ preview_agent.py additionally enforces vision_session_seconds as a hard
935
+ ceiling regardless of activity, so a misbehaving/stuck caller can never
936
+ hold the publisher forever even if it calls this endpoint continuously.
937
+ """
938
+ if not await _authorize_fleet(authorization):
939
+ raise HTTPException(401, "Invalid or missing device token")
940
+ now = datetime.utcnow().isoformat()
941
+ async with aiosqlite.connect(DB_PATH) as db:
942
+ cur = await db.execute(
943
+ "UPDATE sessions SET last_activity_at = ? WHERE id = ? AND ended_at IS NULL",
944
+ (now, session_id),
945
+ )
946
+ await db.commit()
947
+ if cur.rowcount == 0:
948
+ raise HTTPException(404, "Session not found or already ended")
949
+ return {"status": "ok", "last_activity_at": now}
950
+
951
+ @app.get("/api/sessions/{session_id}/status")
952
+ async def get_session_status(session_id: str):
953
+ """Lightweight session status for polling — used by preview_agent.py to
954
+ decide whether a motion-triggered session is still "active" (recent
955
+ last_activity_at) or has gone silent and should be torn down. See
956
+ POST /api/sessions/{session_id}/keepalive for who updates last_activity_at."""
957
+ async with aiosqlite.connect(DB_PATH) as db:
958
+ db.row_factory = aiosqlite.Row
959
+ async with db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)) as c:
960
+ session = await c.fetchone()
961
+ if not session:
962
+ raise HTTPException(404, "Session not found")
963
+ return {
964
+ "session_id": session["id"],
965
+ "started_at": session["started_at"],
966
+ "ended_at": session["ended_at"],
967
+ # Existing rows created before this column existed (or that predate
968
+ # any keepalive call) have NULL here — fall back to started_at.
969
+ "last_activity_at": session["last_activity_at"] or session["started_at"],
970
+ }
971
+
902
972
  # ── Back-compat: legacy web-client session token (ported from the deployed OLD
903
973
  # scheduler so voice_client.html / the old frontend keep working after the
904
974
  # fork reconcile). Raw-PyJWT HS256, publish-capable "Web User" token. ──
@@ -1089,7 +1159,15 @@ async def health_checker():
1089
1159
  if resp.status_code == 200:
1090
1160
  new_status = "online"
1091
1161
  else:
1092
- new_status = "offline"
1162
+ # A bare LiveKit server (no sidecar webhook
1163
+ # service in front of it, e.g. `--dev` mode
1164
+ # with no separate health endpoint) has no
1165
+ # /health route and returns 404 here even
1166
+ # though it's genuinely up — LiveKit's own
1167
+ # root path returns "OK" instead. Fall back
1168
+ # to that before declaring it offline.
1169
+ resp2 = await client.get(server["webhook_url"])
1170
+ new_status = "online" if resp2.status_code == 200 else "offline"
1093
1171
  except:
1094
1172
  new_status = "offline"
1095
1173
 
@@ -1591,6 +1669,20 @@ async def update_device(device_id: str, payload: DeviceUpdate):
1591
1669
  await db.commit()
1592
1670
  if cur.rowcount == 0:
1593
1671
  raise HTTPException(404, "Device not found")
1672
+ # Keep the legacy `robots` table's own `name` column in sync when this
1673
+ # PATCH actually changes the device name. Without this, robots.name
1674
+ # only ever gets set once (at first-creation, via INSERT OR IGNORE)
1675
+ # and silently goes stale on every subsequent rename — the dashboard
1676
+ # reads from /api/robots, not /api/devices, so it would keep showing
1677
+ # the old name forever. Only touch robots.name when a name was part
1678
+ # of this update (don't unconditionally overwrite it on unrelated
1679
+ # field updates), and only if a robots row for this id exists.
1680
+ if "name" in fields:
1681
+ await db.execute(
1682
+ "UPDATE robots SET name = ? WHERE id = ?",
1683
+ (fields["name"], device_id),
1684
+ )
1685
+ await db.commit()
1594
1686
  await broadcast("device_updated", {"device_id": device_id, "fields": list(fields.keys())})
1595
1687
  await _log_history("device", "device", device_id, "updated", "operator", f"fields={','.join(fields.keys())}")
1596
1688
  return await get_device(device_id)
@@ -1769,6 +1861,10 @@ async def enroll_device(payload: DeviceEnrollRequest):
1769
1861
  payload.name, device_id,
1770
1862
  ),
1771
1863
  )
1864
+ # OR IGNORE: only fires the first time this robots row is created.
1865
+ # PATCH /api/devices/{device_id} now propagates renames into
1866
+ # robots.name afterwards, so this is purely a first-creation
1867
+ # concern, not an ongoing sync path.
1772
1868
  await db.execute(
1773
1869
  "INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
1774
1870
  (device_id, existing["name"] or payload.name or device_id, existing["character_id"]),
@@ -1805,6 +1901,10 @@ async def enroll_device(payload: DeviceEnrollRequest):
1805
1901
  new_hash, None, now, now,
1806
1902
  ),
1807
1903
  )
1904
+ # OR IGNORE: only fires the first time this robots row is created.
1905
+ # PATCH /api/devices/{device_id} now propagates renames into
1906
+ # robots.name afterwards, so this is purely a first-creation
1907
+ # concern, not an ongoing sync path.
1808
1908
  await db.execute(
1809
1909
  "INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
1810
1910
  (device_id, (payload.name or f"Pi-{device_id[-4:]}").strip(), payload.character_id),
@@ -1910,6 +2010,10 @@ async def device_request_session(device_id: str,
1910
2010
 
1911
2011
  # Mirror device -> robot identity (id == device_id). Additive + idempotent:
1912
2012
  # if the robot row already exists it is left untouched.
2013
+ # OR IGNORE: only fires the first time this robots row is created.
2014
+ # PATCH /api/devices/{device_id} now propagates renames into
2015
+ # robots.name afterwards, so this is purely a first-creation
2016
+ # concern, not an ongoing sync path.
1913
2017
  await db.execute(
1914
2018
  "INSERT OR IGNORE INTO robots (id, name, character_id) VALUES (?, ?, ?)",
1915
2019
  (device_id, device["name"] or device_id, device["character_id"]),
@@ -1938,10 +2042,11 @@ async def device_request_session(device_id: str,
1938
2042
  # timeout instead of the 30s default).
1939
2043
  room_name = f"robopark-{device_id}-{ts}"
1940
2044
 
2045
+ _now_iso = datetime.utcnow().isoformat()
1941
2046
  await db.execute("""
1942
- INSERT INTO sessions (id, robot_id, server_id, room_name, started_at)
1943
- VALUES (?, ?, ?, ?, ?)
1944
- """, (session_id, device_id, server["id"], room_name, datetime.utcnow().isoformat()))
2047
+ INSERT INTO sessions (id, robot_id, server_id, room_name, started_at, last_activity_at)
2048
+ VALUES (?, ?, ?, ?, ?, ?)
2049
+ """, (session_id, device_id, server["id"], room_name, _now_iso, _now_iso))
1945
2050
 
1946
2051
  # Mark the mirrored robot connecting + count the camera trigger, exactly
1947
2052
  # as the robot path does (request-session is the natural trigger point).