infinicode 2.3.6 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bin/robopark.js +2 -0
  2. package/dist/cli.js +22 -1
  3. package/dist/commands/maintain.d.ts +29 -0
  4. package/dist/commands/maintain.js +186 -0
  5. package/dist/commands/mcp.js +1 -1
  6. package/dist/commands/robopark.d.ts +6 -0
  7. package/dist/commands/robopark.js +450 -0
  8. package/dist/commands/serve.d.ts +1 -0
  9. package/dist/commands/serve.js +152 -11
  10. package/dist/kernel/agents/backends/cli-backend.d.ts +4 -0
  11. package/dist/kernel/agents/backends/cli-backend.js +13 -5
  12. package/dist/kernel/agents/backends/detect.d.ts +12 -0
  13. package/dist/kernel/agents/backends/detect.js +102 -2
  14. package/dist/kernel/agents/backends/profiles.d.ts +67 -0
  15. package/dist/kernel/agents/backends/profiles.js +48 -0
  16. package/dist/kernel/agents/backends/registry.d.ts +2 -1
  17. package/dist/kernel/agents/backends/registry.js +39 -5
  18. package/dist/kernel/agents/backends/rotation.d.ts +51 -0
  19. package/dist/kernel/agents/backends/rotation.js +107 -0
  20. package/dist/kernel/agents/backends/types.d.ts +3 -1
  21. package/dist/kernel/federation/dashboard-html.d.ts +13 -0
  22. package/dist/kernel/federation/dashboard-html.js +371 -0
  23. package/dist/kernel/federation/federation.d.ts +58 -1
  24. package/dist/kernel/federation/federation.js +111 -23
  25. package/dist/kernel/federation/moltfed-adapter.js +1 -0
  26. package/dist/kernel/federation/telemetry.d.ts +1 -1
  27. package/dist/kernel/federation/telemetry.js +2 -1
  28. package/dist/kernel/federation/transport-http.d.ts +17 -1
  29. package/dist/kernel/federation/transport-http.js +65 -2
  30. package/dist/kernel/federation/types.d.ts +20 -0
  31. package/dist/kernel/mcp/mcp-server.js +23 -7
  32. package/dist/robopark-cli.d.ts +2 -0
  33. package/dist/robopark-cli.js +23 -0
  34. package/package.json +6 -3
@@ -11,13 +11,14 @@
11
11
  * (heartbeat / task.dispatch / command / config / update).
12
12
  */
13
13
  import type { Capability, KernelLike, Logger } from '../types.js';
14
- import type { HardwareSnapshot, NodeIdentity, NodeStatus, PeerInfo, SharedConfig, StreamFrame } from './types.js';
14
+ import type { HardwareSnapshot, NodeIdentity, NodeManifest, NodeStatus, PeerInfo, SharedConfig, StreamFrame } from './types.js';
15
15
  import { ConfigSync } from './config-sync.js';
16
16
  import { AutoUpdate } from './auto-update.js';
17
17
  import { ComputeRouter, type ComputeNeed } from './compute-router.js';
18
18
  import { type ManifestInputs } from './node-identity.js';
19
19
  import { type AgentHandle, type RoleContext, type RunRecord } from './types.js';
20
20
  import { AgentBackendRegistry, type BackendId } from '../agents/index.js';
21
+ import { loadCoderConfig, type CoderConfig } from '../agents/backends/profiles.js';
21
22
  export interface DispatchTask {
22
23
  description: string;
23
24
  capabilities: Capability[];
@@ -34,6 +35,14 @@ export interface DispatchTask {
34
35
  sessionId?: string;
35
36
  /** Model hint passed to a CLI backend (provider/model or a CLI-native id). */
36
37
  model?: string;
38
+ /** Named coder profile to run (e.g. "deep", "cheap"). */
39
+ profile?: string;
40
+ /** Rotation strategy: 'auto' | 'tasktype' | 'policy' | 'roundrobin' | 'fallback'. */
41
+ strategy?: string;
42
+ /** Policy tier: 'free-first' | 'cheapest' | 'fastest' | 'highest-quality' | 'balanced'. */
43
+ policy?: string;
44
+ /** Task-type key for routing (e.g. 'refactor', 'docs', 'bulk'). */
45
+ taskType?: string;
37
46
  }
38
47
  export interface FederationOptions {
39
48
  version: string;
@@ -51,6 +60,34 @@ export interface FederationOptions {
51
60
  autoPullConfig?: boolean;
52
61
  /** Satellite: how often to re-pull shared config (ms, default 30000; 0 disables). */
53
62
  configPullMs?: number;
63
+ /** Coder profiles + rotation config (from node config `coder`). Merged over
64
+ * built-in defaults, so the mesh rotates across backends out of the box. */
65
+ coder?: Parameters<typeof loadCoderConfig>[0];
66
+ /** Serve the web control dashboard at GET / and /ui (off unless enabled). */
67
+ dashboard?: boolean;
68
+ /** Publish this node's shared config to the fleet (backs POST /fed/apply +
69
+ * the dashboard's "Apply config to fleet" button). */
70
+ onApply?: () => Promise<unknown> | unknown;
71
+ /** Gate remote/dashboard slash-commands. Returns {ok:false,reason} to reject —
72
+ * used to keep an unauthenticated, network-exposed node read-only. */
73
+ commandGate?: (input: string) => {
74
+ ok: boolean;
75
+ reason?: string;
76
+ };
77
+ }
78
+ /** Consolidated fleet view served at GET /fed/status. */
79
+ export interface FleetStatus {
80
+ self: NodeManifest;
81
+ role: RoleContext | null;
82
+ nodes: NodeStatus[];
83
+ hardware?: HardwareSnapshot;
84
+ runs: RunRecord[];
85
+ agents: AgentHandle[];
86
+ activity: Array<{
87
+ peerId: string;
88
+ f: StreamFrame;
89
+ }>;
90
+ ts: number;
54
91
  }
55
92
  export interface FederationDeps {
56
93
  kernel: KernelLike;
@@ -83,6 +120,10 @@ export declare class Federation {
83
120
  readonly compute: ComputeRouter;
84
121
  /** Executors available on this node (native kernel + installed CLIs). */
85
122
  readonly backends: AgentBackendRegistry;
123
+ /** Resolved coder profiles + rotation policy for this node. */
124
+ readonly coderConfig: CoderConfig;
125
+ /** Per-node round-robin cursor for backend rotation. */
126
+ private readonly rotationState;
86
127
  /** Local subscribers to the merged mesh frame stream (for MCP stream_activity). */
87
128
  private frameSubs;
88
129
  /** Recent frames for catch-up. */
@@ -110,6 +151,20 @@ export declare class Federation {
110
151
  * is captured on the RunRecord so the run can be followed and later messaged.
111
152
  */
112
153
  private runViaBackend;
154
+ /** Stream one backend's frames up the mesh, tagged with the backend id. */
155
+ private backendFrameSink;
156
+ /** Run a single backend attempt. Never throws — a missing/unavailable backend
157
+ * returns a failed result so a rotation chain can fall through to the next. */
158
+ private runBackendOnce;
159
+ /** Write a backend result onto the run record. */
160
+ private writeResult;
161
+ /**
162
+ * Resolve a rotation chain for the task and run it with fallback: try each
163
+ * profile's backend in order, streaming its activity; the first `completed`
164
+ * wins, otherwise fall through to the next. Composes task-type routing, policy
165
+ * tiers, round-robin and fallback (see agents/backends/rotation.ts).
166
+ */
167
+ private runViaBackendChain;
113
168
  /**
114
169
  * Hand a task to the best-fit connected peer (compute-routed) or a named one.
115
170
  * Non-blocking by default — returns a runId to follow. Pass wait to block
@@ -203,6 +258,8 @@ export declare class Federation {
203
258
  /** Neutral node-status list (self + peers) — feeds the mesh map. */
204
259
  nodeStatuses(): NodeStatus[];
205
260
  peers(): PeerInfo[];
261
+ /** Consolidated fleet view for the dashboard + `robopark fleet` (GET /fed/status). */
262
+ statusSnapshot(): FleetStatus;
206
263
  /** Latest local hardware snapshot (for the MOLTFED connector / panels). */
207
264
  latestHardware(): HardwareSnapshot | undefined;
208
265
  /** Subscribe to the merged mesh frame stream (local + peer frames). */
@@ -1,4 +1,5 @@
1
1
  import { MeshServer, MeshClient } from './transport-http.js';
2
+ import { DASHBOARD_HTML } from './dashboard-html.js';
2
3
  import { PeerMesh } from './peer-mesh.js';
3
4
  import { EventBridge } from './event-bridge.js';
4
5
  import { ConfigSync } from './config-sync.js';
@@ -12,6 +13,8 @@ import { discoverTailscalePeers } from './discovery.js';
12
13
  import { LanBeacon } from './lan-discovery.js';
13
14
  import { DEFAULT_MESH_PORT } from './types.js';
14
15
  import { createDefaultBackendRegistry } from '../agents/index.js';
16
+ import { RotationState, selectChain } from '../agents/backends/rotation.js';
17
+ import { loadCoderConfig } from '../agents/backends/profiles.js';
15
18
  export class Federation {
16
19
  deps;
17
20
  server;
@@ -33,6 +36,10 @@ export class Federation {
33
36
  compute = new ComputeRouter();
34
37
  /** Executors available on this node (native kernel + installed CLIs). */
35
38
  backends;
39
+ /** Resolved coder profiles + rotation policy for this node. */
40
+ coderConfig;
41
+ /** Per-node round-robin cursor for backend rotation. */
42
+ rotationState = new RotationState();
36
43
  /** Local subscribers to the merged mesh frame stream (for MCP stream_activity). */
37
44
  frameSubs = new Set();
38
45
  /** Recent frames for catch-up. */
@@ -45,6 +52,7 @@ export class Federation {
45
52
  this.deps = deps;
46
53
  this.client = new MeshClient({ token: deps.options.token });
47
54
  this.backends = deps.backends ?? createDefaultBackendRegistry();
55
+ this.coderConfig = loadCoderConfig(deps.options.coder);
48
56
  this.configSync = new ConfigSync({ logger: deps.logger, apply: deps.options.applyConfig });
49
57
  this.autoUpdate = new AutoUpdate({
50
58
  version: deps.options.version,
@@ -65,7 +73,11 @@ export class Federation {
65
73
  logger,
66
74
  getManifest: () => buildManifest(identity, this.describeWithBackends()),
67
75
  getNodes: () => this.nodeStatuses(),
76
+ getStatus: () => this.statusSnapshot(),
68
77
  runCommand: (input) => this.deps.kernel.command(input),
78
+ commandGate: options.commandGate,
79
+ onApply: options.onApply,
80
+ dashboardHtml: options.dashboard ? DASHBOARD_HTML : undefined,
69
81
  onRpc: (env, remote) => this.onRpc(env, remote),
70
82
  });
71
83
  await this.server.start();
@@ -81,9 +93,15 @@ export class Federation {
81
93
  });
82
94
  this.mesh.start();
83
95
  // Bridge local kernel events → mesh stream (coalesced, animation-friendly).
96
+ // Also record into frameHistory so this node's own activity shows up in the
97
+ // /fed/status snapshot the dashboard + `robopark fleet` poll — not just in
98
+ // the live SSE push to connected harnesses.
84
99
  this.bridge = new EventBridge({
85
100
  nodeId: identity.nodeId,
86
- emit: f => this.server?.broadcast(f),
101
+ emit: f => {
102
+ this.server?.broadcast(f);
103
+ this.ingestFrame(identity.nodeId, f);
104
+ },
87
105
  });
88
106
  this.unsub = this.deps.kernel.subscribeAll(e => this.bridge?.handle(e));
89
107
  // Telemetry heartbeat → stream a HardwareSnapshot on a fixed cadence.
@@ -234,11 +252,17 @@ export class Federation {
234
252
  if (!rec)
235
253
  return;
236
254
  const nodeId = this.deps.identity.nodeId;
237
- const backendId = task.agent && task.agent !== 'kernel' ? task.agent : null;
238
- rec.agent = backendId ?? 'kernel';
255
+ // Rotation is requested when a profile/strategy/route/policy is given, or the
256
+ // caller asks for 'auto'. Otherwise a concrete agent is a single fixed backend.
257
+ const wantsRotation = !!(task.profile || task.strategy || task.taskType || task.policy || task.agent === 'auto');
258
+ const backendId = task.agent && task.agent !== 'kernel' && task.agent !== 'auto' ? task.agent : null;
259
+ rec.agent = wantsRotation ? 'rotate' : (backendId ?? 'kernel');
239
260
  this.server?.broadcast(frame('ev', nodeId, { k: 'RUN_STARTED', run: runId, desc: task.description, agent: rec.agent }));
240
261
  try {
241
- if (backendId) {
262
+ if (wantsRotation) {
263
+ await this.runViaBackendChain(runId, task, rec);
264
+ }
265
+ else if (backendId) {
242
266
  await this.runViaBackend(runId, backendId, task, rec);
243
267
  }
244
268
  else {
@@ -263,31 +287,82 @@ export class Federation {
263
287
  * is captured on the RunRecord so the run can be followed and later messaged.
264
288
  */
265
289
  async runViaBackend(runId, backendId, task, rec) {
290
+ const res = await this.runBackendOnce(runId, backendId, task, task.model);
291
+ this.writeResult(rec, runId, backendId, res);
292
+ }
293
+ /** Stream one backend's frames up the mesh, tagged with the backend id. */
294
+ backendFrameSink(runId, backendId) {
295
+ const nodeId = this.deps.identity.nodeId;
296
+ return (f) => {
297
+ if (f.kind === 'log' && f.text) {
298
+ this.server?.broadcast(frame('log', nodeId, { run: runId, agent: backendId, line: f.text }));
299
+ }
300
+ else {
301
+ this.server?.broadcast(frame('ev', nodeId, { k: 'AGENT_OUTPUT', run: runId, agent: backendId, kind: f.kind, text: f.text }));
302
+ }
303
+ };
304
+ }
305
+ /** Run a single backend attempt. Never throws — a missing/unavailable backend
306
+ * returns a failed result so a rotation chain can fall through to the next. */
307
+ async runBackendOnce(runId, backendId, task, model, args) {
266
308
  const backend = this.backends.get(backendId);
267
309
  if (!backend || !backend.available()) {
268
- throw new Error(`backend not available on this node: ${backendId}`);
310
+ return { status: 'failed', error: `backend not available on this node: ${backendId}` };
269
311
  }
270
- const nodeId = this.deps.identity.nodeId;
271
- const localRole = loadRole();
272
- const preamble = rolePreamble(localRole);
273
- const res = await backend.run({ prompt: task.prompt, capabilities: task.capabilities, model: task.model, sessionId: task.sessionId }, {
274
- runId,
275
- rolePreamble: preamble,
276
- onFrame: f => {
277
- if (f.kind === 'log' && f.text) {
278
- this.server?.broadcast(frame('log', nodeId, { run: runId, agent: backendId, line: f.text }));
279
- }
280
- else {
281
- this.server?.broadcast(frame('ev', nodeId, { k: 'AGENT_OUTPUT', run: runId, agent: backendId, kind: f.kind, text: f.text }));
282
- }
283
- },
284
- });
312
+ const preamble = rolePreamble(loadRole());
313
+ try {
314
+ return await backend.run({ prompt: task.prompt, capabilities: task.capabilities, model: model ?? task.model, args, sessionId: task.sessionId }, { runId, rolePreamble: preamble, onFrame: this.backendFrameSink(runId, backendId) });
315
+ }
316
+ catch (err) {
317
+ return { status: 'failed', error: err instanceof Error ? err.message : String(err) };
318
+ }
319
+ }
320
+ /** Write a backend result onto the run record. */
321
+ writeResult(rec, runId, backendId, res) {
322
+ rec.agent = backendId;
285
323
  rec.status = res.status;
286
324
  rec.sessionId = res.sessionId;
287
325
  rec.missionId = res.sessionId ?? runId;
288
326
  rec.outputs = [{ status: res.status, content: res.content, error: res.error }];
289
- if (res.status === 'failed' && res.error)
290
- rec.error = res.error;
327
+ rec.error = res.status === 'failed' ? res.error : undefined;
328
+ }
329
+ /**
330
+ * Resolve a rotation chain for the task and run it with fallback: try each
331
+ * profile's backend in order, streaming its activity; the first `completed`
332
+ * wins, otherwise fall through to the next. Composes task-type routing, policy
333
+ * tiers, round-robin and fallback (see agents/backends/rotation.ts).
334
+ */
335
+ async runViaBackendChain(runId, task, rec) {
336
+ const nodeId = this.deps.identity.nodeId;
337
+ const req = {
338
+ strategy: task.strategy,
339
+ profile: task.profile,
340
+ model: task.model,
341
+ taskType: task.taskType,
342
+ policy: task.policy,
343
+ backend: task.agent && task.agent !== 'auto' && task.agent !== 'kernel' ? task.agent : undefined,
344
+ };
345
+ const chain = selectChain(this.coderConfig, req, this.rotationState);
346
+ let last = { status: 'failed', error: 'no backend available in rotation chain' };
347
+ for (let i = 0; i < chain.length; i++) {
348
+ const step = chain[i];
349
+ this.server?.broadcast(frame('ev', nodeId, {
350
+ k: 'ROTATION_ATTEMPT', run: runId, agent: step.backend, model: step.model,
351
+ profile: step.profileName, attempt: i + 1, of: chain.length,
352
+ }));
353
+ last = await this.runBackendOnce(runId, step.backend, task, step.model, step.args);
354
+ if (last.status === 'completed') {
355
+ this.writeResult(rec, runId, step.backend, last);
356
+ return;
357
+ }
358
+ if (i < chain.length - 1) {
359
+ this.server?.broadcast(frame('ev', nodeId, {
360
+ k: 'ROTATION_FALLBACK', run: runId, from: step.backend,
361
+ next: chain[i + 1].backend, reason: (last.error ?? '').slice(0, 200),
362
+ }));
363
+ }
364
+ }
365
+ this.writeResult(rec, runId, chain[chain.length - 1]?.backend ?? 'kernel', last);
291
366
  }
292
367
  // ── Outbound operations ──────────────────────────────────────────────────
293
368
  /**
@@ -585,13 +660,26 @@ export class Federation {
585
660
  version: d.version,
586
661
  capabilities: d.capabilities,
587
662
  commands: d.commands,
588
- });
663
+ }, loadRole()?.site);
589
664
  const peers = (this.mesh?.list() ?? []).map(nodeStatusFromPeer);
590
665
  return [self, ...peers];
591
666
  }
592
667
  peers() {
593
668
  return this.mesh?.list() ?? [];
594
669
  }
670
+ /** Consolidated fleet view for the dashboard + `robopark fleet` (GET /fed/status). */
671
+ statusSnapshot() {
672
+ return {
673
+ self: buildManifest(this.deps.identity, this.describeWithBackends()),
674
+ role: this.getRole(),
675
+ nodes: this.nodeStatuses(),
676
+ hardware: this.lastHw,
677
+ runs: this.localRuns(),
678
+ agents: this.localAgents(),
679
+ activity: this.recentFrames(60),
680
+ ts: Date.now(),
681
+ };
682
+ }
595
683
  /** Latest local hardware snapshot (for the MOLTFED connector / panels). */
596
684
  latestHardware() {
597
685
  return this.lastHw;
@@ -17,6 +17,7 @@ export function toNodesStatus(statuses) {
17
17
  // extra infinicode fields are ignored by InfiniBot but useful to richer UIs
18
18
  role: s.role,
19
19
  load: s.load,
20
+ site: s.site,
20
21
  })),
21
22
  };
22
23
  }
@@ -18,4 +18,4 @@ export declare function selfNodeStatus(identity: NodeIdentity, hw: HardwareSnaps
18
18
  version: string;
19
19
  capabilities: NodeStatus['capabilities'];
20
20
  commands: string[];
21
- }): NodeStatus;
21
+ }, site?: string): NodeStatus;
@@ -120,7 +120,7 @@ export function nodeStatusFromPeer(peer) {
120
120
  };
121
121
  }
122
122
  /** Local node's own status entry. */
123
- export function selfNodeStatus(identity, hw, manifest) {
123
+ export function selfNodeStatus(identity, hw, manifest, site) {
124
124
  return {
125
125
  nodeId: identity.nodeId,
126
126
  displayName: identity.displayName,
@@ -134,5 +134,6 @@ export function selfNodeStatus(identity, hw, manifest) {
134
134
  connectedAtMs: Date.now(),
135
135
  load: loadFromSnapshot(hw),
136
136
  lastSeenAt: Date.now(),
137
+ site,
137
138
  };
138
139
  }
@@ -9,9 +9,22 @@ export interface MeshServerOptions {
9
9
  getManifest: () => NodeManifest;
10
10
  /** Optional: full mesh view (self + peers) for the GET /fed/nodes probe. */
11
11
  getNodes?: () => unknown;
12
+ /** Optional: consolidated fleet snapshot (self + nodes + hardware + roles +
13
+ * runs + recent activity) for the dashboard and `robopark fleet` (GET /fed/status). */
14
+ getStatus?: () => unknown;
12
15
  /** Optional: run a kernel slash-command on this node (POST /fed/command).
13
- * Powers the in-TUI mesh command palette. */
16
+ * Powers the in-TUI mesh command palette + the dashboard's action buttons. */
14
17
  runCommand?: (input: string) => Promise<unknown>;
18
+ /** Optional gate on POST /fed/command inputs. Returns {ok:false,reason} to
19
+ * reject — used to keep an unauthenticated, network-exposed node read-only. */
20
+ commandGate?: (input: string) => {
21
+ ok: boolean;
22
+ reason?: string;
23
+ };
24
+ /** Optional: publish this node's shared config to the fleet (POST /fed/apply). */
25
+ onApply?: () => Promise<unknown> | unknown;
26
+ /** Optional: static HTML for the control dashboard, served at GET / and /ui. */
27
+ dashboardHtml?: string;
15
28
  onRpc: RpcHandler;
16
29
  logger: Logger;
17
30
  }
@@ -25,6 +38,9 @@ export declare class MeshServer {
25
38
  start(): Promise<void>;
26
39
  /** Push a frame to every connected stream subscriber. */
27
40
  broadcast(frame: StreamFrame): void;
41
+ /** Token presented on a request — Bearer header (peers/CLI) OR ?token= query
42
+ * (browsers, which can't set headers on navigation or EventSource). */
43
+ private presentedToken;
28
44
  private authOk;
29
45
  private handle;
30
46
  private openStream;
@@ -14,7 +14,16 @@
14
14
  * MeshClient/MeshServer shape later for deep InfiniBot-fabric interop.
15
15
  */
16
16
  import { createServer } from 'node:http';
17
+ import { timingSafeEqual } from 'node:crypto';
17
18
  import { frameToSSE, parseSSE } from './protocol.js';
19
+ /** Constant-time string compare — avoids leaking the token via response timing. */
20
+ function safeEqual(a, b) {
21
+ const ab = Buffer.from(a);
22
+ const bb = Buffer.from(b);
23
+ if (ab.length !== bb.length)
24
+ return false;
25
+ return timingSafeEqual(ab, bb);
26
+ }
18
27
  export class MeshServer {
19
28
  opts;
20
29
  server;
@@ -51,19 +60,67 @@ export class MeshServer {
51
60
  }
52
61
  }
53
62
  }
63
+ /** Token presented on a request — Bearer header (peers/CLI) OR ?token= query
64
+ * (browsers, which can't set headers on navigation or EventSource). */
65
+ presentedToken(req) {
66
+ const h = req.headers['authorization'];
67
+ if (typeof h === 'string' && h.startsWith('Bearer '))
68
+ return h.slice(7);
69
+ const url = req.url ?? '';
70
+ const q = url.indexOf('?');
71
+ if (q >= 0) {
72
+ const t = new URLSearchParams(url.slice(q + 1)).get('token');
73
+ if (t)
74
+ return t;
75
+ }
76
+ return undefined;
77
+ }
54
78
  authOk(req) {
55
79
  if (!this.opts.token)
56
80
  return true;
57
- const h = req.headers['authorization'];
58
- return typeof h === 'string' && h === `Bearer ${this.opts.token}`;
81
+ const presented = this.presentedToken(req);
82
+ return presented !== undefined && safeEqual(presented, this.opts.token);
59
83
  }
60
84
  handle(req, res) {
61
85
  const url = req.url ?? '/';
86
+ const path = url.split('?', 1)[0];
62
87
  if (!this.authOk(req)) {
63
88
  res.writeHead(401).end('unauthorized');
64
89
  return;
65
90
  }
66
91
  const remote = req.socket.remoteAddress ?? 'unknown';
92
+ // Control dashboard (single self-contained page). Same-origin fetches carry
93
+ // the ?token= through, so no CORS is opened.
94
+ if (req.method === 'GET' && (path === '/' || path === '/ui' || path === '/dashboard')) {
95
+ if (!this.opts.dashboardHtml) {
96
+ res.writeHead(404).end('dashboard not enabled (start with --dashboard)');
97
+ return;
98
+ }
99
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
100
+ res.end(this.opts.dashboardHtml);
101
+ return;
102
+ }
103
+ if (req.method === 'GET' && path === '/fed/status') {
104
+ res.writeHead(200, { 'content-type': 'application/json' });
105
+ res.end(JSON.stringify(this.opts.getStatus?.() ?? {}));
106
+ return;
107
+ }
108
+ if (req.method === 'POST' && path.startsWith('/fed/apply')) {
109
+ if (!this.opts.onApply) {
110
+ res.writeHead(404).end('no apply handler');
111
+ return;
112
+ }
113
+ Promise.resolve(this.opts.onApply())
114
+ .then(result => {
115
+ res.writeHead(200, { 'content-type': 'application/json' });
116
+ res.end(JSON.stringify(result ?? { ok: true }));
117
+ })
118
+ .catch(err => {
119
+ this.opts.logger.warn(`[federation] apply error: ${err instanceof Error ? err.message : String(err)}`);
120
+ res.writeHead(500).end('apply failed');
121
+ });
122
+ return;
123
+ }
67
124
  if (req.method === 'GET' && url.startsWith('/fed/manifest')) {
68
125
  res.writeHead(200, { 'content-type': 'application/json' });
69
126
  res.end(JSON.stringify(this.opts.getManifest()));
@@ -93,6 +150,12 @@ export class MeshServer {
93
150
  res.writeHead(400).end('bad json');
94
151
  return;
95
152
  }
153
+ const gate = this.opts.commandGate?.(input);
154
+ if (gate && !gate.ok) {
155
+ res.writeHead(403, { 'content-type': 'application/json' });
156
+ res.end(JSON.stringify({ ok: false, text: gate.reason ?? 'command not permitted on this node' }));
157
+ return;
158
+ }
96
159
  const result = await this.opts.runCommand(input);
97
160
  res.writeHead(200, { 'content-type': 'application/json' });
98
161
  res.end(JSON.stringify(result ?? { ok: false, text: 'no result' }));
@@ -125,6 +125,10 @@ export interface NodeStatus {
125
125
  /** 0–100 rough load, from the latest hardware snapshot. */
126
126
  load?: number;
127
127
  lastSeenAt: number;
128
+ /** Physical deployment this node belongs to (e.g. "Tel Aviv"). Sourced from
129
+ * this node's own RoleContext.site — lets a fleet UI group by location as
130
+ * a customer scales from one site to many, without per-site bespoke code. */
131
+ site?: string;
128
132
  }
129
133
  export type MessageKind = 'hello' | 'welcome' | 'heartbeat' | 'manifest' | 'task.dispatch' | 'task.accepted' | 'task.status' | 'task.result' | 'task.cancel' | 'command' | 'command.result' | 'config.request' | 'config.offer' | 'update.announce' | 'update.request' | 'role.get' | 'role.set' | 'agents.list' | 'agents.offer' | 'message.send' | 'message.accepted' | 'event' | 'telemetry' | 'ack' | 'error';
130
134
  /** A dispatched/received task run — tracked for non-blocking handoff + monitoring. */
@@ -167,10 +171,26 @@ export interface AgentHandle {
167
171
  description: string;
168
172
  lastActiveAt: number;
169
173
  }
174
+ /** Result of a one-shot `infinicode maintain` run, pushed to an InfiniBot gateway. */
175
+ export interface MaintenanceReport {
176
+ nodeId: string;
177
+ role: string;
178
+ site?: string;
179
+ taskName: string;
180
+ startedAt: number;
181
+ finishedAt: number;
182
+ durationMs: number;
183
+ ok: boolean;
184
+ summary: string;
185
+ errors?: string[];
186
+ }
170
187
  /** Persistent per-device role + server-architecture context. */
171
188
  export interface RoleContext {
172
189
  /** e.g. "RoboPanda" — the identity a spawned agent adopts. */
173
190
  role: string;
191
+ /** Physical deployment/location this device belongs to (e.g. "Tel Aviv").
192
+ * Optional — a single-site deployment can leave it unset. */
193
+ site?: string;
174
194
  /** Free-text description of this device's job + the wider architecture. */
175
195
  architecture?: string;
176
196
  /** Extra structured context injected into every task run on this node. */
@@ -37,28 +37,43 @@ export function createMcpServer(deps) {
37
37
  // ── Spawn + dispatch (parallel agents across devices, no SSH) ───────────────
38
38
  server.registerTool('spawn_agent', {
39
39
  title: 'Spawn agent on a device',
40
- description: 'Hand a task to a device\'s local agent (by name or id). Non-blocking: returns a runId immediately — use follow_task to monitor. Pass wait:true to block until done (short tasks). Runs on the target with its persistent role. Set agent to run it with a third-party CLI on the target ("claude" | "codex" | "opencode") instead of the native kernel.',
40
+ description: 'Hand a task to a device\'s local agent (by name or id). Non-blocking: returns a runId immediately — use follow_task to monitor. Pass wait:true to block until done (short tasks). Runs on the target with its persistent role.\n\nExecutor: set `agent` to a fixed CLI ("claude" | "codex" | "opencode" | "gemini") or "kernel" (native). For automatic backend rotation instead of a fixed one, set `agent:"auto"` (or any of profile/strategy/policy/taskType) — the target then resolves a coder-profile *chain* and runs it with fallback (first backend that succeeds wins). Use `profile` to pin a named profile, `taskType` to route by kind (e.g. "refactor","docs","bulk"), `policy` to order by tier ("free-first"|"cheapest"|"fastest"|"highest-quality"|"balanced"), `strategy` to force one mode, and `model` to override the head model.',
41
41
  inputSchema: {
42
42
  task: z.string().describe('what the agent should do'),
43
43
  device: z.string().optional().describe('target device name or node id'),
44
44
  capabilities: z.array(z.string()).optional().describe('required capabilities, e.g. ["coding"]'),
45
- agent: z.string().optional().describe('executor on the target: "kernel" (default), "claude", "codex", or "opencode"'),
45
+ agent: z.string().optional().describe('executor: "kernel" (default), "claude" | "codex" | "opencode" | "gemini", or "auto" to rotate across coder profiles'),
46
+ profile: z.string().optional().describe('pin a named coder profile (e.g. "deep","fast","precise","cheap","oss")'),
47
+ strategy: z.enum(['auto', 'tasktype', 'policy', 'roundrobin', 'fallback']).optional().describe('rotation strategy (default from config: auto)'),
48
+ policy: z.enum(['free-first', 'cheapest', 'fastest', 'highest-quality', 'balanced']).optional().describe('policy tier ordering when rotating'),
49
+ taskType: z.string().optional().describe('route by task kind: refactor|architecture|debug|feature|edit|test|docs|bulk|boilerplate|fix'),
50
+ model: z.string().optional().describe('model override applied to the chosen backend'),
46
51
  role: z.string().optional().describe('override the role context for this task'),
47
52
  wait: z.boolean().optional().describe('block until the run finishes (default false)'),
48
53
  },
49
- }, async ({ task, device, capabilities, agent, role, wait }) => {
50
- const caps = [...(capabilities ?? []), ...(agent && agent !== 'kernel' ? [`backend:${agent}`] : [])];
54
+ }, async ({ task, device, capabilities, agent, profile, strategy, policy, taskType, model, role, wait }) => {
55
+ const rotate = !!(profile || strategy || policy || taskType || agent === 'auto');
56
+ const caps = [
57
+ ...(capabilities ?? []),
58
+ ...(agent && agent !== 'kernel' && agent !== 'auto' ? [`backend:${agent}`] : []),
59
+ ...(rotate ? ['coding'] : []),
60
+ ];
51
61
  const t = {
52
62
  description: task.slice(0, 80),
53
63
  prompt: task,
54
64
  capabilities: caps,
55
65
  agent,
56
66
  role,
67
+ model,
68
+ profile,
69
+ strategy,
70
+ policy,
71
+ taskType,
57
72
  };
58
73
  const res = await federation.dispatch(t, { preferName: device, preferNodeId: device }, { wait });
59
74
  if (!res)
60
75
  return text({ ok: false, error: 'no capable device online for this task' });
61
- return text({ ok: true, peerId: res.peerId, runId: res.runId, agent: agent ?? 'kernel', status: res.record.status, record: res.record });
76
+ return text({ ok: true, peerId: res.peerId, runId: res.runId, agent: rotate ? 'rotate' : (agent ?? 'kernel'), status: res.record.status, record: res.record });
62
77
  });
63
78
  server.registerTool('dispatch', {
64
79
  title: 'Dispatch to best device',
@@ -142,11 +157,12 @@ export function createMcpServer(deps) {
142
157
  description: 'Persist a device\'s role + architecture so spawned agents are proactive and context-aware.',
143
158
  inputSchema: {
144
159
  role: z.string().describe('e.g. "RoboPanda"'),
160
+ site: z.string().optional().describe('physical deployment/location, e.g. "Tel Aviv" — lets a fleet UI group by site as you add more locations'),
145
161
  architecture: z.string().optional().describe('this device\'s job + the wider system'),
146
162
  nodeId: z.string().optional().describe('target node; omit for local'),
147
163
  },
148
- }, async ({ role, architecture, nodeId }) => {
149
- const payload = { role, architecture };
164
+ }, async ({ role, site, architecture, nodeId }) => {
165
+ const payload = { role, site, architecture };
150
166
  return text(nodeId ? await federation.setRoleOf(nodeId, payload) : federation.setRole(payload));
151
167
  });
152
168
  // ── Voice (LiveKit intent — media plane handles TTS) ────────────────────────
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `robopark` — standalone entry for the RoboPark fleet control CLI.
4
+ *
5
+ * Same verbs as `infinicode robopark …`, exposed as a top-level `robopark`
6
+ * command so operators type `robopark fleet` / `robopark setup` directly.
7
+ */
8
+ import { Command } from 'commander';
9
+ import Conf from 'conf';
10
+ import { attachRoboparkSubcommands } from './commands/robopark.js';
11
+ import { DEFAULT_CONFIG } from './kernel/config-schema.js';
12
+ const VERSION = '2.4.0';
13
+ const config = new Conf({
14
+ projectName: 'infinicode', // share infinicode's config store (providers, federation)
15
+ defaults: DEFAULT_CONFIG,
16
+ });
17
+ const program = new Command();
18
+ program
19
+ .name('robopark')
20
+ .description('RoboPark fleet control — setup, fleet status, run, apply, dashboard (rides the infinicode mesh)')
21
+ .version(VERSION);
22
+ attachRoboparkSubcommands(program, config);
23
+ program.parse();
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.3.6",
3
+ "version": "2.4.0",
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",
7
7
  "types": "./dist/kernel/index.d.ts",
8
8
  "bin": {
9
9
  "infinicode": "./bin/infinicode.js",
10
- "ic": "./bin/infinicode.js"
10
+ "ic": "./bin/infinicode.js",
11
+ "robopark": "./bin/robopark.js"
11
12
  },
12
13
  "exports": {
13
14
  ".": {
@@ -17,10 +18,12 @@
17
18
  "./kernel": {
18
19
  "types": "./dist/kernel/index.d.ts",
19
20
  "import": "./dist/kernel/index.js"
20
- }
21
+ },
22
+ "./robopark": "./dist/robopark-cli.js"
21
23
  },
22
24
  "files": [
23
25
  "bin/infinicode.js",
26
+ "bin/robopark.js",
24
27
  "dist",
25
28
  ".opencode/plugins",
26
29
  ".opencode/themes",