agenticros 0.5.3 → 0.5.5

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 (52) hide show
  1. package/README.md +5 -2
  2. package/dist/__tests__/eyes.test.d.ts +2 -0
  3. package/dist/__tests__/eyes.test.d.ts.map +1 -0
  4. package/dist/__tests__/eyes.test.js +30 -0
  5. package/dist/__tests__/eyes.test.js.map +1 -0
  6. package/dist/commands/doctor.d.ts.map +1 -1
  7. package/dist/commands/doctor.js +53 -0
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/down.d.ts +3 -3
  10. package/dist/commands/down.js +4 -4
  11. package/dist/commands/down.js.map +1 -1
  12. package/dist/commands/eyes.d.ts +16 -0
  13. package/dist/commands/eyes.d.ts.map +1 -0
  14. package/dist/commands/eyes.js +113 -0
  15. package/dist/commands/eyes.js.map +1 -0
  16. package/dist/commands/logs.d.ts +1 -0
  17. package/dist/commands/logs.d.ts.map +1 -1
  18. package/dist/commands/logs.js +2 -1
  19. package/dist/commands/logs.js.map +1 -1
  20. package/dist/commands/status.js +1 -1
  21. package/dist/commands/status.js.map +1 -1
  22. package/dist/commands/up.d.ts +6 -0
  23. package/dist/commands/up.d.ts.map +1 -1
  24. package/dist/commands/up.js +8 -0
  25. package/dist/commands/up.js.map +1 -1
  26. package/dist/index.js +28 -3
  27. package/dist/index.js.map +1 -1
  28. package/dist/menu.d.ts.map +1 -1
  29. package/dist/menu.js +5 -0
  30. package/dist/menu.js.map +1 -1
  31. package/dist/util/eyes.d.ts +23 -0
  32. package/dist/util/eyes.d.ts.map +1 -0
  33. package/dist/util/eyes.js +53 -0
  34. package/dist/util/eyes.js.map +1 -0
  35. package/dist/util/pidfile.d.ts +1 -1
  36. package/dist/util/pidfile.d.ts.map +1 -1
  37. package/dist/util/pidfile.js.map +1 -1
  38. package/package.json +1 -1
  39. package/runtime/BUNDLE.json +1 -1
  40. package/runtime/README.md +4 -2
  41. package/runtime/docs/cli.md +15 -5
  42. package/runtime/docs/eyes.md +83 -0
  43. package/runtime/packages/robot-eyes/README.md +25 -0
  44. package/runtime/packages/robot-eyes/package.json +49 -0
  45. package/runtime/packages/robot-eyes/public/eyes.js +246 -0
  46. package/runtime/packages/robot-eyes/public/index.html +14 -0
  47. package/runtime/packages/robot-eyes/public/style.css +22 -0
  48. package/runtime/packages/robot-eyes/public/teleop.js +102 -0
  49. package/runtime/packages/robot-eyes/src/index.js +410 -0
  50. package/runtime/pnpm-lock.yaml +30 -1
  51. package/runtime/ros2_ws/src/agenticros_sim/package.xml +1 -1
  52. package/runtime/scripts/pack-runtime.mjs +4 -3
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @agenticros/eyes — fullscreen robot face driven by ROS 2 cmd_vel Twist.
4
+ *
5
+ * Subscribes to CMD_VEL_TOPIC for gaze (left/right turns). Optionally publishes
6
+ * the same topic from invisible WASD keyboard teleop (disabled with --no-teleop).
7
+ *
8
+ * Env (set by `agenticros eyes` from ~/.agenticros/config.json):
9
+ * CMD_VEL_TOPIC, MAX_LINEAR_VELOCITY, MAX_ANGULAR_VELOCITY, PORT, …
10
+ */
11
+ import { createRequire } from "module";
12
+ import http from "http";
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import { fileURLToPath } from "url";
16
+ import { spawn, execFileSync } from "child_process";
17
+ import { WebSocketServer } from "ws";
18
+
19
+ const require = createRequire(import.meta.url);
20
+ let rclnodejs;
21
+ try {
22
+ rclnodejs = require("rclnodejs");
23
+ } catch (e) {
24
+ console.error(
25
+ "Failed to load rclnodejs. Source ROS 2 and reinstall deps on the robot:\n" +
26
+ " source /opt/ros/<distro>/setup.bash && pnpm install --filter @agenticros/eyes\n" +
27
+ String(e instanceof Error ? e.message : e),
28
+ );
29
+ process.exit(1);
30
+ }
31
+
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ const __dirname = path.dirname(__filename);
34
+ const PUBLIC_DIR = path.join(__dirname, "..", "public");
35
+
36
+ const PORT = Number(process.env.PORT || 8765);
37
+ const TOPIC = process.env.CMD_VEL_TOPIC || "/cmd_vel";
38
+ const ANGULAR_DEADZONE = Number(process.env.ANGULAR_DEADZONE || 0.05);
39
+ const CMD_TIMEOUT_MS = Number(process.env.CMD_TIMEOUT_MS || 300);
40
+ const NO_BROWSER = process.argv.includes("--no-browser");
41
+ const NO_TELEOP =
42
+ process.argv.includes("--no-teleop") ||
43
+ process.env.AGENTICROS_EYES_NO_TELEOP === "1";
44
+
45
+ const MAX_LINEAR = Number(process.env.MAX_LINEAR_VELOCITY || 1.0);
46
+ const MAX_ANGULAR = Number(process.env.MAX_ANGULAR_VELOCITY || 1.5);
47
+
48
+ const TELOP_LINEAR = Math.min(
49
+ Number(process.env.TELOP_LINEAR || 0.25),
50
+ MAX_LINEAR,
51
+ );
52
+ const TELOP_ANGULAR = Math.min(
53
+ Number(process.env.TELOP_ANGULAR || 0.9),
54
+ MAX_ANGULAR,
55
+ );
56
+ const TELOP_SCALE_STEP = Number(process.env.TELOP_SCALE_STEP || 0.15);
57
+ const TELOP_SCALE_MIN = Number(process.env.TELOP_SCALE_MIN || 0.2);
58
+ const TELOP_SCALE_MAX = Number(process.env.TELOP_SCALE_MAX || 3);
59
+ const TELOP_RATE_HZ = Number(process.env.TELOP_RATE_HZ || 20);
60
+
61
+ /** @type {{ gazeX: number, driving: boolean, lastCmdAt: number }} */
62
+ const state = {
63
+ gazeX: 0,
64
+ driving: false,
65
+ lastCmdAt: 0,
66
+ };
67
+
68
+ const teleop = {
69
+ /** @type {Map<object, { w: boolean, a: boolean, s: boolean, d: boolean }>} */
70
+ clients: new Map(),
71
+ scale: 1,
72
+ publishing: false,
73
+ };
74
+
75
+ function mimeType(filePath) {
76
+ const ext = path.extname(filePath).toLowerCase();
77
+ switch (ext) {
78
+ case ".html":
79
+ return "text/html; charset=utf-8";
80
+ case ".js":
81
+ return "application/javascript; charset=utf-8";
82
+ case ".css":
83
+ return "text/css; charset=utf-8";
84
+ case ".svg":
85
+ return "image/svg+xml";
86
+ case ".png":
87
+ return "image/png";
88
+ default:
89
+ return "application/octet-stream";
90
+ }
91
+ }
92
+
93
+ function createHttpServer() {
94
+ return http.createServer((req, res) => {
95
+ const urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
96
+ const rel = urlPath === "/" ? "/index.html" : urlPath;
97
+ const filePath = path.normalize(path.join(PUBLIC_DIR, rel));
98
+
99
+ if (!filePath.startsWith(PUBLIC_DIR)) {
100
+ res.writeHead(403).end("Forbidden");
101
+ return;
102
+ }
103
+
104
+ // Gaze-only mode: serve HTML without the teleop script.
105
+ if (NO_TELEOP && rel === "/index.html") {
106
+ fs.readFile(filePath, "utf8", (err, html) => {
107
+ if (err) {
108
+ res.writeHead(404).end("Not found");
109
+ return;
110
+ }
111
+ const stripped = html.replace(
112
+ /\s*<script type="module" src="\/teleop\.js"><\/script>\s*/i,
113
+ "\n",
114
+ );
115
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
116
+ res.end(stripped);
117
+ });
118
+ return;
119
+ }
120
+
121
+ fs.readFile(filePath, (err, data) => {
122
+ if (err) {
123
+ res.writeHead(404).end("Not found");
124
+ return;
125
+ }
126
+ res.writeHead(200, { "Content-Type": mimeType(filePath) });
127
+ res.end(data);
128
+ });
129
+ });
130
+ }
131
+
132
+ function broadcast(wss, payload) {
133
+ const raw = JSON.stringify(payload);
134
+ for (const client of wss.clients) {
135
+ if (client.readyState === 1) {
136
+ client.send(raw);
137
+ }
138
+ }
139
+ }
140
+
141
+ function gazeFromTwist(msg) {
142
+ const z = msg?.angular?.z ?? 0;
143
+ if (Math.abs(z) < ANGULAR_DEADZONE) {
144
+ return { gazeX: 0, driving: false };
145
+ }
146
+ // +angular.z = left turn → eyes look right (screen +X)
147
+ return { gazeX: z > 0 ? 1 : -1, driving: true };
148
+ }
149
+
150
+ function mergedKeys() {
151
+ const keys = { w: false, a: false, s: false, d: false };
152
+ for (const k of teleop.clients.values()) {
153
+ keys.w ||= k.w;
154
+ keys.a ||= k.a;
155
+ keys.s ||= k.s;
156
+ keys.d ||= k.d;
157
+ }
158
+ return keys;
159
+ }
160
+
161
+ function clampTwist(twist) {
162
+ const { linear, angular } = twist;
163
+ const lx = linear?.x ?? 0;
164
+ const ly = linear?.y ?? 0;
165
+ const lz = linear?.z ?? 0;
166
+ const ax = angular?.x ?? 0;
167
+ const ay = angular?.y ?? 0;
168
+ const az = angular?.z ?? 0;
169
+
170
+ const linMag = Math.sqrt(lx * lx + ly * ly + lz * lz);
171
+ const scaleLin = linMag > MAX_LINEAR && linMag > 0 ? MAX_LINEAR / linMag : 1;
172
+ const angMag = Math.abs(az);
173
+ const scaleAng = angMag > MAX_ANGULAR && angMag > 0 ? MAX_ANGULAR / angMag : 1;
174
+
175
+ return {
176
+ linear: { x: lx * scaleLin, y: ly * scaleLin, z: lz * scaleLin },
177
+ angular: {
178
+ x: ax * scaleAng,
179
+ y: ay * scaleAng,
180
+ z: Math.max(-MAX_ANGULAR, Math.min(MAX_ANGULAR, az)),
181
+ },
182
+ };
183
+ }
184
+
185
+ function twistFromKeys(keys) {
186
+ const linear = TELOP_LINEAR * teleop.scale;
187
+ const angular = TELOP_ANGULAR * teleop.scale;
188
+ let x = 0;
189
+ let z = 0;
190
+ if (keys.w) x += linear;
191
+ if (keys.s) x -= linear;
192
+ if (keys.a) z += angular;
193
+ if (keys.d) z -= angular;
194
+ return clampTwist({
195
+ linear: { x, y: 0, z: 0 },
196
+ angular: { x: 0, y: 0, z },
197
+ });
198
+ }
199
+
200
+ function anyKeyDown(keys) {
201
+ return keys.w || keys.a || keys.s || keys.d;
202
+ }
203
+
204
+ function findBrowser() {
205
+ const candidates = [
206
+ process.env.BROWSER,
207
+ "firefox",
208
+ "chromium-browser",
209
+ "chromium",
210
+ "google-chrome",
211
+ "google-chrome-stable",
212
+ ].filter(Boolean);
213
+
214
+ for (const bin of candidates) {
215
+ try {
216
+ execFileSync("which", [bin], { stdio: "ignore" });
217
+ return bin;
218
+ } catch {
219
+ // try next
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
225
+ function launchKiosk(url) {
226
+ const bin = findBrowser();
227
+ if (!bin) {
228
+ console.warn("No browser found. Open this URL fullscreen manually:", url);
229
+ return null;
230
+ }
231
+
232
+ const args = bin.includes("firefox")
233
+ ? ["--kiosk", url]
234
+ : ["--kiosk", "--noerrdialogs", "--disable-infobars", `--app=${url}`, url];
235
+
236
+ console.log(`Launching ${bin} in kiosk mode → ${url}`);
237
+ const child = spawn(bin, args, {
238
+ stdio: "ignore",
239
+ detached: true,
240
+ env: { ...process.env, DISPLAY: process.env.DISPLAY || ":0" },
241
+ });
242
+ child.unref();
243
+ child.on("error", (err) => {
244
+ console.warn(`Failed to launch ${bin}:`, err.message);
245
+ console.warn("Open this URL fullscreen manually:", url);
246
+ });
247
+ return child;
248
+ }
249
+
250
+ async function main() {
251
+ await rclnodejs.init();
252
+ const node = rclnodejs.createNode("robot_eyes");
253
+ const publisher = NO_TELEOP
254
+ ? null
255
+ : node.createPublisher("geometry_msgs/msg/Twist", TOPIC);
256
+
257
+ const server = createHttpServer();
258
+ const wss = new WebSocketServer({ server });
259
+
260
+ const publishStop = () => {
261
+ if (!publisher) return;
262
+ publisher.publish({
263
+ linear: { x: 0, y: 0, z: 0 },
264
+ angular: { x: 0, y: 0, z: 0 },
265
+ });
266
+ teleop.publishing = false;
267
+ };
268
+
269
+ const tickTeleop = () => {
270
+ if (NO_TELEOP || !publisher) return;
271
+ const keys = mergedKeys();
272
+ if (!anyKeyDown(keys)) {
273
+ if (teleop.publishing) {
274
+ publishStop();
275
+ }
276
+ return;
277
+ }
278
+ publisher.publish(twistFromKeys(keys));
279
+ teleop.publishing = true;
280
+ };
281
+
282
+ if (!NO_TELEOP) {
283
+ setInterval(tickTeleop, Math.max(10, Math.round(1000 / TELOP_RATE_HZ)));
284
+ }
285
+
286
+ wss.on("connection", (ws) => {
287
+ if (!NO_TELEOP) {
288
+ teleop.clients.set(ws, { w: false, a: false, s: false, d: false });
289
+ }
290
+
291
+ ws.send(
292
+ JSON.stringify({
293
+ type: "gaze",
294
+ gazeX: state.gazeX,
295
+ driving: state.driving,
296
+ }),
297
+ );
298
+
299
+ ws.on("message", (raw) => {
300
+ if (NO_TELEOP) return;
301
+ let msg;
302
+ try {
303
+ msg = JSON.parse(String(raw));
304
+ } catch {
305
+ return;
306
+ }
307
+
308
+ if (msg.type === "keys" && msg.keys && typeof msg.keys === "object") {
309
+ teleop.clients.set(ws, {
310
+ w: Boolean(msg.keys.w),
311
+ a: Boolean(msg.keys.a),
312
+ s: Boolean(msg.keys.s),
313
+ d: Boolean(msg.keys.d),
314
+ });
315
+ tickTeleop();
316
+ return;
317
+ }
318
+
319
+ if (msg.type === "speed" && (msg.delta === 1 || msg.delta === -1)) {
320
+ const next = teleop.scale + msg.delta * TELOP_SCALE_STEP;
321
+ teleop.scale = Math.min(
322
+ TELOP_SCALE_MAX,
323
+ Math.max(TELOP_SCALE_MIN, next),
324
+ );
325
+ console.log(
326
+ `teleop speed scale: ${teleop.scale.toFixed(2)} ` +
327
+ `(linear≈${(TELOP_LINEAR * teleop.scale).toFixed(2)} m/s, ` +
328
+ `angular≈${(TELOP_ANGULAR * teleop.scale).toFixed(2)} rad/s)`,
329
+ );
330
+ }
331
+ });
332
+
333
+ ws.on("close", () => {
334
+ if (!NO_TELEOP) {
335
+ teleop.clients.delete(ws);
336
+ tickTeleop();
337
+ }
338
+ });
339
+ });
340
+
341
+ node.createSubscription("geometry_msgs/msg/Twist", TOPIC, (msg) => {
342
+ const next = gazeFromTwist(msg);
343
+ state.gazeX = next.gazeX;
344
+ state.driving = next.driving;
345
+ state.lastCmdAt = Date.now();
346
+ broadcast(wss, {
347
+ type: "gaze",
348
+ gazeX: state.gazeX,
349
+ driving: state.driving,
350
+ });
351
+ });
352
+
353
+ setInterval(() => {
354
+ if (!state.driving) return;
355
+ if (Date.now() - state.lastCmdAt < CMD_TIMEOUT_MS) return;
356
+ state.gazeX = 0;
357
+ state.driving = false;
358
+ broadcast(wss, {
359
+ type: "gaze",
360
+ gazeX: 0,
361
+ driving: false,
362
+ });
363
+ }, 50);
364
+
365
+ server.listen(PORT, "127.0.0.1", () => {
366
+ const url = `http://127.0.0.1:${PORT}/`;
367
+ console.log(`robot-eyes listening on ${url}`);
368
+ console.log(
369
+ `${NO_TELEOP ? "Subscribed" : "Subscribed + publishing"} ${TOPIC} ` +
370
+ `(deadzone=${ANGULAR_DEADZONE}, maxLin=${MAX_LINEAR}, maxAng=${MAX_ANGULAR})`,
371
+ );
372
+ if (NO_TELEOP) {
373
+ console.log("Keyboard teleop disabled (--no-teleop / gaze-only)");
374
+ } else {
375
+ console.log(
376
+ `Keyboard teleop: WASD drive, Q faster, Z slower ` +
377
+ `(base linear=${TELOP_LINEAR}, angular=${TELOP_ANGULAR})`,
378
+ );
379
+ }
380
+ if (!NO_BROWSER) {
381
+ launchKiosk(url);
382
+ } else {
383
+ console.log("--no-browser: open the URL yourself for fullscreen");
384
+ }
385
+ });
386
+
387
+ rclnodejs.spin(node);
388
+
389
+ const shutdown = async () => {
390
+ console.log("\nShutting down…");
391
+ try {
392
+ publishStop();
393
+ wss.close();
394
+ server.close();
395
+ node.destroy();
396
+ await rclnodejs.shutdown();
397
+ } catch {
398
+ // ignore
399
+ }
400
+ process.exit(0);
401
+ };
402
+
403
+ process.on("SIGINT", shutdown);
404
+ process.on("SIGTERM", shutdown);
405
+ }
406
+
407
+ main().catch((err) => {
408
+ console.error(err);
409
+ process.exit(1);
410
+ });
@@ -157,6 +157,16 @@ importers:
157
157
  specifier: ^5.7.0
158
158
  version: 5.9.3
159
159
 
160
+ packages/robot-eyes:
161
+ dependencies:
162
+ ws:
163
+ specifier: ^8.18.0
164
+ version: 8.19.0
165
+ optionalDependencies:
166
+ rclnodejs:
167
+ specifier: ^1.9.0
168
+ version: 1.9.0
169
+
160
170
  packages/ros-camera:
161
171
  dependencies:
162
172
  fzstd:
@@ -1687,6 +1697,11 @@ packages:
1687
1697
  engines: {node: '>= 16.13.0'}
1688
1698
  hasBin: true
1689
1699
 
1700
+ rclnodejs@1.9.0:
1701
+ resolution: {integrity: sha512-5nQ39WCnfzYoC1nppBaJR5bzfMHWrRRLGltsS6WqGb8KFJnguOCDMC9tZSxK4YU0oAtciG3/GZqDgZmfAxgUDQ==}
1702
+ engines: {node: '>= 16.13.0'}
1703
+ hasBin: true
1704
+
1690
1705
  readable-stream@3.6.2:
1691
1706
  resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
1692
1707
  engines: {node: '>= 6'}
@@ -1991,7 +2006,7 @@ snapshots:
1991
2006
  zod: 3.25.76
1992
2007
  optionalDependencies:
1993
2008
  node-datachannel: 0.12.0
1994
- rclnodejs: 1.8.2
2009
+ rclnodejs: 1.9.0
1995
2010
  transitivePeerDependencies:
1996
2011
  - bufferutil
1997
2012
  - jiti
@@ -3510,6 +3525,20 @@ snapshots:
3510
3525
  - supports-color
3511
3526
  optional: true
3512
3527
 
3528
+ rclnodejs@1.9.0:
3529
+ dependencies:
3530
+ '@rclnodejs/ref-array-di': 1.2.2
3531
+ '@rclnodejs/ref-struct-di': 1.1.1
3532
+ bindings: 1.5.0
3533
+ debug: 4.4.3
3534
+ json-bigint: 1.0.0
3535
+ node-addon-api: 8.6.0
3536
+ rxjs: 7.8.2
3537
+ walk: 2.3.15
3538
+ transitivePeerDependencies:
3539
+ - supports-color
3540
+ optional: true
3541
+
3513
3542
  readable-stream@3.6.2:
3514
3543
  dependencies:
3515
3544
  inherits: 2.0.4
@@ -21,7 +21,7 @@
21
21
  <exec_depend>robot_state_publisher</exec_depend>
22
22
  <exec_depend>xacro</exec_depend>
23
23
  <exec_depend>rviz2</exec_depend>
24
- <!-- Optional Nav2 stack for `sim_amr_nav2.launch.py` / `agenticros up sim-amr --nav2` -->
24
+ <!-- Optional Nav2 stack (sim_amr_nav2.launch.py / agenticros up sim-amr with nav2) -->
25
25
  <exec_depend>nav2_bringup</exec_depend>
26
26
  <exec_depend>nav2_bt_navigator</exec_depend>
27
27
  <exec_depend>nav2_amcl</exec_depend>
@@ -14,8 +14,8 @@
14
14
  * What ships:
15
15
  * - scripts/ shell helpers, install scripts, sim entrypoints
16
16
  * - ros2_ws/src/agenticros_* our ROS 2 packages (msgs + Python nodes)
17
- * - packages/{core,ros-camera,agenticros,agenticros-claude-code,agenticros-gemini}
18
- * TypeScript sources for the workspace, NO dist or node_modules
17
+ * - packages/{core,ros-camera,agenticros,agenticros-claude-code,agenticros-gemini,robot-eyes}
18
+ * TypeScript/JS sources for the workspace, NO dist or node_modules
19
19
  * - packages/agenticros-claude-code/dist (pre-built so `npx agenticros` works without colcon)
20
20
  * - package.json / pnpm-workspace.yaml / tsconfig.base.json monorepo manifests
21
21
  * - patches/ pnpm patchedDependencies (zenoh-ts, ...)
@@ -53,6 +53,7 @@ const INCLUDED_PACKAGES = [
53
53
  "agenticros",
54
54
  "agenticros-claude-code",
55
55
  "agenticros-gemini",
56
+ "robot-eyes",
56
57
  ];
57
58
 
58
59
  // NOTE: .npmrc is deliberately absent here. npm pack strips .npmrc from
@@ -67,7 +68,7 @@ const TOP_LEVEL_FILES = [
67
68
  "README.md",
68
69
  ];
69
70
 
70
- const DOCS_FILES = ["cli.md", "architecture.md", "robot-setup.md", "memory.md"];
71
+ const DOCS_FILES = ["cli.md", "architecture.md", "robot-setup.md", "memory.md", "eyes.md"];
71
72
 
72
73
  const EXCLUDED_DIR_NAMES = new Set([
73
74
  "node_modules",