agenticros 0.3.3 → 0.3.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 (94) hide show
  1. package/README.md +25 -7
  2. package/dist/__tests__/claude-config.test.d.ts +2 -0
  3. package/dist/__tests__/claude-config.test.d.ts.map +1 -0
  4. package/dist/__tests__/claude-config.test.js +77 -0
  5. package/dist/__tests__/claude-config.test.js.map +1 -0
  6. package/dist/__tests__/mcp-setup.test.d.ts +2 -0
  7. package/dist/__tests__/mcp-setup.test.d.ts.map +1 -0
  8. package/dist/__tests__/mcp-setup.test.js +23 -0
  9. package/dist/__tests__/mcp-setup.test.js.map +1 -0
  10. package/dist/commands/claude.d.ts +16 -0
  11. package/dist/commands/claude.d.ts.map +1 -0
  12. package/dist/commands/claude.js +30 -0
  13. package/dist/commands/claude.js.map +1 -0
  14. package/dist/commands/codex.d.ts +4 -4
  15. package/dist/commands/codex.d.ts.map +1 -1
  16. package/dist/commands/codex.js +21 -90
  17. package/dist/commands/codex.js.map +1 -1
  18. package/dist/commands/doctor.d.ts.map +1 -1
  19. package/dist/commands/doctor.js +22 -27
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/hermes.d.ts +3 -3
  22. package/dist/commands/hermes.d.ts.map +1 -1
  23. package/dist/commands/hermes.js +13 -76
  24. package/dist/commands/hermes.js.map +1 -1
  25. package/dist/commands/init.d.ts +2 -3
  26. package/dist/commands/init.d.ts.map +1 -1
  27. package/dist/commands/init.js +16 -38
  28. package/dist/commands/init.js.map +1 -1
  29. package/dist/commands/mcp.d.ts +20 -0
  30. package/dist/commands/mcp.d.ts.map +1 -0
  31. package/dist/commands/mcp.js +31 -0
  32. package/dist/commands/mcp.js.map +1 -0
  33. package/dist/index.js +57 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/util/claude-config.d.ts +47 -0
  36. package/dist/util/claude-config.d.ts.map +1 -0
  37. package/dist/util/claude-config.js +227 -0
  38. package/dist/util/claude-config.js.map +1 -0
  39. package/dist/util/mcp-setup.d.ts +55 -0
  40. package/dist/util/mcp-setup.d.ts.map +1 -0
  41. package/dist/util/mcp-setup.js +235 -0
  42. package/dist/util/mcp-setup.js.map +1 -0
  43. package/dist/util/skill-manifest.d.ts.map +1 -1
  44. package/dist/util/skill-manifest.js +22 -1
  45. package/dist/util/skill-manifest.js.map +1 -1
  46. package/package.json +1 -1
  47. package/runtime/BUNDLE.json +1 -1
  48. package/runtime/README.md +31 -6
  49. package/runtime/docs/cli.md +54 -14
  50. package/runtime/docs/robot-setup.md +1 -1
  51. package/runtime/packages/agenticros/openclaw.plugin.json +147 -1
  52. package/runtime/packages/agenticros/src/__tests__/capabilities-plugin.test.ts +2 -0
  53. package/runtime/packages/agenticros/src/routes.ts +4 -3
  54. package/runtime/packages/agenticros/src/tools/index.ts +6 -5
  55. package/runtime/packages/agenticros/src/tools/mission-pause.ts +49 -0
  56. package/runtime/packages/agenticros/src/tools/mission-resume.ts +41 -0
  57. package/runtime/packages/agenticros/src/tools/ros2-mission.ts +33 -84
  58. package/runtime/packages/agenticros-claude-code/README.md +2 -0
  59. package/runtime/packages/agenticros-claude-code/dist/tools.d.ts.map +1 -1
  60. package/runtime/packages/agenticros-claude-code/dist/tools.js +130 -111
  61. package/runtime/packages/agenticros-claude-code/dist/tools.js.map +1 -1
  62. package/runtime/packages/agenticros-claude-code/src/tools.ts +140 -99
  63. package/runtime/packages/agenticros-gemini/package.json +2 -0
  64. package/runtime/packages/agenticros-gemini/src/find-object/coco-classes.ts +38 -0
  65. package/runtime/packages/agenticros-gemini/src/find-object/find-object.ts +191 -0
  66. package/runtime/packages/agenticros-gemini/src/follow-me/controller.ts +109 -0
  67. package/runtime/packages/agenticros-gemini/src/follow-me/depth-loop.ts +452 -0
  68. package/runtime/packages/agenticros-gemini/src/follow-me/detector.ts +303 -0
  69. package/runtime/packages/agenticros-gemini/src/follow-me/loop.ts +359 -0
  70. package/runtime/packages/agenticros-gemini/src/tools.ts +351 -90
  71. package/runtime/packages/core/README.md +1 -1
  72. package/runtime/packages/core/package.json +1 -1
  73. package/runtime/packages/core/src/__tests__/config-persistence.test.ts +26 -0
  74. package/runtime/packages/core/src/__tests__/external-capability.test.ts +72 -0
  75. package/runtime/packages/core/src/__tests__/heartbeat-fleet.test.ts +152 -0
  76. package/runtime/packages/core/src/__tests__/mission-bindings.test.ts +101 -0
  77. package/runtime/packages/core/src/__tests__/mission-pause.test.ts +104 -0
  78. package/runtime/packages/core/src/capabilities.ts +38 -3
  79. package/runtime/packages/core/src/capability-schema.ts +58 -0
  80. package/runtime/packages/core/src/config.ts +20 -0
  81. package/runtime/packages/core/src/discovery.ts +21 -3
  82. package/runtime/packages/core/src/external-capability.ts +219 -0
  83. package/runtime/packages/core/src/fleet-config.ts +91 -0
  84. package/runtime/packages/core/src/heartbeat.ts +167 -0
  85. package/runtime/packages/core/src/index.ts +55 -0
  86. package/runtime/packages/core/src/mission-bindings.ts +189 -0
  87. package/runtime/packages/core/src/mission-registry.ts +31 -3
  88. package/runtime/packages/core/src/mission.ts +64 -7
  89. package/runtime/packages/core/src/robots.ts +6 -2
  90. package/runtime/packages/core/src/transport/rosbridge/client.ts +7 -0
  91. package/runtime/pnpm-lock.yaml +6 -0
  92. package/templates/skills/camera/package.json +1 -1
  93. package/templates/skills/depth/package.json +1 -1
  94. package/templates/skills/robot/package.json +1 -1
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Heartbeat + fleet-config unit tests.
3
+ */
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import {
11
+ detectRobotsFromTopics,
12
+ discoverRobots,
13
+ } from "../discovery.js";
14
+ import {
15
+ isHeartbeatFresh,
16
+ mergeRobotHeartbeats,
17
+ onlineIdsFromHeartbeats,
18
+ parseRobotInfoMessage,
19
+ DEFAULT_HEARTBEAT_STALENESS_MS,
20
+ } from "../heartbeat.js";
21
+ import { applyFleetOverride, loadFleetFile } from "../fleet-config.js";
22
+ import { listRobots } from "../robots.js";
23
+ import { parseConfig } from "../config.js";
24
+
25
+ test("parseRobotInfoMessage extracts fields", () => {
26
+ const hb = parseRobotInfoMessage("/bot1/agenticros/robot_info", {
27
+ id: "kitchen",
28
+ name: "Kitchen Bot",
29
+ kind: "amr",
30
+ robot_namespace: "bot1",
31
+ capability_ids: ["drive_base", "follow_person"],
32
+ has_realsense: true,
33
+ has_lidar: false,
34
+ has_arm: false,
35
+ stamp: { sec: 1_700_000_000, nanosec: 0 },
36
+ });
37
+ assert.equal(hb.id, "kitchen");
38
+ assert.equal(hb.sensors.has_realsense, true);
39
+ assert.equal(hb.stamp_ms, 1_700_000_000_000);
40
+ assert.deepEqual(hb.capability_ids, ["drive_base", "follow_person"]);
41
+ });
42
+
43
+ test("isHeartbeatFresh respects 5s window", () => {
44
+ const now = 1_000_000;
45
+ const fresh = parseRobotInfoMessage("/n/agenticros/robot_info", {
46
+ id: "a",
47
+ robot_namespace: "n",
48
+ stamp: { sec: Math.floor((now - 2000) / 1000), nanosec: 0 },
49
+ });
50
+ // Fix stamp_ms manually for precise control
51
+ fresh.stamp_ms = now - 2000;
52
+ assert.equal(isHeartbeatFresh(fresh, { nowMs: now }), true);
53
+
54
+ const stale = { ...fresh, stamp_ms: now - DEFAULT_HEARTBEAT_STALENESS_MS - 1 };
55
+ assert.equal(isHeartbeatFresh(stale, { nowMs: now }), false);
56
+ });
57
+
58
+ test("onlineIdsFromHeartbeats maps to configured ids", () => {
59
+ const now = Date.now();
60
+ const hb = parseRobotInfoMessage("/robotabc/agenticros/robot_info", {
61
+ id: "wire-id",
62
+ robot_namespace: "robotabc",
63
+ stamp: { sec: Math.floor(now / 1000), nanosec: 0 },
64
+ });
65
+ hb.stamp_ms = now;
66
+ const map = new Map([["robotabc", "configured-kitchen"]]);
67
+ const online = onlineIdsFromHeartbeats([hb], {
68
+ nowMs: now,
69
+ configuredIdByNamespace: map,
70
+ });
71
+ assert.ok(online.has("configured-kitchen"));
72
+ });
73
+
74
+ test("mergeRobotHeartbeats keeps freshest per namespace", () => {
75
+ const now = Date.now();
76
+ const older = parseRobotInfoMessage("/robotn/agenticros/robot_info", {
77
+ id: "a",
78
+ robot_namespace: "robotn",
79
+ name: "old",
80
+ });
81
+ older.stamp_ms = now - 1000;
82
+ const newer = parseRobotInfoMessage("/robotn/agenticros/robot_info", {
83
+ id: "a",
84
+ robot_namespace: "robotn",
85
+ name: "new",
86
+ });
87
+ newer.stamp_ms = now;
88
+ const merged = mergeRobotHeartbeats([older, newer], { nowMs: now });
89
+ assert.equal(merged.get("robotn")?.name, "new");
90
+ });
91
+
92
+ test("detectRobotsFromTopics includes robot_info-only namespaces", () => {
93
+ const detected = detectRobotsFromTopics([
94
+ { name: "/arm1/agenticros/robot_info", type: "agenticros_msgs/msg/RobotInfo" },
95
+ ]);
96
+ assert.equal(detected.length, 1);
97
+ assert.equal(detected[0]!.id, "arm1");
98
+ });
99
+
100
+ test("discoverRobots treats robot_info topic as online signal", () => {
101
+ // Isolate from a real ~/.agenticros/fleet.json on the developer machine.
102
+ const prev = process.env.AGENTICROS_FLEET_PATH;
103
+ process.env.AGENTICROS_FLEET_PATH = join(tmpdir(), "no-fleet-for-discover-test.json");
104
+ try {
105
+ // Namespace must already be the on-wire form (or UUID) so
106
+ // effectiveCmdVelNamespace matches the topic segment.
107
+ const cfg = parseConfig({
108
+ robots: [{ id: "arm1", namespace: "robotarm1", kind: "arm" }],
109
+ });
110
+ const result = discoverRobots(
111
+ [{ name: "/robotarm1/agenticros/robot_info", type: "agenticros_msgs/msg/RobotInfo" }],
112
+ cfg,
113
+ );
114
+ assert.equal(result.configured_online.length, 1);
115
+ assert.equal(result.configured_online[0]!.id, "arm1");
116
+ } finally {
117
+ if (prev === undefined) delete process.env.AGENTICROS_FLEET_PATH;
118
+ else process.env.AGENTICROS_FLEET_PATH = prev;
119
+ }
120
+ });
121
+
122
+ test("loadFleetFile + applyFleetOverride wins over config.robots", () => {
123
+ const dir = mkdtempSync(join(tmpdir(), "agenticros-fleet-"));
124
+ const path = join(dir, "fleet.json");
125
+ try {
126
+ writeFileSync(
127
+ path,
128
+ JSON.stringify([{ id: "fleet-bot", namespace: "fleetns", kind: "amr", name: "From Fleet" }]),
129
+ );
130
+ const loaded = loadFleetFile(path);
131
+ assert.equal(loaded.used, true);
132
+ assert.equal(loaded.robots[0]!.id, "fleet-bot");
133
+
134
+ const cfg = parseConfig({
135
+ robots: [{ id: "config-bot", namespace: "configns" }],
136
+ });
137
+ const overridden = applyFleetOverride(cfg, path);
138
+ const robots = listRobots(overridden);
139
+ // listRobots also applies fleet from default path — pass overridden
140
+ // config that already has fleet robots embedded:
141
+ assert.equal(overridden.robots?.[0]?.id, "fleet-bot");
142
+ assert.equal(robots[0]!.id, "fleet-bot");
143
+ } finally {
144
+ rmSync(dir, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test("loadFleetFile missing file is unused", () => {
149
+ const result = loadFleetFile(join(tmpdir(), "no-such-agenticros-fleet-xyz.json"));
150
+ assert.equal(result.used, false);
151
+ assert.equal(result.robots.length, 0);
152
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Unit tests for buildMissionBindings — Phase 1 dynamic bindings.
3
+ */
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ BUILTIN_MISSION_BINDINGS,
9
+ buildMissionBindings,
10
+ defaultToolForCapability,
11
+ externalToolName,
12
+ isExternalToolName,
13
+ } from "../mission-bindings.js";
14
+ import type { Capability } from "../capabilities.js";
15
+
16
+ test("buildMissionBindings includes all builtins", () => {
17
+ const bindings = buildMissionBindings([]);
18
+ for (const id of Object.keys(BUILTIN_MISSION_BINDINGS)) {
19
+ assert.ok(bindings[id], `missing builtin binding ${id}`);
20
+ }
21
+ });
22
+
23
+ test("buildMissionBindings derives ros2_<id> for skill capabilities", () => {
24
+ const caps: Capability[] = [
25
+ {
26
+ id: "wave_hand",
27
+ verb: "wave",
28
+ description: "Wave",
29
+ source: { kind: "skill", skillId: "wave", package: "agenticros-skill-wave" },
30
+ },
31
+ ];
32
+ const bindings = buildMissionBindings(caps);
33
+ assert.equal(bindings.wave_hand.tool, "ros2_wave_hand");
34
+ assert.deepEqual(bindings.wave_hand.buildArgs({ a: 1 }), { a: 1 });
35
+ });
36
+
37
+ test("buildMissionBindings uses external: prefix for external_ros_node", () => {
38
+ const caps: Capability[] = [
39
+ {
40
+ id: "navigate_to",
41
+ verb: "navigate",
42
+ description: "Nav2",
43
+ implementation: {
44
+ kind: "external_ros_node",
45
+ action: "navigate_to_pose",
46
+ msg_type: "nav2_msgs/action/NavigateToPose",
47
+ },
48
+ source: { kind: "skill", skillId: "navigate", package: "navigate-to" },
49
+ },
50
+ ];
51
+ const bindings = buildMissionBindings(caps);
52
+ assert.equal(bindings.navigate_to.tool, externalToolName("navigate_to"));
53
+ assert.ok(isExternalToolName(bindings.navigate_to.tool));
54
+ });
55
+
56
+ test("buildMissionBindings respects toolNameResolver", () => {
57
+ const caps: Capability[] = [
58
+ {
59
+ id: "custom_cap",
60
+ verb: "custom",
61
+ description: "Custom",
62
+ source: { kind: "skill", skillId: "c", package: "c" },
63
+ },
64
+ ];
65
+ const bindings = buildMissionBindings(caps, {
66
+ toolNameResolver: (cap) => (cap.id === "custom_cap" ? "my_custom_tool" : undefined),
67
+ });
68
+ assert.equal(bindings.custom_cap.tool, "my_custom_tool");
69
+ });
70
+
71
+ test("defaultToolForCapability prefers explicit tool field", () => {
72
+ assert.equal(
73
+ defaultToolForCapability({
74
+ id: "x",
75
+ verb: "x",
76
+ description: "x",
77
+ tool: "ros2_special",
78
+ }),
79
+ "ros2_special",
80
+ );
81
+ });
82
+
83
+ test("extra bindings win over derived", () => {
84
+ const caps: Capability[] = [
85
+ {
86
+ id: "wave_hand",
87
+ verb: "wave",
88
+ description: "Wave",
89
+ source: { kind: "skill", skillId: "wave", package: "w" },
90
+ },
91
+ ];
92
+ const bindings = buildMissionBindings(caps, {
93
+ extra: {
94
+ wave_hand: {
95
+ tool: "override_tool",
96
+ buildArgs: () => ({ overridden: true }),
97
+ },
98
+ },
99
+ });
100
+ assert.equal(bindings.wave_hand.tool, "override_tool");
101
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pause / resume tests for runMission + MissionRegistry.
3
+ */
4
+
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { runMission, type CapabilityToolBindings, type Mission } from "../mission.js";
8
+ import { MissionRegistry } from "../mission-registry.js";
9
+ import type { Capability } from "../capabilities.js";
10
+
11
+ const CAPS: Capability[] = [
12
+ { id: "drive_base", verb: "drive", description: "Drive", source: { kind: "builtin" } },
13
+ { id: "list_topics", verb: "list", description: "List", source: { kind: "builtin" } },
14
+ ];
15
+
16
+ const BINDINGS: CapabilityToolBindings = {
17
+ drive_base: {
18
+ tool: "ros2_publish",
19
+ buildArgs: (inputs) => ({ topic: "cmd_vel", msg: inputs }),
20
+ },
21
+ list_topics: {
22
+ tool: "ros2_list_topics",
23
+ buildArgs: () => ({}),
24
+ },
25
+ };
26
+
27
+ test("pause blocks next step until resume", async () => {
28
+ const token = { cancelled: false, paused: true, reason: "human in aisle" };
29
+ const calls: string[] = [];
30
+ const mission: Mission = {
31
+ name: "pause-test",
32
+ steps: [
33
+ { id: "a", capability: "list_topics" },
34
+ { id: "b", capability: "drive_base", inputs: { linear_x: 0.1 } },
35
+ ],
36
+ };
37
+
38
+ const runPromise = runMission(mission, CAPS, BINDINGS, async (tool) => {
39
+ calls.push(tool);
40
+ return { text: JSON.stringify({ ok: true }) };
41
+ }, { cancellation: token, mission_id: "mn_pause" });
42
+
43
+ // First step should not run while paused — wait a bit then resume.
44
+ await new Promise((r) => setTimeout(r, 150));
45
+ assert.equal(calls.length, 0, "no tools while paused before first step");
46
+ token.paused = false;
47
+
48
+ const result = await runPromise;
49
+ assert.equal(result.status, "ok");
50
+ assert.equal(calls.length, 2);
51
+ assert.deepEqual(calls, ["ros2_list_topics", "ros2_publish"]);
52
+ });
53
+
54
+ test("cancel while paused aborts remaining steps", async () => {
55
+ const token = { cancelled: false, paused: true };
56
+ const mission: Mission = {
57
+ steps: [
58
+ { id: "a", capability: "list_topics" },
59
+ { id: "b", capability: "drive_base" },
60
+ ],
61
+ };
62
+
63
+ const runPromise = runMission(mission, CAPS, BINDINGS, async () => ({ text: "{}" }), {
64
+ cancellation: token,
65
+ });
66
+
67
+ await new Promise((r) => setTimeout(r, 80));
68
+ token.cancelled = true;
69
+ token.paused = false;
70
+
71
+ const result = await runPromise;
72
+ assert.equal(result.status, "cancelled");
73
+ assert.ok(result.steps.every((s) => s.status === "cancelled"));
74
+ });
75
+
76
+ test("MissionRegistry pause/resume/cancel", () => {
77
+ const reg = new MissionRegistry();
78
+ const { entry, dispose } = reg.register("mn_1", { name: "t" });
79
+ assert.equal(entry.cancellation.paused, false);
80
+
81
+ const p1 = reg.pause("mn_1", "wait");
82
+ assert.equal(p1.found, true);
83
+ assert.equal(p1.alreadyPaused, false);
84
+ assert.equal(entry.cancellation.paused, true);
85
+ assert.equal(entry.cancellation.reason, "wait");
86
+
87
+ const p2 = reg.pause("mn_1");
88
+ assert.equal(p2.alreadyPaused, true);
89
+
90
+ const r1 = reg.resume("mn_1");
91
+ assert.equal(r1.found, true);
92
+ assert.equal(r1.wasPaused, true);
93
+ assert.equal(entry.cancellation.paused, false);
94
+
95
+ assert.equal(reg.pause("missing").found, false);
96
+ assert.equal(reg.resume("missing").found, false);
97
+
98
+ reg.pause("mn_1");
99
+ reg.cancel("mn_1", "abort");
100
+ assert.equal(entry.cancellation.cancelled, true);
101
+ assert.equal(entry.cancellation.paused, false);
102
+
103
+ dispose();
104
+ });
@@ -193,9 +193,30 @@ function normalizeCapability(
193
193
  };
194
194
  }
195
195
 
196
+ function readCapabilitiesJson(
197
+ packageDir: string,
198
+ ): Array<Partial<Capability> & { id: string }> {
199
+ const capsPath = join(packageDir, "capabilities.json");
200
+ if (!existsSync(capsPath)) return [];
201
+ try {
202
+ const raw = JSON.parse(readFileSync(capsPath, "utf-8")) as unknown;
203
+ const list = Array.isArray(raw)
204
+ ? raw
205
+ : raw && typeof raw === "object" && Array.isArray((raw as { capabilities?: unknown }).capabilities)
206
+ ? (raw as { capabilities: unknown[] }).capabilities
207
+ : [];
208
+ const out: Array<Partial<Capability> & { id: string }> = [];
209
+ for (const c of list) if (isCapabilityLike(c)) out.push(c);
210
+ return out;
211
+ } catch {
212
+ return [];
213
+ }
214
+ }
215
+
196
216
  /**
197
- * Read the `agenticros.capabilities[]` array from a skill's package.json.
198
- * Returns `null` if the package doesn't declare a valid `agenticros` block.
217
+ * Read the `agenticros.capabilities[]` array from a skill's package.json,
218
+ * merging any sibling `capabilities.json`. Returns `null` if the package
219
+ * doesn't declare a valid `agenticros` block.
199
220
  *
200
221
  * The legacy `agenticrosSkill: true | { capabilities }` form is NOT supported
201
222
  * — every skill must use the single `agenticros` block as its source of truth.
@@ -220,10 +241,24 @@ function readSkillManifestFromDir(
220
241
  const packageName = pkg.name ?? "unknown";
221
242
  const skillId = pkg.agenticros.id;
222
243
  const rawCaps: Array<Partial<Capability> & { id: string }> = [];
244
+ const seenIds = new Set<string>();
223
245
 
224
246
  const caps = pkg.agenticros.capabilities;
225
247
  if (Array.isArray(caps)) {
226
- for (const c of caps) if (isCapabilityLike(c)) rawCaps.push(c);
248
+ for (const c of caps) {
249
+ if (isCapabilityLike(c) && !seenIds.has(c.id)) {
250
+ seenIds.add(c.id);
251
+ rawCaps.push(c);
252
+ }
253
+ }
254
+ }
255
+
256
+ // Sibling capabilities.json merges in (package.json wins on id conflict).
257
+ for (const c of readCapabilitiesJson(packageDir)) {
258
+ if (!seenIds.has(c.id)) {
259
+ seenIds.add(c.id);
260
+ rawCaps.push(c);
261
+ }
227
262
  }
228
263
 
229
264
  return { packageName, skillId, rawCaps };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Zod schemas for capability manifests (skill load / CLI publish).
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ export const CapabilityFieldSchema = z.object({
8
+ type: z.string(),
9
+ description: z.string().optional(),
10
+ optional: z.boolean().optional(),
11
+ default: z.unknown().optional(),
12
+ });
13
+
14
+ export const CapabilityImplementationSchema = z.union([
15
+ z.object({ kind: z.literal("in_process") }),
16
+ z.object({
17
+ kind: z.literal("external_ros_node"),
18
+ package: z.string().optional(),
19
+ launch: z.string().optional(),
20
+ action: z.string().optional(),
21
+ service: z.string().optional(),
22
+ topic: z.string().optional(),
23
+ msg_type: z.string().optional(),
24
+ }),
25
+ ]);
26
+
27
+ export const CapabilitySchema = z.object({
28
+ id: z.string().min(1),
29
+ verb: z.string().min(1),
30
+ description: z.string().default(""),
31
+ inputs: z.record(CapabilityFieldSchema).optional(),
32
+ outputs: z.record(CapabilityFieldSchema).optional(),
33
+ preconditions: z.array(z.string()).optional(),
34
+ interruptible: z.boolean().optional(),
35
+ blocks_base: z.boolean().optional(),
36
+ implementation: CapabilityImplementationSchema.optional(),
37
+ tool: z.string().optional(),
38
+ });
39
+
40
+ export type ParsedCapability = z.infer<typeof CapabilitySchema>;
41
+
42
+ /**
43
+ * Validate a raw capability object. Throws ZodError on failure.
44
+ */
45
+ export function parseCapability(raw: unknown): ParsedCapability {
46
+ return CapabilitySchema.parse(raw);
47
+ }
48
+
49
+ /**
50
+ * Soft-validate: returns { ok, capability?, error? }.
51
+ */
52
+ export function safeParseCapability(
53
+ raw: unknown,
54
+ ): { ok: true; capability: ParsedCapability } | { ok: false; error: string } {
55
+ const result = CapabilitySchema.safeParse(raw);
56
+ if (result.success) return { ok: true, capability: result.data };
57
+ return { ok: false, error: result.error.message };
58
+ }
@@ -441,6 +441,26 @@ export function parseConfig(raw: Record<string, unknown>): AgenticROSConfig {
441
441
  return AgenticROSConfigSchema.parse(normalized);
442
442
  }
443
443
 
444
+ /**
445
+ * Strip Zod defaults that were never present in the raw config before parse.
446
+ * The config page validates via parseConfig() but should not inject keys the
447
+ * user never set — e.g. `robots: []` from the schema default breaks OpenClaw's
448
+ * strict plugin configSchema when that field isn't declared yet.
449
+ */
450
+ export function prepareConfigForPersistence(
451
+ parsed: AgenticROSConfig,
452
+ raw: Record<string, unknown>,
453
+ ): Record<string, unknown> {
454
+ const out = JSON.parse(JSON.stringify(parsed)) as Record<string, unknown>;
455
+ if (!Object.prototype.hasOwnProperty.call(raw, "robots")) {
456
+ const robots = out.robots;
457
+ if (Array.isArray(robots) && robots.length === 0) {
458
+ delete out.robots;
459
+ }
460
+ }
461
+ return out;
462
+ }
463
+
444
464
  /**
445
465
  * Build TransportConfig from full config for createTransport().
446
466
  */
@@ -102,9 +102,13 @@ export function effectiveCmdVelNamespace(robotNamespace: string): string {
102
102
  /**
103
103
  * Find every robot-shaped namespace in a topic list.
104
104
  *
105
- * Detection rule: any `/<seg>/cmd_vel` topic implies a robot with
106
- * namespace `<seg>`. The unnamespaced `/cmd_vel` topic registers the
107
- * empty namespace (id=""). Topic count under each namespace is
105
+ * Detection rules (either is enough):
106
+ * 1. `/<seg>/cmd_vel` classic mobile-base signal
107
+ * 2. `/<seg>/agenticros/robot_info` Phase 1.d heartbeat (arms/drones
108
+ * without cmd_vel still show up)
109
+ *
110
+ * The unnamespaced `/cmd_vel` / `/agenticros/robot_info` topics register
111
+ * the empty namespace (id=""). Topic count under each namespace is
108
112
  * accumulated so consumers can rank by liveness.
109
113
  *
110
114
  * The returned `DetectedRobot.configuredRobotId` is always `null` here —
@@ -124,6 +128,20 @@ export function detectRobotsFromTopics(topics: TopicInfo[]): DetectedRobot[] {
124
128
  } else if (t.name === "/cmd_vel" && !byNs.has("")) {
125
129
  byNs.set("", { cmdVelTopic: "/cmd_vel", topicCount: 0 });
126
130
  }
131
+
132
+ // Heartbeat topic — covers robots that don't publish cmd_vel.
133
+ const hb = t.name.match(/^\/([^/]+)\/agenticros\/robot_info$/);
134
+ if (hb) {
135
+ const ns = hb[1]!;
136
+ if (!byNs.has(ns)) {
137
+ byNs.set(ns, { cmdVelTopic: `/${ns}/cmd_vel`, topicCount: 0 });
138
+ }
139
+ } else if (
140
+ (t.name === "/agenticros/robot_info" || t.name === "agenticros/robot_info") &&
141
+ !byNs.has("")
142
+ ) {
143
+ byNs.set("", { cmdVelTopic: "/cmd_vel", topicCount: 0 });
144
+ }
127
145
  }
128
146
 
129
147
  // Count topics under each detected namespace. Empty-namespace robots