infinicode 2.3.5 → 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 (64) hide show
  1. package/.opencode/plugins/home-logo-animation.tsx +5 -1
  2. package/.opencode/plugins/mesh-commands.tsx +4 -3
  3. package/.opencode/plugins/routing-mode-display.tsx +6 -2
  4. package/.opencode/tui.json +2 -1
  5. package/bin/robopark.js +2 -0
  6. package/dist/cli.js +46 -1
  7. package/dist/commands/maintain.d.ts +29 -0
  8. package/dist/commands/maintain.js +186 -0
  9. package/dist/commands/mcp.js +1 -1
  10. package/dist/commands/mesh-install.d.ts +10 -0
  11. package/dist/commands/mesh-install.js +160 -0
  12. package/dist/commands/robopark.d.ts +6 -0
  13. package/dist/commands/robopark.js +450 -0
  14. package/dist/commands/run.js +82 -22
  15. package/dist/commands/serve.d.ts +1 -0
  16. package/dist/commands/serve.js +152 -11
  17. package/dist/kernel/agents/backends/cli-backend.d.ts +41 -0
  18. package/dist/kernel/agents/backends/cli-backend.js +140 -0
  19. package/dist/kernel/agents/backends/detect.d.ts +17 -0
  20. package/dist/kernel/agents/backends/detect.js +140 -0
  21. package/dist/kernel/agents/backends/index.d.ts +11 -0
  22. package/dist/kernel/agents/backends/index.js +3 -0
  23. package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
  24. package/dist/kernel/agents/backends/parse-utils.js +72 -0
  25. package/dist/kernel/agents/backends/profiles.d.ts +67 -0
  26. package/dist/kernel/agents/backends/profiles.js +48 -0
  27. package/dist/kernel/agents/backends/registry.d.ts +31 -0
  28. package/dist/kernel/agents/backends/registry.js +174 -0
  29. package/dist/kernel/agents/backends/rotation.d.ts +51 -0
  30. package/dist/kernel/agents/backends/rotation.js +107 -0
  31. package/dist/kernel/agents/backends/types.d.ts +67 -0
  32. package/dist/kernel/agents/backends/types.js +1 -0
  33. package/dist/kernel/agents/index.d.ts +8 -0
  34. package/dist/kernel/agents/index.js +8 -0
  35. package/dist/kernel/federation/dashboard-html.d.ts +13 -0
  36. package/dist/kernel/federation/dashboard-html.js +371 -0
  37. package/dist/kernel/federation/federation.d.ts +112 -2
  38. package/dist/kernel/federation/federation.js +270 -10
  39. package/dist/kernel/federation/moltfed-adapter.js +1 -0
  40. package/dist/kernel/federation/telemetry.d.ts +1 -1
  41. package/dist/kernel/federation/telemetry.js +2 -1
  42. package/dist/kernel/federation/transport-http.d.ts +17 -1
  43. package/dist/kernel/federation/transport-http.js +65 -2
  44. package/dist/kernel/federation/types.d.ts +46 -1
  45. package/dist/kernel/free-providers.js +83 -0
  46. package/dist/kernel/gateway/openai-gateway.d.ts +55 -3
  47. package/dist/kernel/gateway/openai-gateway.js +428 -49
  48. package/dist/kernel/index.d.ts +2 -1
  49. package/dist/kernel/index.js +3 -1
  50. package/dist/kernel/logger.d.ts +16 -3
  51. package/dist/kernel/logger.js +38 -0
  52. package/dist/kernel/mcp/mcp-server.js +65 -10
  53. package/dist/kernel/orchestrator.d.ts +4 -0
  54. package/dist/kernel/orchestrator.js +34 -0
  55. package/dist/kernel/provider-manager.d.ts +13 -0
  56. package/dist/kernel/provider-manager.js +58 -5
  57. package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
  58. package/dist/kernel/providers/openai-compatible-provider.js +22 -3
  59. package/dist/kernel/recovery-manager.js +55 -15
  60. package/dist/kernel/router.js +15 -2
  61. package/dist/kernel/types.d.ts +12 -2
  62. package/dist/robopark-cli.d.ts +2 -0
  63. package/dist/robopark-cli.js +23 -0
  64. package/package.json +6 -3
@@ -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';
@@ -11,6 +12,9 @@ import { loadRole, saveRole, rolePreamble } from './role-context.js';
11
12
  import { discoverTailscalePeers } from './discovery.js';
12
13
  import { LanBeacon } from './lan-discovery.js';
13
14
  import { DEFAULT_MESH_PORT } from './types.js';
15
+ import { createDefaultBackendRegistry } from '../agents/index.js';
16
+ import { RotationState, selectChain } from '../agents/backends/rotation.js';
17
+ import { loadCoderConfig } from '../agents/backends/profiles.js';
14
18
  export class Federation {
15
19
  deps;
16
20
  server;
@@ -30,6 +34,12 @@ export class Federation {
30
34
  configSync;
31
35
  autoUpdate;
32
36
  compute = new ComputeRouter();
37
+ /** Executors available on this node (native kernel + installed CLIs). */
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();
33
43
  /** Local subscribers to the merged mesh frame stream (for MCP stream_activity). */
34
44
  frameSubs = new Set();
35
45
  /** Recent frames for catch-up. */
@@ -41,6 +51,8 @@ export class Federation {
41
51
  constructor(deps) {
42
52
  this.deps = deps;
43
53
  this.client = new MeshClient({ token: deps.options.token });
54
+ this.backends = deps.backends ?? createDefaultBackendRegistry();
55
+ this.coderConfig = loadCoderConfig(deps.options.coder);
44
56
  this.configSync = new ConfigSync({ logger: deps.logger, apply: deps.options.applyConfig });
45
57
  this.autoUpdate = new AutoUpdate({
46
58
  version: deps.options.version,
@@ -59,9 +71,13 @@ export class Federation {
59
71
  host: options.host,
60
72
  token: options.token,
61
73
  logger,
62
- getManifest: () => buildManifest(identity, this.deps.describe()),
74
+ getManifest: () => buildManifest(identity, this.describeWithBackends()),
63
75
  getNodes: () => this.nodeStatuses(),
76
+ getStatus: () => this.statusSnapshot(),
64
77
  runCommand: (input) => this.deps.kernel.command(input),
78
+ commandGate: options.commandGate,
79
+ onApply: options.onApply,
80
+ dashboardHtml: options.dashboard ? DASHBOARD_HTML : undefined,
65
81
  onRpc: (env, remote) => this.onRpc(env, remote),
66
82
  });
67
83
  await this.server.start();
@@ -77,9 +93,15 @@ export class Federation {
77
93
  });
78
94
  this.mesh.start();
79
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.
80
99
  this.bridge = new EventBridge({
81
100
  nodeId: identity.nodeId,
82
- emit: f => this.server?.broadcast(f),
101
+ emit: f => {
102
+ this.server?.broadcast(f);
103
+ this.ingestFrame(identity.nodeId, f);
104
+ },
83
105
  });
84
106
  this.unsub = this.deps.kernel.subscribeAll(e => this.bridge?.handle(e));
85
107
  // Telemetry heartbeat → stream a HardwareSnapshot on a fixed cadence.
@@ -107,7 +129,7 @@ export class Federation {
107
129
  return reply(env, 'ack', self, { load: loadFromSnapshot(this.lastHw) });
108
130
  case 'hello':
109
131
  this.learnInboundPeer(env, remote);
110
- return reply(env, 'welcome', self, buildManifest(this.deps.identity, this.deps.describe()));
132
+ return reply(env, 'welcome', self, buildManifest(this.deps.identity, this.describeWithBackends()));
111
133
  case 'task.dispatch': {
112
134
  // Non-blocking handoff: register the run, ack with a runId immediately,
113
135
  // execute in the background, and let the caller follow it.
@@ -144,6 +166,29 @@ export class Federation {
144
166
  const saved = saveRole(r);
145
167
  return reply(env, 'ack', self, saved);
146
168
  }
169
+ case 'agents.list':
170
+ return reply(env, 'agents.offer', self, this.localAgents());
171
+ case 'message.send': {
172
+ // Wake / continue a CLI agent session on this node with a new message.
173
+ const d = env.data;
174
+ const runId = newId();
175
+ this.runs.set(runId, {
176
+ runId,
177
+ status: 'running',
178
+ description: `msg→${(d.sessionId ?? '').slice(0, 8)}`,
179
+ agent: d.agent,
180
+ sessionId: d.sessionId,
181
+ startedAt: Date.now(),
182
+ });
183
+ void this.runTaskAsync(runId, {
184
+ description: 'message',
185
+ capabilities: [],
186
+ prompt: d.message,
187
+ agent: d.agent,
188
+ sessionId: d.sessionId,
189
+ });
190
+ return reply(env, 'message.accepted', self, { runId, sessionId: d.sessionId });
191
+ }
147
192
  default:
148
193
  return reply(env, 'ack', self);
149
194
  }
@@ -207,12 +252,25 @@ export class Federation {
207
252
  if (!rec)
208
253
  return;
209
254
  const nodeId = this.deps.identity.nodeId;
210
- this.server?.broadcast(frame('ev', nodeId, { k: 'RUN_STARTED', run: runId, desc: task.description }));
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');
260
+ this.server?.broadcast(frame('ev', nodeId, { k: 'RUN_STARTED', run: runId, desc: task.description, agent: rec.agent }));
211
261
  try {
212
- const result = await this.runTask(task);
213
- rec.status = result.status === 'COMPLETED' ? 'completed' : 'failed';
214
- rec.missionId = result.missionId;
215
- rec.outputs = result.outputs;
262
+ if (wantsRotation) {
263
+ await this.runViaBackendChain(runId, task, rec);
264
+ }
265
+ else if (backendId) {
266
+ await this.runViaBackend(runId, backendId, task, rec);
267
+ }
268
+ else {
269
+ const result = await this.runTask(task);
270
+ rec.status = result.status === 'COMPLETED' ? 'completed' : 'failed';
271
+ rec.missionId = result.missionId;
272
+ rec.outputs = result.outputs;
273
+ }
216
274
  rec.completedAt = Date.now();
217
275
  }
218
276
  catch (err) {
@@ -222,6 +280,90 @@ export class Federation {
222
280
  }
223
281
  this.server?.broadcast(frame('ev', nodeId, { k: 'RUN_COMPLETED', run: runId, status: rec.status }));
224
282
  }
283
+ /**
284
+ * Execute a dispatched task with a third-party CLI backend (Claude Code /
285
+ * Codex / OpenCode) instead of the native kernel. The backend's activity
286
+ * streams up the same mesh SSE feed (log/ev frames), and its native session id
287
+ * is captured on the RunRecord so the run can be followed and later messaged.
288
+ */
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) {
308
+ const backend = this.backends.get(backendId);
309
+ if (!backend || !backend.available()) {
310
+ return { status: 'failed', error: `backend not available on this node: ${backendId}` };
311
+ }
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;
323
+ rec.status = res.status;
324
+ rec.sessionId = res.sessionId;
325
+ rec.missionId = res.sessionId ?? runId;
326
+ rec.outputs = [{ status: res.status, content: res.content, 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);
366
+ }
225
367
  // ── Outbound operations ──────────────────────────────────────────────────
226
368
  /**
227
369
  * Hand a task to the best-fit connected peer (compute-routed) or a named one.
@@ -292,6 +434,96 @@ export class Federation {
292
434
  const res = await this.client.rpc(peer.url, envelope('command', this.deps.identity.nodeId, { input }, { to: nodeId }));
293
435
  return res.data;
294
436
  }
437
+ // ── Agent mailbox (CLI sessions message each other) ────────────────────────
438
+ /**
439
+ * CLI agent sessions on THIS node — any run that used a CLI backend and
440
+ * captured a session id. Live while running, dormant (still resumable) once
441
+ * complete. Deduped by session, newest kept, so a session that's been messaged
442
+ * several times shows once.
443
+ */
444
+ localAgents() {
445
+ const self = this.deps.identity.nodeId;
446
+ const bySession = new Map();
447
+ for (const rec of this.runs.values()) {
448
+ if (!rec.sessionId || !rec.agent || rec.agent === 'kernel')
449
+ continue;
450
+ const existing = bySession.get(rec.sessionId);
451
+ if (existing && existing.lastActiveAt >= (rec.completedAt ?? rec.startedAt))
452
+ continue;
453
+ bySession.set(rec.sessionId, {
454
+ agentId: rec.runId,
455
+ nodeId: self,
456
+ backend: rec.agent,
457
+ sessionId: rec.sessionId,
458
+ address: `${self}/${rec.sessionId}`,
459
+ status: rec.status,
460
+ description: rec.description,
461
+ lastActiveAt: rec.completedAt ?? rec.startedAt,
462
+ });
463
+ }
464
+ return [...bySession.values()];
465
+ }
466
+ /** All messageable CLI agent sessions across the mesh (local + peers). */
467
+ async agents() {
468
+ const self = this.deps.identity.nodeId;
469
+ const out = [...this.localAgents()];
470
+ for (const peer of this.mesh?.connected() ?? []) {
471
+ try {
472
+ const res = await this.client.rpc(peer.url, envelope('agents.list', self, {}, { to: peer.nodeId }));
473
+ if (Array.isArray(res.data))
474
+ out.push(...res.data);
475
+ }
476
+ catch {
477
+ // peer unreachable — skip
478
+ }
479
+ }
480
+ return out;
481
+ }
482
+ /** Resolve an address (`nodeId/sessionId`, a sessionId, or a runId) to a handle. */
483
+ async resolveAgent(address) {
484
+ const dir = await this.agents();
485
+ if (address.includes('/')) {
486
+ const slash = address.indexOf('/');
487
+ const nodeId = address.slice(0, slash);
488
+ const sessionId = address.slice(slash + 1);
489
+ return dir.find(a => a.nodeId === nodeId && a.sessionId === sessionId) ?? null;
490
+ }
491
+ return dir.find(a => a.agentId === address) ?? dir.find(a => a.sessionId === address) ?? null;
492
+ }
493
+ /**
494
+ * Message a CLI agent session — wherever it lives on the mesh. Resumes that
495
+ * exact session with the text (so the agent keeps its context) and returns a
496
+ * runId to follow the reply. This is how one CLI agent talks to another.
497
+ */
498
+ async sendTo(address, text, opts) {
499
+ const target = await this.resolveAgent(address);
500
+ if (!target)
501
+ return null;
502
+ const self = this.deps.identity.nodeId;
503
+ if (target.nodeId === self) {
504
+ const runId = newId();
505
+ this.runs.set(runId, {
506
+ runId, status: 'running', description: `msg→${target.sessionId.slice(0, 8)}`,
507
+ agent: target.backend, sessionId: target.sessionId, startedAt: Date.now(),
508
+ });
509
+ void this.runTaskAsync(runId, {
510
+ description: 'message', capabilities: [], prompt: text,
511
+ agent: target.backend, sessionId: target.sessionId,
512
+ });
513
+ const record = opts?.wait ? await this.awaitRun(runId, opts.timeoutMs ?? 120_000) : undefined;
514
+ return { runId, nodeId: self, record };
515
+ }
516
+ const peer = this.mesh?.get(target.nodeId);
517
+ if (!peer)
518
+ return null;
519
+ const res = await this.client.rpc(peer.url, envelope('message.send', self, {
520
+ sessionId: target.sessionId, agent: target.backend, message: text, from: self,
521
+ }, { to: target.nodeId }));
522
+ const runId = res.data.runId;
523
+ this.dispatchedRuns.set(runId, target.nodeId);
524
+ const record = opts?.wait ? await this.awaitRun(runId, opts?.timeoutMs ?? 120_000) : undefined;
525
+ return { runId, nodeId: target.nodeId, record };
526
+ }
295
527
  /** Hub: publish shared config (providers/API keys/policy) to the fleet. */
296
528
  publishConfig(cfg) {
297
529
  return this.configSync.publish(cfg);
@@ -406,20 +638,48 @@ export class Federation {
406
638
  this.lan.start();
407
639
  }
408
640
  // ── Views ────────────────────────────────────────────────────────────────
641
+ /** Backend ids this node can execute tasks with ('kernel' + installed CLIs). */
642
+ availableBackends() {
643
+ return this.backends.availableIds();
644
+ }
645
+ /**
646
+ * The manifest inputs augmented with `backend:<id>` capability tags for each
647
+ * installed executor. This lets the compute-router match a task that asks for
648
+ * a specific agent (e.g. capabilities:['backend:claude']) to a node that
649
+ * actually has that CLI — without changing the caller's describe().
650
+ */
651
+ describeWithBackends() {
652
+ const d = this.deps.describe();
653
+ const tags = this.backends.availableIds().map(id => `backend:${id}`);
654
+ return { ...d, capabilities: [...d.capabilities, ...tags] };
655
+ }
409
656
  /** Neutral node-status list (self + peers) — feeds the mesh map. */
410
657
  nodeStatuses() {
411
- const d = this.deps.describe();
658
+ const d = this.describeWithBackends();
412
659
  const self = selfNodeStatus(this.deps.identity, this.lastHw ?? this.telemetry.collect(), {
413
660
  version: d.version,
414
661
  capabilities: d.capabilities,
415
662
  commands: d.commands,
416
- });
663
+ }, loadRole()?.site);
417
664
  const peers = (this.mesh?.list() ?? []).map(nodeStatusFromPeer);
418
665
  return [self, ...peers];
419
666
  }
420
667
  peers() {
421
668
  return this.mesh?.list() ?? [];
422
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
+ }
423
683
  /** Latest local hardware snapshot (for the MOLTFED connector / panels). */
424
684
  latestHardware() {
425
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,8 +125,12 @@ 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
- 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' | 'event' | 'telemetry' | 'ack' | 'error';
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. */
131
135
  export interface RunRecord {
132
136
  runId: string;
@@ -141,11 +145,52 @@ export interface RunRecord {
141
145
  error?: string;
142
146
  startedAt: number;
143
147
  completedAt?: number;
148
+ /** Which executor ran this — 'kernel' (native) or a CLI backend id. */
149
+ agent?: string;
150
+ /** The executor's native session id (CLI backends) — enables resume + messaging. */
151
+ sessionId?: string;
152
+ }
153
+ /**
154
+ * A messageable CLI agent session on the mesh. Any run that used a CLI backend
155
+ * and captured a native session id becomes an addressable agent — live while
156
+ * running, dormant (but still resumable) once complete. Its address is
157
+ * `nodeId/sessionId`; sending a message resumes that exact CLI session.
158
+ */
159
+ export interface AgentHandle {
160
+ /** runId that created/owns the session (the follow handle). */
161
+ agentId: string;
162
+ /** node holding the session. */
163
+ nodeId: string;
164
+ /** which CLI backend runs it — 'claude' | 'codex' | 'opencode'. */
165
+ backend: string;
166
+ /** the CLI's native session id (what resume targets). */
167
+ sessionId: string;
168
+ /** routable address: `${nodeId}/${sessionId}`. */
169
+ address: string;
170
+ status: 'running' | 'completed' | 'failed';
171
+ description: string;
172
+ lastActiveAt: number;
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[];
144
186
  }
145
187
  /** Persistent per-device role + server-architecture context. */
146
188
  export interface RoleContext {
147
189
  /** e.g. "RoboPanda" — the identity a spawned agent adopts. */
148
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;
149
194
  /** Free-text description of this device's job + the wider architecture. */
150
195
  architecture?: string;
151
196
  /** Extra structured context injected into every task run on this node. */