infinicode 2.8.56 → 2.8.58

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.
package/dist/cli.js CHANGED
@@ -305,6 +305,7 @@ program
305
305
  .option('--token <token>', 'shared bearer token peers must present')
306
306
  .option('--seed <url...>', 'peer base URL(s) to connect to on start')
307
307
  .option('--auto-update', 'accept auto-updates announced by a hub/relay')
308
+ .option('--update-poll-ms <ms>', 'hub/relay npm-release polling interval (default 60000)')
308
309
  .option('--supervised', 'this node runs under a supervisor (systemd/pm2/Task Scheduler) — let /update restart it to activate a new version')
309
310
  .option('--tailscale', 'auto-discover + connect peers over Tailscale')
310
311
  .option('--lan', 'auto-discover + connect peers on the same LAN via UDP broadcast (no seed/Tailscale needed)')
@@ -8,6 +8,7 @@ export interface ServeOptions {
8
8
  token?: string;
9
9
  seed?: string[];
10
10
  autoUpdate?: boolean;
11
+ updatePollMs?: string;
11
12
  hub?: boolean;
12
13
  tailscale?: boolean;
13
14
  tag?: string;
@@ -156,6 +156,7 @@ export async function serve(config, opts) {
156
156
  token,
157
157
  heartbeatMs: (fed.heartbeatSec ?? 30) * 1000,
158
158
  autoUpdate: opts.autoUpdate ?? fed.autoUpdate,
159
+ updatePollMs: opts.updatePollMs ? Number(opts.updatePollMs) : fed.updatePollMs,
159
160
  applyConfig,
160
161
  autoPullConfig: role === 'satellite',
161
162
  dashboard: opts.dashboard,
@@ -43,6 +43,8 @@ export interface FederationConfig {
43
43
  heartbeatSec?: number;
44
44
  /** Accept + apply auto-updates announced by a hub/relay. */
45
45
  autoUpdate?: boolean;
46
+ /** Hub/relay npm-release polling interval in milliseconds (default 60000). */
47
+ updatePollMs?: number;
46
48
  /** Node runs under a process supervisor — allow `/update` to exit-for-restart. */
47
49
  supervised?: boolean;
48
50
  /** Zero-config LAN discovery: broadcast + auto-connect peers on this subnet. */
@@ -54,6 +54,8 @@ export interface FederationOptions {
54
54
  /** Base URLs of peers to connect to on start (static seeds). */
55
55
  seeds?: string[];
56
56
  autoUpdate?: boolean;
57
+ /** Hub/relay npm-release polling interval. Defaults to one minute. */
58
+ updatePollMs?: number;
57
59
  /** Satellite: how to apply an incoming shared config (register providers etc.). */
58
60
  applyConfig?: (cfg: SharedConfig) => void | Promise<void>;
59
61
  /** Satellite: auto-pull shared config from any hub/relay we connect to. */
@@ -133,6 +135,7 @@ export declare class Federation {
133
135
  /** nodeIds we're currently dialing back after an inbound heartbeat/hello. */
134
136
  private inboundConnecting;
135
137
  private configPullTimer?;
138
+ private updatePollTimer?;
136
139
  readonly configSync: ConfigSync;
137
140
  readonly autoUpdate: AutoUpdate;
138
141
  readonly compute: ComputeRouter;
@@ -259,6 +262,8 @@ export declare class Federation {
259
262
  private maybeAutoPull;
260
263
  /** Satellite: re-pull from the first connected hub/relay (periodic refresh). */
261
264
  private autoPullFromHub;
265
+ /** Fetch npm's latest tag, announce it to satellites, then update this hub. */
266
+ private pollNpmRelease;
262
267
  /** Auto-discover peers over Tailscale and connect to each (skips self/known). */
263
268
  discoverAndConnect(tag?: string): Promise<number>;
264
269
  /**
@@ -3,7 +3,7 @@ import { DASHBOARD_HTML } from './dashboard-html.js';
3
3
  import { PeerMesh } from './peer-mesh.js';
4
4
  import { EventBridge } from './event-bridge.js';
5
5
  import { ConfigSync } from './config-sync.js';
6
- import { AutoUpdate } from './auto-update.js';
6
+ import { AutoUpdate, compareVersions } from './auto-update.js';
7
7
  import { performSelfUpdate } from './self-update.js';
8
8
  import { ComputeRouter } from './compute-router.js';
9
9
  import { TelemetryCollector, loadFromSnapshot, selfNodeStatus, nodeStatusFromPeer } from './telemetry.js';
@@ -32,6 +32,7 @@ export class Federation {
32
32
  /** nodeIds we're currently dialing back after an inbound heartbeat/hello. */
33
33
  inboundConnecting = new Set();
34
34
  configPullTimer;
35
+ updatePollTimer;
35
36
  configSync;
36
37
  autoUpdate;
37
38
  compute = new ComputeRouter();
@@ -134,6 +135,15 @@ export class Federation {
134
135
  if (options.autoPullConfig && options.configPullMs !== 0) {
135
136
  this.configPullTimer = setInterval(() => void this.autoPullFromHub(), options.configPullMs ?? 30_000);
136
137
  }
138
+ // The hub owns release discovery. Satellites never poll npm: they only
139
+ // trust signed mesh announcements from a hub/relay. This makes npm publish
140
+ // -> fleet update a real loop, while retaining the --auto-update opt-in.
141
+ if (options.autoUpdate && (identity.role === 'hub' || identity.role === 'relay')) {
142
+ const pollMs = Math.max(30_000, options.updatePollMs ?? 60_000);
143
+ void this.pollNpmRelease();
144
+ this.updatePollTimer = setInterval(() => void this.pollNpmRelease(), pollMs);
145
+ logger.info(`[federation] npm release watcher enabled (every ${Math.round(pollMs / 1000)}s)`);
146
+ }
137
147
  logger.info(`[federation] node ${identity.displayName} (${identity.role}) online on :${port}`);
138
148
  }
139
149
  // ── Inbound RPC ────────────────────────────────────────────────────────────
@@ -172,7 +182,10 @@ export class Federation {
172
182
  return reply(env, 'ack', self);
173
183
  case 'update.announce': {
174
184
  const peer = this.mesh?.get(env.from);
175
- await this.autoUpdate.onAnnounce(env.data, peer?.role ?? 'peer');
185
+ // Acknowledge immediately. npm can take minutes on a Pi, while the
186
+ // sender's RPC timeout is intentionally short; the update continues
187
+ // in the background and exits the supervised mesh process on success.
188
+ void this.autoUpdate.onAnnounce(env.data, peer?.role ?? 'peer');
176
189
  return reply(env, 'ack', self);
177
190
  }
178
191
  case 'role.get':
@@ -625,6 +638,30 @@ export class Federation {
625
638
  if (hub)
626
639
  await this.maybeAutoPull(hub);
627
640
  }
641
+ /** Fetch npm's latest tag, announce it to satellites, then update this hub. */
642
+ async pollNpmRelease() {
643
+ try {
644
+ const response = await fetch('https://registry.npmjs.org/infinicode/latest', {
645
+ signal: AbortSignal.timeout(10_000),
646
+ });
647
+ if (!response.ok)
648
+ throw new Error(`npm registry ${response.status}`);
649
+ const payload = await response.json();
650
+ const version = typeof payload.version === 'string' ? payload.version.trim() : '';
651
+ if (!/^\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
652
+ throw new Error('npm registry returned an invalid version');
653
+ }
654
+ await this.mesh?.announceUpdate({ version, channel: 'npm' });
655
+ const current = this.autoUpdate.currentVersion();
656
+ if (compareVersions(version, current) > 0) {
657
+ this.deps.logger.info(`[federation] npm release ${version} announced to fleet; updating ${current} -> ${version}`);
658
+ await this.autoUpdate.onAnnounce({ version, channel: 'npm' }, this.deps.identity.role);
659
+ }
660
+ }
661
+ catch (err) {
662
+ this.deps.logger.warn(`[federation] npm release check failed: ${err instanceof Error ? err.message : String(err)}`);
663
+ }
664
+ }
628
665
  /** Auto-discover peers over Tailscale and connect to each (skips self/known). */
629
666
  async discoverAndConnect(tag) {
630
667
  const port = this.deps.options.port ?? DEFAULT_MESH_PORT;
@@ -752,6 +789,8 @@ export class Federation {
752
789
  this.lan?.stop();
753
790
  if (this.configPullTimer)
754
791
  clearInterval(this.configPullTimer);
792
+ if (this.updatePollTimer)
793
+ clearInterval(this.updatePollTimer);
755
794
  if (this.telemetryTimer)
756
795
  clearInterval(this.telemetryTimer);
757
796
  this.mesh?.stop();
@@ -10,7 +10,7 @@
10
10
  * manage (satellites are accept-only). A satellite never dials us.
11
11
  */
12
12
  import type { Logger } from '../types.js';
13
- import type { FederationEnvelope, NodeIdentity, PeerInfo, StreamFrame } from './types.js';
13
+ import type { FederationEnvelope, NodeIdentity, PeerInfo, StreamFrame, UpdateAnnounce } from './types.js';
14
14
  import { MeshClient } from './transport-http.js';
15
15
  export interface PeerMeshOptions {
16
16
  self: NodeIdentity;
@@ -38,6 +38,8 @@ export declare class PeerMesh {
38
38
  connected(): PeerInfo[];
39
39
  /** Discover + attach a peer at a base URL (manifest probe + open its stream). */
40
40
  connect(url: string): Promise<PeerInfo | null>;
41
+ /** Send a hub/relay version announcement to every currently connected peer. */
42
+ announceUpdate(announce: UpdateAnnounce): Promise<void>;
41
43
  private openStream;
42
44
  start(): void;
43
45
  private tick;
@@ -22,6 +22,11 @@ export class PeerMesh {
22
22
  async connect(url) {
23
23
  try {
24
24
  const manifest = await this.opts.client.fetchManifest(url);
25
+ // A hub may discover or seed its own advertised address. Never add self
26
+ // as a peer: it duplicates the hub in fleet status and can create a
27
+ // pointless self-stream/update loop.
28
+ if (manifest.nodeId === this.opts.self.nodeId)
29
+ return null;
25
30
  const peer = {
26
31
  nodeId: manifest.nodeId,
27
32
  displayName: manifest.displayName,
@@ -48,6 +53,17 @@ export class PeerMesh {
48
53
  return null;
49
54
  }
50
55
  }
56
+ /** Send a hub/relay version announcement to every currently connected peer. */
57
+ async announceUpdate(announce) {
58
+ await Promise.all(this.connected().map(async (peer) => {
59
+ try {
60
+ await this.opts.client.rpc(peer.url, envelope('update.announce', this.opts.self.nodeId, announce, { to: peer.nodeId }));
61
+ }
62
+ catch (err) {
63
+ this.opts.logger.warn(`[federation] update announce to ${peer.displayName} failed: ${err instanceof Error ? err.message : String(err)}`);
64
+ }
65
+ }));
66
+ }
51
67
  openStream(peer) {
52
68
  this.streams.get(peer.nodeId)?.(); // close any prior
53
69
  const stop = this.opts.client.openStream(peer.url, f => {
@@ -35,7 +35,22 @@ export async function performSelfUpdate(opts = {}) {
35
35
  try {
36
36
  // Use npm.cmd on Windows so we never need a shell (no injection surface).
37
37
  const bin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
38
- const res = await execa(bin, ['install', '-g', spec], { all: true, timeout: 300_000 });
38
+ const args = ['install', '-g', spec];
39
+ let res;
40
+ try {
41
+ res = await execa(bin, args, { all: true, timeout: 300_000 });
42
+ }
43
+ catch (err) {
44
+ const message = String(err.all ?? err.message ?? err);
45
+ const unprivileged = process.platform !== 'win32' && process.getuid?.() !== 0;
46
+ if (!unprivileged || !/\bEACCES\b|permission denied/i.test(message))
47
+ throw err;
48
+ // RoboPark systemd services normally run as root. This fallback also
49
+ // supports an operator-launched satellite when sudo is intentionally
50
+ // configured non-interactively; it never prompts or shells out.
51
+ info('[update] global npm directory is root-owned; retrying with sudo -n…');
52
+ res = await execa('sudo', ['-n', bin, ...args], { all: true, timeout: 300_000 });
53
+ }
39
54
  const tail = String(res.all ?? res.stdout ?? '').split('\n').filter(Boolean).slice(-2).join(' ').trim();
40
55
  info(`[update] installed ${spec}${tail ? ' — ' + tail : ''}`);
41
56
  }
@@ -8,7 +8,7 @@
8
8
  * it exits unexpectedly.
9
9
  */
10
10
  import { spawn } from 'node:child_process';
11
- import { existsSync, createWriteStream, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
@@ -50,8 +50,10 @@ function runtimeLog(label, foreground) {
50
50
  mkdirSync(logDir, { recursive: true });
51
51
  return [
52
52
  'ignore',
53
- createWriteStream(join(logDir, `${label}.log`), { flags: 'a' }),
54
- createWriteStream(join(logDir, `${label}.err.log`), { flags: 'a' }),
53
+ // spawn() accepts file descriptors, but not WriteStreams before their
54
+ // asynchronous open event. openSync makes detached startup reliable.
55
+ openSync(join(logDir, `${label}.log`), 'a'),
56
+ openSync(join(logDir, `${label}.err.log`), 'a'),
55
57
  ];
56
58
  }
57
59
  async function waitForVision() {
@@ -76,6 +78,7 @@ export async function roboparkRobotRuntime(opts) {
76
78
  const cli = roboparkCliPath();
77
79
  const children = [];
78
80
  let stopping = false;
81
+ let recycling = false;
79
82
  const infinicode = await resolveInfinicodeBin();
80
83
  if (!infinicode)
81
84
  throw new Error('could not resolve infinicode CLI');
@@ -84,7 +87,7 @@ export async function roboparkRobotRuntime(opts) {
84
87
  command: infinicode.node,
85
88
  args: [infinicode.script, 'serve', '--role', 'satellite', '--name', opts.name,
86
89
  '--port', port, '--token', opts.token, '--seed', opts.hubUrl,
87
- network === 'tailscale' ? '--tailscale' : '--lan', '--auto-update'],
90
+ network === 'tailscale' ? '--tailscale' : '--lan', '--auto-update', '--supervised'],
88
91
  });
89
92
  if (opts.vision !== false) {
90
93
  children.push({
@@ -114,11 +117,64 @@ export async function roboparkRobotRuntime(opts) {
114
117
  entry.child = undefined;
115
118
  if (stopping)
116
119
  return;
120
+ // A clean mesh exit is how /update signals that npm replaced this
121
+ // package. Reload every child so the robot runs one coherent release.
122
+ if (entry.label === 'mesh' && code === 0) {
123
+ void recycleAfterMeshUpdate();
124
+ return;
125
+ }
126
+ if (recycling)
127
+ return;
117
128
  console.error(chalk.yellow(` ${entry.label} exited (${signal ?? code ?? 'unknown'}); restarting in 5s`));
118
129
  setTimeout(() => { if (!stopping)
119
130
  start(entry); }, RESTART_DELAY_MS);
120
131
  });
121
132
  };
133
+ const stopChild = async (entry) => {
134
+ const child = entry.child;
135
+ if (!child)
136
+ return;
137
+ await new Promise(resolve => {
138
+ const timer = setTimeout(resolve, 5_000);
139
+ child.once('exit', () => {
140
+ clearTimeout(timer);
141
+ resolve();
142
+ });
143
+ try {
144
+ child.kill('SIGTERM');
145
+ }
146
+ catch {
147
+ clearTimeout(timer);
148
+ resolve();
149
+ }
150
+ });
151
+ };
152
+ const recycleAfterMeshUpdate = async () => {
153
+ if (stopping || recycling)
154
+ return;
155
+ recycling = true;
156
+ console.log(chalk.dim(' mesh updated; restarting RoboVision and preview on the new package...'));
157
+ await Promise.all([...children.filter(entry => entry.label !== 'mesh'), preview].map(stopChild));
158
+ if (stopping)
159
+ return;
160
+ start(children[0]);
161
+ if (opts.vision !== false) {
162
+ start(children[1]);
163
+ if (!await waitForVision()) {
164
+ console.error(chalk.red(' RoboVision did not recover after the mesh update; retrying in 5s'));
165
+ // Stop the partially restarted stack before retrying. Otherwise a
166
+ // second mesh child would collide with the existing listener.
167
+ await Promise.all([...children, preview].map(stopChild));
168
+ recycling = false;
169
+ setTimeout(() => { if (!stopping)
170
+ void recycleAfterMeshUpdate(); }, RESTART_DELAY_MS);
171
+ return;
172
+ }
173
+ }
174
+ start(preview);
175
+ recycling = false;
176
+ console.log(chalk.green(' robot runtime updated and healthy'));
177
+ };
122
178
  console.log(chalk.bold('\n robopark robot up'));
123
179
  console.log(chalk.dim(' ' + '─'.repeat(52)));
124
180
  console.log(` robot: ${chalk.cyan(opts.name)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.56",
3
+ "version": "2.8.58",
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",
@@ -74,7 +74,6 @@
74
74
  "node": ">=20"
75
75
  },
76
76
  "dependencies": {
77
- "@anthropic-ai/claude-code": "^2.1.207",
78
77
  "@modelcontextprotocol/sdk": "^1.29.0",
79
78
  "chalk": "^5.3.0",
80
79
  "commander": "^12.1.0",